diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72446f4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/jest.config.js b/jest.config.js index a994f99..2559f58 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,13 @@ module.exports = { moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/', }), + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + }, + }, moduleDirectories: ['node_modules', 'src'], modulePaths: [''], setupFiles: ['/jest.setup.js'], diff --git a/package-lock.json b/package-lock.json index c0da99f..b1e274d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,13 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", + "@types/fetch-mock": "^7.3.8", "@types/node": "^20.12.12", + "fetch-mock": "^9.11.0", "husky": "^9.0.11", "jest": "^29.7.0", "lint-staged": "^15.2.4", + "node-fetch": "^2.7.0", "prettier": "^3.2.5", "ts-jest": "^29.1.3", "ts-node": "^10.9.2", @@ -521,6 +524,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.6.tgz", + "integrity": "sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -1340,6 +1355,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/fetch-mock": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@types/fetch-mock/-/fetch-mock-7.3.8.tgz", + "integrity": "sha512-ztsIGiyUvD0GaqPc9/hb8k20gnr6lupqA6SFtqt+8v2mtHhNO/Ebb6/b7N6af/7x0A7s1C8nxrEGzajMBqz8qA==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2026,6 +2047,17 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", + "integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2316,6 +2348,39 @@ "bser": "2.1.1" } }, + "node_modules/fetch-mock": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", + "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.0.0", + "@babel/runtime": "^7.0.0", + "core-js": "^3.0.0", + "debug": "^4.1.1", + "glob-to-regexp": "^0.4.0", + "is-subset": "^0.1.1", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "querystring": "^0.2.0", + "whatwg-url": "^6.5.0" + }, + "engines": { + "node": ">=4.0.0" + }, + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2441,6 +2506,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -2632,6 +2703,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4244,12 +4321,24 @@ "node": ">=8" } }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/log-update": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.0.0.tgz", @@ -4419,6 +4508,48 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4593,6 +4724,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -4698,6 +4835,15 @@ "node": ">= 6" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -4714,12 +4860,28 @@ } ] }, + "node_modules/querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5110,6 +5272,15 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/ts-jest": { "version": "29.1.3", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.3.tgz", @@ -5346,6 +5517,23 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9806314..a7540d6 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "handlebars": "^4.7.8" }, "devDependencies": { + "@types/fetch-mock": "^7.3.8", + "fetch-mock": "^9.11.0", + "node-fetch": "^2.7.0", "@jest/globals": "^29.7.0", "@types/node": "^20.12.12", "husky": "^9.0.11", diff --git a/src/client.ts b/src/client.ts index 1cbf054..74bbc13 100644 --- a/src/client.ts +++ b/src/client.ts @@ -346,10 +346,6 @@ function buildQueryString( break case 'update': - if (!queryBuilder || !queryBuilder.whereClauses) { - break - } - const columnsToUpdate = Object.keys(queryBuilder.data || {}) const setClauses = queryType === QueryType.named @@ -361,8 +357,8 @@ function buildQueryString( .join(', ') query.query = `UPDATE ${queryBuilder.table || ''} SET ${setClauses}` - if (queryBuilder.whereClauses?.length > 0) { - query.query += ` WHERE ${queryBuilder.whereClauses.join(' AND ')}` + if (queryBuilder.whereClauses?.length ?? 0 > 0) { + query.query += ` WHERE ${queryBuilder.whereClauses?.join(' AND ')}` } if (queryType === QueryType.named) { @@ -387,8 +383,6 @@ function buildQueryString( query.query += ` WHERE ${queryBuilder.whereClauses.join(' AND ')}` } break - default: - throw new Error('Invalid action') } return query @@ -619,16 +613,18 @@ export function lessThanOrEqualNumber(a: any, b: any) { return `${a} <= ${b}` } -export function inValues(a: any, b: any[]) { - return `${a} IN ('${b.join("', '").replace(/'/g, "\\'")}')` +export function inValues(a: any, b: any[]): string { + const sanitizedValues = b.map((val) => val.replace(/'/g, "\\'")) + return `${a} IN ('${sanitizedValues.join("', '")}')` } export function inNumbers(a: any, b: any[]) { return `${a} IN (${b.join(', ')})` } -export function notInValues(a: any, b: any[]) { - return `${a} NOT IN ('${b.join("', '").replace(/'/g, "\\'")}')` +export function notInValues(a: any, b: any[]): string { + const sanitizedValues = b.map((val) => val.replace(/'/g, "\\'")) + return `${a} NOT IN ('${sanitizedValues.join("', '")}')` } export function notInNumbers(a: any, b: any[]) { @@ -644,7 +640,7 @@ export function isNumber(a: any, b: any) { return `${a} IS ${b}` } -export function isNot(this: any, a: any, b: null) { +export function isNot(this: any, a: any, b: string | null) { if (b === null) return `${this} IS NOT NULL` return `${a} IS NOT ${b}` } diff --git a/src/connections/cloudflare.ts b/src/connections/cloudflare.ts index 3c4580c..6d386c1 100644 --- a/src/connections/cloudflare.ts +++ b/src/connections/cloudflare.ts @@ -27,11 +27,8 @@ export class CloudflareD1Connection implements Connection { * Performs a connect action on the current Connection object. * In this particular use case Cloudflare is a REST API and * requires an API key for authentication. - * - * @param details - Unused in the Cloudflare scenario. - * @returns Promise */ - async connect(details: Record): Promise { + async connect(): Promise { return Promise.resolve() } @@ -68,7 +65,6 @@ export class CloudflareD1Connection implements Connection { if (!this.accountId) throw new Error('Cloudflare account ID is not set') if (!this.databaseId) throw new Error('Cloudflare database ID is not set') - if (!query) throw new Error('A SQL query was not provided') const response = await fetch( `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/d1/database/${this.databaseId}/query`, @@ -87,8 +83,8 @@ export class CloudflareD1Connection implements Connection { let json = await response.json() let error = null - const resultArray = (await json?.result) ?? [] - const items = (await resultArray[0]?.results) ?? [] + const resultArray = json?.result ?? [] + const items = resultArray[0]?.results ?? [] const rawSQL = constructRawQuery(query) return { diff --git a/src/connections/outerbase.ts b/src/connections/outerbase.ts index 22d6fb0..9c49f75 100644 --- a/src/connections/outerbase.ts +++ b/src/connections/outerbase.ts @@ -61,7 +61,6 @@ export class OuterbaseConnection implements Connection { query: Query ): Promise<{ data: any; error: Error | null; query: string }> { if (!this.api_key) throw new Error('Outerbase API key is not set') - if (!query) throw new Error('Query was not provided') const response = await fetch(`${API_URL}/api/v1/ezql/raw`, { method: 'POST', @@ -104,7 +103,6 @@ export class OuterbaseConnection implements Connection { queryId: string ): Promise<{ data: any; error: Error | null }> { if (!this.api_key) throw new Error('Outerbase API key is not set') - if (!queryId) throw new Error('Query ID is not set') const response = await fetch( `${API_URL}/api/v1/ezql/query/${queryId}`, diff --git a/src/models/decorators.ts b/src/models/decorators.ts index c8a2fe1..6c3a8dc 100644 --- a/src/models/decorators.ts +++ b/src/models/decorators.ts @@ -1,34 +1,37 @@ /** * A registry of metadata for classes decorated with the @Entity decorator. - * The metadata is stored as a Map where the key is the class constructor and + * The metadata is stored as a Map where the key is the class constructor and * the value is an object with the following properties: * - columns: an object where the keys are property keys and the values are objects with column options * - primaryKey: the property key of the primary key column * @type {Map} */ -export const metadataRegistry = new Map(); +export const metadataRegistry = new Map() -export function Column(options?: { - unique?: boolean, - primary?: boolean, - nullable?: boolean, - name?: string, +export function Column(options?: { + unique?: boolean + primary?: boolean + nullable?: boolean + name?: string relation?: any }): PropertyDecorator { - return function(target: any, propertyKey: string | symbol): void { - const constructor = target.constructor; + return function (target: any, propertyKey: string | symbol): void { + const constructor = target.constructor if (!metadataRegistry.has(constructor)) { - metadataRegistry.set(constructor, { columns: {}, primaryKey: undefined }); + metadataRegistry.set(constructor, { + columns: {}, + primaryKey: undefined, + }) } - const classMetadata = metadataRegistry.get(constructor); + const classMetadata = metadataRegistry.get(constructor) - const columnName = options?.name || propertyKey.toString(); - const relationName = options?.relation || propertyKey.toString(); + const columnName = options?.name || propertyKey.toString() + const relationName = options?.relation || propertyKey.toString() // Initialize the column metadata if it doesn't exist if (!classMetadata.columns[propertyKey]) { - classMetadata.columns[propertyKey] = {}; + classMetadata.columns[propertyKey] = {} } // Update the column metadata with new options @@ -36,40 +39,54 @@ export function Column(options?: { ...classMetadata.columns[propertyKey], ...options, name: columnName, - relation: relationName - }; + relation: relationName, + } if (options?.primary) { if (classMetadata.primaryKey) { - throw new Error(`Multiple primary keys are not allowed: ${constructor.name} already has a primary key on property '${String(classMetadata.primaryKey)}'.`); + throw new Error( + `Multiple primary keys are not allowed: ${constructor.name} already has a primary key on property '${String(classMetadata.primaryKey)}'.` + ) } - classMetadata.primaryKey = propertyKey; + classMetadata.primaryKey = propertyKey } - }; + } } export function isColumn(targetClass: Function, propertyName: string): boolean { - const metadata = metadataRegistry.get(targetClass); - return metadata && metadata.columns[propertyName]; + const metadata = metadataRegistry.get(targetClass) + return metadata && metadata.columns[propertyName] } -export function isPropertyUnique(targetClass: Function, propertyName: string): boolean { - const metadata = metadataRegistry.get(targetClass); +export function isPropertyUnique( + targetClass: Function, + propertyName: string +): boolean { + const metadata = metadataRegistry.get(targetClass) return metadata && metadata[propertyName] && metadata[propertyName].unique } -export function isColumnNullable(targetClass: Function, propertyName: string): boolean { - const metadata = metadataRegistry.get(targetClass); - if (metadata && metadata.columns[propertyName] && metadata.columns[propertyName].hasOwnProperty('nullable')) { - return metadata.columns[propertyName].nullable; +export function isColumnNullable( + targetClass: Function, + propertyName: string +): boolean { + const metadata = metadataRegistry.get(targetClass) + if ( + metadata && + metadata.columns[propertyName] && + metadata.columns[propertyName].hasOwnProperty('nullable') + ) { + return metadata.columns[propertyName].nullable } - return false; + return false } -export function getPrimaryKey(targetClass: Function): string | symbol | undefined { - const metadata = metadataRegistry.get(targetClass); +export function getPrimaryKey( + targetClass: Function +): string | symbol | undefined { + const metadata = metadataRegistry.get(targetClass) if (metadata) { - return metadata.primaryKey; + return metadata.primaryKey } - return undefined; -} \ No newline at end of file + return undefined +} diff --git a/src/query.ts b/src/query.ts index ce05004..dd39efd 100644 --- a/src/query.ts +++ b/src/query.ts @@ -15,10 +15,7 @@ function rawQueryFromNamedParams(query: Query): string { for (const [key, value] of Object.entries(query.parameters ?? {})) { if (typeof value === 'string') { - queryWithParams = queryWithParams.replace( - `:${key}`, - `'${value}'` - ) + queryWithParams = queryWithParams.replace(`:${key}`, `'${value}'`) } else { queryWithParams = queryWithParams.replace(`:${key}`, value) } @@ -47,9 +44,9 @@ function rawQueryFromPositionalParams(query: Query): string { export function constructRawQuery(query: Query) { if (isQueryParamsNamed(query.parameters) || !query.parameters) { return rawQueryFromNamedParams(query) - } else if (isQueryParamsPositional(query.parameters)) { - return rawQueryFromPositionalParams(query) } - return '' + // This isnt needed. Keeping it for posterity in case something changes + // if (isQueryParamsPositional(query.parameters)) { + return rawQueryFromPositionalParams(query) } diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..5c44cc3 --- /dev/null +++ b/tests/client.test.ts @@ -0,0 +1,794 @@ +import { beforeEach, describe, expect, jest, test } from '@jest/globals' +import { CloudflareD1Connection } from '../src/connections/cloudflare' +import { OuterbaseConnection } from '../src/connections/outerbase' +import { + greaterThanNumber, + lessThan, + lessThanNumber, + greaterThanOrEqual, + greaterThanOrEqualNumber, + lessThanOrEqual, + lessThanOrEqualNumber, + inValues, + inNumbers, + notInValues, + notInNumbers, + is, + isNumber, + isNot, + isNotNumber, + like, + notLike, + ilike, + notILike, + isNull, + isNotNull, + between, + betweenNumbers, + notBetween, + notBetweenNumbers, + ascending, + descending, + Outerbase, + equals, + equalsNumber, + equalsColumn, + notEquals, + notEqualsNumber, + notEqualsColumn, + greaterThan, + OuterbaseType, +} from '../src/client' +import { Connection } from '../src/connections' +import fetchMock from 'fetch-mock' + +describe('Query Builder', () => { + let outerbaseD1: OuterbaseType + let outerbase: OuterbaseType + let mockCloudflareConnection: Connection + let mockOuterbaseConnection: Connection + + beforeEach(() => { + mockOuterbaseConnection = new OuterbaseConnection('API_KEY') + mockCloudflareConnection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + outerbaseD1 = Outerbase(mockCloudflareConnection) + outerbase = Outerbase(mockOuterbaseConnection) + fetchMock.reset() + }) + test('Update a table', () => { + const query = outerbaseD1 + .update({ crab: 'cake' }) + .where('cheese = thing') + .into('testTable') + .toString() + expect(query).toBe( + "UPDATE testTable SET crab = 'cake' WHERE cheese = thing" + ) + }) + test('Update a table with named', () => { + const query = outerbase + .insert({ garbage: 'andy' }) + .update({ crab: 'cake', cake: 'crab' }) + .into('testTable') + .toString() + expect(query).toBe("UPDATE testTable SET crab = 'cake', cake = 'crab'") + }) + + test('Update a table without where', () => { + const query = outerbaseD1 + .update({ test: 'insert' }) + .where('egg = nog') + .into('testTable') + .toString() + expect(query).toBe( + "UPDATE testTable SET test = 'insert' WHERE egg = nog" + ) + }) + test('Update a table without where', () => { + const query = outerbaseD1 + .update({ test: 'insert' }) + .where([]) + .into('testTable') + .toString() + expect(query).toBe("UPDATE testTable SET test = 'insert' WHERE ") + }) + test('Select from a table', () => { + const query = outerbaseD1 + .where('testWhere') + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + { + table: 'testTable2', + columns: ['testColumn2'], + }, + ]) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn, testTable2.testColumn2 FROM testTable' + ) + }) + test('Select from a table with reserved word', () => { + const query = outerbaseD1 + .where('testWhere') + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + { + table: 'testTable2', + columns: ['ABORT'], + }, + ]) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn, testTable2."ABORT" FROM testTable' + ) + }) + test('Limit a select', () => { + const query = outerbaseD1 + .where('testWhere') + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .limit(50) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable LIMIT 50' + ) + }) + test('Limit a select with offset', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .limit(50) + .offset(5) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable LIMIT 50 OFFSET 5' + ) + }) + test('Order By Ascending', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .orderBy('testColumn', 'ASC') + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable ORDER BY testColumn' + ) + }) + test('Inner join', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .innerJoin('testJoinTable', 'condition') + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable INNER JOIN testJoinTable ON condition' + ) + }) + test('Inner join with escape option true', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .innerJoin('testJoinTable', "'condition'", { + escape_single_quotes: true, + }) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable INNER JOIN testJoinTable ON condition' + ) + }) + test('Inner join with escape option false', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .innerJoin('testJoinTable', "'condition'", { + escape_single_quotes: false, + }) + .toString() + + expect(query).toBe( + "SELECT testTable.testColumn FROM testTable INNER JOIN testJoinTable ON 'condition'" + ) + }) + test('Left join with escape option false', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .leftJoin('testJoinTable', "'condition'", { + escape_single_quotes: false, + }) + .toString() + + expect(query).toBe( + "SELECT testTable.testColumn FROM testTable LEFT JOIN testJoinTable ON 'condition'" + ) + }) + test('Left join with escape option true', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .leftJoin('testJoinTable', "'condition'", { + escape_single_quotes: true, + }) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable LEFT JOIN testJoinTable ON condition' + ) + }) + test('Right join with escape option true', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .rightJoin('testJoinTable', "'condition'", { + escape_single_quotes: true, + }) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable RIGHT JOIN testJoinTable ON condition' + ) + }) + test('Right join with escape option false', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .rightJoin('testJoinTable', "'condition'", { + escape_single_quotes: false, + }) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable RIGHT JOIN testJoinTable ON condition' + ) + }) + test('Outer join with escape option false', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .outerJoin('testJoinTable', "'condition'", { + escape_single_quotes: false, + }) + .toString() + + expect(query).toBe( + "SELECT testTable.testColumn FROM testTable OUTER JOIN testJoinTable ON 'condition'" + ) + }) + test('Outer join with escape option true', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .outerJoin('testJoinTable', "'condition'", { + escape_single_quotes: true, + }) + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable OUTER JOIN testJoinTable ON condition' + ) + }) + test('Insert', () => { + const query = outerbaseD1 + .insert({ test: 'column' }) + .into('testTable') + .toString() + + expect(query).toBe("INSERT INTO testTable (test) VALUES ('column')") + }) + test('Insert with named', () => { + const query = outerbase + .insert({ test: 'column' }) + .into('testTable') + .returning(['test', 'test1']) + .toString() + + expect(query).toBe( + "INSERT INTO testTable (test) VALUES ('column') RETURNING test, test1" + ) + }) + + test('Delete From', () => { + const query = outerbaseD1 + .deleteFrom('testTable') + .where('egg') + .toString() + + expect(query).toBe('DELETE FROM testTable WHERE egg') + }) + test('Where before deletefrom', () => { + const query = outerbaseD1 + .where('egg') + .deleteFrom('testTable') + .toString() + + expect(query).toBe('') + }) + test('Returning', () => { + const query = outerbaseD1 + .deleteFrom('testTable') + .returning(['column1', 'column2']) + + expect(query).toBe(query) + }) + test('GroupBy', () => { + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .groupBy('testColumn') + .toString() + + expect(query).toBe( + 'SELECT testTable.testColumn FROM testTable GROUP BY testColumn' + ) + }) + test('AsClass', () => { + class ExampleClass { + value: any + constructor(data: any) { + this.value = data + } + } + + const query = outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .asClass(new ExampleClass('cheeses')) + .toString() + + expect(query).toBe('SELECT testTable.testColumn FROM testTable') + }) + test('query', async () => { + class ExampleClass { + value: any + constructor(data: any) { + this.value = data + } + } + fetchMock.postOnce(`*`, { + body: { + result: [ + { + results: 'hello world', + }, + ], + }, + }) + + const query = await outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .asClass(ExampleClass) + .query() + + expect(query.data).toEqual({ value: 'hello world' }) + expect(query.error).toBeNull() + expect(query.query).toMatchObject({ + parameters: [], + query: 'SELECT testTable.testColumn FROM testTable', + }) + }) + test('query', async () => { + class ExampleClass { + value: any + constructor(data: any) { + this.value = data + } + } + fetchMock.postOnce(`*`, { + body: { + result: [ + { + results: ['hello world', 'hello brayden'], + }, + ], + }, + }) + + const query = await outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .asClass(ExampleClass) + .query() + + expect(query.data).toEqual([ + { value: 'hello world' }, + { value: 'hello brayden' }, + ]) + expect(query.error).toBeNull() + expect(query.query).toMatchObject({ + parameters: [], + query: 'SELECT testTable.testColumn FROM testTable', + }) + }) + test('query', async () => { + const query = await outerbaseD1 + .selectFrom([ + { + table: 'testTable', + columns: ['testColumn'], + }, + ]) + .query() + expect(query.data).toEqual([]) + expect(query.error).toBeNull() + expect(query.query).toMatchObject({ + parameters: [], + query: 'SELECT testTable.testColumn FROM testTable', + }) + }) + test('queryRaw', async () => { + const thing = new (class cheese {})() + + const query = await outerbaseD1 + .asClass(thing) + .queryRaw('select * from testTable', { + test: 'params', + }) + expect(query.data).toEqual([]) + expect(query.error).toBeNull() + expect(query.query).toBe('select * from testTable') + }) + test('queryRaw with parameters', async () => { + const query = await outerbaseD1.queryRaw('select * from testTable') + expect(query.data).toEqual([]) + expect(query.error).toBeNull() + expect(query.query).toBe('select * from testTable') + }) +}) +describe('Helper Functions', () => { + describe('equals', () => { + test('Adds a equal sign between the two values', () => { + const actual = equals('test', 'data') + const expected = "test = 'data'" + expect(actual).toBe(expected) + }) + }) + describe('equalsNumber', () => { + test('Adds a equal sign between the two values', () => { + const actual = equalsNumber('test', 'data') + const expected = 'test = data' + expect(actual).toBe(expected) + }) + }) + describe('notEquals', () => { + test('Adds a equal sign between the two values', () => { + const actual = notEquals('test', 'data') + const expected = "test != 'data'" + expect(actual).toBe(expected) + }) + }) + describe('notEqualsNumber', () => { + test('Adds a equal sign between the two values', () => { + const actual = notEqualsNumber('test', 'data') + const expected = 'test != data' + expect(actual).toBe(expected) + }) + }) + describe('greaterThan', () => { + test('Adds a greater than sign between the two values', () => { + const actual = greaterThan('test', 'data') + const expected = "test > 'data'" + expect(actual).toBe(expected) + }) + }) + describe('notEqualsColumn', () => { + test('Adds a equal sign between the two values', () => { + const actual = notEqualsColumn('test', 'data') + const expected = 'test != data' + expect(actual).toBe(expected) + }) + }) + describe('equalsColumn', () => { + test('Adds a equal sign between the two values', () => { + const actual = equalsColumn('test', 'data') + const expected = 'test = data' + expect(actual).toBe(expected) + }) + }) + describe('greaterThanNumber', () => { + test('Adds a greater than sign between the two values', () => { + const actual = greaterThanNumber('test', 'data') + const expected = 'test > data' + expect(actual).toBe(expected) + }) + }) + + describe('lessThan', () => { + test('Adds a less than sign between the two values and single quotes around second parameter', () => { + const actual = lessThan('test', 'data') + const expected = "test < 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('lessThanNumber', () => { + test('Adds a less than sign between the two values', () => { + const actual = lessThanNumber('test', 'data') + const expected = 'test < data' + expect(actual).toBe(expected) + }) + }) + + describe('greaterThanOrEqual', () => { + test('Adds a greater than or equal sign between the two values and single quotes around second parameter', () => { + const actual = greaterThanOrEqual('test', 'data') + const expected = "test >= 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('greaterThanOrEqualNumber', () => { + test('Adds a greater than or equal sign between the two values', () => { + const actual = greaterThanOrEqualNumber('test', 'data') + const expected = 'test >= data' + expect(actual).toBe(expected) + }) + }) + + describe('lessThanOrEqual', () => { + test('Adds a less than or equal sign between the two values and single quotes around second parameter', () => { + const actual = lessThanOrEqual('test', 'data') + const expected = "test <= 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('lessThanOrEqualNumber', () => { + test('Adds a less than or equal sign between the two values', () => { + const actual = lessThanOrEqualNumber('test', 'data') + const expected = 'test <= data' + expect(actual).toBe(expected) + }) + }) + + describe('inValues', () => { + test('Formats an IN clause with single quotes around each value', () => { + const actual = inValues('test', ['data1', 'data2']) + const expected = "test IN ('data1', 'data2')" + expect(actual).toBe(expected) + }) + }) + + describe('inNumbers', () => { + test('Formats an IN clause without single quotes around each value', () => { + const actual = inNumbers('test', [1, 2]) + const expected = 'test IN (1, 2)' + expect(actual).toBe(expected) + }) + }) + + describe('notInValues', () => { + test('Formats a NOT IN clause with single quotes around each value', () => { + const actual = notInValues('test', ['data1', 'data2']) + const expected = "test NOT IN ('data1', 'data2')" + expect(actual).toBe(expected) + }) + }) + + describe('notInNumbers', () => { + test('Formats a NOT IN clause without single quotes around each value', () => { + const actual = notInNumbers('test', [1, 2]) + const expected = 'test NOT IN (1, 2)' + expect(actual).toBe(expected) + }) + }) + + describe('is', () => { + test('Formats an IS clause with single quotes around second parameter', () => { + const actual = is('test', 'data') + const expected = "test IS 'data'" + expect(actual).toBe(expected) + }) + test('Formats an IS clause for NULL values', () => { + const actual = is.call('test', 'column', null) + const expected = 'test IS NULL' + expect(actual).toBe(expected) + }) + }) + + describe('isNumber', () => { + test('Formats an IS clause without single quotes around second parameter', () => { + const actual = isNumber('test', 123) + const expected = 'test IS 123' + expect(actual).toBe(expected) + }) + }) + + describe('isNot', () => { + test('Formats an IS NOT clause with single quotes around second parameter', () => { + const actual = isNot.call('banana', 'column', null) + const expected = 'banana IS NOT NULL' + expect(actual).toBe(expected) + }) + test('Formats an IS NOT clause for NULL values', () => { + const actual = isNot.call('thing', 'column', 'banana') + const expected = 'column IS NOT banana' + expect(actual).toBe(expected) + }) + }) + + describe('isNotNumber', () => { + test('Formats an IS NOT clause without single quotes around second parameter', () => { + const actual = isNotNumber('test', 123) + const expected = 'test IS NOT 123' + expect(actual).toBe(expected) + }) + }) + + describe('like', () => { + test('Formats a LIKE clause with single quotes around second parameter', () => { + const actual = like('test', 'data') + const expected = "test LIKE 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('notLike', () => { + test('Formats a NOT LIKE clause with single quotes around second parameter', () => { + const actual = notLike('test', 'data') + const expected = "test NOT LIKE 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('ilike', () => { + test('Formats an ILIKE clause with single quotes around second parameter', () => { + const actual = ilike('test', 'data') + const expected = "test ILIKE 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('notILike', () => { + test('Formats a NOT ILIKE clause with single quotes around second parameter', () => { + const actual = notILike('test', 'data') + const expected = "test NOT ILIKE 'data'" + expect(actual).toBe(expected) + }) + }) + + describe('isNull', () => { + test('Formats an IS NULL clause', () => { + const actual = isNull('test') + const expected = 'test IS NULL' + expect(actual).toBe(expected) + }) + }) + + describe('isNotNull', () => { + test('Formats an IS NOT NULL clause', () => { + const actual = isNotNull('test') + const expected = 'test IS NOT NULL' + expect(actual).toBe(expected) + }) + }) + + describe('between', () => { + test('Formats a BETWEEN clause with single quotes around both bounds', () => { + const actual = between('test', 'data1', 'data2') + const expected = "test BETWEEN 'data1' AND 'data2'" + expect(actual).toBe(expected) + }) + }) + + describe('betweenNumbers', () => { + test('Formats a BETWEEN clause without single quotes around both bounds', () => { + const actual = betweenNumbers('test', 1, 2) + const expected = 'test BETWEEN 1 AND 2' + expect(actual).toBe(expected) + }) + }) + + describe('notBetween', () => { + test('Formats a NOT BETWEEN clause with single quotes around both bounds', () => { + const actual = notBetween('test', 'data1', 'data2') + const expected = "test NOT BETWEEN 'data1' AND 'data2'" + expect(actual).toBe(expected) + }) + }) + + describe('notBetweenNumbers', () => { + test('Formats a NOT BETWEEN clause without single quotes around both bounds', () => { + const actual = notBetweenNumbers('test', 1, 2) + const expected = 'test NOT BETWEEN 1 AND 2' + expect(actual).toBe(expected) + }) + }) + + describe('ascending', () => { + test('Formats an ASC clause', () => { + const actual = ascending('test') + const expected = 'test ASC' + expect(actual).toBe(expected) + }) + }) + + describe('descending', () => { + test('Formats a DESC clause', () => { + const actual = descending('test') + const expected = 'test DESC' + expect(actual).toBe(expected) + }) + }) +}) diff --git a/tests/connections/cloudflare.test.ts b/tests/connections/cloudflare.test.ts index 74d9249..44c8df1 100644 --- a/tests/connections/cloudflare.test.ts +++ b/tests/connections/cloudflare.test.ts @@ -1,9 +1,156 @@ -import { describe, expect, test } from '@jest/globals' +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from '@jest/globals' -import { CloudflareD1Connection } from 'src/connections/cloudflare' -import { QueryType } from 'src/query-params' +import { CloudflareD1Connection } from '../../src/connections/cloudflare' +import { QueryType } from '../../src/query-params' +import fetchMock from 'fetch-mock' describe('CloudflareD1Connection', () => { + test('Can connect', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + const actual = await connection.connect() + expect(actual).toBe(undefined) + }) + test('Can disconnect', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + const actual = await connection.disconnect() + expect(actual).toBe(undefined) + }) + describe('Query', () => { + const mockApiKey = 'API_KEY' + const mockAccountId = 'ACCOUNT_ID' + const mockDatabaseId = 'DATABASE_ID' + let connection: CloudflareD1Connection + + beforeEach(() => { + connection = new CloudflareD1Connection( + mockApiKey, + mockAccountId, + mockDatabaseId + ) + fetchMock.reset() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + const TEST_QUERY = { + query: 'SELECT * FROM FAKE_TABLE;', + } + test('Should throw error when missing apiKey', async () => { + connection.apiKey = undefined + await expect(connection.query(TEST_QUERY)).rejects.toThrow( + 'Cloudflare API key is not set' + ) + }) + test('Should throw error when missing accountId', async () => { + connection.accountId = undefined + await expect(connection.query(TEST_QUERY)).rejects.toThrow( + 'Cloudflare account ID is not set' + ) + }) + test('Should throw error when missing databaseId', async () => { + connection.databaseId = undefined + await expect(connection.query(TEST_QUERY)).rejects.toThrow( + 'Cloudflare database ID is not set' + ) + }) + test('Should return on successful response', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + fetchMock.postOnce(`*`, { + body: { + result: [ + { + results: 'hello world', + }, + ], + }, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: 'hello world', + error: null, + query: TEST_QUERY.query, + } + // Need to type the actual so I can pass it around + expect(expected).toMatchObject(actual as any) + }) + test('Should return on return empty array on no results', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + fetchMock.postOnce(`*`, { + body: {}, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: [], + error: null, + query: TEST_QUERY.query, + } + + expect(actual.data).toEqual([]) + expect(actual.error).toBeNull() + expect(actual.query).toEqual(TEST_QUERY.query) + }) + test('Should return on return empty array on no results', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + fetchMock.postOnce(`*`, { + body: null, + }) + const actual = await connection.query(TEST_QUERY) + + expect(actual.data).toEqual([]) + expect(actual.error).toBeNull() + expect(actual.query).toEqual(TEST_QUERY.query) + }) + test('Should return on successful response hey', async () => { + const connection = new CloudflareD1Connection( + 'API_KEY', + 'ACCOUNT_ID', + 'DATABASE_ID' + ) + fetchMock.postOnce(`*`, { + body: { + result: [], + }, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: [], + error: null, + query: TEST_QUERY.query, + } + // Need to type the actual so I can pass it around + expect(expected).toMatchObject(actual as any) + }) + }) + describe('Query Type', () => { const connection = new CloudflareD1Connection( 'API_KEY', diff --git a/tests/connections/outerbase.test.ts b/tests/connections/outerbase.test.ts index 83205e0..e6e991c 100644 --- a/tests/connections/outerbase.test.ts +++ b/tests/connections/outerbase.test.ts @@ -1,18 +1,154 @@ -import { describe, expect, test } from '@jest/globals' +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from '@jest/globals' -import { OuterbaseConnection } from 'src/connections/outerbase' -import { QueryType } from 'src/query-params' +import { OuterbaseConnection } from '../../src/connections/outerbase' +import { QueryType } from '../../src/query-params' +import fetchMock from 'fetch-mock' describe('OuterbaseConnection', () => { + test('Can connect', async () => { + const connection = new OuterbaseConnection('API_KEY') + const actual = await connection.connect({ more: 'details' }) + expect(actual).toBe(undefined) + }) + test('Can disconnect', async () => { + const connection = new OuterbaseConnection('API_KEY') + const actual = await connection.disconnect() + expect(actual).toBe(undefined) + }) + describe('Query', () => { + const mockApiKey = 'API_KEY' + let connection: OuterbaseConnection + + beforeEach(() => { + connection = new OuterbaseConnection(mockApiKey) + fetchMock.reset() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + const TEST_QUERY = { + query: 'SELECT * FROM FAKE_TABLE;', + } + test('Should throw error when missing apiKey', async () => { + connection.api_key = undefined + await expect(connection.query(TEST_QUERY)).rejects.toThrow( + 'Outerbase API key is not set' + ) + }) + test('Should return on successful response', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: { + result: [ + { + results: 'hello world', + }, + ], + }, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: [], + error: null, + query: TEST_QUERY.query, + } + // Need to type the actual so I can pass it around + expect(expected).toMatchObject(actual as any) + }) + test('Should return on return empty array on no results', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: {}, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: [], + error: null, + query: TEST_QUERY.query, + } + + expect(actual.data).toEqual([]) + expect(actual.error).toBeNull() + expect(actual.query).toEqual(TEST_QUERY.query) + }) + test('Should return on return empty array on no results', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: null, + }) + const actual = await connection.query(TEST_QUERY) + + expect(actual.data).toEqual([]) + expect(actual.error).toBeNull() + expect(actual.query).toEqual(TEST_QUERY.query) + }) + test('Should return on successful response hey', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: { + result: [], + }, + }) + const actual = await connection.query(TEST_QUERY) + const expected = { + data: [], + error: null, + query: TEST_QUERY.query, + } + // Need to type the actual so I can pass it around + expect(expected).toMatchObject(actual as any) + }) + }) + describe('Run Saved Query', () => { + beforeEach(() => { + fetchMock.reset() + }) + const TEST_QUERY = 'SELECT * FROM FAKE_TABLE;' + + test('Should fail if the key isnt set', async () => { + const connection = new OuterbaseConnection('API_KEY') + connection.api_key = undefined + await expect(connection.runSavedQuery(TEST_QUERY)).rejects.toThrow( + 'Outerbase API key is not set' + ) + }) + test('Should run a saved query', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: { + response: { + results: { + items: ['hello world'], + }, + }, + }, + }) + const response = await connection.runSavedQuery('1234') + expect(response.data).toEqual(['hello world']) + }) + test('Should run a saved query and return no data if nothing responded', async () => { + const connection = new OuterbaseConnection('API_KEY') + fetchMock.postOnce(`*`, { + body: undefined, + }) + const response = await connection.runSavedQuery('1234') + expect(response.data).toEqual([]) + }) + }) describe('Query Type', () => { - const connection = new OuterbaseConnection('FAKE_API_KEY') + const connection = new OuterbaseConnection('API_KEY') test('Query type is set to named', () => { expect(connection.queryType).toBe(QueryType.named) }) - - test('Query type is set not positional', () => { - expect(connection.queryType).not.toBe(QueryType.positional) - }) }) }) diff --git a/tests/models/decorators.test.ts b/tests/models/decorators.test.ts new file mode 100644 index 0000000..8bfa37b --- /dev/null +++ b/tests/models/decorators.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from '@jest/globals' +import { + metadataRegistry, + Column, + isColumn, + isPropertyUnique, + isColumnNullable, + getPrimaryKey, +} from '../../src/models/decorators' // Adjust the path as necessary + +class TestEntity { + @Column({ primary: true }) + id: number + + @Column({ unique: true, nullable: false }) + uniqueColumn: string + + @Column({ nullable: true }) + nullableColumn: string + + @Column() + regularColumn: string +} + +describe('metadataRegistry', () => { + it('should register primary key column', () => { + expect(getPrimaryKey(TestEntity)).toBe('id') + }) + + it('should register columns with correct metadata', () => { + const metadata = metadataRegistry.get(TestEntity) + expect(metadata).toBeDefined() + expect(metadata.columns).toHaveProperty('id') + expect(metadata.columns.id).toHaveProperty('primary', true) + expect(metadata.columns.id).toHaveProperty('name', 'id') + expect(metadata.columns).toHaveProperty('uniqueColumn') + expect(metadata.columns.uniqueColumn).toHaveProperty('unique', true) + expect(metadata.columns.uniqueColumn).toHaveProperty('nullable', false) + expect(metadata.columns).toHaveProperty('nullableColumn') + expect(metadata.columns.nullableColumn).toHaveProperty('nullable', true) + expect(metadata.columns).toHaveProperty('regularColumn') + }) + + it('should detect if a property is a column', () => { + expect(isColumn(TestEntity, 'id')).toBe(true) + expect(isColumn(TestEntity, 'uniqueColumn')).toBe(true) + expect(isColumn(TestEntity, 'nullableColumn')).toBe(true) + expect(isColumn(TestEntity, 'regularColumn')).toBe(true) + expect(isColumn(TestEntity, 'nonExistentColumn')).toBe(false) + }) + + it('should detect if a column is unique', () => { + expect(isPropertyUnique(TestEntity, 'uniqueColumn')).toBe(true) + expect(isPropertyUnique(TestEntity, 'nullableColumn')).toBe(false) + expect(isPropertyUnique(TestEntity, 'regularColumn')).toBe(false) + }) + + it('should detect if a column is nullable', () => { + expect(isColumnNullable(TestEntity, 'nullableColumn')).toBe(true) + expect(isColumnNullable(TestEntity, 'uniqueColumn')).toBe(false) + expect(isColumnNullable(TestEntity, 'regularColumn')).toBe(false) + }) +}) diff --git a/tests/models/index.test.ts b/tests/models/index.test.ts new file mode 100644 index 0000000..2836625 --- /dev/null +++ b/tests/models/index.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from '@jest/globals' +import { BaseTable } from '../../src/models/index' // Adjust the path as necessary + +describe('BaseTable class', () => { + it('should create an instance with a name and schema', () => { + const name = 'testTable' + const schema = 'testSchema' + const baseTable = new BaseTable({ _name: name, _schema: schema }) + + expect(baseTable).toBeInstanceOf(BaseTable) + expect(baseTable._name).toBe(name) + expect(baseTable._schema).toBe(schema) + }) + + it('should create an instance with only a name', () => { + const name = 'testTable' + const baseTable = new BaseTable({ _name: name }) + + expect(baseTable).toBeInstanceOf(BaseTable) + expect(baseTable._name).toBe(name) + expect(baseTable._schema).toBeUndefined() + }) + + it('should handle missing schema parameter', () => { + const name = 'testTable' + const baseTable = new BaseTable({ _name: name }) + + expect(baseTable._name).toBe(name) + expect(baseTable._schema).toBeUndefined() + }) +}) diff --git a/tests/query.test.ts b/tests/query.test.ts index 8cac931..73708e7 100644 --- a/tests/query.test.ts +++ b/tests/query.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from '@jest/globals' -import { Query, constructRawQuery } from 'src/query' +import { Query, constructRawQuery } from '../src/query' describe('Query', () => { describe('INSERT INTO - Named Parameters', () => { @@ -61,6 +61,15 @@ describe('Query', () => { }) describe('INSERT INTO - Positional Parameters', () => { + test('No positional parameter', () => { + const query: Query = { + query: 'INSERT INTO person (name) VALUES (?)', + } + + expect(constructRawQuery(query)).toBe( + 'INSERT INTO person (name) VALUES (?)' + ) + }) test('One positional parameter', () => { const query: Query = { query: 'INSERT INTO person (name) VALUES (?)', diff --git a/tsconfig.json b/tsconfig.json index 889e1b5..012225e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2017", + "target": "ES2020", "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, @@ -11,6 +11,7 @@ "outDir": "./dist", "experimentalDecorators": true, "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, "strict": true, "baseUrl": "./", "paths": {