├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── errors.ts ├── index.ts ├── query │ ├── buildNarrowedQuery.ts │ ├── buildQuery.ts │ ├── buildSelection.ts │ ├── buildSql.ts │ ├── columns.ts │ ├── delete.ts │ ├── expressions.ts │ ├── index.ts │ ├── insert.ts │ ├── insertStatement.ts │ ├── query.ts │ ├── queryItem.ts │ ├── sql.ts │ ├── table.ts │ └── update.ts ├── types │ ├── expression │ │ ├── andOr.ts │ │ ├── caseWhen.ts │ │ ├── coalesce.ts │ │ ├── eq.ts │ │ ├── exists.ts │ │ ├── expression.ts │ │ ├── expressionFactory.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── isNull.ts │ │ ├── literal.ts │ │ ├── not.ts │ │ ├── param.ts │ │ ├── subquery.ts │ │ └── subqueryExpression.ts │ ├── helpers.ts │ ├── index.ts │ ├── query │ │ ├── databaseClient.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ ├── insert.ts │ │ ├── insertStatement.ts │ │ ├── joins.ts │ │ ├── queryBottom.ts │ │ ├── queryRoot.ts │ │ └── update.ts │ └── table │ │ ├── column.ts │ │ ├── index.ts │ │ └── table.ts └── utils.ts ├── test-d ├── delete.test-d.ts ├── expressions │ ├── andOr.test-d.ts │ ├── caseWhen.test-d.ts │ ├── coalesce.test-d.ts │ ├── eq.test-d.ts │ ├── exists.test-d.ts │ ├── helpers.ts │ ├── isIn.test-d.ts │ ├── isNull.test-d.ts │ ├── literal.test-d.ts │ └── not.test-d.ts ├── helpers │ ├── classicGames.ts │ ├── index.ts │ └── pcComponents.ts ├── insert.test-d.ts ├── join.test-d.ts ├── limit.test-d.ts ├── lock.test-d.ts ├── narrow.test-d.ts ├── orderBy.test-d.ts ├── select-json.test-d.ts ├── select-nested-json.test-d.ts ├── select-subselect.test-d.ts ├── select.test-d.ts ├── table.test-d.ts ├── tsconfig.json ├── union.test-d.ts ├── update.test-d.ts ├── where.test-d.ts └── withRecursive.test-d.ts ├── test ├── delete.test.ts ├── discriminatedUnion │ ├── narrow.test.ts │ ├── non-narrowed.json.test.ts │ ├── non-narrowed.test.ts │ └── table.test.ts ├── helpers │ ├── classicGames.ts │ ├── index.ts │ ├── pcComponents.ts │ └── testSchema.sql ├── insert.test.ts ├── insertStatement.test.ts ├── join.test.ts ├── query │ ├── fetch.test.ts │ ├── limitOffset.test.ts │ ├── lock.test.ts │ └── orderBy.test.ts ├── select │ ├── select-checks.test.ts │ ├── select-subquery.test.ts │ ├── select.all.test.ts │ ├── select.exclude.test.ts │ ├── select.include.test.ts │ ├── select.rename.test.ts │ ├── selectJsonArray.test.ts │ ├── selectJsonObject.test.ts │ └── selectJsonObjectArray.test.ts ├── table.test.ts ├── update.test.ts ├── utils.test.ts └── where │ ├── where.eq.test.ts │ ├── where.exists.test.ts │ ├── where.isIn.test.ts │ └── where.isNull.test.ts ├── tsconfig.build.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | .rts2_cache_system 8 | dist 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 4.0.0 2 | 3 | - `Query.select` and new methods on `Table`: `include`, `exclude`, `all`, `json` 4 | - `Query.where` to create complex where conditions (replaces `Query.whereSql`) 5 | 6 | ### 3.6.0 7 | 8 | - `ANY_PARAM` to disable `whereEq` and `whereIn` queries 9 | 10 | ### 3.5.1 11 | 12 | - do not crash on empty inserts 13 | - json-stringify json column parameters that are passed into insert and update queries 14 | (see https://github.com/brianc/node-postgres/issues/442) 15 | 16 | ### 3.5.0 17 | 18 | - wrap any runtype errors raised in column validators in `QueryBuilderValidationError` 19 | - extend `QueryBuilderValidationError` to contain context info on where validation failed 20 | 21 | ### 3.4.2 22 | 23 | - fix crash when consuming subselects via `selectAsJson` or `selectAsJsonAgg` and deselecting their primary column(s) 24 | 25 | ### 3.4.1 26 | 27 | - fix `Column.enum()` validation for number based enums. 28 | 29 | ### 3.4.0 30 | 31 | - add `lockParam` to pass locking behaviour in query parameters and extend 32 | `LockMode` with `'none'` to request no locking 33 | 34 | ### 3.3.0 35 | 36 | - add explicit checks for ambiguous columns when building a query 37 | - add `explainAnalyze` 38 | - internals: selecting into json or json agg does not generate subselects any more 39 | 40 | ### 3.2.0 41 | 42 | - fix empty `select` 43 | - fix `fromJson` conversions in left-joined subqueries that use `selectAsJson` 44 | - add `updateOne` and `updateExactlyOne` 45 | 46 | ### 3.1.2 47 | 48 | - fix `fromJson` and left joining to not crash on non-null columns 49 | - fix `selectAsJson` left joining (was creating an object with all keys null instead of a single null value) 50 | 51 | ### 3.1.1 52 | 53 | - fix Query.use() type declaration 54 | 55 | ### 3.1.0 56 | 57 | - make `Query.use()` passing a Query (not a `Statement`) to allow modifying the query 58 | 59 | ### 3.0.0 60 | 61 | - add typing to sql fragment parameters via `sql.number`, `sql.string` .. up to `sql.param` 62 | - limit `whereSql` to 5 `SqlFragments` and add `whereSqlUntyped` for >5 `SqlFragments` 63 | - add `enum` and `stringUnion` column types 64 | - rename `Column.nullable()` to `Column.null()` 65 | - change `query.table()` to create unique references every time its called 66 | 67 | ### 2.0.0 68 | 69 | - add Error classes (`QueryBuilderError`, ...) 70 | - change columns api to be chainable and rename some methods: 71 | renamed: `hasDefault` to `default` 72 | old: `hasDefault(primary(integer('id')))` 73 | new: `column('id').integer().primary().default()` 74 | - add `omit` and `pick` utility functions 75 | - add `limit` and `offset` 76 | - add `use` 77 | - add `fetchOne` to fetch 0 or 1 rows, throwing for results with >1 rows 78 | - rename `fetchOne` to `fetchExactlyOne` 79 | - add `orderBy` to sort results 80 | - add `selectAs` to rename selected columns 81 | - rename `selectAs` to `selectAsJson` 82 | - add `primary` column marker to create correct group-by statements for `selectAsJsonAgg` 83 | - add `whereSql` and the `sql` tagged template function 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Erik Soehnel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typesafe-query-builder", 3 | "version": "4.0.0-rc.1", 4 | "license": "MIT", 5 | "author": "Erik Soehnel", 6 | "homepage": "https://github.com/hoeck/typesafe-query-builder", 7 | "repository": "github:hoeck/typesafe-query-builder", 8 | "type": "module", 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "tsd": { 12 | "directory": "test-d", 13 | "compilerOptions": { 14 | "noErrorTruncation": true 15 | } 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "tsup": { 21 | "entry": [ 22 | "src/index.ts" 23 | ], 24 | "splitting": false, 25 | "sourcemap": true, 26 | "clean": true, 27 | "dts": true, 28 | "format": [ 29 | "esm" 30 | ] 31 | }, 32 | "jest": { 33 | "preset": "ts-jest", 34 | "testEnvironment": "node" 35 | }, 36 | "scripts": { 37 | "build": "tsup src/index.ts", 38 | "test": "jest", 39 | "test:watch": "jest --watch", 40 | "test:types": "tsd -f test-d", 41 | "typecheck": "tsc --project tsconfig.json --noEmit", 42 | "typecheck:watch": "tsc --project tsconfig.json --noEmit --watch --preserveWatchOutput --pretty", 43 | "typecheck:verbose": "tsc --project tsconfig.json --noEmit --noErrorTruncation", 44 | "test-database:start": "docker run --rm -e POSTGRES_PASSWORD=password --publish 54321:5432 --volume $PWD/test/helpers:/docker-entrypoint-initdb.d postgres:latest", 45 | "test-database:psql": "docker run --network=host --rm -it -e PGPASSWORD=password postgres:latest psql --port=54321 --host=127.0.0.1 --user=postgres test_schema", 46 | "build-readme": "markdown-toc -i --maxdepth 4 README.md" 47 | }, 48 | "peerDependencies": {}, 49 | "prettier": { 50 | "printWidth": 80, 51 | "semi": false, 52 | "singleQuote": true, 53 | "trailingComma": "all" 54 | }, 55 | "devDependencies": { 56 | "@types/jest": "^29.5.12", 57 | "@types/pg": "^8.11.6", 58 | "jest": "^29.7.0", 59 | "markdown-toc": "^1.2.0", 60 | "pg": "^8.12.0", 61 | "prettier": "^3.3.3", 62 | "ts-jest": "^29.2.4", 63 | "tsd": "^0.31.1", 64 | "tslib": "^2.6.3", 65 | "tsup": "^8.2.4", 66 | "typescript": "^5.5.4" 67 | }, 68 | "dependencies": {} 69 | } 70 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Superclass of all errors 3 | */ 4 | export class QueryBuilderError extends Error {} 5 | 6 | /** 7 | * Raised when the builder API is misused. 8 | * 9 | * Not all usages are fully covered by the type system. 10 | * For example, selecting the same column name multiple times or renaming a 11 | * column into a name that is already used in the selection will raise an 12 | * error while the query is being constructed. 13 | */ 14 | export class QueryBuilderUsageError extends QueryBuilderError {} 15 | 16 | /** 17 | * Assert condition and throw a usage error otherwise. 18 | */ 19 | export function assertUsage( 20 | condition: boolean, 21 | msg: string, 22 | ): asserts condition { 23 | if (!condition) { 24 | throw new QueryBuilderUsageError(msg) 25 | } 26 | } 27 | 28 | /** 29 | * Raised when something unexpected happens. 30 | * 31 | * In contrast to QueryBuilderUsageError, this error should have been caught 32 | * by the type system. 33 | * 34 | * If you encounter this error, there might be some bug in the types or you 35 | * have escaped the typesystem e.b. through an accidential `any` somewhere. 36 | */ 37 | export class QueryBuilderAssertionError extends QueryBuilderError {} 38 | 39 | /** 40 | * Thrown upon validation fails when using column validation. 41 | * 42 | * Either directly by the builtin colum types or as a wrapper around checking 43 | * insert data to add column and row information to exceptions thrown by 44 | * columns runtypes. 45 | */ 46 | export class QueryBuilderValidationError extends QueryBuilderError { 47 | // wrapped error thrown by column validator and additional context info to 48 | // determine what was invalid 49 | originalError?: Error 50 | table?: string 51 | column?: string 52 | rowNumber?: number 53 | row?: any 54 | 55 | constructor( 56 | message?: string, 57 | table?: string, 58 | column?: string, 59 | rowNumber?: number, 60 | row?: string, 61 | originalError?: Error, 62 | ) { 63 | super(message) 64 | 65 | this.name = 'QueryBuilderValidationError' 66 | 67 | this.table = table 68 | this.column = column 69 | this.rowNumber = rowNumber 70 | this.row = row 71 | this.originalError = originalError 72 | } 73 | } 74 | 75 | /** 76 | * Thrown by specialized fetch and update functions. 77 | * 78 | * E.g. if fetchOne, fetchExactlyOne, updateOne or updateExactlyOne 79 | * encounter more than 1 row. 80 | */ 81 | export class QueryBuilderResultError extends QueryBuilderError { 82 | constructor(message?: string) { 83 | super(message) 84 | 85 | this.name = 'QueryBuilderResultError' 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | QueryBuilderError, 3 | QueryBuilderResultError, 4 | QueryBuilderUsageError, 5 | QueryBuilderValidationError, 6 | } from './errors' 7 | export { column, expressionFactory, query, table } from './query' 8 | export type { 9 | Column, 10 | DatabaseClient, 11 | DefaultValue, 12 | Expression, 13 | ExpressionFactory, 14 | ResultType, 15 | RowLockMode, 16 | Table, 17 | TableRow, 18 | TableRowInsert, 19 | TableRowInsertOptional, 20 | TableType, 21 | } from './types' 22 | export { omit, pick } from './utils' 23 | -------------------------------------------------------------------------------- /src/query/buildSql.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilderAssertionError } from '../errors' 2 | import { DatabaseEscapeFunctions } from '../types' 3 | import { assertNever } from '../utils' 4 | import { SqlToken } from './sql' 5 | import { TableImplementation } from './table' 6 | 7 | class Parameters { 8 | counter = 1 // postgres query parameters start at $1 9 | mapping: Map = new Map() 10 | 11 | getPosition(name: string): number { 12 | const entry = this.mapping.get(name) 13 | 14 | if (entry !== undefined) { 15 | return entry 16 | } 17 | 18 | const cnt = this.counter 19 | 20 | this.mapping.set(name, cnt) 21 | this.counter++ 22 | 23 | return cnt 24 | } 25 | 26 | getSql(name: string) { 27 | return '$' + this.getPosition(name) 28 | } 29 | 30 | getMapping(): string[] { 31 | const res: string[] = new Array(this.mapping.size) 32 | 33 | this.mapping.forEach((value, key) => { 34 | res[value - 1] = key 35 | }) 36 | 37 | return res 38 | } 39 | 40 | hasParameters() { 41 | return this.counter > 1 42 | } 43 | } 44 | 45 | class TableAliases { 46 | aliases = 'abcdefghijklmnopqrstuvwxyz' 47 | tables = new Map() 48 | counter = 0 49 | 50 | getAlias(table: TableImplementation) { 51 | const existingAlias = this.tables.get(table.tableId) 52 | 53 | if (existingAlias) { 54 | return existingAlias 55 | } 56 | 57 | const newAlias = this.aliases[this.counter] 58 | 59 | this.tables.set(table.tableId, newAlias) 60 | this.counter++ 61 | 62 | return newAlias 63 | } 64 | } 65 | 66 | export function createSql( 67 | client: DatabaseEscapeFunctions, 68 | sqlTokens: SqlToken[], 69 | ) { 70 | const res: string[] = [] 71 | const parameters = new Parameters() 72 | const parameterValues: any[] = [] 73 | const aliases = new TableAliases() 74 | 75 | // simple indentation to be able to debug sql statements without 76 | // passing them into an sql formatter 77 | const indent = ' ' 78 | const indentation: string[] = [] 79 | 80 | for (const token of sqlTokens) { 81 | if (typeof token === 'string') { 82 | res.push(token) 83 | } else { 84 | switch (token.type) { 85 | case 'sqlParenOpen': 86 | indentation.push(indent) 87 | res.push('(', '\n', indentation.join('')) 88 | break 89 | case 'sqlParenClose': 90 | indentation.pop() 91 | res.push('\n', indentation.join(''), ')') 92 | break 93 | case 'sqlIndent': 94 | indentation.push(indent) 95 | break 96 | case 'sqlDedent': 97 | indentation.pop() 98 | break 99 | case 'sqlNewline': 100 | res.push('\n', indentation.join('')) 101 | break 102 | case 'sqlWhitespace': 103 | res.push(' ') 104 | break 105 | case 'sqlParameter': 106 | res.push(parameters.getSql(token.parameterName)) 107 | break 108 | case 'sqlParameterValue': 109 | parameterValues.push(token.value) 110 | res.push('$' + parameterValues.length) 111 | break 112 | case 'sqlLiteral': 113 | if (typeof token.value === 'string') { 114 | res.push(client.escapeLiteral(token.value)) 115 | } else if (token.value instanceof Date) { 116 | res.push(`'${token.value.toJSON()}'::timestamp`) 117 | } else if (token.value === null) { 118 | res.push('NULL') 119 | } else { 120 | res.push(token.value.toString()) 121 | } 122 | break 123 | case 'sqlTableColumn': 124 | res.push( 125 | aliases.getAlias(token.table), 126 | '.', 127 | token.table.getColumn(token.columnName).name, 128 | ) 129 | break 130 | case 'sqlIdentifier': 131 | res.push(client.escapeIdentifier(token.value)) 132 | break 133 | case 'sqlTable': 134 | res.push(token.table.tableName) 135 | break 136 | case 'sqlTableAlias': 137 | res.push(aliases.getAlias(token.table)) 138 | break 139 | default: 140 | assertNever(token) 141 | } 142 | } 143 | } 144 | 145 | if (parameterValues.length && parameters.hasParameters()) { 146 | throw new QueryBuilderAssertionError( 147 | 'cannot use sqlParameterValue and sqlParameter sql tokens in the same token array', 148 | ) 149 | } 150 | 151 | return { 152 | // contains $n parameters 153 | sql: res.join(''), 154 | // position -> name 155 | parameters: parameters.getMapping(), 156 | // parameter values and parameters are exclusive 157 | parameterValues, 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/query/delete.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryBuilderAssertionError, 3 | QueryBuilderResultError, 4 | QueryBuilderUsageError, 5 | } from '../errors' 6 | import { DatabaseClient, DatabaseEscapeFunctions, Table } from '../types' 7 | import { formatValues } from '../utils' 8 | import { 9 | projectionToRowTransformer, 10 | projectionToSqlTokens, 11 | } from './buildSelection' 12 | import { createSql } from './buildSql' 13 | import { ExprFactImpl } from './expressions' 14 | import { 15 | ExprImpl, 16 | SqlToken, 17 | joinTokens, 18 | sqlDedent, 19 | sqlIndent, 20 | sqlNewline, 21 | sqlWhitespace, 22 | } from './sql' 23 | import { 24 | SelectionImplementation, 25 | TableImplementation, 26 | getTableImplementation, 27 | } from './table' 28 | 29 | export class DeleteImplementation { 30 | static create(t: Table) { 31 | const ti = getTableImplementation(t) 32 | 33 | return new DeleteImplementation(ti) 34 | } 35 | 36 | private __table: TableImplementation 37 | private __whereExprs: ExprImpl[] = [] 38 | private __expectedRowCount?: number | { min?: number; max?: number } 39 | private __returning?: SelectionImplementation 40 | 41 | constructor(table: TableImplementation) { 42 | this.__table = table 43 | } 44 | 45 | where(cb: (f: ExprFactImpl) => ExprImpl) { 46 | this.__whereExprs.push(cb(new ExprFactImpl([this.__table]))) 47 | 48 | return this 49 | } 50 | 51 | expectDeletedRowCount( 52 | exactCountOrRange: number | { min?: number; max?: number }, 53 | ) { 54 | if (this.__expectedRowCount !== undefined) { 55 | throw new QueryBuilderUsageError( 56 | 'query.delete: expectDeletedRowCount() should only be called once', 57 | ) 58 | } 59 | 60 | this.__expectedRowCount = exactCountOrRange 61 | 62 | return this 63 | } 64 | 65 | returning(selection: SelectionImplementation) { 66 | this.__returning = selection 67 | 68 | return this 69 | } 70 | 71 | sql(client: DatabaseEscapeFunctions, params?: any) { 72 | const tokens = this._buildSql() 73 | 74 | if (!params) { 75 | return createSql(client, tokens).sql 76 | } 77 | 78 | const tokensWithParameterValues = tokens.map((t): typeof t => { 79 | if (typeof t !== 'string' && t.type === 'sqlParameter') { 80 | return { type: 'sqlLiteral', value: params[t.parameterName] } 81 | } 82 | 83 | return t 84 | }) 85 | 86 | return createSql(client, tokensWithParameterValues).sql 87 | } 88 | 89 | sqlLog(client: DatabaseEscapeFunctions, params?: any) { 90 | console.log(this.sql(client, params)) 91 | 92 | return this 93 | } 94 | 95 | async execute(client: DatabaseClient, params?: any) { 96 | const { sql, parameters } = createSql(client, this._buildSql()) 97 | const resultTransformer = this._getResultTransformer() 98 | 99 | if (!parameters.length) { 100 | if (params !== undefined) { 101 | throw new QueryBuilderAssertionError( 102 | `expected no parameters for this query`, 103 | ) 104 | } 105 | } 106 | 107 | const result = await client.query( 108 | sql, 109 | parameters.map((p) => params[p]), 110 | ) 111 | 112 | if (this.__expectedRowCount !== undefined) { 113 | if (typeof this.__expectedRowCount === 'number') { 114 | if (result.rowCount !== this.__expectedRowCount) { 115 | throw new QueryBuilderResultError( 116 | `query.delete: table ${formatValues( 117 | this.__table.tableName, 118 | )} - expected to delete exactly ${ 119 | this.__expectedRowCount 120 | } rows but got ${result.rowCount} instead.`, 121 | ) 122 | } 123 | } else { 124 | if ( 125 | this.__expectedRowCount.min !== undefined && 126 | result.rowCount < this.__expectedRowCount.min 127 | ) { 128 | throw new QueryBuilderResultError( 129 | `query.delete: table ${formatValues( 130 | this.__table.tableName, 131 | )} - expected to delete no less than ${ 132 | this.__expectedRowCount.min 133 | } rows but got ${result.rowCount} instead.`, 134 | ) 135 | } 136 | 137 | if ( 138 | this.__expectedRowCount.max !== undefined && 139 | result.rowCount > this.__expectedRowCount.max 140 | ) { 141 | throw new QueryBuilderResultError( 142 | `query.delete: table ${formatValues( 143 | this.__table.tableName, 144 | )} - expected to delete no more than ${ 145 | this.__expectedRowCount.max 146 | } rows but got ${result.rowCount} instead.`, 147 | ) 148 | } 149 | } 150 | } 151 | 152 | if (!resultTransformer) { 153 | return 154 | } 155 | 156 | resultTransformer(result.rows) 157 | 158 | return result.rows 159 | } 160 | 161 | private _buildWhereSql() { 162 | if (!this.__whereExprs.length) { 163 | return [] 164 | } 165 | 166 | return [ 167 | 'WHERE', 168 | sqlIndent, 169 | sqlNewline, 170 | ...joinTokens( 171 | this.__whereExprs.map((e) => { 172 | return e.exprTokens 173 | }), 174 | [sqlWhitespace, 'AND', sqlWhitespace], 175 | ), 176 | sqlDedent, 177 | ] 178 | } 179 | 180 | private _buildReturning(): SqlToken[] { 181 | if (!this.__returning) { 182 | return [] 183 | } 184 | 185 | return [ 186 | sqlNewline, 187 | 'RETURNING', 188 | sqlIndent, 189 | sqlNewline, 190 | ...projectionToSqlTokens({ 191 | type: 'plain', 192 | selections: [this.__returning], 193 | }), 194 | sqlDedent, 195 | ] 196 | } 197 | 198 | private _buildSql(): SqlToken[] { 199 | return [ 200 | 'DELETE FROM', 201 | sqlIndent, 202 | sqlNewline, 203 | { type: 'sqlTable', table: this.__table }, 204 | sqlWhitespace, 205 | 'AS', 206 | sqlWhitespace, 207 | { type: 'sqlTableAlias', table: this.__table }, 208 | sqlDedent, 209 | sqlNewline, 210 | ...this._buildWhereSql(), 211 | ...this._buildReturning(), 212 | ] 213 | } 214 | 215 | private _getResultTransformer() { 216 | if (!this.__returning) { 217 | return 218 | } 219 | 220 | const rowTransformer = projectionToRowTransformer({ 221 | type: 'plain', 222 | selections: [this.__returning], 223 | }) 224 | 225 | if (rowTransformer) { 226 | return (rows: any[]) => { 227 | for (let i = 0; i < rows.length; i++) { 228 | rowTransformer(rows[i]) 229 | } 230 | } 231 | } else { 232 | return () => {} 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/query/index.ts: -------------------------------------------------------------------------------- 1 | export { column } from './columns' 2 | export { expressionFactory } from './expressions' 3 | export { query } from './query' 4 | export { table } from './table' 5 | -------------------------------------------------------------------------------- /src/query/insertStatement.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseClient, DatabaseEscapeFunctions, Table } from '../types' 2 | import { createSql } from './buildSql' 3 | import { 4 | InsertIntoImplementation, 5 | InsertStatementColumnReferenceImplementation, 6 | } from './insert' 7 | import { 8 | SqlToken, 9 | sqlDedent, 10 | sqlIndent, 11 | sqlNewline, 12 | sqlParenClose, 13 | sqlParenOpen, 14 | sqlWhitespace, 15 | } from './sql' 16 | import { SelectionImplementation } from './table' 17 | 18 | export class InsertStatementImplementation { 19 | static create(cb: (builder: InsertStatementBuilderImplementation) => void) { 20 | const builder = new InsertStatementBuilderImplementation() 21 | 22 | cb(builder) 23 | 24 | return new InsertStatementImplementation(builder) 25 | } 26 | 27 | constructor(private __builder: InsertStatementBuilderImplementation) { 28 | this.__builder = __builder 29 | } 30 | 31 | sql(client: DatabaseEscapeFunctions) { 32 | const { sql } = createSql(client, this._buildSql(client)) 33 | 34 | return sql 35 | } 36 | 37 | sqlLog(client: DatabaseEscapeFunctions) { 38 | console.log(this.sql(client)) 39 | 40 | return this 41 | } 42 | 43 | async execute(client: DatabaseClient) { 44 | const { sql, parameterValues } = createSql(client, this._buildSql(client)) 45 | 46 | const res = await client.query(sql, parameterValues) 47 | 48 | if (!this.__builder.__returning.length) { 49 | return 50 | } 51 | 52 | return res.rows.map((r) => r.result) 53 | } 54 | 55 | _buildSql(client: DatabaseEscapeFunctions): SqlToken[] { 56 | // build the inserts 57 | const tokens: SqlToken[] = ['WITH', sqlIndent, sqlNewline] 58 | 59 | for (let i = 0; i < this.__builder.__inserts.length; i++) { 60 | const ins = this.__builder.__inserts[i] 61 | const insertIntoSql = ins._buildSql() 62 | 63 | tokens.push( 64 | { type: 'sqlIdentifier', value: ins.__id }, 65 | sqlWhitespace, 66 | 'AS', 67 | sqlWhitespace, 68 | sqlParenOpen, 69 | ...insertIntoSql, 70 | sqlParenClose, 71 | ) 72 | 73 | if (i < this.__builder.__inserts.length - 1) { 74 | tokens.push(',', sqlNewline) 75 | } 76 | } 77 | 78 | tokens.push(sqlDedent, sqlNewline) 79 | 80 | // build the returning 81 | if (this.__builder.__returning) { 82 | for (let i = 0; i < this.__builder.__returning.length; i++) { 83 | const ret = this.__builder.__returning[i] 84 | 85 | tokens.push('SELECT', sqlWhitespace, 'JSON_BUILD_OBJECT(') 86 | 87 | const keys = Object.getOwnPropertyNames(ret) 88 | 89 | for (let j = 0; j < keys.length; j++) { 90 | const k = keys[j] 91 | 92 | tokens.push( 93 | { type: 'sqlLiteral', value: k }, 94 | ',', 95 | sqlWhitespace, 96 | '(', 97 | 'SELECT', 98 | sqlWhitespace, 99 | { 100 | type: 'sqlIdentifier', 101 | value: ret[k].getColName(), 102 | }, 103 | 104 | sqlWhitespace, 105 | 'FROM', 106 | sqlWhitespace, 107 | { 108 | type: 'sqlIdentifier', 109 | value: ret[k].getFromName(), 110 | }, 111 | ')', 112 | ) 113 | 114 | if (j < keys.length - 1) { 115 | tokens.push(',', sqlWhitespace) 116 | } 117 | } 118 | 119 | tokens.push(') AS result') 120 | 121 | if (i < this.__builder.__returning.length - 1) { 122 | tokens.push(sqlNewline, 'UNION ALL', sqlNewline) 123 | } 124 | } 125 | } else { 126 | // just an empty select 127 | tokens.push('SELECT NULL AS result') 128 | } 129 | 130 | return tokens 131 | } 132 | } 133 | 134 | class InsertStatementBuilderImplementation { 135 | public __inserts: InsertStatementInsertIntoImplementation[] = [] 136 | public __returning: any[] = [] 137 | private __idCounter = 0 138 | 139 | addInsertInto = (table: Table) => { 140 | const ins = new InsertStatementInsertIntoImplementation( 141 | this.__idCounter++, 142 | table, 143 | ) 144 | 145 | this.__inserts.push(ins) 146 | 147 | return ins 148 | } 149 | 150 | addReturnValue = (value: any) => { 151 | this.__returning.push(value) 152 | } 153 | } 154 | 155 | class InsertStatementInsertIntoImplementation { 156 | public __id: string 157 | public __insert: InsertIntoImplementation 158 | 159 | constructor(id: number, table: Table) { 160 | this.__id = `tsqb_insert_alias_${id}` 161 | this.__insert = InsertIntoImplementation.create(table) 162 | } 163 | 164 | value(row: any) { 165 | this.__insert = this.__insert.value(row) 166 | 167 | return this 168 | } 169 | 170 | valueOptional(row: any) { 171 | this.__insert = this.__insert.valueOptional(row) 172 | 173 | return this 174 | } 175 | 176 | returning(selection: SelectionImplementation) { 177 | this.__insert = this.__insert.returning(selection) 178 | 179 | return Object.fromEntries( 180 | selection 181 | .getSelectedColumnNames() 182 | .map((n) => [ 183 | n, 184 | new InsertStatementColumnReferenceImplementation(this.__id, n), 185 | ]), 186 | ) 187 | } 188 | 189 | _buildSql() { 190 | return this.__insert._buildInsertStatement() 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/query/queryItem.ts: -------------------------------------------------------------------------------- 1 | import { RowLockMode } from '../types' 2 | import { ExprImpl } from './sql' 3 | import { SelectionImplementation, TableImplementation } from './table' 4 | import { QueryImplementation } from './query' 5 | 6 | /** 7 | * Recording parts of a query to be able to generate sql from 8 | */ 9 | export type QueryItem = 10 | | FromItem 11 | | JoinItem 12 | | LimitItem 13 | | LockItem 14 | | OffsetItem 15 | | OrderByItem 16 | | SelectItem 17 | | WhereItem 18 | | NarrowItem 19 | 20 | export interface FromItem { 21 | type: 'from' 22 | table: TableImplementation 23 | } 24 | 25 | export interface JoinItem { 26 | type: 'join' 27 | table: TableImplementation 28 | joinType: 'join' | 'leftJoin' 29 | expr: ExprImpl 30 | } 31 | 32 | export interface LimitItem { 33 | type: 'limit' 34 | count: number | string 35 | } 36 | 37 | export interface LockItem { 38 | type: 'lock' 39 | rowLockMode: RowLockMode 40 | } 41 | 42 | export interface OffsetItem { 43 | type: 'offset' 44 | offset: number | string 45 | } 46 | 47 | export interface OrderByItem { 48 | type: 'orderBy' 49 | expr: ExprImpl 50 | direction: 'asc' | 'desc' | undefined 51 | nulls: 'nullsFirst' | 'nullsLast' | undefined 52 | } 53 | 54 | export interface SelectItem { 55 | type: 'select' 56 | projection: 57 | | { 58 | type: 'plain' 59 | selections: (SelectionImplementation | QueryImplementation)[] 60 | } 61 | | { 62 | type: 'jsonObject' 63 | key: string 64 | selections: (SelectionImplementation | QueryImplementation)[] 65 | } 66 | | { 67 | type: 'jsonArray' 68 | key: string 69 | orderBy?: ExprImpl // a table column 70 | direction?: 'asc' | 'desc' 71 | selection: SelectionImplementation | QueryImplementation 72 | } 73 | | { 74 | type: 'jsonObjectArray' 75 | key: string 76 | orderBy?: ExprImpl // a table column 77 | direction?: 'asc' | 'desc' 78 | selections: (SelectionImplementation | QueryImplementation)[] 79 | } 80 | } 81 | 82 | export interface WhereItem { 83 | type: 'where' 84 | expr: ExprImpl 85 | } 86 | 87 | export interface NarrowItem { 88 | type: 'narrow' 89 | key: string 90 | values: string[] 91 | queryItems: QueryItem[] 92 | } 93 | -------------------------------------------------------------------------------- /src/query/sql.ts: -------------------------------------------------------------------------------- 1 | import { TableImplementation } from './table' 2 | 3 | export const sqlParenOpen = { type: 'sqlParenOpen' } as const 4 | export const sqlParenClose = { type: 'sqlParenClose' } as const 5 | export const sqlIndent = { type: 'sqlIndent' } as const 6 | export const sqlDedent = { type: 'sqlDedent' } as const 7 | export const sqlWhitespace = { type: 'sqlWhitespace' } as const 8 | export const sqlNewline = { type: 'sqlNewline' } as const 9 | 10 | export interface SqlParameter { 11 | type: 'sqlParameter' 12 | parameterName: string 13 | } 14 | 15 | export interface SqlParameterValue { 16 | // inserts a positional parameter directly, collecting its value, used for 17 | // inserts to be able to insert multiple rows where a key:value parameter 18 | // mapping does not work 19 | type: 'sqlParameterValue' 20 | value: any 21 | } 22 | 23 | export interface SqlLiteral { 24 | type: 'sqlLiteral' 25 | value: string | number | boolean | BigInt | Date | null 26 | } 27 | 28 | export interface SqlIdentifier { 29 | type: 'sqlIdentifier' 30 | value: string 31 | } 32 | 33 | // ` ` 34 | // tableAlias is resolved once the sql tokens are turned into an sql string 35 | export interface SqlTable { 36 | type: 'sqlTable' 37 | table: TableImplementation 38 | } 39 | 40 | export interface SqlTableAlias { 41 | type: 'sqlTableAlias' 42 | table: TableImplementation 43 | } 44 | 45 | // `.` 46 | // tableAlias is resolved once the sql tokens are turned into an sql string 47 | export interface SqlTableColumn { 48 | type: 'sqlTableColumn' 49 | table: TableImplementation 50 | columnName: string 51 | } 52 | 53 | // Construct a query step by step out of sequences of sql tokens. 54 | // This allows us to: 55 | // - delay parameter mapping until every parameter is known 56 | // - delay table alias generation in the same way 57 | // - apply crude formatting/indentation/prettifycation for inspecting 58 | // generated sql queries 59 | export type SqlToken = 60 | | typeof sqlParenOpen 61 | | typeof sqlParenClose 62 | | typeof sqlIndent 63 | | typeof sqlDedent 64 | | typeof sqlWhitespace 65 | | typeof sqlNewline 66 | | SqlParameter 67 | | SqlParameterValue 68 | | SqlLiteral 69 | | SqlIdentifier 70 | | SqlTable 71 | | SqlTableAlias 72 | | SqlTableColumn 73 | | string 74 | 75 | export function joinTokens( 76 | tokens: SqlToken[][], 77 | separator: SqlToken[], 78 | ): SqlToken[] { 79 | if (!tokens.length) { 80 | return [] 81 | } 82 | 83 | const res: SqlToken[] = tokens[0] 84 | 85 | for (let i = 1; i < tokens.length; i++) { 86 | res.push(...separator, ...tokens[i]) 87 | } 88 | 89 | return res 90 | } 91 | 92 | export function wrapInParens(tokens: SqlToken[]): SqlToken[] { 93 | return [sqlParenOpen, ...tokens, sqlParenClose] 94 | } 95 | 96 | export interface ExprImpl { 97 | exprTokens: SqlToken[] 98 | exprAlias?: string 99 | } 100 | 101 | export interface ExprImplWithAlias { 102 | exprTokens: SqlToken[] 103 | exprAlias: string 104 | } 105 | -------------------------------------------------------------------------------- /src/types/expression/andOr.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { PropagateNull } from './helpers' 3 | 4 | /** 5 | * a AND b / a OR b 6 | * 7 | * Up to 11 arguments, nest `and`s / `or`s if you need more than that. 8 | */ 9 | export interface AndOr { 10 | // 1 parameter 11 | ( 12 | a: Expression, 13 | ): Expression, T, P0> 14 | 15 | // 2 parameter overload 16 | ( 17 | a: Expression, 18 | b: Expression, 19 | ): Expression, T, P0 & P1> 20 | 21 | // 3 parameter overload 22 | ( 23 | a: Expression, 24 | b: Expression, 25 | c: Expression, 26 | ): Expression, T, P0 & P1 & P2> 27 | 28 | // 4 parameter overload 29 | < 30 | ET extends boolean | null, 31 | P0 extends {}, 32 | P1 extends {}, 33 | P2 extends {}, 34 | P3 extends {}, 35 | >( 36 | a: Expression, 37 | b: Expression, 38 | c: Expression, 39 | d: Expression, 40 | ): Expression, T, P0 & P1 & P2 & P3> 41 | 42 | // 5 parameter overload 43 | < 44 | ET extends boolean | null, 45 | P0 extends {}, 46 | P1 extends {}, 47 | P2 extends {}, 48 | P3 extends {}, 49 | P4 extends {}, 50 | >( 51 | a: Expression, 52 | b: Expression, 53 | c: Expression, 54 | d: Expression, 55 | e: Expression, 56 | ): Expression, T, P0 & P1 & P2 & P3 & P4> 57 | 58 | // 6 parameter overload 59 | < 60 | ET extends boolean | null, 61 | P0 extends {}, 62 | P1 extends {}, 63 | P2 extends {}, 64 | P3 extends {}, 65 | P4 extends {}, 66 | P5 extends {}, 67 | >( 68 | a: Expression, 69 | b: Expression, 70 | c: Expression, 71 | d: Expression, 72 | e: Expression, 73 | f: Expression, 74 | ): Expression, T, P0 & P1 & P2 & P3 & P4 & P5> 75 | 76 | // 7 parameter overload 77 | < 78 | ET extends boolean | null, 79 | P0 extends {}, 80 | P1 extends {}, 81 | P2 extends {}, 82 | P3 extends {}, 83 | P4 extends {}, 84 | P5 extends {}, 85 | P6 extends {}, 86 | >( 87 | a: Expression, 88 | b: Expression, 89 | c: Expression, 90 | d: Expression, 91 | e: Expression, 92 | f: Expression, 93 | g: Expression, 94 | ): Expression< 95 | boolean | PropagateNull, 96 | T, 97 | P0 & P1 & P2 & P3 & P4 & P5 & P6 98 | > 99 | 100 | // 8 parameter overload 101 | < 102 | ET extends boolean | null, 103 | P0 extends {}, 104 | P1 extends {}, 105 | P2 extends {}, 106 | P3 extends {}, 107 | P4 extends {}, 108 | P5 extends {}, 109 | P6 extends {}, 110 | P7 extends {}, 111 | >( 112 | a: Expression, 113 | b: Expression, 114 | c: Expression, 115 | d: Expression, 116 | e: Expression, 117 | f: Expression, 118 | g: Expression, 119 | h: Expression, 120 | ): Expression< 121 | boolean | PropagateNull, 122 | T, 123 | P0 & P1 & P2 & P3 & P4 & P5 & P6 & P7 124 | > 125 | 126 | // 9 parameter overload 127 | < 128 | ET extends boolean | null, 129 | P0 extends {}, 130 | P1 extends {}, 131 | P2 extends {}, 132 | P3 extends {}, 133 | P4 extends {}, 134 | P5 extends {}, 135 | P6 extends {}, 136 | P7 extends {}, 137 | P8 extends {}, 138 | >( 139 | a: Expression, 140 | b: Expression, 141 | c: Expression, 142 | d: Expression, 143 | e: Expression, 144 | f: Expression, 145 | g: Expression, 146 | h: Expression, 147 | i: Expression, 148 | ): Expression< 149 | boolean | PropagateNull, 150 | T, 151 | P0 & P1 & P2 & P3 & P4 & P5 & P6 & P7 & P8 152 | > 153 | 154 | // 10 parameter overload 155 | < 156 | ET extends boolean | null, 157 | P0 extends {}, 158 | P1 extends {}, 159 | P2 extends {}, 160 | P3 extends {}, 161 | P4 extends {}, 162 | P5 extends {}, 163 | P6 extends {}, 164 | P7 extends {}, 165 | P8 extends {}, 166 | P9 extends {}, 167 | >( 168 | a: Expression, 169 | b: Expression, 170 | c: Expression, 171 | d: Expression, 172 | e: Expression, 173 | f: Expression, 174 | g: Expression, 175 | h: Expression, 176 | i: Expression, 177 | j: Expression, 178 | ): Expression< 179 | boolean | PropagateNull, 180 | T, 181 | P0 & P1 & P2 & P3 & P4 & P5 & P6 & P7 & P8 & P9 182 | > 183 | 184 | // 11 parameter overload 185 | < 186 | ET extends boolean | null, 187 | P0 extends {}, 188 | P1 extends {}, 189 | P2 extends {}, 190 | P3 extends {}, 191 | P4 extends {}, 192 | P5 extends {}, 193 | P6 extends {}, 194 | P7 extends {}, 195 | P8 extends {}, 196 | P9 extends {}, 197 | P10 extends {}, 198 | >( 199 | a: Expression, 200 | b: Expression, 201 | c: Expression, 202 | d: Expression, 203 | e: Expression, 204 | f: Expression, 205 | g: Expression, 206 | h: Expression, 207 | i: Expression, 208 | j: Expression, 209 | k: Expression, 210 | ): Expression< 211 | boolean | PropagateNull, 212 | T, 213 | P0 & P1 & P2 & P3 & P4 & P5 & P6 & P7 & P8 & P9 & P10 214 | > 215 | } 216 | -------------------------------------------------------------------------------- /src/types/expression/caseWhen.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | 3 | /** 4 | * CASE WHEN a[0] THEN a[1] ELSE e END 5 | * 6 | * With else being optional. 7 | */ 8 | export interface CaseWhen { 9 | __t: T 10 | 11 | // 1 case 12 | < 13 | ResultType, 14 | ConditionParam0 extends {}, 15 | ResultParam0 extends {}, 16 | ElseParam extends {}, 17 | >( 18 | case0: [ 19 | Expression, 20 | Expression, 21 | ], 22 | caseElse?: Expression, 23 | ): Expression 24 | 25 | // 2 cases 26 | < 27 | ResultType, 28 | ConditionParam0 extends {}, 29 | ResultParam0 extends {}, 30 | ConditionParam1 extends {}, 31 | ResultParam1 extends {}, 32 | ElseParam extends {}, 33 | >( 34 | case0: [ 35 | Expression, 36 | Expression, 37 | ], 38 | case1: [ 39 | Expression, 40 | Expression, 41 | ], 42 | caseElse?: Expression, 43 | ): Expression< 44 | ResultType, 45 | T, 46 | ConditionParam0 & ResultParam0 & ConditionParam1 & ResultParam1 & ElseParam 47 | > 48 | 49 | // 3 cases 50 | < 51 | ResultType, 52 | ConditionParam0 extends {}, 53 | ResultParam0 extends {}, 54 | ConditionParam1 extends {}, 55 | ResultParam1 extends {}, 56 | ConditionParam2 extends {}, 57 | ResultParam2 extends {}, 58 | ElseParam extends {}, 59 | >( 60 | case0: [ 61 | Expression, 62 | Expression, 63 | ], 64 | case1: [ 65 | Expression, 66 | Expression, 67 | ], 68 | case2: [ 69 | Expression, 70 | Expression, 71 | ], 72 | caseElse?: Expression, 73 | ): Expression< 74 | ResultType, 75 | T, 76 | ConditionParam0 & 77 | ResultParam0 & 78 | ConditionParam1 & 79 | ResultParam1 & 80 | ConditionParam2 & 81 | ResultParam2 & 82 | ElseParam 83 | > 84 | 85 | // 4 cases 86 | < 87 | ResultType, 88 | ConditionParam0 extends {}, 89 | ResultParam0 extends {}, 90 | ConditionParam1 extends {}, 91 | ResultParam1 extends {}, 92 | ConditionParam2 extends {}, 93 | ResultParam2 extends {}, 94 | ConditionParam3 extends {}, 95 | ResultParam3 extends {}, 96 | ElseParam extends {}, 97 | >( 98 | case0: [ 99 | Expression, 100 | Expression, 101 | ], 102 | case1: [ 103 | Expression, 104 | Expression, 105 | ], 106 | case2: [ 107 | Expression, 108 | Expression, 109 | ], 110 | case3: [ 111 | Expression, 112 | Expression, 113 | ], 114 | caseElse?: Expression, 115 | ): Expression< 116 | ResultType, 117 | T, 118 | ConditionParam0 & 119 | ResultParam0 & 120 | ConditionParam1 & 121 | ResultParam1 & 122 | ConditionParam2 & 123 | ResultParam2 & 124 | ConditionParam3 & 125 | ResultParam3 & 126 | ElseParam 127 | > 128 | 129 | // 5 cases 130 | < 131 | ResultType, 132 | ConditionParam0 extends {}, 133 | ResultParam0 extends {}, 134 | ConditionParam1 extends {}, 135 | ResultParam1 extends {}, 136 | ConditionParam2 extends {}, 137 | ResultParam2 extends {}, 138 | ConditionParam3 extends {}, 139 | ResultParam3 extends {}, 140 | ConditionParam4 extends {}, 141 | ResultParam4 extends {}, 142 | ElseParam extends {}, 143 | >( 144 | case0: [ 145 | Expression, 146 | Expression, 147 | ], 148 | case1: [ 149 | Expression, 150 | Expression, 151 | ], 152 | case2: [ 153 | Expression, 154 | Expression, 155 | ], 156 | case3: [ 157 | Expression, 158 | Expression, 159 | ], 160 | case4: [ 161 | Expression, 162 | Expression, 163 | ], 164 | caseElse?: Expression, 165 | ): Expression< 166 | ResultType, 167 | T, 168 | ConditionParam0 & 169 | ResultParam0 & 170 | ConditionParam1 & 171 | ResultParam1 & 172 | ConditionParam2 & 173 | ResultParam2 & 174 | ConditionParam3 & 175 | ResultParam3 & 176 | ConditionParam4 & 177 | ResultParam4 & 178 | ElseParam 179 | > 180 | } 181 | -------------------------------------------------------------------------------- /src/types/expression/coalesce.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | 3 | /** 4 | * `COALESCE(a, b)` 5 | */ 6 | export interface Coalesce { 7 | ( 8 | a: Expression, 9 | b: Expression, 10 | ): Expression 11 | } 12 | -------------------------------------------------------------------------------- /src/types/expression/eq.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { ComparableTypes, PropagateNull } from './helpers' 3 | 4 | /** 5 | * a = b 6 | */ 7 | export interface Eq { 8 | // compare two expressions 9 | ( 10 | a: Expression, 11 | b: Expression, 12 | ): Expression, T, PA & PB> 13 | 14 | expressionEqExpression< 15 | ET extends ComparableTypes, 16 | PA extends {}, 17 | PB extends {}, 18 | >( 19 | a: Expression, 20 | b: Expression, 21 | ): Expression, T, PA & PB> 22 | 23 | // compare a parameter against an expression 24 | ( 25 | a: K, 26 | b: Expression, 27 | ): Expression< 28 | boolean | PropagateNull, 29 | T, 30 | P & { [KK in K]: Exclude } 31 | > 32 | 33 | parameterEqExpression< 34 | ET extends ComparableTypes, 35 | P extends {}, 36 | K extends string, 37 | >( 38 | a: K, 39 | b: Expression, 40 | ): Expression< 41 | boolean | PropagateNull, 42 | T, 43 | P & { [KK in K]: Exclude } 44 | > 45 | 46 | // compare a expression against a parameter 47 | ( 48 | a: Expression, 49 | b: K, 50 | ): Expression< 51 | boolean | PropagateNull, 52 | T, 53 | P & { [KK in K]: Exclude } 54 | > 55 | 56 | expressionEqParameter< 57 | ET extends ComparableTypes, 58 | P extends {}, 59 | K extends string, 60 | >( 61 | a: Expression, 62 | b: K, 63 | ): Expression< 64 | boolean | PropagateNull, 65 | T, 66 | { [KK in K]: Exclude } 67 | > 68 | } 69 | -------------------------------------------------------------------------------- /src/types/expression/exists.ts: -------------------------------------------------------------------------------- 1 | import { QueryBottom } from '../query' 2 | import { Expression } from './expression' 3 | 4 | /** 5 | * `EXISTS a` 6 | */ 7 | export interface Exists { 8 |

(a: QueryBottom): Expression 9 | } 10 | -------------------------------------------------------------------------------- /src/types/expression/expression.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An SQL expression. 3 | * 4 | * Use this to build where and join conditions. 5 | */ 6 | export declare class Expression { 7 | // result type of the expression 8 | protected __expressionResult: R 9 | 10 | // union of all tables allowed in the expression 11 | protected __expressionTables: T 12 | 13 | // parameters used in the expression 14 | protected __parameters: P 15 | 16 | // Name of the column in case the expression is used in a select. 17 | // Similar to sql, this is the name of the column or the single selected 18 | // column in a subselect. 19 | protected __expressionAlias: A 20 | } 21 | 22 | export type ExpressionType = E extends Expression 23 | ? R 24 | : never 25 | 26 | export type ExpressionTable = E extends Expression 27 | ? T 28 | : never 29 | 30 | export type ExpressionParameter = E extends Expression< 31 | any, 32 | any, 33 | infer P, 34 | any 35 | > 36 | ? P 37 | : never 38 | 39 | export type ExpressionAlias = E extends Expression 40 | ? A 41 | : never 42 | -------------------------------------------------------------------------------- /src/types/expression/expressionFactory.ts: -------------------------------------------------------------------------------- 1 | import { AndOr } from './andOr' 2 | import { CaseWhen } from './caseWhen' 3 | import { Coalesce } from './coalesce' 4 | import { Eq } from './eq' 5 | import { Exists } from './exists' 6 | import { IsNull } from './isNull' 7 | import { Literal, LiteralString } from './literal' 8 | import { Not } from './not' 9 | import { Param } from './param' 10 | import { Subquery } from './subquery' 11 | import { SubqueryExpression } from './subqueryExpression' 12 | 13 | export declare class ExpressionFactory { 14 | protected __t: T // union of all tables allowed in each expression 15 | 16 | // boolean operators 17 | 18 | /** 19 | * SQL AND 20 | * 21 | * `and(a,b,c)` => `a AND b AND c` 22 | */ 23 | and: AndOr 24 | 25 | /** 26 | * SQL OR 27 | * 28 | * `or(a,b,c)` => `a OR b OR c` 29 | */ 30 | or: AndOr 31 | 32 | /** 33 | * SQL NOT 34 | * 35 | * `not(a)` => `NOT a` 36 | */ 37 | not: Not 38 | 39 | // equality 40 | 41 | /** 42 | * SQL equals 43 | * 44 | * `eq(a,b)` => `a = b` 45 | * 46 | * Instead of an expression a or b, you can use a string that will be taken 47 | * as a query parameter: 48 | * 49 | * `eq(a, 'myParameterName')` 50 | * ... 51 | * `await q.fetch(client, {myParameterName: ...})` 52 | */ 53 | eq: Eq 54 | 55 | // nulls 56 | 57 | /** 58 | * SQL COALESCE 59 | * 60 | * `coalesce(a,b)` => `COALESCE(a,b)` 61 | */ 62 | coalesce: Coalesce 63 | 64 | /** 65 | * SQL IS NULL 66 | * 67 | * `isNull(a)` => `a IS NULL` 68 | */ 69 | isNull: IsNull 70 | 71 | // conditionals 72 | 73 | /** 74 | * SQL CASE WHEN ... THEN ... ELSE ... END 75 | * 76 | * `caseWhen([a, b])` => `CASE WHEN a THEN b END` 77 | * `caseWhen([a, b], e)` => `CASE WHEN a THEN b ELSE e END` 78 | * `caseWhen([a, b], [c, d])` => `CASE WHEN a THEN b WHEN c THEN d END` 79 | * `caseWhen([a, b], [c, d], e)` => `CASE WHEN a THEN b WHEN c THEN d ELSE e END` 80 | */ 81 | caseWhen: CaseWhen 82 | 83 | // atoms 84 | 85 | /** 86 | * An SQL literal value of type string, number, boolean or null. 87 | * 88 | * Literals are mostly used in comparisons with inferred parameters, so their 89 | * type is widened from literals to the base type, e.g. `literal(true)` is of 90 | * type `boolean`, not `true`. 91 | */ 92 | literal: Literal 93 | 94 | /** 95 | * An SQL literal string. 96 | */ 97 | literalString: LiteralString 98 | 99 | /** 100 | * A query parameter with an explicit type. 101 | * 102 | * Use this if you cannot use a string parameter shortcut or if you need 103 | * to manually narrow a parameter type. 104 | */ 105 | param: Param 106 | 107 | // subqeries 108 | 109 | /** 110 | * A (correlated) subquery. 111 | * 112 | * The created subquery can be used in place of any expression, for example: 113 | * 114 | * eq('idParam', subquery(ExampleTable).select(ExampleTable.include('id'))) 115 | */ 116 | subquery: Subquery 117 | 118 | /** 119 | * SQL = ANY 120 | * 121 | * `isIn(a, b)` => `a = ANY(b)` 122 | */ 123 | isIn: SubqueryExpression 124 | 125 | /** 126 | * SQL <> ALL 127 | * 128 | * `isNotIn(a, b)` => `a <> ALL(b)` 129 | */ 130 | isNotIn: SubqueryExpression 131 | 132 | /** 133 | * SQL EXISTS 134 | * 135 | * `exists(a)` => `EXISTS a` 136 | * 137 | * a must be a subquery 138 | */ 139 | exists: Exists 140 | } 141 | -------------------------------------------------------------------------------- /src/types/expression/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The union of types which can be used in comparisons. 3 | * 4 | * Postgres docs call them "built-in data types that have a natural ordering". 5 | * See https://www.postgresql.org/docs/current/functions-comparison.html 6 | * 7 | * Keep null in here as it is allowed e.g. to compare a nullable integer 8 | * column to an integer parameter. 9 | */ 10 | export type ComparableTypes = string | number | boolean | BigInt | Date | null 11 | 12 | /** 13 | * Helper type for whereIsNull to determine whether a column can be null. 14 | * 15 | * Does give a false positive for json columns that include `null` as a valid 16 | * json value. 17 | */ 18 | export type IsNullable = null extends T ? T : never 19 | 20 | /** 21 | * Used to emulate SQLs NULL propagation. 22 | * 23 | * For example, `a = b`, `a AND b` and many others will return null if a or b 24 | * are null. 25 | */ 26 | export type PropagateNull = T extends null ? null : never 27 | -------------------------------------------------------------------------------- /src/types/expression/index.ts: -------------------------------------------------------------------------------- 1 | // export Expression for end-users only, expression will be directly imported 2 | // by query and table to avoid broad circular imports 3 | export type { 4 | Expression, 5 | ExpressionAlias, 6 | ExpressionParameter, 7 | ExpressionTable, 8 | ExpressionType, 9 | } from './expression' 10 | export type { ExpressionFactory } from './expressionFactory' 11 | -------------------------------------------------------------------------------- /src/types/expression/isNull.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { IsNullable } from './helpers' 3 | 4 | /** 5 | * a IS NULL 6 | */ 7 | export interface IsNull { 8 | (a: Expression, T, EP>): Expression< 9 | boolean, 10 | T, 11 | EP 12 | > 13 | } 14 | -------------------------------------------------------------------------------- /src/types/expression/literal.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { ComparableTypes } from './helpers' 3 | 4 | type Widen = T extends string 5 | ? string 6 | : T extends number 7 | ? number 8 | : T extends boolean 9 | ? boolean 10 | : T 11 | 12 | /** 13 | * A literal sql value. 14 | * 15 | * Literals are mostly used in comparisons to inferred parameters, so their 16 | * type is widened from literals to the base type, e.g. `literal(true)` is of 17 | * type `boolean`, not `true`. 18 | */ 19 | export interface Literal { 20 | (value: V): Expression, any, {}> 21 | } 22 | 23 | /** 24 | * A literal string value. 25 | */ 26 | export interface LiteralString { 27 | (value: V): Expression 28 | } 29 | -------------------------------------------------------------------------------- /src/types/expression/not.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { PropagateNull } from './helpers' 3 | 4 | /** 5 | * NOT a 6 | */ 7 | export interface Not { 8 | ( 9 | a: Expression, 10 | ): Expression, T, EP> 11 | } 12 | -------------------------------------------------------------------------------- /src/types/expression/param.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { ComparableTypes } from './helpers' 3 | 4 | /** 5 | * A parameter (placeholder for values when executing the query). 6 | */ 7 | export interface Param { 8 | (parameterName: N): { 9 | // second method specifying the type to work around typescripts missing 10 | // partial-inference for generics 11 | type(): Expression 12 | 13 | string(): Expression 14 | number(): Expression 15 | boolean(): Expression 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types/expression/subquery.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '../query/joins' 2 | import { Table } from '../table' 3 | 4 | // Cannot use a plain `query` because type-inference for the correlated table 5 | // `C` does not work. With `subquery`, the correlated table(s) are passed 6 | // directly to the query constructor and do not need to be inferred by TS. 7 | 8 | export interface Subquery { 9 | (t: Table): Query< 10 | TT, 11 | P, 12 | T // in the subquery, you can use all tables of the surrounding query 13 | > 14 | } 15 | -------------------------------------------------------------------------------- /src/types/expression/subqueryExpression.ts: -------------------------------------------------------------------------------- 1 | import { AssertHasSingleKey } from '../helpers' 2 | import { QueryBottom } from '../query' 3 | import { Expression } from './expression' 4 | import { ComparableTypes, PropagateNull } from './helpers' 5 | 6 | /** 7 | * `a ANY b` 8 | */ 9 | export interface SubqueryExpression { 10 | // expression + subquery 11 | < 12 | ET extends ComparableTypes, 13 | PA extends {}, 14 | PB extends {}, 15 | S extends { [K in keyof any]: ET | null }, 16 | >( 17 | a: Expression, 18 | b: QueryBottom, T>, 19 | ): Expression, T, PA & PB> 20 | 21 | // expression + param 22 | ( 23 | a: Expression, 24 | b: K, 25 | ): Expression< 26 | boolean | PropagateNull, 27 | T, 28 | PA & { [Key in K]: Exclude[] } 29 | > 30 | 31 | // expression + expression 32 | ( 33 | a: Expression, 34 | b: Expression<(ET | null)[], T, PB>, 35 | ): Expression, T, PA & PB> 36 | } 37 | -------------------------------------------------------------------------------- /src/types/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test whether T is a union type or not 3 | * 4 | * see https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union 5 | */ 6 | export type IsUnion = ( 7 | T extends any ? (U extends T ? false : true) : never 8 | ) extends false 9 | ? false 10 | : true 11 | 12 | /** 13 | * Test whether T is an object with a single key 14 | * 15 | * Evaluates to T (exactly 1 key) or unknown (0, 2 or more keys or not an object). 16 | */ 17 | export type AssertHasSingleKey = keyof T extends never 18 | ? never 19 | : IsUnion extends true 20 | ? never 21 | : T 22 | 23 | /** 24 | * Resolve to the value type of a single key object {key: value}. 25 | */ 26 | export type SingleSelectionValue = keyof T extends never 27 | ? unknown 28 | : IsUnion extends true 29 | ? unknown 30 | : T[keyof T] 31 | 32 | /** 33 | * Resolve to the key type of a single key object {key: value}. 34 | * 35 | * When the object type T does contain zero or more than 1 key, resolve to 36 | * unknown. The latter is not assignable to string so QueryBottom.select will 37 | * yield an error when passing an expression that doesn't exactly contain 1 38 | * aliased value/column. 39 | */ 40 | export type SingleSelectionKey = keyof T extends never 41 | ? unknown 42 | : IsUnion extends true 43 | ? unknown 44 | : keyof T 45 | 46 | /** 47 | * Like Partial but with null instead of optional. 48 | */ 49 | export type Nullable = { [K in keyof T]: T[K] | null } 50 | 51 | /** 52 | * Merge two types. 53 | */ 54 | export type Merge = { 55 | [K in keyof A | keyof B]: K extends keyof A 56 | ? A[K] 57 | : K extends keyof B 58 | ? B[K] 59 | : never 60 | } 61 | 62 | /** 63 | * Omit which distributes over unions. 64 | * 65 | * Required for discriminated union tables when removing common columns. 66 | */ 67 | export type DistributiveOmit = T extends any 68 | ? Omit 69 | : never 70 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Expression, 3 | ExpressionAlias, 4 | ExpressionFactory, 5 | ExpressionParameter, 6 | ExpressionTable, 7 | ExpressionType, 8 | } from './expression' 9 | export type { 10 | DatabaseClient, 11 | DatabaseEscapeFunctions, 12 | Delete, 13 | InsertInto, 14 | InsertIntoConstructor, 15 | InsertStatementConstructor, 16 | Query, 17 | QueryBottom, 18 | QueryRoot, 19 | ResultType, 20 | RowLockMode, 21 | Update, 22 | } from './query' 23 | export type { 24 | Column, 25 | ColumnConstructor, 26 | DatabaseTable, 27 | DefaultValue, 28 | Selection, 29 | Table, 30 | TableConstructor, 31 | TableName, 32 | TableRow, 33 | TableRowInsert, 34 | TableRowInsertOptional, 35 | TableType, 36 | TableUnionConstructor, 37 | } from './table' 38 | -------------------------------------------------------------------------------- /src/types/query/databaseClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The parts of the postgres client required for fetching and validating queries. 3 | */ 4 | export interface DatabaseClient { 5 | /** 6 | * Execute a query and fetch the result. 7 | * 8 | * Matches `query` of node-postres. 9 | */ 10 | query( 11 | sql: string, 12 | values: any[], 13 | ): Promise<{ 14 | rows: Array<{ [key: string]: any }> 15 | rowCount: number 16 | }> 17 | 18 | /** 19 | * Escape a string so it can be safely insert into a query. 20 | * 21 | * Required for `literal` in expressions. 22 | */ 23 | escapeLiteral(value: string): string 24 | 25 | /** 26 | * Escape a string so it can be safely used as an identifier. 27 | * 28 | * Required to use any alias in selects. 29 | */ 30 | escapeIdentifier(value: string): string 31 | } 32 | 33 | /** 34 | * Some inspection methods only need the escape parts of node-postgres. 35 | */ 36 | export type DatabaseEscapeFunctions = Pick< 37 | DatabaseClient, 38 | 'escapeLiteral' | 'escapeIdentifier' 39 | > 40 | -------------------------------------------------------------------------------- /src/types/query/delete.ts: -------------------------------------------------------------------------------- 1 | import { Expression, ExpressionFactory } from '../expression' 2 | import { Selection } from '../table' 3 | import { DatabaseClient } from './databaseClient' 4 | 5 | /** 6 | * SQL DELETE statement builder. 7 | */ 8 | export declare class Delete { 9 | protected __deleteTable: T 10 | protected __deleteParams: P 11 | protected __deleteReturning: S 12 | 13 | /** 14 | * Use an Expression as the where clause. 15 | */ 16 | where( 17 | e: (b: ExpressionFactory) => Expression, 18 | ): Delete 19 | 20 | /** 21 | * Raise an exception if the deleted row count differs from the expectation. 22 | * 23 | * Raising the exception will abort any pending transaction. 24 | * 25 | * Passing a single number to expect exactly that number of rows. 26 | * Pass an object to define an *inclusive* range. If min or max are not 27 | * specified, Infinity is assumed. 28 | */ 29 | expectDeletedRowCount(exactCount: number): Delete 30 | expectDeletedRowCount(range: { min: number }): Delete 31 | expectDeletedRowCount(range: { max: number }): Delete 32 | expectDeletedRowCount(range: { min: number; max: number }): Delete 33 | 34 | /** 35 | * Specify a RETURNING clause. 36 | */ 37 | returning(selection: Selection): Delete 38 | 39 | /** 40 | * Return the generated sql string. 41 | */ 42 | sql: (client: DatabaseClient, params?: P) => string 43 | 44 | /** 45 | * Log the generated sql string to the console. 46 | */ 47 | sqlLog: (client: DatabaseClient, params?: P) => Delete 48 | 49 | /** 50 | * Perform the delete. 51 | */ 52 | execute: {} extends P 53 | ? (client: DatabaseClient) => Promise<{} extends S ? void : S[]> 54 | : (client: DatabaseClient, params: P) => Promise<{} extends S ? void : S[]> 55 | } 56 | -------------------------------------------------------------------------------- /src/types/query/index.ts: -------------------------------------------------------------------------------- 1 | export * from './databaseClient' 2 | export * from './delete' 3 | export * from './insert' 4 | export * from './insertStatement' 5 | export * from './joins' 6 | export * from './queryBottom' 7 | export * from './queryRoot' 8 | export * from './update' 9 | -------------------------------------------------------------------------------- /src/types/query/insert.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseTable, Selection, SetDefault, SetOptional } from '../table' 2 | import { DatabaseClient, DatabaseEscapeFunctions } from './databaseClient' 3 | 4 | /** 5 | * Basic `INSERT INTO ... VALUES ... [RETURNING ...]` into a single table. 6 | */ 7 | export interface InsertIntoConstructor { 8 | (table: DatabaseTable): InsertInto 9 | } 10 | 11 | export declare class InsertInto { 12 | protected __table: T 13 | protected __defaultColumns: D 14 | 15 | /** 16 | * Insert a single row. 17 | * 18 | * Default values must be included in the row with `query.DEFAULT` as the 19 | * value. 20 | */ 21 | value(row: SetDefault): InsertIntoSingle 22 | 23 | /** 24 | * Insert a single row with default values being optional. 25 | */ 26 | valueOptional(row: SetOptional): InsertIntoSingle 27 | 28 | /** 29 | * Insert many rows at once. 30 | */ 31 | values(rows: SetDefault[]): InsertIntoMany 32 | 33 | /** 34 | * Insert many rows at once with default values being optional. 35 | */ 36 | valuesOptional(rows: SetOptional[]): InsertIntoMany 37 | } 38 | 39 | export declare class InsertIntoSingle { 40 | protected __table: T 41 | 42 | returning(selection: Selection): InsertIntoExecute 43 | 44 | /** 45 | * Return the generated sql string to help with debugging. 46 | */ 47 | sql(client: DatabaseEscapeFunctions): string 48 | 49 | /** 50 | * Log the generated sql string to the console. 51 | */ 52 | sqlLog(client: DatabaseEscapeFunctions): InsertIntoSingle 53 | 54 | execute(client: DatabaseClient): Promise 55 | } 56 | 57 | export declare class InsertIntoMany { 58 | protected __table: T 59 | 60 | returning(selection: Selection): InsertIntoExecute 61 | 62 | /** 63 | * Return the generated sql string to help with debugging. 64 | */ 65 | sql(client: DatabaseEscapeFunctions): string 66 | 67 | /** 68 | * Log the generated sql string to the console. 69 | */ 70 | sqlLog(client: DatabaseEscapeFunctions): InsertIntoSingle 71 | 72 | execute(client: DatabaseClient): Promise 73 | } 74 | 75 | export declare class InsertIntoExecute { 76 | protected __returning: R 77 | 78 | /** 79 | * Return the generated sql string to help with debugging. 80 | */ 81 | sql(client: DatabaseEscapeFunctions): string 82 | 83 | /** 84 | * Log the generated sql string to the console. 85 | */ 86 | sqlLog(client: DatabaseEscapeFunctions): InsertIntoExecute 87 | 88 | execute(client: DatabaseClient): Promise 89 | } 90 | -------------------------------------------------------------------------------- /src/types/query/insertStatement.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseTable, Selection, SetDefault, SetOptional } from '../table' 2 | import { DatabaseClient, DatabaseEscapeFunctions } from './databaseClient' 3 | 4 | /** 5 | * Build an insert statetement that inserts related data. 6 | * 7 | * Compiles down to a single sql statement with `WITH`, for example: 8 | * 9 | * const result = await query 10 | * .insertStatement<{id: number}>(({ addInsertInto, addReturnValue }) => { 11 | * const { id: newMyTableId } = addInsertInto(MyTable) 12 | * .value({ name: foo }) 13 | * .returning(MyTable.id) 14 | * 15 | * addReturnValue({id: newMyTableId}) 16 | * 17 | * addInsertInto(MyRelatedTable).value({ 18 | * myTableId: newMyTableId, 19 | * property: 'foo', 20 | * }) 21 | * addInsertInto(MyRelatedTable).value({ 22 | * myTableId: newMyTableId, 23 | * property: 'bar', 24 | * }) 25 | * }) 26 | * .execute(client) 27 | * 28 | * will result in the following sql: 29 | * 30 | * WITH 31 | * my_table_1 AS (INSERT INTO my_table (name) VALUES ('foo') RETURNING id, 32 | * my_related_table_1 AS (INSERT INTO my_related_table ((SELECT id FROM my_table_1), property) VALUES ('foo') RETURNING (id, my_table_id)), 33 | * SELECT json_build_object('myNewTableId', id) FROM my_table_1; 34 | * 35 | * The returning type must be explicitly declared to be able to use 36 | * `addReturnValue`. 37 | * The returning type does not work with `Date` or other casted columns and 38 | * does not support discminated unions (it lacks the postprocessing that 39 | * queries and simple inserts have) 40 | */ 41 | export interface InsertStatementConstructor { 42 | ( 43 | callback: (builder: InsertStatementBuilder) => void, 44 | ): InsertStatement 45 | } 46 | 47 | /** 48 | * An insert statement ready to be sent to the database. 49 | */ 50 | export declare class InsertStatement { 51 | sql(client: DatabaseEscapeFunctions): string 52 | 53 | sqlLog(client: DatabaseEscapeFunctions): InsertStatement 54 | 55 | execute(client: DatabaseClient): Promise 56 | } 57 | 58 | /** 59 | * Factory for insert statement builder methods. 60 | * 61 | * The builder does not execute any inserts. It collects them and registers 62 | * their dependencies so it can build and run the full insert statement once 63 | * `.execute()` is called. 64 | */ 65 | interface InsertStatementBuilder { 66 | /** 67 | * Add an `INSERT INTO ... VALUES ... [RETURNING ...]` to the statement. 68 | */ 69 | addInsertInto( 70 | table: DatabaseTable, 71 | ): InsertStatementInsertInto 72 | 73 | /** 74 | * Add a return value. 75 | * 76 | * The value must stem from the returning clause of an `addInsertInto`-call. 77 | * 78 | * No postprocessing (discriminated union support, casting of columns) is 79 | * done with the result, so only basic types are allowed. 80 | */ 81 | addReturnValue(value: { 82 | [K in keyof R]: R[K] extends string | number | boolean | null 83 | ? InsertStatementColumnReference 84 | : never 85 | }): void 86 | } 87 | 88 | /** 89 | * Insert statement awaiting an insert value. 90 | */ 91 | export interface InsertStatementInsertInto { 92 | // When inserting multiple rows, the `returning` order is not guaranteed to 93 | // match the order of the inserts (?) so we allow only a single insert 94 | 95 | /** 96 | * Insert a single row. 97 | * 98 | * In addition to its defined type, each value can also be a 99 | * `InsertStatementColumnReference` from a previous `addInsertInto` 100 | * call to e.g. use an autogenerated ID from a previous insert. 101 | */ 102 | value( 103 | row: SetDefault< 104 | { [K in keyof T]: T[K] | InsertStatementColumnReference }, 105 | D 106 | >, 107 | ): InsertStatementInsertIntoValue 108 | 109 | /** 110 | * Insert a single row with default values being optional. 111 | */ 112 | valueOptional( 113 | row: SetOptional< 114 | { [K in keyof T]: T[K] | InsertStatementColumnReference }, 115 | D 116 | >, 117 | ): InsertStatementInsertIntoValue 118 | } 119 | 120 | /** 121 | * Insert statement awaiting an optional returning clause. 122 | */ 123 | export interface InsertStatementInsertIntoValue { 124 | returning(selection: Selection): { 125 | [Key in keyof S]: InsertStatementColumnReference 126 | } 127 | } 128 | 129 | /** 130 | * Value of a inserted column to be used in other inserts. 131 | */ 132 | export declare class InsertStatementColumnReference { 133 | protected __columnReferenceValue: V 134 | } 135 | -------------------------------------------------------------------------------- /src/types/query/joins.ts: -------------------------------------------------------------------------------- 1 | import { Table } from '../table' 2 | import { QueryBottom } from './queryBottom' 3 | import { Expression } from '../expression/expression' 4 | import { ExpressionFactory } from '../expression/expressionFactory' 5 | 6 | /** 7 | * Query for a single table ("select * from table") 8 | */ 9 | export interface Query 10 | extends QueryBottom { 11 | /** 12 | * JOIN this query with another table J. 13 | */ 14 | join( 15 | j: Table, 16 | on: (f: ExpressionFactory) => Expression< 17 | boolean | null, 18 | T | J, 19 | // IMHO there is no need to have parameters in join conditions. 20 | // Not having them keeps the number of generic variables low. 21 | {} 22 | >, 23 | ): Join2 24 | 25 | /** 26 | * LEFT JOIN this query with another table J. 27 | */ 28 | leftJoin( 29 | j: Table, 30 | on: (f: ExpressionFactory) => Expression, 31 | ): Join2 32 | } 33 | 34 | /** 35 | * Join over two tables 36 | */ 37 | export interface Join2 38 | extends QueryBottom { 39 | /** 40 | * JOIN this query with a third table. 41 | */ 42 | join( 43 | j: Table, 44 | on: ( 45 | f: ExpressionFactory, 46 | ) => Expression, 47 | ): Join3 48 | 49 | /** 50 | * LEFT JOIN this query with a third table. 51 | */ 52 | leftJoin( 53 | j: Table, 54 | on: ( 55 | f: ExpressionFactory, 56 | ) => Expression, 57 | ): Join3 58 | } 59 | 60 | /** 61 | * Join over 3 tables 62 | */ 63 | export interface Join3 64 | extends QueryBottom { 65 | /** 66 | * JOIN this query with a fourth table. 67 | */ 68 | join( 69 | j: Table, 70 | on: ( 71 | f: ExpressionFactory, 72 | ) => Expression, 73 | ): Join4 74 | 75 | /** 76 | * LEFT JOIN this query with a fourth table. 77 | */ 78 | leftJoin( 79 | j: Table, 80 | on: ( 81 | f: ExpressionFactory, 82 | ) => Expression, 83 | ): Join4 84 | } 85 | 86 | /** 87 | * Join over 4 tables 88 | */ 89 | export interface Join4 90 | extends QueryBottom { 91 | /** 92 | * JOIN this query with a fifth table. 93 | */ 94 | join( 95 | j: Table, 96 | on: ( 97 | f: ExpressionFactory, 98 | ) => Expression, 99 | ): Join5 100 | 101 | /** 102 | * LEFT JOIN this query with a fifth table. 103 | */ 104 | leftJoin( 105 | j: Table, 106 | on: ( 107 | f: ExpressionFactory, 108 | ) => Expression, 109 | ): Join5 110 | } 111 | 112 | /** 113 | * Join over 5 tables 114 | */ 115 | export interface Join5 116 | extends QueryBottom { 117 | /** 118 | * JOIN this query with a sixth table. 119 | */ 120 | join( 121 | j: Table, 122 | on: ( 123 | f: ExpressionFactory, 124 | ) => Expression, 125 | ): Join6 126 | 127 | /** 128 | * LEFT JOIN this query with a sixth table. 129 | */ 130 | leftJoin( 131 | j: Table, 132 | on: ( 133 | f: ExpressionFactory, 134 | ) => Expression, 135 | ): Join6 136 | } 137 | 138 | /** 139 | * Join over 6 tables 140 | */ 141 | export interface Join6 142 | extends QueryBottom { 143 | /** 144 | * JOIN this query with a seventh table. 145 | */ 146 | join( 147 | j: Table, 148 | on: ( 149 | f: ExpressionFactory, 150 | ) => Expression, 151 | ): Join7 152 | 153 | /** 154 | * LEFT JOIN this query with a seventh table. 155 | */ 156 | leftJoin( 157 | j: Table, 158 | on: ( 159 | f: ExpressionFactory, 160 | ) => Expression, 161 | ): Join7 162 | } 163 | 164 | /** 165 | * Join over 7 tables 166 | * 167 | * TODO: add more joins 168 | */ 169 | export interface Join7 170 | extends QueryBottom {} 171 | -------------------------------------------------------------------------------- /src/types/query/queryRoot.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseTable, DefaultValue, Table } from '../table' 2 | import { DatabaseClient } from './databaseClient' 3 | import { Delete } from './delete' 4 | import { InsertIntoConstructor } from './insert' 5 | import { InsertStatementConstructor } from './insertStatement' 6 | import { Query } from './joins' 7 | import { QueryBottom } from './queryBottom' 8 | import { Update } from './update' 9 | 10 | /** 11 | * Chaining API root. 12 | */ 13 | export interface QueryRoot { 14 | // query constructor 15 | (table: Table): Query 16 | 17 | /** 18 | * Common table expression (`WITH`). 19 | * 20 | * Returns a table that, when used in a query, puts this tables query 21 | * expression in a `WITH` clause. 22 | * 23 | * The following SQL: 24 | * 25 | * WITH foo AS ( 26 | * SELECT id, name FROM foo_table) 27 | * ) 28 | * SELECT * FROM foo WHERE id = 1 29 | * 30 | * is generated using this typesafe query: 31 | * 32 | * const foo = query.with(() => 33 | * query('foo').select(foo.include('id', 'name')), 34 | * ) 35 | * 36 | * console.log( 37 | * query(foo) 38 | * .whereEq(foo.id, 'id') 39 | * .sql() 40 | * ) 41 | */ 42 | with(f: () => QueryBottom): Table 43 | 44 | /** 45 | * Recursive common table expression (`WITH RECURSIVE`). 46 | */ 47 | withRecursive( 48 | f: () => QueryBottom, 49 | ): Table 50 | 51 | /** 52 | * SQL UNION of a set of queries 53 | */ 54 | union( 55 | q0: QueryBottom, 56 | q1: QueryBottom, 57 | ): QueryBottom 58 | union( 59 | q0: QueryBottom, 60 | q1: QueryBottom, 61 | q2: QueryBottom, 62 | ): QueryBottom 63 | union( 64 | q0: QueryBottom, 65 | q1: QueryBottom, 66 | q2: QueryBottom, 67 | q3: QueryBottom, 68 | ): QueryBottom 69 | union< 70 | S, 71 | P0 extends {}, 72 | P1 extends {}, 73 | P2 extends {}, 74 | P3 extends {}, 75 | P4 extends {}, 76 | >( 77 | q0: QueryBottom, 78 | q1: QueryBottom, 79 | q2: QueryBottom, 80 | q3: QueryBottom, 81 | q4: QueryBottom, 82 | ): QueryBottom 83 | union< 84 | S, 85 | P0 extends {}, 86 | P1 extends {}, 87 | P2 extends {}, 88 | P3 extends {}, 89 | P4 extends {}, 90 | P5 extends {}, 91 | >( 92 | q0: QueryBottom, 93 | q1: QueryBottom, 94 | q2: QueryBottom, 95 | q3: QueryBottom, 96 | q4: QueryBottom, 97 | q5: QueryBottom, 98 | ): QueryBottom 99 | union< 100 | S, 101 | P0 extends {}, 102 | P1 extends {}, 103 | P2 extends {}, 104 | P3 extends {}, 105 | P4 extends {}, 106 | P5 extends {}, 107 | P6 extends {}, 108 | >( 109 | q0: QueryBottom, 110 | q1: QueryBottom, 111 | q2: QueryBottom, 112 | q3: QueryBottom, 113 | q4: QueryBottom, 114 | q5: QueryBottom, 115 | q6: QueryBottom, 116 | ): QueryBottom 117 | 118 | /** 119 | * SQL UNION ALL of a set of queries 120 | */ 121 | unionAll( 122 | q0: QueryBottom, 123 | q1: QueryBottom, 124 | ): QueryBottom 125 | unionAll( 126 | q0: QueryBottom, 127 | q1: QueryBottom, 128 | q2: QueryBottom, 129 | ): QueryBottom 130 | unionAll( 131 | q0: QueryBottom, 132 | q1: QueryBottom, 133 | q2: QueryBottom, 134 | q3: QueryBottom, 135 | ): QueryBottom 136 | unionAll< 137 | S, 138 | P0 extends {}, 139 | P1 extends {}, 140 | P2 extends {}, 141 | P3 extends {}, 142 | P4 extends {}, 143 | >( 144 | q0: QueryBottom, 145 | q1: QueryBottom, 146 | q2: QueryBottom, 147 | q3: QueryBottom, 148 | q4: QueryBottom, 149 | ): QueryBottom 150 | unionAll< 151 | S, 152 | P0 extends {}, 153 | P1 extends {}, 154 | P2 extends {}, 155 | P3 extends {}, 156 | P4 extends {}, 157 | P5 extends {}, 158 | >( 159 | q0: QueryBottom, 160 | q1: QueryBottom, 161 | q2: QueryBottom, 162 | q3: QueryBottom, 163 | q4: QueryBottom, 164 | q5: QueryBottom, 165 | ): QueryBottom 166 | unionAll< 167 | S, 168 | P0 extends {}, 169 | P1 extends {}, 170 | P2 extends {}, 171 | P3 extends {}, 172 | P4 extends {}, 173 | P5 extends {}, 174 | P6 extends {}, 175 | >( 176 | q0: QueryBottom, 177 | q1: QueryBottom, 178 | q2: QueryBottom, 179 | q3: QueryBottom, 180 | q4: QueryBottom, 181 | q5: QueryBottom, 182 | q6: QueryBottom, 183 | ): QueryBottom 184 | 185 | insertInto: InsertIntoConstructor 186 | insertStatement: InsertStatementConstructor 187 | 188 | /** 189 | * Marker for default values in inserts and updates. 190 | */ 191 | DEFAULT: DefaultValue 192 | 193 | /** 194 | * SQL UPDATE. 195 | */ 196 | update(table: DatabaseTable): Update 197 | 198 | /** 199 | * SQL DELETE. 200 | */ 201 | deleteFrom(table: DatabaseTable): Delete 202 | } 203 | -------------------------------------------------------------------------------- /src/types/query/update.ts: -------------------------------------------------------------------------------- 1 | import { Expression, ExpressionFactory } from '../expression' 2 | import { RemoveTableName, Selection, Table } from '../table' 3 | import { DatabaseClient } from './databaseClient' 4 | import { NarrowDiscriminatedUnion } from './queryBottom' 5 | 6 | /** 7 | * SQL UPDATE statement builder. 8 | */ 9 | export declare class Update { 10 | protected __updateTable: T 11 | protected __updateParams: P 12 | protected __updateReturning: S 13 | 14 | /** 15 | * The parameter name that adds "colname: value" pairs to the `SET` clause. 16 | */ 17 | data( 18 | paramKey: N, 19 | selection: Selection, 20 | ): Update 21 | 22 | /** 23 | * Define a single expression added to the `SET` clause for the update. 24 | */ 25 | set( 26 | columnName: K, 27 | expression: (f: ExpressionFactory) => Expression, 28 | ): Update 29 | 30 | /** 31 | * Use an Expression as the where clause. 32 | */ 33 | where( 34 | e: (f: ExpressionFactory) => Expression, 35 | ): Update 36 | 37 | /** 38 | * Discriminated union support. 39 | * 40 | * Define a part of update against a single discrimiated union subtype. 41 | */ 42 | narrow< 43 | Key extends keyof T, 44 | Vals extends T[Key], 45 | NarrowedTable extends NarrowDiscriminatedUnion, 46 | P1 extends {}, 47 | S1 extends {}, 48 | >( 49 | key: Key, 50 | values: Vals | Vals[], 51 | cb: ( 52 | q: Update, 53 | t: Table, 54 | ) => Update, 55 | ): Update 56 | 57 | /** 58 | * Raise an exception if updated row count differs from the expectation. 59 | * 60 | * Raising the exception will abort any pending transaction. 61 | * 62 | * Passing a single number to expect exactly that number of rows. 63 | * Pass an object to define an *inclusive* range. If min or max are not 64 | * specified, Infinity is assumed. 65 | */ 66 | expectUpdatedRowCount(exactCount: number): Update 67 | expectUpdatedRowCount(range: { min: number }): Update 68 | expectUpdatedRowCount(range: { max: number }): Update 69 | expectUpdatedRowCount(range: { min: number; max: number }): Update 70 | 71 | /** 72 | * Specify a RETURNING clause. 73 | */ 74 | returning(selection: Selection): Update 75 | 76 | /** 77 | * Return the generated sql string. 78 | */ 79 | sql: (client: DatabaseClient, params?: P) => string 80 | 81 | /** 82 | * Log the generated sql string to the console. 83 | */ 84 | sqlLog: (client: DatabaseClient, params?: P) => Update 85 | 86 | /** 87 | * Perform the update. 88 | */ 89 | execute: {} extends P 90 | ? (client: DatabaseClient) => Promise<{} extends S ? void : S[]> 91 | : (client: DatabaseClient, params: P) => Promise<{} extends S ? void : S[]> 92 | } 93 | -------------------------------------------------------------------------------- /src/types/table/column.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from '../expression/expression' 2 | import { ExpressionFactory } from '../expression/expressionFactory' 3 | import { Table } from './table' 4 | 5 | type EnumObject = { [key: string]: string | number } 6 | 7 | /** 8 | * Marker for a columns default value. 9 | * 10 | * Used in the type definition and in inserts / updates similar to SQL's 11 | * `DEFAULT` keyword. 12 | */ 13 | export declare class DefaultValue { 14 | protected _typesafeQueryBuilderDefaultValue_: symbol 15 | } 16 | 17 | /** 18 | * A column of a table 19 | * 20 | * T .. column type 21 | */ 22 | export declare class Column { 23 | // column value type 24 | protected __t: T 25 | 26 | /// Builtin Column Types 27 | 28 | /** 29 | * Map this column to a typescript number that must be an integer. 30 | * 31 | * Column values being integers is only checked when inserting or updating 32 | * data. 33 | */ 34 | integer(): Column 35 | 36 | /** 37 | * Map this column to a string. 38 | * 39 | * postgres types: TEXT, VARCHAR 40 | */ 41 | string(): Column 42 | 43 | /** 44 | * Map this column to a boolean. 45 | * 46 | * postgres type: BOOLEAN 47 | */ 48 | boolean(): Column 49 | 50 | /** 51 | * Map this column to a date. 52 | * 53 | * postgres types: TIMESTAMPTZ 54 | */ 55 | date(): Column 56 | 57 | /** 58 | * Map this column to an json object. 59 | * 60 | * Validator should be function that validates the type of the incoming 61 | * data. Validator is called before inserting or updating. 62 | * The resulting value is JSON.stringified before being inserted into 63 | * postgres or passed to and update query. 64 | * 65 | * postgres types: JSON, JSONB 66 | */ 67 | json(validator: (data: unknown) => J): Column 68 | 69 | /** 70 | * Literal type or literal union type. 71 | * 72 | * Use a string / number literal union in place of an enum. 73 | * Use a single string / number literal as type tags in discriminated unions. 74 | */ 75 | literal(...values: A): Column 76 | 77 | /** 78 | * A typescript enum mapped to an INT or TEXT column . 79 | */ 80 | enum(enumObject: T): Column 81 | 82 | /// Modifiers 83 | 84 | /** 85 | * Mark this column as being the sole or part of the tables primary key. 86 | * 87 | * Has no meaning right now and is just to document the schema 88 | */ 89 | primary(): Column 90 | 91 | /** 92 | * Mark this column has having a default value. 93 | * 94 | * Columns with defaults can be ommitted in insert queries (using 95 | * `valueWithDefaults` or `valuesWithDefaults` from `insertInto`) or require 96 | * an explicit `query.DEFAULT` value in inserts. 97 | * 98 | * Most useful for autogenerated id queries. 99 | */ 100 | default(): Column 101 | 102 | /** 103 | * Make this column nullable. 104 | * 105 | * That means it can hold `null`. 106 | * 107 | * In contrast to SQL, declaring the column nullable will not result in it 108 | * using `null` as the default value - if you want that behaviour, eplicitly 109 | * mark the column as `.default()`. 110 | */ 111 | null(): Column 112 | 113 | /// Building Custom Column Types 114 | 115 | /** 116 | * A column which is defined by an arbitrary runtype. 117 | */ 118 | type(runtype: (v: unknown) => T): Column 119 | 120 | /** 121 | * Apply a cast and result transformation when selecting this column. 122 | * 123 | * `cast` is an sql expression which is applied whenever the column is 124 | * selected. Use `t.value` to reference the selected column value. 125 | * 126 | * `resultTransformation` is a function that is called once the query has 127 | * been fetched on the casted result. It should return the actual result 128 | * value. 129 | * 130 | * Use this to define custom type casts that work through plan selects *and* JSON 131 | * object / array selectds & aggregations. 132 | * 133 | * For example, the builtin `date` column type uses this to extract a dates 134 | * unix timestamp via `EXTRACT(EPOCH FROM t.value) * 1000` and later creates 135 | * a Javascript Date object from the resulting timestamp. In contrast to the 136 | * plain node-postgres type mapping, this works when selecting date columns 137 | * with `selectJsonObject` (which uses json_build_object under the hood) too. 138 | */ 139 | cast>( 140 | cast: (e: ExpressionFactory, t: V) => Expression, 141 | resultTransformation: (value: I) => R, 142 | ): Column 143 | 144 | /** 145 | * Set the name of the columns sql type. 146 | * 147 | * Used to check tables against the schema and to ensure all overlapping 148 | * columns of discriminated unions have the same type. 149 | */ 150 | sqlType(name: string): Column 151 | } 152 | 153 | /** 154 | * Column constructor 155 | * 156 | * `sqlName` is the name of the column in the database table, e.g. `user_id`. 157 | */ 158 | export interface ColumnConstructor { 159 | (sqlName: string): Column 160 | } 161 | -------------------------------------------------------------------------------- /src/types/table/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './column' 2 | export type * from './table' 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | import * as nodeAssert from 'assert' 3 | 4 | /** 5 | * Pick defined keys from an object returning a new object. 6 | */ 7 | export function pick( 8 | obj: T, 9 | ...keys: U[] 10 | ): Pick { 11 | const res: any = {} 12 | 13 | for (let i = 0; i < keys.length; i++) { 14 | if (obj.hasOwnProperty(keys[i])) { 15 | // Only pick keys that are present on the source object. 16 | // Same behavior as lodash.pick. 17 | // Allows picking keys from Partial objects without introducing 18 | // undefined values in the resulting object 19 | // e.g.: 20 | // pick({}, 'a') is {} and not {a: undefined} 21 | // and: 22 | // pick({a: undefined}, 'a') is {a: undefined} and not {} 23 | res[keys[i]] = obj[keys[i]] 24 | } 25 | } 26 | 27 | return res 28 | } 29 | 30 | /** 31 | * Omit keys from an object returning a new object. 32 | */ 33 | export function omit( 34 | obj: T, 35 | ...keys: U[] 36 | ): Omit { 37 | const res: any = { ...obj } 38 | 39 | for (let i = 0; i < keys.length; i++) { 40 | delete res[keys[i]] 41 | } 42 | 43 | return res 44 | } 45 | 46 | export function assert(condition: boolean, msg?: string): asserts condition { 47 | if (!condition) { 48 | nodeAssert.fail(msg || 'assertion failed') 49 | } 50 | } 51 | 52 | export function assertFail(msg?: string): never { 53 | nodeAssert.fail(msg || 'assertion failed') 54 | } 55 | 56 | export function assertNever(x: never): never { 57 | nodeAssert.fail('Unexpected value. Should have been never.') 58 | } 59 | 60 | /** 61 | * Return duplicate strings in the array or undefined. 62 | */ 63 | export function findDuplicates(src: string[]): string[] | undefined { 64 | const s = new Set(src) 65 | 66 | if (s.size === src.length) { 67 | return 68 | } 69 | 70 | return src.filter((x) => { 71 | if (s.has(x)) { 72 | s.delete(x) 73 | 74 | return false 75 | } 76 | 77 | return true 78 | }) 79 | } 80 | 81 | /** 82 | * Intersection of arrays of strings. 83 | */ 84 | export function intersection(...a: string[][]): Set { 85 | if (!a.length) { 86 | return new Set() 87 | } 88 | 89 | const res = new Set(a[0]) 90 | 91 | for (let i = 1; i < a.length; i++) { 92 | const s = new Set(a[i]) 93 | 94 | res.forEach((x) => { 95 | if (x !== undefined && !s.has(x)) { 96 | res.delete(x) 97 | } 98 | }) 99 | } 100 | 101 | return res 102 | } 103 | 104 | /** 105 | * Format values readably for error messages. 106 | */ 107 | export function formatValues(...vals: any[]): string { 108 | return vals 109 | .map((v: any) => { 110 | const s = inspect(v, { 111 | // without newlines so it lands conveniently on a single live 112 | // in the servers log 113 | compact: true, 114 | breakLength: 2 ** 16, 115 | }) 116 | 117 | if (s.length < 254) { 118 | return s 119 | } 120 | 121 | return s.slice(0, 250) + '...' 122 | }) 123 | .join(', ') 124 | } 125 | -------------------------------------------------------------------------------- /test-d/delete.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { Delete } from '../src/types' 4 | import { client } from './helpers' 5 | import { Devices, Systems } from './helpers/classicGames' 6 | 7 | function deleteParams(t: Delete): X { 8 | return {} as any 9 | } 10 | 11 | function deleteResult(t: Delete): X { 12 | return {} as any 13 | } 14 | 15 | { 16 | const q = query 17 | .deleteFrom(Systems) 18 | .where(({ eq }) => eq(Systems.id, 'id')) 19 | .expectDeletedRowCount(1) 20 | 21 | expectType<{ id: number }>(deleteParams(q)) 22 | expectType<{}>(deleteResult(q)) 23 | expectType>(q.execute(client, { id: 1 })) 24 | 25 | expectType>( 26 | q.returning(Systems.include('name', 'year')).execute(client, { id: 1 }), 27 | ) 28 | 29 | expectError( 30 | query.deleteFrom(Systems).where(({ eq }) => 31 | eq( 32 | // table not referenced in delete 33 | Devices.id, 34 | 'id', 35 | ), 36 | ), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /test-d/expressions/coalesce.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { Franchises, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | { 9 | // param 10 | expectType<['a' | 'b', { a: 'a' | null }]>( 11 | expressionType( 12 | f.coalesce(f.param('a').type<'a' | null>(), f.literalString('b')), 13 | ), 14 | ) 15 | 16 | // nullable is optional 17 | expectType<[string, { a: string }]>( 18 | expressionType(f.coalesce(f.param('a').string(), f.literalString('b'))), 19 | ) 20 | 21 | // table column 22 | expectType<[number, {}]>( 23 | expressionType(f.coalesce(Franchises.manufacturerId, f.literal(0))), 24 | ) 25 | 26 | // invalid table column 27 | expectError(f.coalesce(Systems.id, f.literal(0))) 28 | 29 | // union types are not allowed (sql rule) 30 | expectError(f.coalesce(f.param('a').number(), f.literal('foobar'))) 31 | } 32 | -------------------------------------------------------------------------------- /test-d/expressions/eq.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { Expression, expressionFactory } from '../../src' 3 | import { Franchises, Manufacturers, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | // column + param 9 | { 10 | expectType<[boolean, { id: number }]>( 11 | expressionType(f.eq(Franchises.id, 'id')), 12 | ) 13 | expectType<[boolean, { name: string }]>( 14 | expressionType(f.eq(Franchises.name, 'name')), 15 | ) 16 | 17 | expectError(f.eq(Systems.name, 'name')) // table not used in query 18 | } 19 | 20 | // nullable column + param 21 | { 22 | expectType< 23 | [ 24 | // -> comparing against a nullable column may result in null 25 | boolean | null, 26 | // -> the inferred parameter is non-null because you cannot compare 27 | // against null using `=` 28 | { mid: number }, 29 | ] 30 | >(expressionType(f.eq(Franchises.manufacturerId, 'mid'))) 31 | } 32 | 33 | // column (expression) + expression 34 | { 35 | expectType<[boolean, {}]>(expressionType(f.eq(Franchises.id, f.literal(10)))) 36 | expectType<[boolean, {}]>( 37 | expressionType(f.eq(Franchises.name, f.literal('Ultima'))), 38 | ) 39 | 40 | expectError(f.eq(Franchises.id, f.literal('10'))) // mismatching rhs type 41 | expectError(f.eq(Franchises.name, f.literal(1))) // mismatching rhs type 42 | expectError(f.eq(Systems.name, f.literal('Ultima'))) // unknown table 43 | } 44 | 45 | // expression + expression 46 | { 47 | expectType<[boolean, {}]>(expressionType(f.eq(f.literal(42), f.literal(42)))) 48 | 49 | expectError(f.eq(f.literal(42), f.literal('42'))) // mismatching types 50 | } 51 | 52 | // nullable expression + expression 53 | { 54 | const nullable: Expression = 0 as any 55 | 56 | expectType<[boolean | null, {}]>( 57 | expressionType(f.eq(f.literal(42), nullable)), 58 | ) 59 | expectType<[boolean | null, {}]>(expressionType(f.eq(nullable, nullable))) 60 | } 61 | 62 | // param + expression / expression + param 63 | { 64 | expectType<[boolean, { isTrue: boolean }]>( 65 | expressionType(f.eq('isTrue', f.literal(true))), 66 | ) 67 | expectType<[boolean, { isTrue: boolean }]>( 68 | expressionType(f.eq(f.literal(true), 'isTrue')), 69 | ) 70 | } 71 | 72 | // expression + uncorrelated subquery 73 | { 74 | expectType<[boolean | null, { name: string }]>( 75 | expressionType( 76 | f.eq( 77 | Franchises.manufacturerId, 78 | f 79 | .subquery(Manufacturers) 80 | .select(Manufacturers.include('id')) 81 | .where(({ eq }) => eq(Manufacturers.name, 'name')), 82 | ), 83 | ), 84 | ) 85 | 86 | expectError( 87 | f.eq( 88 | Franchises.manufacturerId, 89 | f 90 | .subquery(Manufacturers) 91 | .select(Manufacturers.include('name')) // selected column type mismatch 92 | .where(({ eq }) => eq(Manufacturers.name, 'name')), 93 | ), 94 | ) 95 | 96 | expectError( 97 | f.eq( 98 | Franchises.name, 99 | f 100 | .subquery(Manufacturers) 101 | .select(Manufacturers.include('country', 'name')) // more than 1 selected column 102 | .where(({ eq }) => eq(Manufacturers.name, 'name')), 103 | ), 104 | ) 105 | } 106 | 107 | // expression + correlated subquery 108 | { 109 | expectType<[boolean | null, { name: string }]>( 110 | expressionType( 111 | f.eq( 112 | f.param('name').string(), 113 | f 114 | .subquery(Manufacturers) 115 | .select(Manufacturers.include('name')) 116 | .where((d) => d.eq(Manufacturers.id, Franchises.manufacturerId)), 117 | ), 118 | ), 119 | ) 120 | 121 | expectError( 122 | f.eq( 123 | f.param('name').string(), 124 | f 125 | .subquery(Manufacturers) 126 | .select(Manufacturers.include('name')) 127 | .where((d) => 128 | d.eq( 129 | Manufacturers.id, 130 | // non-correlated table 131 | Systems.id, 132 | ), 133 | ), 134 | ), 135 | ) 136 | 137 | expectError( 138 | f.eq( 139 | f.param('name').string(), 140 | f 141 | .subquery(Manufacturers) 142 | .select(Manufacturers.include('name')) 143 | .where((d) => 144 | d.eq( 145 | Manufacturers.id, 146 | // invalid correlated column type 147 | Franchises.name, 148 | ), 149 | ), 150 | ), 151 | ) 152 | 153 | expectError( 154 | f.eq( 155 | f.param('name').string(), 156 | f 157 | .subquery(Manufacturers) 158 | .select(Manufacturers.include('name')) 159 | .where((d) => 160 | d.eq( 161 | Manufacturers.name, 162 | // invalid correlated column type 163 | Franchises.id, 164 | ), 165 | ), 166 | ), 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /test-d/expressions/exists.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { Franchises, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | { 9 | expectType<[boolean, {}]>( 10 | expressionType(f.exists(f.subquery(Systems).select(Systems.include('id')))), 11 | ) 12 | 13 | expectType<[boolean, { year: number }]>( 14 | expressionType( 15 | f.exists( 16 | f 17 | .subquery(Systems) 18 | .select(Systems.include('id')) 19 | .where(({ eq }) => eq(Systems.year, 'year')), 20 | ), 21 | ), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /test-d/expressions/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from '../../src' 2 | 3 | // needed as otherwise tsd will not accept that 4 | // `{a: A} & {b: B}` and `{a: A, b: B}` are the same types. 5 | type Simplify = { [K in keyof T]: T[K] } 6 | 7 | export function expressionType( 8 | e: Expression, 9 | ): [R, Simplify

] { 10 | return e as any 11 | } 12 | -------------------------------------------------------------------------------- /test-d/expressions/isIn.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { Franchises, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | { 9 | // subselect 10 | expectType<[boolean | null, {}]>( 11 | expressionType( 12 | f.isIn(f.literal(1), f.subquery(Systems).select(Systems.include('id'))), 13 | ), 14 | ) 15 | 16 | // null propagation 17 | expectType<[boolean | null, {}]>( 18 | expressionType( 19 | f.isIn( 20 | f.literal(1), 21 | f.subquery(Franchises).select(Franchises.include('manufacturerId')), 22 | ), 23 | ), 24 | ) 25 | 26 | expectError( 27 | // invalid type comparison: string IN number[] 28 | f.isIn(f.literal('foo'), f.subquery(Systems).select(Systems.include('id'))), 29 | ) 30 | 31 | expectError( 32 | // invalid type comparison: boolean IN string[] 33 | f.isIn( 34 | f.literal(false), 35 | f.subquery(Systems).select(Systems.include('name')), 36 | ), 37 | ) 38 | } 39 | 40 | { 41 | // expression + param 42 | expectType<[boolean, { ids: number[] }]>( 43 | expressionType(f.isIn(Franchises.id, 'ids')), 44 | ) 45 | 46 | // null propagation 47 | expectType<[boolean | null, { mIds: number[] }]>( 48 | expressionType(f.isIn(Franchises.manufacturerId, 'mIds')), 49 | ) 50 | } 51 | 52 | { 53 | // expression 54 | expectType<[boolean, { alist: string[] }]>( 55 | expressionType(f.isIn(f.literal('a'), f.param('alist').type())), 56 | ) 57 | 58 | // wrong type 59 | expectError(f.isIn(f.literal(1), f.param('alist').type())) 60 | } 61 | -------------------------------------------------------------------------------- /test-d/expressions/isNull.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { Franchises, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | { 9 | // table column param 10 | expectType<[boolean, {}]>(expressionType(f.isNull(Franchises.manufacturerId))) 11 | 12 | expectError(f.isNull(Franchises.name)) // column not nullable 13 | expectError(f.eq(Systems.name, 'name')) // unknown table 14 | } 15 | 16 | { 17 | // expression param 18 | expectType<[boolean, {}]>(expressionType(f.isNull(f.literal(null)))) 19 | 20 | expectError(f.isNull(f.literal('foo'))) // expression not nullable 21 | } 22 | -------------------------------------------------------------------------------- /test-d/expressions/literal.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { Franchises } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(Franchises) 7 | 8 | { 9 | // literal types are widened to their bases 10 | expectType<[string, {}]>(expressionType(f.literal('foo'))) 11 | expectType<[number, {}]>(expressionType(f.literal(42))) 12 | expectType<[null, {}]>(expressionType(f.literal(null))) 13 | expectType<[boolean, {}]>(expressionType(f.literal(false))) 14 | expectType<[bigint, {}]>(expressionType(f.literal(BigInt(24)))) 15 | 16 | expectError(f.literal(Symbol('x'))) // not a literal sql type 17 | expectError(f.literal({ a: 1 })) // not a literal sql type 18 | } 19 | 20 | { 21 | // use literalString to explicitly get a string literal type 22 | expectType<['foo', {}]>(expressionType(f.literalString('foo'))) 23 | expectType<['bar', {}]>(expressionType(f.literalString('bar'))) 24 | expectType<['', {}]>(expressionType(f.literalString(''))) 25 | 26 | expectError(f.literalString(null)) // not a string 27 | expectError(f.literalString(123)) // not a string 28 | expectError(f.literalString(false)) // not a string 29 | } 30 | -------------------------------------------------------------------------------- /test-d/expressions/not.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { expressionFactory } from '../../src' 3 | import { GamesSystems, Systems } from '../helpers/classicGames' 4 | import { expressionType } from './helpers' 5 | 6 | const f = expressionFactory(GamesSystems) 7 | const g = expressionFactory(Systems) 8 | 9 | { 10 | // param 11 | expectType<[boolean, { test: boolean }]>( 12 | expressionType(f.not(f.param('test').boolean())), 13 | ) 14 | 15 | // null propagation 16 | expectType<[boolean | null, { test: boolean | null }]>( 17 | expressionType(f.not(f.param('test').type())), 18 | ) 19 | 20 | // table column 21 | expectType<[boolean, {}]>(expressionType(f.not(GamesSystems.played))) 22 | 23 | // not a boolean 24 | expectError(f.not(GamesSystems.gameId)) 25 | 26 | // invalid table 27 | expectError(g.not(GamesSystems.played)) 28 | } 29 | -------------------------------------------------------------------------------- /test-d/helpers/classicGames.ts: -------------------------------------------------------------------------------- 1 | export * from '../../test/helpers/classicGames' 2 | -------------------------------------------------------------------------------- /test-d/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { QueryBottom, DatabaseClient } from '../../src/types/query' 2 | 3 | // functions to extract types from a query to assert them with tsd's expectType 4 | // QueryBottom 5 | 6 | // needed as otherwise tsd will not accept that 7 | // `{a: A} & {b: B}` and `{a: A, b: B}` are the same types. 8 | type Simplify = { [K in keyof T]: T[K] } 9 | 10 | export function parameterType( 11 | q: QueryBottom, 12 | ): Simplify

{ 13 | return q as any 14 | } 15 | 16 | export function resultType( 17 | q: QueryBottom, 18 | ): S { 19 | return q as any 20 | } 21 | 22 | // fake database client 23 | export const client: DatabaseClient = {} as DatabaseClient 24 | -------------------------------------------------------------------------------- /test-d/helpers/pcComponents.ts: -------------------------------------------------------------------------------- 1 | export * from '../../test/helpers/pcComponents' 2 | -------------------------------------------------------------------------------- /test-d/join.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { 5 | Franchises, 6 | Games, 7 | GamesSystems, 8 | Manufacturers, 9 | Systems, 10 | } from './helpers/classicGames' 11 | 12 | // join 13 | 14 | { 15 | // 2 table join with a 2 param select 16 | const q = query(Manufacturers) 17 | .join(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 18 | .select( 19 | Manufacturers.include('id', 'name').rename({ name: 'manufacturer' }), 20 | Systems.include('name').rename({ name: 'system' }), 21 | ) 22 | 23 | expectType<{}>(parameterType(q)) 24 | expectType<{ id: number; manufacturer: string } & { system: string }>( 25 | resultType(q), 26 | ) 27 | 28 | expectError( 29 | query(Manufacturers) 30 | .join(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 31 | // Games is a table not used in this query 32 | .join(Franchises, ({ eq }) => eq(Games.id, Manufacturers.id)), 33 | ) 34 | } 35 | 36 | { 37 | // 3 table join with a 3 param select 38 | const q = query(Manufacturers) 39 | .join(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 40 | .join(Franchises, ({ eq }) => 41 | eq(Manufacturers.id, Franchises.manufacturerId), 42 | ) 43 | .select( 44 | Manufacturers.include('name').rename({ name: 'manufacturer' }), 45 | Systems.include('name').rename({ name: 'system' }), 46 | Franchises.include('name').rename({ name: 'franchise' }), 47 | ) 48 | 49 | expectType<{}>(parameterType(q)) 50 | expectType< 51 | { manufacturer: string } & { system: string } & { franchise: string } 52 | >(resultType(q)) 53 | 54 | expectError( 55 | query(Manufacturers) 56 | .join(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 57 | .join(Franchises, ({ eq }) => 58 | eq(Manufacturers.id, Franchises.manufacturerId), 59 | ) 60 | .select( 61 | Manufacturers.include('name').rename({ name: 'manufacturer' }), 62 | Systems.include('name').rename({ name: 'system' }), 63 | // non joined table 64 | Games.include('title'), 65 | ), 66 | ) 67 | } 68 | 69 | { 70 | // 3 table left-join with a 3 param select 71 | const q = query(Manufacturers) 72 | .leftJoin(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 73 | .leftJoin(Franchises, ({ eq }) => 74 | eq(Manufacturers.id, Franchises.manufacturerId), 75 | ) 76 | .select( 77 | Manufacturers.include('name').rename({ name: 'manufacturer' }), 78 | Systems.include('name').rename({ name: 'system' }), 79 | Franchises.include('name').rename({ name: 'franchise' }), 80 | ) 81 | expectType<{}>(parameterType(q)) 82 | expectType< 83 | { 84 | manufacturer: string 85 | } & { 86 | system: string | null 87 | } & { 88 | franchise: string | null 89 | } 90 | >(resultType(q)) 91 | } 92 | 93 | { 94 | // left join and json object select 95 | const q = query(Manufacturers) 96 | .leftJoin(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 97 | .leftJoin(Franchises, ({ eq }) => 98 | eq(Manufacturers.id, Franchises.manufacturerId), 99 | ) 100 | .selectJsonObject( 101 | { key: 'object' }, 102 | Manufacturers.include('name').rename({ name: 'manufacturer' }), 103 | Systems.include('name').rename({ name: 'system' }), 104 | Franchises.include('name').rename({ name: 'franchise' }), 105 | ) 106 | 107 | expectType<{}>(parameterType(q)) 108 | 109 | expectType<{ 110 | object: { 111 | manufacturer: string 112 | } & { 113 | system: string | null 114 | } & { 115 | franchise: string | null 116 | } 117 | }>(resultType(q)) 118 | } 119 | 120 | { 121 | // 5 table join (don't have more test tables) 122 | const q = query(Manufacturers) 123 | .join(Systems, ({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)) 124 | .join(Franchises, ({ eq }) => 125 | eq(Manufacturers.id, Franchises.manufacturerId), 126 | ) 127 | .join(GamesSystems, ({ eq }) => eq(Systems.id, GamesSystems.systemId)) 128 | .join(Games, ({ and, eq }) => 129 | and( 130 | eq(GamesSystems.gameId, Games.id), 131 | eq(Games.franchiseId, Franchises.id), 132 | ), 133 | ) 134 | .select( 135 | Manufacturers.include('name').rename({ name: 'manufacturer' }), 136 | Systems.include('name').rename({ name: 'system' }), 137 | Franchises.include('name').rename({ name: 'franchise' }), 138 | GamesSystems.include('releaseDate'), 139 | Games.include('title'), 140 | ) 141 | 142 | expectType<{}>(parameterType(q)) 143 | expectType< 144 | { 145 | manufacturer: string 146 | } & { 147 | system: string 148 | } & { 149 | franchise: string 150 | } & { 151 | releaseDate: Date | null 152 | } & { 153 | title: string 154 | } 155 | >(resultType(q)) 156 | } 157 | -------------------------------------------------------------------------------- /test-d/limit.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { Systems } from './helpers/classicGames' 5 | 6 | { 7 | const q = query(Systems).select(Systems.include('name')).limit(1).offset(1) 8 | 9 | expectType<{ name: string }>(resultType(q)) 10 | expectType<{}>(parameterType(q)) 11 | } 12 | 13 | { 14 | // with parameters 15 | const q = query(Systems) 16 | .select(Systems.include('name')) 17 | .limit('a') 18 | .offset('b') 19 | 20 | expectType<{ name: string }>(resultType(q)) 21 | expectType<{ a: number; b: number }>(parameterType(q)) 22 | } 23 | -------------------------------------------------------------------------------- /test-d/lock.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { Systems } from './helpers/classicGames' 5 | 6 | { 7 | const q = query(Systems).select(Systems.include('name')).lock('forUpdate') 8 | 9 | expectType<{ name: string }>(resultType(q)) 10 | expectType<{}>(parameterType(q)) 11 | } 12 | -------------------------------------------------------------------------------- /test-d/orderBy.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { Games, Systems } from './helpers/classicGames' 5 | 6 | { 7 | const q = query(Systems).select(Systems.include('name')).orderBy(Systems.year) 8 | 9 | expectType<{ name: string }>(resultType(q)) 10 | expectType<{}>(parameterType(q)) 11 | } 12 | 13 | // invalid table 14 | expectError(query(Systems).select(Systems.include('name')).orderBy(Games.id)) 15 | 16 | // column type not sortable: json 17 | expectError(query(Games).select(Games.include('id')).orderBy(Games.urls)) 18 | -------------------------------------------------------------------------------- /test-d/select-json.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType, expectError } from 'tsd' 2 | import { DatabaseClient, query } from '../src' 3 | import { resultType } from './helpers' 4 | import { 5 | Franchises, 6 | GamesSystems, 7 | Manufacturers, 8 | Systems, 9 | } from './helpers/classicGames' 10 | 11 | { 12 | // selecting columns into a json object 13 | expectType<{ system: { id: number; name: string } }>( 14 | resultType( 15 | query(Systems).selectJsonObject( 16 | { key: 'system' }, 17 | Systems.include('id', 'name'), 18 | ), 19 | ), 20 | ) 21 | } 22 | 23 | { 24 | expectType<{ system: { system_id: number; system_name: string } }>( 25 | resultType( 26 | query(Systems).selectJsonObject( 27 | { key: 'system' }, 28 | Systems.include('id', 'name') 29 | // renaming columns of a json object 30 | .rename({ 31 | id: 'system_id', 32 | name: 'system_name', 33 | }), 34 | ), 35 | ), 36 | ) 37 | } 38 | 39 | { 40 | // selecting a single column into a json array 41 | expectType<{ systemNames: string[] }>( 42 | resultType( 43 | query(Systems).selectJsonArray( 44 | { key: 'systemNames' }, 45 | Systems.include('name'), 46 | ), 47 | ), 48 | ) 49 | 50 | // order 51 | expectType<{ systemNames: string[] }>( 52 | resultType( 53 | query(Systems).selectJsonArray( 54 | { key: 'systemNames', orderBy: Systems.name, direction: 'desc' }, 55 | Systems.include('name'), 56 | ), 57 | ), 58 | ) 59 | 60 | expectType<{ systems: unknown[] }>( 61 | resultType( 62 | query(Systems) 63 | // its an error if the selection contains more than 1 col 64 | .selectJsonArray({ key: 'systems' }, Systems.include('id', 'name')), 65 | ), 66 | ) 67 | 68 | expectType<{ systemNames: unknown[] }>( 69 | resultType( 70 | query(Systems) 71 | // its an error if the selection contains no column 72 | .selectJsonArray({ key: 'systemNames' }, Systems.include()), 73 | ), 74 | ) 75 | 76 | expectError( 77 | resultType( 78 | query(Systems).selectJsonArray( 79 | // invalid order column 80 | { key: 'systemNames', orderBy: GamesSystems.gameId, direction: 'desc' }, 81 | Systems.include('name'), 82 | ), 83 | ), 84 | ) 85 | } 86 | 87 | { 88 | // selecting columns into a json object array 89 | 90 | expectType<{ systems: { year: number; name: string }[] }>( 91 | resultType( 92 | query(Systems).selectJsonObjectArray( 93 | { key: 'systems' }, 94 | Systems.include('year', 'name'), 95 | ), 96 | ), 97 | ) 98 | 99 | expectType<{ systems: { systems_year: number; name: string }[] }>( 100 | resultType( 101 | query(Systems).selectJsonObjectArray( 102 | { key: 'systems' }, 103 | Systems.include('year', 'name').rename({ year: 'systems_year' }), 104 | ), 105 | ), 106 | ) 107 | } 108 | 109 | { 110 | // json object array as a subselect 111 | expectAssignable<{ 112 | name: string 113 | franchises: { id: number; name: string }[] | null 114 | }>( 115 | resultType( 116 | query(Manufacturers).select(Manufacturers.include('name'), (subquery) => 117 | subquery(Franchises) 118 | .selectJsonObjectArray( 119 | { key: 'franchises' }, 120 | Franchises.include('id', 'name'), 121 | ) 122 | .where(({ eq }) => eq(Franchises.manufacturerId, Manufacturers.id)), 123 | ), 124 | ), 125 | ) 126 | } 127 | 128 | { 129 | // selecting a date via json will still result in a date because of internal 130 | // casts 131 | expectType<{ nested: { gameId: number; releaseDate: Date | null } }>( 132 | resultType( 133 | query(GamesSystems).selectJsonObject( 134 | { key: 'nested' }, 135 | GamesSystems.include('gameId', 'releaseDate'), 136 | ), 137 | ), 138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /test-d/select-nested-json.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { resultType } from './helpers' 4 | import { 5 | Games, 6 | GamesSystems, 7 | Manufacturers, 8 | Systems, 9 | } from './helpers/classicGames' 10 | 11 | { 12 | // building nested json from subqueries 13 | const q = query(Manufacturers).select( 14 | Manufacturers.include('name').rename({ name: 'company' }), 15 | (subquery) => 16 | subquery(Systems) 17 | .selectJsonObjectArray( 18 | { key: 'systems', orderBy: Systems.year, direction: 'asc' }, 19 | Systems.include('name', 'id'), 20 | (subquery) => 21 | subquery(Games) 22 | .join(GamesSystems, ({ eq }) => eq(Games.id, GamesSystems.gameId)) 23 | .selectJsonObjectArray( 24 | { key: 'games', orderBy: Games.title, direction: 'asc' }, 25 | Games.include('title'), 26 | GamesSystems.include('releaseDate'), 27 | ) 28 | .where(({ eq }) => eq(Systems.id, GamesSystems.systemId)), 29 | ) 30 | .where(({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)), 31 | ) 32 | 33 | const res = resultType(q) 34 | 35 | expectType(res.company) 36 | expectType(res.systems?.[0].id) 37 | expectType(res.systems?.[0].name) 38 | expectType(res.systems?.[0].games?.[0].title) 39 | expectType(res.systems?.[0].games?.[0].releaseDate) 40 | 41 | expectType< 42 | // need to manually concatenate types with `&` t make tsd happy 43 | { 44 | company: string 45 | } & { 46 | systems: 47 | | ({ id: number; name: string } & { 48 | games: 49 | | ({ 50 | title: string 51 | } & { 52 | releaseDate: Date | null 53 | })[] 54 | | null 55 | })[] 56 | | null 57 | } 58 | >(resultType(q)) 59 | } 60 | -------------------------------------------------------------------------------- /test-d/select-subselect.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectError, expectType } from 'tsd' 2 | import { DatabaseClient, query } from '../src' 3 | import { 4 | Franchises, 5 | Games, 6 | GamesSystems, 7 | Manufacturers, 8 | Systems, 9 | } from './helpers/classicGames' 10 | import { resultType, parameterType } from './helpers' 11 | import { 12 | ExpressionAlias, 13 | ExpressionType, 14 | ExpressionParameter, 15 | ExpressionTable, 16 | } from '../src/types/expression/expression' 17 | 18 | const client: DatabaseClient = {} as DatabaseClient 19 | 20 | { 21 | // single subselect 22 | 23 | const q = query(Systems).select((subquery) => 24 | subquery(Manufacturers) 25 | .select(Manufacturers.include('name')) 26 | .where(({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)), 27 | ) 28 | 29 | expectType<{}>(parameterType(q)) 30 | expectType<{ name: string | null }>(resultType(q)) 31 | 32 | expectError( 33 | query(Systems).select((subquery) => 34 | subquery(Manufacturers) 35 | // more than 1 col selected 36 | .select(Manufacturers.include('name', 'id')) 37 | .where(({ eq }) => eq(Manufacturers.id, Systems.id)), 38 | ), 39 | ) 40 | 41 | expectError( 42 | query(Systems).select((subquery) => 43 | subquery(Manufacturers) 44 | // mismatching column types 45 | .where(({ eq }) => eq(Manufacturers.id, Systems.name)) 46 | .select(Manufacturers.include('name')), 47 | ), 48 | ) 49 | } 50 | 51 | { 52 | // select and subselect 53 | 54 | const q = query(Systems) 55 | .select(Systems.include('id', 'manufacturerId')) 56 | .select((subquery) => 57 | subquery(Manufacturers) 58 | .where(({ eq }) => eq(Manufacturers.id, Systems.id)) 59 | .select(Manufacturers.include('name')), 60 | ) 61 | 62 | expectType<{}>(parameterType(q)) 63 | expectType<{ id: number; manufacturerId: number } & { name: string | null }>( 64 | resultType(q), 65 | ) 66 | } 67 | 68 | { 69 | // two subqueries 70 | const q = query(Systems).select( 71 | (subquery) => 72 | subquery(Manufacturers) 73 | .where(({ eq }) => eq(Manufacturers.id, Systems.id)) 74 | .select(Manufacturers.include('name')), 75 | (subquery) => 76 | subquery(Games) 77 | .join(GamesSystems, ({ eq }) => eq(Games.id, GamesSystems.gameId)) 78 | .where(({ eq }) => eq(GamesSystems.systemId, Systems.id)) 79 | .select(Games.include('title')), 80 | ) 81 | 82 | expectType<{}>(parameterType(q)) 83 | expectType<{ name: string | null } & { title: string | null }>(resultType(q)) 84 | } 85 | -------------------------------------------------------------------------------- /test-d/select.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectError, expectType } from 'tsd' 2 | import { DatabaseClient, query } from '../src' 3 | import { 4 | Franchises, 5 | Games, 6 | GamesSystems, 7 | Manufacturers, 8 | Systems, 9 | } from './helpers/classicGames' 10 | 11 | const client: DatabaseClient = {} as DatabaseClient 12 | 13 | const selectTests = (async () => { 14 | // `.include` 15 | 16 | expectType<{ name: string }[]>( 17 | await query(Systems).select(Systems.include('name')).fetch(client), 18 | ) 19 | 20 | expectType<{ id: number; name: string }[]>( 21 | await query(Systems).select(Systems.include('id', 'name')).fetch(client), 22 | ) 23 | 24 | expectError( 25 | await query(Systems) 26 | // selecting from a table not included in the query 27 | .select(Manufacturers.include('id', 'name')) 28 | .fetch(client), 29 | ) 30 | 31 | expectError( 32 | await query(Systems) 33 | // selecting fields not int the table 34 | .select(Systems.include('id', 'non-existing-field')) 35 | .fetch(client), 36 | ) 37 | 38 | // `.all` 39 | 40 | expectType< 41 | { id: number; name: string; year: number; manufacturerId: number }[] 42 | >(await query(Systems).select(Systems.all()).fetch(client)) 43 | 44 | // .select over joined columns 45 | 46 | expectType<{ name: string }[]>( 47 | await query(Franchises) 48 | .join(Manufacturers, ({ eq }) => 49 | eq(Franchises.manufacturerId, Manufacturers.id), 50 | ) 51 | .select(Manufacturers.include('name')) 52 | .fetch(client), 53 | ) 54 | 55 | // .select over left-joined columns 56 | 57 | expectType<{ name: string | null }[]>( 58 | await query(Franchises) 59 | .leftJoin(Manufacturers, ({ eq }) => 60 | eq(Franchises.manufacturerId, Manufacturers.id), 61 | ) 62 | .select(Manufacturers.include('name')) 63 | .fetch(client), 64 | ) 65 | 66 | // .select over join and 2 arg overload 67 | 68 | expectAssignable< 69 | { id: number; manufacturerId: number | null; name: string }[] 70 | >( 71 | await query(Franchises) 72 | .join(Manufacturers, ({ eq }) => 73 | eq(Franchises.manufacturerId, Manufacturers.id), 74 | ) 75 | .select(Franchises.include('id', 'manufacturerId')) 76 | .select(Manufacturers.include('name')) 77 | .fetch(client), 78 | ) 79 | 80 | expectAssignable< 81 | { id: number; manufacturerId: number | null; name: string | null }[] 82 | >( 83 | await query(Franchises) 84 | .leftJoin(Manufacturers, ({ eq }) => 85 | eq(Franchises.manufacturerId, Manufacturers.id), 86 | ) 87 | .select(Franchises.include('id', 'manufacturerId')) 88 | .select(Manufacturers.include('name')) 89 | .fetch(client), 90 | ) 91 | 92 | expectError( 93 | await query(Franchises) 94 | .leftJoin(Manufacturers, ({ eq }) => 95 | eq(Franchises.manufacturerId, Manufacturers.id), 96 | ) 97 | // selecting from a non-queried table 98 | .select(Games.include('id', 'title')) 99 | .select(Manufacturers.include('name')) 100 | .fetch(client), 101 | ) 102 | 103 | expectError( 104 | await query(Franchises) 105 | // joining from a non-queried table 106 | .leftJoin(Manufacturers, ({ eq }) => 107 | eq(Systems.manufacturerId, Manufacturers.id), 108 | ) 109 | .select(Franchises.include('id', 'name')) 110 | .select(Manufacturers.include('name')) 111 | .fetch(client), 112 | ) 113 | 114 | expectError( 115 | await query(Franchises) 116 | // joining a non-matching datatype 117 | .leftJoin(Manufacturers, ({ eq }) => 118 | eq(Franchises.id, Manufacturers.country), 119 | ) 120 | .select(Franchises.include('id', 'name')) 121 | .select(Manufacturers.include('name')) 122 | .fetch(client), 123 | ) 124 | 125 | // TODO: .select detecting duplicate columns 126 | 127 | // expectType( 128 | // await query(Franchises) 129 | // .leftJoin(Franchises.manufacturerId, Manufacturers.id) 130 | // // works only for single-select calls though 131 | // // mmh, should I enforce only to only have a single select call? 132 | // .select(Franchises.include('id', 'name'), Manufacturers.include('id')) 133 | // .fetch(client), 134 | // ) 135 | 136 | // rename 137 | 138 | expectType<{ systemId: number; name: string }[]>( 139 | await query(Systems) 140 | .select( 141 | Systems.include('id', 'name').rename({ 142 | id: 'systemId', 143 | }), 144 | ) 145 | .fetch(client), 146 | ) 147 | 148 | expectError( 149 | await query(Systems) 150 | .select( 151 | Systems.include('id', 'name').rename({ 152 | // column is not selected 153 | manufacturerId: 'foo', 154 | }), 155 | ) 156 | .fetch(client), 157 | ) 158 | })() 159 | -------------------------------------------------------------------------------- /test-d/table.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectAssignable, expectType } from 'tsd' 2 | import { 3 | Column, 4 | TableRow, 5 | TableRowInsert, 6 | TableRowInsertOptional, 7 | column, 8 | query, 9 | } from '../src' 10 | import { Franchises, Devices } from './helpers/classicGames' 11 | 12 | // column types 13 | 14 | expectType>(column('as').integer()) 15 | expectType>(column('as').string()) 16 | expectType>(column('as').date()) 17 | expectType>(column('as').boolean()) 18 | expectType>(column('as').string().null()) 19 | expectType>( 20 | column('as').string().default(), 21 | ) 22 | 23 | // table row type 24 | expectType<{ id: number; name: string; manufacturerId: number | null }>( 25 | {} as TableRow, 26 | ) 27 | 28 | // insert with default values 29 | expectType<{ 30 | id: number | typeof query.DEFAULT 31 | name: string 32 | manufacturerId: number | null 33 | }>({} as TableRowInsert) 34 | expectType<{ manufacturerId: number | null }>( 35 | {} as Pick, 'manufacturerId'>, 36 | ) 37 | 38 | // insert with defaults being optional (== undefined) 39 | // expectType does not work reliably with optionals 40 | expectAssignable<{ 41 | id?: number 42 | name: string 43 | manufacturerId: number | null 44 | }>({} as TableRowInsertOptional) 45 | 46 | expectAssignable<{ id?: number }>( 47 | {} as Pick, 'id'>, 48 | ) 49 | 50 | // table row of a discriminated union 51 | expectType< 52 | | { 53 | id: number 54 | name: string 55 | type: 'console' 56 | systemId: number 57 | revision: number | null 58 | } 59 | | { 60 | id: number 61 | name: string 62 | type: 'dedicatedConsole' 63 | systemId: number 64 | gamesCount: number 65 | } 66 | | { id: number; name: string; type: 'emulator'; url: string } 67 | >({} as TableRow) 68 | 69 | // table row with defaults of a discriminated union 70 | expectType< 71 | | { 72 | id: number | typeof query.DEFAULT 73 | name: string 74 | type: 'console' 75 | systemId: number 76 | revision: number | null 77 | } 78 | | { 79 | id: number | typeof query.DEFAULT 80 | name: string 81 | type: 'dedicatedConsole' 82 | systemId: number 83 | gamesCount: number 84 | } 85 | | { 86 | id: number | typeof query.DEFAULT 87 | name: string 88 | type: 'emulator' 89 | url: string 90 | } 91 | >({} as TableRowInsert) 92 | 93 | // table row with optionals of a discriminated union 94 | expectAssignable< 95 | | { 96 | id?: number 97 | name: string 98 | type: 'console' 99 | systemId: number 100 | revision: number | null 101 | } 102 | | { 103 | id?: number 104 | name: string 105 | type: 'dedicatedConsole' 106 | systemId: number 107 | gamesCount: number 108 | } 109 | | { 110 | id?: number 111 | name: string 112 | type: 'emulator' 113 | url: string 114 | } 115 | >({} as TableRowInsertOptional) 116 | -------------------------------------------------------------------------------- /test-d/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "strict": true, 6 | "skipLibCheck": false 7 | "noErrorTruncation": true, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test-d/union.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { DatabaseClient, query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { Systems } from './helpers/classicGames' 5 | 6 | const client: DatabaseClient = {} as DatabaseClient 7 | 8 | const unionTests = (async () => { 9 | const q = query.union( 10 | query(Systems) 11 | .select(Systems.include('id', 'name')) 12 | .where(({ eq }) => eq(Systems.id, 'idParam')), 13 | query(Systems) 14 | .select(Systems.include('id', 'name')) 15 | .where(({ eq }) => eq(Systems.name, 'nameParam')), 16 | ) 17 | 18 | expectType<{ idParam: number; nameParam: string }>(parameterType(q)) 19 | expectType<{ id: number; name: string }>(resultType(q)) 20 | 21 | expectError( 22 | query.union( 23 | query(Systems).select(Systems.include('name')), 24 | // mismatching union signature 25 | // Note that typescripts type compatibility is weaker than what is 26 | // required by an sql union. We need to catch those errors when 27 | // the union query object is built. 28 | query(Systems).select(Systems.include('id')), 29 | ), 30 | ) 31 | })() 32 | 33 | const unionAllTests = (async () => { 34 | const q = query.unionAll( 35 | query(Systems) 36 | .select(Systems.include('id', 'name')) 37 | .where(({ eq }) => eq(Systems.id, 'idParam')), 38 | query(Systems) 39 | .select(Systems.include('id', 'name')) 40 | .where(({ eq }) => eq(Systems.name, 'nameParam')), 41 | ) 42 | 43 | expectType<{ idParam: number; nameParam: string }>(parameterType(q)) 44 | expectType<{ id: number; name: string }>(resultType(q)) 45 | 46 | expectError( 47 | query.unionAll( 48 | query(Systems).select(Systems.include('name')), 49 | // mismatching union signature 50 | query(Systems).select(Systems.include('id')), 51 | ), 52 | ) 53 | })() 54 | -------------------------------------------------------------------------------- /test-d/update.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { TableRow, query } from '../src' 3 | import { Update } from '../src/types' 4 | import { client } from './helpers' 5 | import { Devices, GamesSystems, Systems } from './helpers/classicGames' 6 | 7 | function updateParams(t: Update): X { 8 | return {} as any 9 | } 10 | 11 | function updateResult(t: Update): X { 12 | return {} as any 13 | } 14 | 15 | { 16 | // data parameter only 17 | const q = query.update(Systems).data('data', Systems.include('name')) 18 | 19 | expectType<{ data: { name: string } }>(updateParams(q)) 20 | expectType<{}>(updateResult(q)) 21 | expectType>(q.execute(client, { data: { name: '-' } })) 22 | 23 | expectError( 24 | query 25 | .update(Systems) 26 | .data('data', Systems.include('name')) 27 | // incorrect parameter name 28 | .execute(client, { name: '-' }), 29 | ) 30 | 31 | expectError( 32 | query 33 | .update(Systems) 34 | .data('data', Systems.include('name')) 35 | // correct parameter name but incorrect column type 36 | .execute(client, { data: { name: 12 } }), 37 | ) 38 | } 39 | 40 | { 41 | // data parameter and where filter 42 | const q = query 43 | .update(Systems) 44 | .data('data', Systems.include('name')) 45 | .where(({ eq }) => eq(Systems.id, 'id')) 46 | 47 | expectType<{ id: number } & { data: { name: string } }>(updateParams(q)) 48 | expectType<{}>(updateResult(q)) 49 | expectType>( 50 | q.execute(client, { id: 1, data: { name: 'MASTER SYSTEM' } }), 51 | ) 52 | } 53 | 54 | { 55 | // single set expression 56 | const q = query 57 | .update(GamesSystems) 58 | .set('played', ({ eq, literal }) => eq(GamesSystems.systemId, literal(1))) 59 | 60 | expectType<{}>(updateParams(q)) 61 | expectType<{}>(updateResult(q)) 62 | expectType>(q.execute(client)) 63 | 64 | expectError(q.execute(client, {})) // no second parameter arg must be present 65 | 66 | expectError( 67 | query.update(GamesSystems).set('played', ({ eq, literal }) => 68 | eq( 69 | // table not used in update 70 | Systems.id, 71 | literal(1), 72 | ), 73 | ), 74 | ) 75 | 76 | expectError( 77 | query.update(GamesSystems).set('played', ({ literal }) => 78 | // column type boolean does not match expression type string 79 | literal('foo'), 80 | ), 81 | ) 82 | 83 | expectError( 84 | // column does not exist on GamesSystems 85 | query 86 | .update(GamesSystems) 87 | .set('nonExistingColumnName', ({ literal }) => literal('foo')), 88 | ) 89 | } 90 | 91 | { 92 | // single set with where parameter 93 | const q = query 94 | .update(Systems) 95 | .set('name', ({ caseWhen, eq, param }) => 96 | caseWhen( 97 | [eq(Systems.name, 'oldName'), param('newName').string()], 98 | Systems.name, 99 | ), 100 | ) 101 | 102 | expectType<{ oldName: string } & { newName: string }>(updateParams(q)) 103 | expectType<{}>(updateResult(q)) 104 | expectType>( 105 | q.execute(client, { 106 | oldName: 'NES', 107 | newName: 'Nintendo Entertainment System', 108 | }), 109 | ) 110 | } 111 | 112 | { 113 | // two sets 114 | const q = query 115 | .update(Systems) 116 | .set('name', ({ param }) => param('setName').string()) 117 | .set('year', ({ param }) => param('setYear').number()) 118 | 119 | expectType<{ setName: string } & { setYear: number }>(updateParams(q)) 120 | expectType<{}>(updateResult(q)) 121 | } 122 | 123 | { 124 | // returning 125 | const q = query 126 | .update(Systems) 127 | .set('name', ({ param }) => param('name').string()) 128 | .where(({ eq }) => eq(Systems.id, 'id')) 129 | .returning(Systems.include('id', 'name', 'year')) 130 | 131 | expectType<{ name: string } & { id: number }>(updateParams(q)) 132 | expectType<{ id: number; name: string; year: number }>(updateResult(q)) 133 | expectType>( 134 | q.execute(client, { id: 1, name: 'SMS' }), 135 | ) 136 | } 137 | 138 | { 139 | // discriminated unions - narrowing 140 | const q = query 141 | .update(Devices) 142 | .narrow('type', 'emulator', (q, t) => 143 | q 144 | .set('url', (e) => e.literal('')) 145 | .where(({ eq }) => eq(t.id, 'emulatorId')) 146 | .returning(t.include('name')), 147 | ) 148 | .narrow('type', 'console', (q, t) => 149 | q 150 | .set('name', (e) => e.literal('')) 151 | .where(({ eq }) => eq(t.id, 'consoleId')) 152 | .returning(t.include('id', 'revision')), 153 | ) 154 | 155 | expectType<{ emulatorId: number } & { consoleId: number }>(updateParams(q)) 156 | expectType<{ name: string } | { id: number; revision: number | null }>( 157 | updateResult(q), 158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /test-d/where.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectError, expectType } from 'tsd' 2 | import { query } from '../src' 3 | import { parameterType, resultType, client } from './helpers' 4 | import { Games, Systems, Manufacturers } from './helpers/classicGames' 5 | 6 | // test some basic expressions within a query 7 | // exhaustive expression & expression factory tests are in a separate file/folder 8 | 9 | { 10 | // eq without a parameter 11 | const q = query(Systems) 12 | .select(Systems.include('name')) 13 | .where(({ eq }) => eq(Systems.id, Systems.manufacturerId)) 14 | 15 | expectType<{ name: string }>(resultType(q)) 16 | expectType<{}>(parameterType(q)) 17 | } 18 | 19 | { 20 | // eq with a parameter 21 | const q = query(Systems) 22 | .select(Systems.include('name')) 23 | .where(({ eq }) => eq(Systems.id, 'id')) 24 | 25 | expectType<{ name: string }>(resultType(q)) 26 | expectType<{ id: number }>(parameterType(q)) 27 | } 28 | 29 | { 30 | // eq with two parameters 31 | const q = query(Systems) 32 | .select(Systems.include('name')) 33 | .where(({ and, eq }) => and(eq(Systems.id, 'id'), eq(Systems.name, 'name'))) 34 | 35 | expectType<{ name: string }>(resultType(q)) 36 | expectType<{ id: number; name: string }>(parameterType(q)) 37 | } 38 | 39 | { 40 | // invalid table in expression 41 | expectError( 42 | query(Systems) 43 | .select(Systems.include('name')) 44 | .where(({ eq }) => 45 | // table not part of query 46 | eq(Games.id, 'id'), 47 | ), 48 | ) 49 | } 50 | 51 | { 52 | // caseWhen to choose filter parameters 53 | const q = query(Systems) 54 | .select(Systems.include('name')) 55 | .where(({ eq, caseWhen, literal }) => 56 | caseWhen( 57 | [eq('useId', literal(true)), eq(Systems.id, 'id')], 58 | [eq('useName', literal(true)), eq(Systems.name, 'name')], 59 | literal(false), 60 | ), 61 | ) 62 | 63 | expectType<{ name: string }>(resultType(q)) 64 | expectType<{ useId: boolean; useName: boolean; id: number; name: string }>( 65 | parameterType(q), 66 | ) 67 | } 68 | 69 | { 70 | // eq + uncorrelated subquery 71 | const q = query(Systems) 72 | .select(Systems.include('id', 'name')) 73 | .where((e) => 74 | e.eq.expressionEqExpression( 75 | Systems.manufacturerId, 76 | e 77 | .subquery(Manufacturers) 78 | .select(Manufacturers.include('id')) 79 | .where((f) => f.eq(Manufacturers.name, 'name')), 80 | ), 81 | ) 82 | 83 | expectType<{ id: number; name: string }>(resultType(q)) 84 | expectType<{ name: string }>(parameterType(q)) 85 | } 86 | 87 | { 88 | // eq + correlated subquery 89 | const q = query(Systems) 90 | .select(Systems.include('id', 'name')) 91 | .where(({ eq, param, subquery }) => 92 | eq( 93 | param('name').type(), 94 | subquery(Manufacturers) 95 | .select(Manufacturers.include('name')) 96 | .where(({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)), 97 | ), 98 | ) 99 | 100 | expectType<{ id: number; name: string }>(resultType(q)) 101 | expectType<{ name: string | null }>(parameterType(q)) 102 | } 103 | 104 | /* 105 | { 106 | // single parameter using a column reference 107 | const q = query(Systems) 108 | .select(Systems.include('name')) 109 | .where( 110 | { 111 | systemId: Systems.id, 112 | id: query.paramOf(Systems.id), 113 | }, 114 | 'systemId = id', 115 | ) 116 | 117 | expectType<{ name: string }>(resultType(q)) 118 | expectType<{ id: number }>(parameterType(q)) 119 | 120 | // errors: 121 | 122 | expectError( 123 | query(Systems) 124 | .select(Systems.include('name')) 125 | .where( 126 | { 127 | // table not part of query 128 | systemId: Games.id, 129 | id: query.paramOf(Systems.id), 130 | }, 131 | 'systemId = id', 132 | ), 133 | ) 134 | 135 | // the following should be an error 136 | // TODO: fix or maybe check against future typescript versions 137 | query(Systems) 138 | .select(Systems.include('name')) 139 | .where( 140 | { 141 | systemId: Systems.id, 142 | // table not part of query 143 | id: query.paramOf(Games.id), 144 | }, 145 | 'systemId = id', 146 | ) 147 | } 148 | 149 | { 150 | // only a single column 151 | const q = query(Systems).select(Systems.include('name')).where( 152 | { 153 | systemId: Systems.id, 154 | }, 155 | 'systemId = 1', 156 | ) 157 | 158 | expectType<{ name: string }>(resultType(q)) 159 | expectType<{}>(parameterType(q)) 160 | } 161 | 162 | { 163 | // many parameters 164 | const q = query(Systems) 165 | .select(Systems.include('id')) 166 | .where( 167 | { 168 | systemYear: Systems.year, 169 | systemCol: Systems.name, 170 | lower: query.paramOf(Systems.year), 171 | upper: query.paramOf(Systems.year), 172 | name: query.paramOf(Systems.name), 173 | }, 174 | 'systemYearCol BETWEEN lower AND upper', 175 | 'AND systemCol ILIKE name', 176 | ) 177 | 178 | expectType<{ id: number }>(resultType(q)) 179 | expectType<{ lower: number; upper: number; name: string }>(parameterType(q)) 180 | } 181 | 182 | */ 183 | -------------------------------------------------------------------------------- /test-d/withRecursive.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import { DatabaseClient, query } from '../src' 3 | import { parameterType, resultType } from './helpers' 4 | import { PcComponents, PcComponentsFits } from './helpers/pcComponents' 5 | 6 | const client: DatabaseClient = {} as DatabaseClient 7 | 8 | const withRecursiveTests = (async () => { 9 | // create a "Table" that results in a `WITH RECURSIVE` clause when used in a 10 | // query 11 | const fittingComponents = query.withRecursive(() => { 12 | const r0 = query(PcComponents) 13 | .select(PcComponents.include('id', 'name')) 14 | .where(({ eq }) => eq(PcComponents.name, 'name')) 15 | 16 | return query.union( 17 | r0, 18 | query(PcComponents) 19 | .join(PcComponentsFits, ({ eq }) => 20 | eq(PcComponents.id, PcComponentsFits.componentId), 21 | ) 22 | .join(r0.table(), ({ eq }) => 23 | eq(PcComponentsFits.fitsOnComponentId, r0.table().id), 24 | ) 25 | .select(PcComponents.include('id', 'name')), 26 | ) 27 | }) 28 | 29 | const q = await query(fittingComponents) 30 | .select(fittingComponents.all()) 31 | .limit(1000) 32 | 33 | // should result in the following SQL: 34 | // 35 | // WITH RECURSIVE components AS ( 36 | // SELECT id, name 37 | // FROM pc_components 38 | // WHERE name = 'Mainboard' 39 | // UNION 40 | // SELECT a.id, a.name 41 | // FROM pc_components a 42 | // JOIN pc_components_fits b ON a.id = b.component_id 43 | // JOIN components ON components.id = b.fits_on_component_id 44 | // ) 45 | // SELECT id FROM pc_components LIMIT 1000 46 | 47 | expectType<{ name: string }>(parameterType(q)) 48 | expectType<{ id: number; name: string }>(resultType(q)) 49 | })() 50 | -------------------------------------------------------------------------------- /test/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../src' 2 | import { Games, GamesSystems, Manufacturers, Systems, client } from './helpers' 3 | 4 | describe('delete', () => { 5 | beforeEach(async () => { 6 | await client.query('BEGIN') 7 | 8 | // to be able to delete games without getting foreign key errors 9 | await client.query('DELETE FROM classicgames.games_systems') 10 | }) 11 | 12 | afterEach(async () => { 13 | await client.query('ROLLBACK') 14 | }) 15 | 16 | describe('delete', () => { 17 | test('everything', async () => { 18 | const res = await query.deleteFrom(Games).execute(client) 19 | 20 | expect(res).toEqual(undefined) 21 | expect(await query(Games).select(Games.all()).fetch(client)).toEqual([]) 22 | }) 23 | 24 | test('with condition', async () => { 25 | const res = await query 26 | .deleteFrom(Games) 27 | .where(({ eq }) => eq(Games.title, 'title')) 28 | .execute(client, { title: 'Laser Blast' }) 29 | 30 | expect(res).toEqual(undefined) 31 | expect( 32 | await query(Games).select(Games.include('title')).fetch(client), 33 | ).toEqual([ 34 | { title: 'Sonic the Hedgehog' }, 35 | { title: 'Super Mario Land' }, 36 | { title: 'Super Mario Bros' }, 37 | { title: 'Ultima IV' }, 38 | { title: 'Virtua Racing' }, 39 | ]) 40 | }) 41 | 42 | test.each` 43 | ids | expected | error 44 | ${[1]} | ${1} | ${null} 45 | ${[1, 1, 1]} | ${1} | ${null} 46 | ${[1, 2]} | ${2} | ${null} 47 | ${[1, 2]} | ${1} | ${"query.delete: table 'classicgames.games' - expected to delete exactly 1 rows but got 2 instead."} 48 | ${[]} | ${1} | ${"query.delete: table 'classicgames.games' - expected to delete exactly 1 rows but got 0 instead."} 49 | ${[2]} | ${{ min: 1, max: 1 }} | ${null} 50 | ${[2, 4]} | ${{ min: 1, max: 1 }} | ${"query.delete: table 'classicgames.games' - expected to delete no more than 1 rows but got 2 instead."} 51 | ${[1, 2, 3, 4]} | ${{ min: 3, max: 4 }} | ${null} 52 | ${[1, 2, 4]} | ${{ min: 3, max: 4 }} | ${null} 53 | ${[2, 4]} | ${{ min: 3, max: 4 }} | ${"query.delete: table 'classicgames.games' - expected to delete no less than 3 rows but got 2 instead."} 54 | ${[2, 4, 1, 3, 5]} | ${{ min: 3 }} | ${null} 55 | ${[2, 4]} | ${{ min: 3 }} | ${"query.delete: table 'classicgames.games' - expected to delete no less than 3 rows but got 2 instead."} 56 | ${[]} | ${{ max: 1 }} | ${null} 57 | ${[2, 4]} | ${{ max: 1 }} | ${"query.delete: table 'classicgames.games' - expected to delete no more than 1 rows but got 2 instead."} 58 | `('expectDeletedRowCount $expected', ({ ids, expected, error }) => { 59 | const q = query 60 | .deleteFrom(Games) 61 | .where(({ isIn }) => isIn(Games.id, 'ids')) 62 | .returning(Games.include('id')) 63 | 64 | if (error === null) { 65 | expect( 66 | q.expectDeletedRowCount(expected).execute(client, { ids }), 67 | ).resolves.toEqual( 68 | expect.arrayContaining(ids.map((id: number) => ({ id }))), 69 | ) 70 | } else { 71 | expect( 72 | q.expectDeletedRowCount(expected).execute(client, { ids }), 73 | ).rejects.toThrow(error) 74 | } 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/discriminatedUnion/non-narrowed.json.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { Devices, client, expectValuesUnsorted } from '../helpers' 3 | 4 | describe('querying discriminatedUnion tables', () => { 5 | describe('non-narrowed json', () => { 6 | test('select the whole row into a json object', async () => { 7 | const res = await query(Devices) 8 | .selectJsonObject({ key: 'device' }, Devices.all()) 9 | .fetch(client) 10 | 11 | expectValuesUnsorted(res, [ 12 | { 13 | device: { 14 | id: 1, 15 | name: 'Master System', 16 | type: 'console', 17 | systemId: 1, 18 | revision: 1, 19 | }, 20 | }, 21 | { 22 | device: { 23 | id: 2, 24 | name: 'Master System II', 25 | type: 'console', 26 | systemId: 1, 27 | revision: 2, 28 | }, 29 | }, 30 | { 31 | device: { 32 | id: 3, 33 | name: 'Sega Genesis Mini', 34 | type: 'dedicatedConsole', 35 | systemId: 2, 36 | gamesCount: 42, 37 | }, 38 | }, 39 | { 40 | device: { 41 | id: 4, 42 | name: 'NES Classic Edition', 43 | type: 'dedicatedConsole', 44 | systemId: 4, 45 | gamesCount: 30, 46 | }, 47 | }, 48 | { 49 | device: { 50 | id: 5, 51 | name: 'Fusion', 52 | type: 'emulator', 53 | url: 'https://www.carpeludum.com/kega-fusion/', 54 | }, 55 | }, 56 | { 57 | device: { 58 | id: 6, 59 | name: 'Gens', 60 | type: 'emulator', 61 | url: 'http://gens.me/', 62 | }, 63 | }, 64 | ]) 65 | }) 66 | 67 | test('dividing the selection between plain cols and a json object', async () => { 68 | const res = await query(Devices) 69 | .selectJsonObject({ key: 'details' }, Devices.exclude('id')) 70 | .select(Devices.include('id')) 71 | .fetch(client) 72 | 73 | expectValuesUnsorted(res, [ 74 | { 75 | details: { 76 | name: 'Master System', 77 | type: 'console', 78 | systemId: 1, 79 | revision: 1, 80 | }, 81 | id: 1, 82 | }, 83 | { 84 | details: { 85 | name: 'Master System II', 86 | type: 'console', 87 | systemId: 1, 88 | revision: 2, 89 | }, 90 | id: 2, 91 | }, 92 | { 93 | details: { 94 | name: 'Sega Genesis Mini', 95 | type: 'dedicatedConsole', 96 | systemId: 2, 97 | gamesCount: 42, 98 | }, 99 | id: 3, 100 | }, 101 | { 102 | details: { 103 | name: 'NES Classic Edition', 104 | type: 'dedicatedConsole', 105 | systemId: 4, 106 | gamesCount: 30, 107 | }, 108 | id: 4, 109 | }, 110 | { 111 | details: { 112 | name: 'Fusion', 113 | type: 'emulator', 114 | url: 'https://www.carpeludum.com/kega-fusion/', 115 | }, 116 | id: 5, 117 | }, 118 | { 119 | details: { name: 'Gens', type: 'emulator', url: 'http://gens.me/' }, 120 | id: 6, 121 | }, 122 | ]) 123 | }) 124 | 125 | test('select a part of the row into a json object array', async () => { 126 | const res = await query(Devices) 127 | .selectJsonObjectArray( 128 | { key: 'devices' }, 129 | Devices.exclude('id', 'name'), 130 | ) 131 | .fetch(client) 132 | 133 | expectValuesUnsorted(res, [ 134 | { 135 | devices: [ 136 | { type: 'console', systemId: 1, revision: 1 }, 137 | { type: 'console', systemId: 1, revision: 2 }, 138 | { type: 'dedicatedConsole', systemId: 2, gamesCount: 42 }, 139 | { type: 'dedicatedConsole', systemId: 4, gamesCount: 30 }, 140 | { 141 | type: 'emulator', 142 | url: 'https://www.carpeludum.com/kega-fusion/', 143 | }, 144 | { type: 'emulator', url: 'http://gens.me/' }, 145 | ], 146 | }, 147 | ]) 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /test/discriminatedUnion/non-narrowed.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { Devices, Systems, client, expectValuesUnsorted } from '../helpers' 3 | 4 | describe('querying discriminatedUnion tables', () => { 5 | describe('non-narrowed', () => { 6 | test('select the whole row with `.all`', async () => { 7 | const res = await query(Devices).select(Devices.all()).fetch(client) 8 | 9 | // ... and get back types that only contain their own fields: 10 | expectValuesUnsorted(res, [ 11 | { 12 | id: 1, 13 | name: 'Master System', 14 | type: 'console', 15 | systemId: 1, 16 | revision: 1, 17 | }, 18 | { 19 | id: 2, 20 | name: 'Master System II', 21 | type: 'console', 22 | systemId: 1, 23 | revision: 2, 24 | }, 25 | { 26 | id: 3, 27 | name: 'Sega Genesis Mini', 28 | type: 'dedicatedConsole', 29 | systemId: 2, 30 | gamesCount: 42, 31 | }, 32 | { 33 | id: 4, 34 | name: 'NES Classic Edition', 35 | type: 'dedicatedConsole', 36 | systemId: 4, 37 | gamesCount: 30, 38 | }, 39 | { 40 | id: 5, 41 | name: 'Fusion', 42 | type: 'emulator', 43 | url: 'https://www.carpeludum.com/kega-fusion/', 44 | }, 45 | { id: 6, name: 'Gens', type: 'emulator', url: 'http://gens.me/' }, 46 | ]) 47 | }) 48 | 49 | test('ommitting comming colmns with `.exclude()`', async () => { 50 | const res = await query(Devices) 51 | .select(Devices.exclude('id', 'name')) 52 | .fetch(client) 53 | 54 | // ... and get back types that only contain their own fields: 55 | expectValuesUnsorted(res, [ 56 | { 57 | type: 'console', 58 | systemId: 1, 59 | revision: 1, 60 | }, 61 | { 62 | type: 'console', 63 | systemId: 1, 64 | revision: 2, 65 | }, 66 | { 67 | type: 'dedicatedConsole', 68 | systemId: 2, 69 | gamesCount: 42, 70 | }, 71 | { 72 | type: 'dedicatedConsole', 73 | systemId: 4, 74 | gamesCount: 30, 75 | }, 76 | { 77 | type: 'emulator', 78 | url: 'https://www.carpeludum.com/kega-fusion/', 79 | }, 80 | { type: 'emulator', url: 'http://gens.me/' }, 81 | ]) 82 | }) 83 | 84 | test('ommitting the type tag column with `.exclude()` is an error', async () => { 85 | // Internal logic require this column to be present. Selecting it as a 86 | // shadow column would be more work and I don't see any value in it atm. 87 | expect(() => 88 | query(Devices).select(Devices.exclude('id', 'type')), 89 | ).toThrow( 90 | "table 'classicgames.devices' - you cannot omit the type tag column ('type') when selecting from a discriminated union table", 91 | ) 92 | }) 93 | 94 | test('destroying the union type with .include()', async () => { 95 | // include selects only common properties 96 | const res = await query(Devices) 97 | .select(Devices.include('id'), Devices.include('type')) 98 | .fetch(client) 99 | 100 | expectValuesUnsorted(res, [ 101 | { id: 1, type: 'console' }, 102 | { id: 2, type: 'console' }, 103 | { id: 3, type: 'dedicatedConsole' }, 104 | { id: 4, type: 'dedicatedConsole' }, 105 | { id: 5, type: 'emulator' }, 106 | { id: 6, type: 'emulator' }, 107 | ]) 108 | }) 109 | 110 | test('joins', async () => { 111 | const res = await query(Devices) 112 | // stupid join condition but I don't have a better schema and the 113 | // types and database don't care 114 | .join(Systems, ({ eq, literal }) => eq(Systems.id, literal(1))) 115 | .select( 116 | Devices.all(), 117 | Systems.include('year').rename({ year: 'systemYear' }), 118 | ) 119 | .fetch(client) 120 | 121 | expectValuesUnsorted(res, [ 122 | { 123 | id: 1, 124 | name: 'Master System', 125 | type: 'console', 126 | systemId: 1, 127 | revision: 1, 128 | systemYear: 1985, 129 | }, 130 | { 131 | id: 2, 132 | name: 'Master System II', 133 | type: 'console', 134 | systemId: 1, 135 | revision: 2, 136 | systemYear: 1985, 137 | }, 138 | { 139 | id: 3, 140 | name: 'Sega Genesis Mini', 141 | type: 'dedicatedConsole', 142 | systemId: 2, 143 | gamesCount: 42, 144 | systemYear: 1985, 145 | }, 146 | { 147 | id: 4, 148 | name: 'NES Classic Edition', 149 | type: 'dedicatedConsole', 150 | systemId: 4, 151 | gamesCount: 30, 152 | systemYear: 1985, 153 | }, 154 | { 155 | id: 5, 156 | name: 'Fusion', 157 | type: 'emulator', 158 | url: 'https://www.carpeludum.com/kega-fusion/', 159 | systemYear: 1985, 160 | }, 161 | { 162 | id: 6, 163 | name: 'Gens', 164 | type: 'emulator', 165 | url: 'http://gens.me/', 166 | systemYear: 1985, 167 | }, 168 | ]) 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /test/discriminatedUnion/table.test.ts: -------------------------------------------------------------------------------- 1 | import { table, column } from '../../src' 2 | 3 | describe('creating discriminatedUnion tables', () => { 4 | test('creating a correct table', () => { 5 | const devicesCommonColumns = { 6 | id: column('id').integer().default(), 7 | name: column('name').string(), 8 | } 9 | 10 | const Devices = table.discriminatedUnion( 11 | table('devices', { 12 | ...devicesCommonColumns, 13 | type: column('type').literal('console'), 14 | systemId: column('system_id').integer(), 15 | revision: column('revision').integer().null(), 16 | }), 17 | table('devices', { 18 | ...devicesCommonColumns, 19 | type: column('type').literal('dedicatedConsole'), 20 | systemId: column('system_id').integer(), 21 | gamesCount: column('gamesCount').integer(), 22 | }), 23 | table('devices', { 24 | ...devicesCommonColumns, 25 | type: column('type').literal('emulator'), 26 | url: column('url').string(), 27 | }), 28 | ) 29 | }) 30 | 31 | describe('table checks', () => { 32 | test('differing table names', () => { 33 | expect(() => { 34 | table.discriminatedUnion( 35 | table('devices_x', { 36 | id: column('id').integer(), 37 | type: column('type').literal('console'), 38 | systemId: column('system_id').integer(), 39 | }), 40 | table('devices', { 41 | id: column('id').integer(), 42 | type: column('type').literal('dedicatedConsole'), 43 | systemId: column('system_id').integer(), 44 | }), 45 | table('devices', { 46 | id: column('id').integer(), 47 | type: column('type').literal('emulator'), 48 | url: column('url').string(), 49 | }), 50 | ) 51 | }).toThrow( 52 | "table 'devices_x' - discriminated union table members must all have the same name, not: 'devices_x', 'devices'", 53 | ) 54 | }) 55 | }) 56 | 57 | describe('common column and type tag checks', () => { 58 | test('no common columns at all', () => { 59 | expect(() => { 60 | table.discriminatedUnion( 61 | table('devices', { 62 | id: column('id').integer(), 63 | }), 64 | table('devices', { 65 | id: column('id').integer(), 66 | }), 67 | table('devices', { 68 | url: column('url').string(), 69 | }), 70 | ) 71 | }).toThrow( 72 | "table 'devices' - discriminated union table members must have a *single* non-null literal value column that serves as a type tag", 73 | ) 74 | }) 75 | 76 | test('no literal column that clearly identifies each member type', () => { 77 | expect(() => { 78 | table.discriminatedUnion( 79 | table('devices', { 80 | id: column('id').integer(), 81 | type: column('type').literal('a'), 82 | }), 83 | table('devices', { 84 | id: column('id').integer(), 85 | type: column('type').literal('b'), 86 | }), 87 | table('devices', { 88 | id: column('id').integer(), 89 | type: column('type').literal('c', 'd'), // not a single literal 90 | }), 91 | ) 92 | }).toThrow( 93 | "table 'devices' - discriminated union table members must have a *single* non-null literal value column that serves as a type tag", 94 | ) 95 | }) 96 | 97 | test('more than 1 literal type tag column', () => { 98 | expect(() => { 99 | table.discriminatedUnion( 100 | table('devices', { 101 | id: column('id').integer(), 102 | type: column('type').literal('a'), 103 | type2: column('type2').literal('x'), 104 | }), 105 | table('devices', { 106 | id: column('id').integer(), 107 | type: column('type').literal('b'), 108 | type2: column('type2').literal('y'), 109 | }), 110 | 111 | table('devices', { 112 | id: column('id').integer(), 113 | type: column('type').literal('c'), 114 | type2: column('type2').literal('z'), 115 | }), 116 | ) 117 | }).toThrow( 118 | "table 'devices' - discriminated union table members must have a *single* non-null literal value column that serves as a type tag, not 2: 'type', 'type2'", 119 | ) 120 | }) 121 | 122 | test('shared columns must map to the same sql type', () => { 123 | // because in sql you cannot have a union type like `int | string` in 124 | // columns or as the result of a select - the only allowed union type is 125 | // `T | null`. 126 | expect(() => { 127 | table.discriminatedUnion( 128 | table('devices', { 129 | type: column('type').literal('a'), 130 | label: column('label').string(), 131 | }), 132 | table('devices', { 133 | type: column('type').literal('b'), 134 | label: column('label').integer(), 135 | }), 136 | table('devices', { 137 | type: column('type').literal('c'), 138 | }), 139 | ) 140 | }).toThrow( 141 | "table 'devices', column 'label' - columns shared between discriminated union table members must have the same sql type, not 'text', 'int'", 142 | ) 143 | }) 144 | 145 | test('shared columns must map to the same sql name', () => { 146 | expect(() => { 147 | table.discriminatedUnion( 148 | table('devices', { 149 | type: column('type').literal('a'), 150 | label: column('label').string(), 151 | }), 152 | table('devices', { 153 | type: column('type').literal('b'), 154 | label: column('label_2').integer(), 155 | }), 156 | table('devices', { 157 | type: column('type').literal('c'), 158 | }), 159 | ) 160 | }).toThrow( 161 | "table 'devices', column 'label' - columns shared between discriminated union table members must have the same sql name, not 'label', 'label_2'", 162 | ) 163 | }) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /test/helpers/classicGames.ts: -------------------------------------------------------------------------------- 1 | import { table, column as col } from '../../src' 2 | 3 | export const Manufacturers = table('classicgames.manufacturers', { 4 | id: col('id').integer().primary().default(), 5 | name: col('name').string(), 6 | country: col('country').string(), 7 | }) 8 | 9 | export const Systems = table('classicgames.systems', { 10 | id: col('id').integer().primary().default(), 11 | name: col('name').string(), 12 | year: col('year').integer(), 13 | manufacturerId: col('manufacturer_id').integer(), 14 | }) 15 | 16 | export const Franchises = table('classicgames.franchises', { 17 | id: col('id').integer().primary().default(), 18 | name: col('name').string(), 19 | manufacturerId: col('manufacturer_id').integer().null(), 20 | }) 21 | 22 | export const Games = table('classicgames.games', { 23 | id: col('id').integer().primary().default(), 24 | title: col('title').string(), 25 | urls: col('urls') 26 | .json((v) => { 27 | if (typeof v !== 'object' || v === null) { 28 | throw new Error('invalid value') 29 | } 30 | 31 | const anyV: any = v 32 | if (typeof anyV.wiki !== 'string' && typeof anyV.wiki !== 'undefined') { 33 | throw new Error('invalid value for "wiki"') 34 | } 35 | 36 | if (typeof anyV.ign !== 'string' && typeof anyV.ign !== 'undefined') { 37 | throw new Error('invalid value for "ign"') 38 | } 39 | 40 | if (typeof anyV.misc !== 'string' && typeof anyV.misc !== 'undefined') { 41 | throw new Error('invalid value for "ign"') 42 | } 43 | 44 | for (let k in anyV) { 45 | if (k !== 'wiki' && k !== 'ign' && k !== 'misc') { 46 | throw new Error(`invalid key: "${k}"`) 47 | } 48 | } 49 | 50 | return anyV as { wiki?: string; ign?: string; misc?: string } 51 | }) 52 | .null(), 53 | franchiseId: col('franchise_id').integer().null(), 54 | }) 55 | 56 | export const GamesSystems = table('classicgames.games_systems', { 57 | gameId: col('game_id').integer(), 58 | systemId: col('system_id').integer(), 59 | releaseDate: col('release_date').date().null(), 60 | played: col('played').boolean().default(), 61 | }) 62 | 63 | const devicesCommonColumns = { 64 | id: col('id').integer().default(), 65 | name: col('name').string(), 66 | } 67 | 68 | export const Devices = table.discriminatedUnion( 69 | table('classicgames.devices', { 70 | ...devicesCommonColumns, 71 | type: col('type').literal('console'), 72 | systemId: col('system_id').integer(), 73 | revision: col('revision').integer().null(), 74 | }), 75 | table('classicgames.devices', { 76 | ...devicesCommonColumns, 77 | type: col('type').literal('dedicatedConsole'), 78 | systemId: col('system_id').integer(), 79 | gamesCount: col('games_count').integer(), 80 | }), 81 | table('classicgames.devices', { 82 | ...devicesCommonColumns, 83 | type: col('type').literal('emulator'), 84 | url: col('url').string(), 85 | }), 86 | ) 87 | -------------------------------------------------------------------------------- /test/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'pg' 2 | 3 | // enable "deep" console.log 4 | require('util').inspect.defaultOptions.depth = null 5 | 6 | // test database 7 | export const client = new Client({ 8 | user: 'postgres', 9 | host: '127.0.0.1', 10 | database: 'test_schema', 11 | password: 'password', 12 | port: 54321, 13 | }) 14 | 15 | beforeAll(async () => { 16 | await client.connect() 17 | }) 18 | 19 | afterAll(async () => { 20 | await client.end() 21 | }) 22 | 23 | export * from './classicGames' 24 | export * from './pcComponents' 25 | 26 | // return the object with keys sorted 27 | function sortKeys(o: any): any { 28 | if ( 29 | !o || 30 | Array.isArray(o) || 31 | typeof o !== 'object' || 32 | !Object.keys(o).length 33 | ) { 34 | return o 35 | } 36 | 37 | return Object.fromEntries( 38 | Object.keys(o) 39 | .sort() 40 | .map((k) => { 41 | const v = o[k] 42 | 43 | return [k, sortKeys(v)] 44 | }), 45 | ) 46 | } 47 | 48 | function sortByJsonComparator(a: any, b: any) { 49 | const ja = JSON.stringify(sortKeys(a)) 50 | const jb = JSON.stringify(sortKeys(b)) 51 | 52 | return ja === jb ? 0 : ja < jb ? 1 : -1 53 | } 54 | 55 | /** 56 | * Compare values against expected ignoring order. 57 | * 58 | * Sort order is only ignored in the top level array so we can compare db 59 | * query results which do not use order by. 60 | */ 61 | export function expectValuesUnsorted(values: T[] | null, expected: T[]) { 62 | expect(values).not.toBeNull() 63 | 64 | if (values === null) { 65 | return 66 | } 67 | 68 | const valueSorted = [...values].sort(sortByJsonComparator) 69 | const expectedSorted = [...expected].sort(sortByJsonComparator) 70 | 71 | expect(valueSorted).toEqual(expectedSorted) 72 | } 73 | 74 | /** 75 | * Like expect(values).toRquak(expected) but typesafe. 76 | */ 77 | export function expectValues(values: T[], expected: T[]) { 78 | expect(values).toEqual(expected) 79 | } 80 | -------------------------------------------------------------------------------- /test/helpers/pcComponents.ts: -------------------------------------------------------------------------------- 1 | import { table, column as col } from '../../src' 2 | 3 | export const PcComponents = table('pc_components', { 4 | id: col('id').integer().primary().default(), 5 | name: col('name').string(), 6 | }) 7 | 8 | export const PcComponentsFits = table('pc_components_fits', { 9 | componentId: col('componentId').integer(), 10 | fitsOnComponentId: col('fitsOnComponentId').integer(), 11 | }) 12 | -------------------------------------------------------------------------------- /test/helpers/testSchema.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE IF EXISTS test_schema; 2 | 3 | CREATE DATABASE test_schema; 4 | 5 | \connect test_schema; 6 | 7 | -- an empty table 8 | 9 | CREATE TABLE empty_table ( 10 | id SERIAL PRIMARY KEY, 11 | value TEXT NOT NULL, 12 | active BOOLEAN NOT NULL 13 | ); 14 | 15 | -- a json table 16 | 17 | CREATE TABLE json_any_table ( 18 | id SERIAL PRIMARY KEY, 19 | value JSON 20 | ); 21 | 22 | 23 | -- 24 | -- Classic Console Games Inventory: 25 | -- Manufacturers 1-* Systems *-* Games *-1 Franchises 26 | -- 27 | 28 | CREATE SCHEMA classicgames; 29 | 30 | CREATE TABLE classicgames.manufacturers ( 31 | id SERIAL PRIMARY KEY, 32 | name TEXT NOT NULL, 33 | country TEXT NOT NULL 34 | ); 35 | 36 | INSERT INTO classicgames.manufacturers 37 | (id, name, country) 38 | VALUES 39 | (1, 'Sega', 'Japan'), 40 | (2, 'Nintendo', 'Japan'), 41 | (3, 'Atari', 'USA'); 42 | 43 | SELECT pg_catalog.setval('classicgames.manufacturers_id_seq', 4, false); 44 | 45 | CREATE TABLE classicgames.systems ( 46 | id SERIAL PRIMARY KEY, 47 | name TEXT NOT NULL, 48 | year INT, 49 | manufacturer_id INT NOT NULL, 50 | FOREIGN KEY (manufacturer_id) REFERENCES classicgames.manufacturers(id) 51 | ); 52 | 53 | INSERT INTO classicgames.systems 54 | (id, name, year, manufacturer_id) 55 | VALUES 56 | (1, 'Master System', 1985, 1), 57 | (2, 'Genesis', 1988, 1), 58 | (3, 'Game Gear', 1990, 1), 59 | (4, 'NES', 1983, 2), 60 | (5, 'SNES', 1990, 2), 61 | (6, 'Game Boy', 1989, 2), 62 | (7, 'Atari 2600', 1977, 3); 63 | 64 | SELECT pg_catalog.setval('classicgames.systems_id_seq', 8, false); 65 | 66 | CREATE TABLE classicgames.franchises ( 67 | id SERIAL PRIMARY KEY, 68 | name TEXT NOT NULL, 69 | manufacturer_id INT, 70 | FOREIGN KEY (manufacturer_id) REFERENCES classicgames.manufacturers(id) 71 | ); 72 | 73 | INSERT INTO classicgames.franchises 74 | (id, name, manufacturer_id) 75 | VALUES 76 | (1, 'Ultima', NULL), 77 | (2, 'Sonic', 1), 78 | (3, 'Mario', 2); 79 | 80 | SELECT pg_catalog.setval('classicgames.franchises_id_seq', 4, false); 81 | 82 | CREATE TABLE classicgames.games ( 83 | id SERIAL PRIMARY KEY, 84 | title TEXT NOT NULL, 85 | urls JSON, 86 | franchise_id INT, 87 | FOREIGN KEY (franchise_id) REFERENCES classicgames.franchises(id) 88 | ); 89 | 90 | INSERT INTO classicgames.games 91 | (id, title, franchise_id, urls) 92 | VALUES 93 | (1, 'Sonic the Hedgehog', 2, '{"wiki": "https://de.wikipedia.org/wiki/Sonic_the_Hedgehog_(1991)", "misc": "https://www.sega.com/games/sonic-hedgehog"}'), 94 | (2, 'Super Mario Land', 3, NULL), 95 | (3, 'Super Mario Bros', 3, NULL), 96 | (4, 'Ultima IV', 1, '{"wiki": "https://en.wikipedia.org/wiki/Ultima_IV:_Quest_of_the_Avatar"}'), 97 | (5, 'Virtua Racing', NULL, '{"wiki":"https://en.wikipedia.org/wiki/Virtua_Racing","ign":"https://www.ign.com/games/virtua-racing"}'), 98 | (6, 'Laser Blast', NULL, '{"wiki": "https://en.wikipedia.org/wiki/Laser_Blast"}'); 99 | 100 | SELECT pg_catalog.setval('classicgames.games_id_seq', 7, false); 101 | 102 | CREATE TABLE classicgames.games_systems ( 103 | game_id INT NOT NULL, 104 | system_id INT NOT NULL, 105 | release_date TIMESTAMPTZ, 106 | played BOOLEAN NOT NULL DEFAULT FALSE, 107 | PRIMARY KEY (game_id, system_id), 108 | FOREIGN KEY (game_id) REFERENCES classicgames.games(id), 109 | FOREIGN KEY (system_id) REFERENCES classicgames.systems(id) 110 | ); 111 | 112 | INSERT INTO classicgames.games_systems 113 | (game_id, system_id, release_date, played) 114 | VALUES 115 | -- sonic 116 | (1, 1, '1991-10-25', true), -- sms 117 | (1, 2, '1991-07-26', true), -- genesis 118 | (1, 3, null , true), -- gg 119 | -- mario land 120 | (2, 6, '1989-04-21', true), -- gb 121 | -- mario bros 122 | (3, 4, '1983-07-14', false), -- nes 123 | -- ultima iv 124 | (4, 1, '1990-01-01', true), -- sms 125 | (4, 4, '1990-01-01', false), -- nes 126 | -- virtua racing 127 | (5, 2, '1994-08-18', true), -- genesis 128 | -- laser blast 129 | (6, 7, '1981-03-01', true); -- 2600 130 | 131 | -- a table for a discriminated union type 132 | CREATE TABLE classicgames.devices ( 133 | id SERIAL PRIMARY KEY, 134 | name TEXT NOT NULL, 135 | type TEXT NOT NULL, 136 | system_id INT, -- console, dedicatedConsole 137 | revision INT, -- console 138 | games_count INT, -- dedicatedConsole 139 | url TEXT -- emulator 140 | ); 141 | 142 | INSERT INTO classicgames.devices 143 | (id, name, type, system_id, revision, games_count, url) 144 | VALUES 145 | (1, 'Master System', 'console', 1, 1, null, null), 146 | (2, 'Master System II', 'console', 1, 2, null, null), 147 | (3, 'Sega Genesis Mini', 'dedicatedConsole', 2, null, 42, null), 148 | (4, 'NES Classic Edition', 'dedicatedConsole', 4, null, 30, null), 149 | (5, 'Fusion', 'emulator', null, null, null, 'https://www.carpeludum.com/kega-fusion/'), 150 | (6, 'Gens', 'emulator', null, null, null, 'http://gens.me/'); 151 | 152 | SELECT pg_catalog.setval('classicgames.devices_id_seq', 7, false); 153 | 154 | -- 155 | -- Desktop computer component dependencies 156 | -- Intended for testing WITH RECURSIVE queries 157 | -- 158 | 159 | CREATE TABLE pc_components ( 160 | id SERIAL PRIMARY KEY, 161 | name TEXT NOT NULL 162 | ); 163 | 164 | CREATE TABLE pc_components_fits ( 165 | component_id INT NOT NULL, 166 | fits_on_component_id INT NOT NULL, 167 | FOREIGN KEY (component_id) REFERENCES pc_components(id), 168 | FOREIGN KEY (fits_on_component_id) REFERENCES pc_components(id) 169 | ); 170 | 171 | INSERT INTO pc_components 172 | (id, name) 173 | VALUES 174 | (1, 'CPU'), 175 | (2, 'Mainboard'), 176 | (3, 'RAM'), 177 | (4, 'Power Supply'), 178 | (5, 'Case'), 179 | (6, 'SSD'), 180 | (7, 'Fan'), 181 | (8, 'Graphics Card'); 182 | 183 | INSERT INTO pc_components_fits 184 | (component_id, fits_on_component_id) 185 | VALUES 186 | (1, 2), -- cpu on mainboard 187 | (2, 5), -- mainboard on case 188 | (3, 2), -- ram on mainboard 189 | (4, 5), -- power supply on case 190 | (6, 5), -- disk on case 191 | (7, 5), -- fans on case 192 | (7, 1), -- cpu 193 | (7, 8), -- graphics card 194 | (8, 2); -- graphics card on mainboard 195 | -------------------------------------------------------------------------------- /test/insertStatement.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../src' 2 | import { Games, GamesSystems, Systems, client } from './helpers' 3 | 4 | describe('insertStatement to insert nested / related data ', () => { 5 | beforeEach(async () => { 6 | await client.query('BEGIN') 7 | }) 8 | 9 | afterEach(async () => { 10 | await client.query('ROLLBACK') 11 | }) 12 | 13 | test('insert nested data', async () => { 14 | const games = [ 15 | { title: 'Sonic 2', systemIds: [1, 2, 3] }, 16 | { title: 'Sonic and Knuckles', systemIds: [2] }, 17 | ] 18 | 19 | const res = await query 20 | .insertStatement<{ gameId: number }>( 21 | ({ addInsertInto, addReturnValue }) => { 22 | games.forEach((g) => { 23 | const { id: gameId } = addInsertInto(Games) 24 | .value({ 25 | id: query.DEFAULT, 26 | franchiseId: null, 27 | urls: null, 28 | title: g.title, 29 | }) 30 | .returning(Games.include('id')) 31 | 32 | addReturnValue({ gameId }) 33 | 34 | g.systemIds.forEach((systemId) => { 35 | addInsertInto(GamesSystems).value({ 36 | played: query.DEFAULT, 37 | releaseDate: null, 38 | gameId, 39 | systemId, 40 | }) 41 | }) 42 | }) 43 | }, 44 | ) 45 | .execute(client) 46 | 47 | expect(res).toEqual([ 48 | { gameId: expect.any(Number) }, 49 | { gameId: expect.any(Number) }, 50 | ]) 51 | 52 | // check that data was inserted correctly 53 | // below is the query-counterpart to the above insert: 54 | 55 | const gamesSystemsQuery = query(Games) 56 | .select(Games.include('title'), (subquery) => 57 | subquery(GamesSystems) 58 | .join(Systems, ({ eq }) => eq(Systems.id, GamesSystems.systemId)) 59 | .where(({ eq }) => eq(GamesSystems.gameId, Games.id)) 60 | .selectJsonArray( 61 | { key: 'systems', orderBy: Systems.name }, 62 | Systems.include('name'), 63 | ), 64 | ) 65 | .where(({ eq }) => eq(Games.title, 'title')) 66 | 67 | const sonic2 = await gamesSystemsQuery.fetchExactlyOne(client, { 68 | title: 'Sonic 2', 69 | }) 70 | 71 | expect(sonic2).toEqual({ 72 | title: 'Sonic 2', 73 | systems: ['Game Gear', 'Genesis', 'Master System'], 74 | }) 75 | 76 | const sonicAndKnuckles = await gamesSystemsQuery.fetchExactlyOne(client, { 77 | title: 'Sonic and Knuckles', 78 | }) 79 | 80 | expect(sonicAndKnuckles).toEqual({ 81 | title: 'Sonic and Knuckles', 82 | systems: ['Genesis'], 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/query/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { query, DatabaseClient } from '../../src' 2 | import { client, Systems, expectValuesUnsorted } from '../helpers' 3 | 4 | describe('query.fetch*', () => { 5 | describe('.fetch()', () => { 6 | test('basic fetch', async () => { 7 | const res = await query(Systems) 8 | .select(Systems.include('id')) 9 | .fetch(client) 10 | 11 | expectValuesUnsorted(res, [ 12 | { id: 1 }, 13 | { id: 2 }, 14 | { id: 3 }, 15 | { id: 4 }, 16 | { id: 5 }, 17 | { id: 6 }, 18 | { id: 7 }, 19 | ]) 20 | }) 21 | 22 | test('empty fetch', async () => { 23 | const res = await query(Systems) 24 | .select(Systems.include('id')) 25 | .where(({ literal }) => literal(false)) 26 | .fetch(client) 27 | 28 | expect(res).toEqual([]) 29 | }) 30 | 31 | test('fetch with parameters', async () => { 32 | const res = await query(Systems) 33 | .select(Systems.include('id')) 34 | .where(({ eq, or }) => or(eq(Systems.id, 'id1'), eq(Systems.id, 'id2'))) 35 | .fetch(client, { id1: 1, id2: 3 }) 36 | 37 | expectValuesUnsorted(res, [{ id: 1 }, { id: 3 }]) 38 | }) 39 | }) 40 | 41 | describe('.fetchOne()', () => { 42 | test('basic fetchOne', async () => { 43 | const res = await query(Systems) 44 | .select(Systems.include('id')) 45 | .where(({ eq, literal }) => eq(Systems.id, literal(1))) 46 | .fetchOne(client) 47 | 48 | expect(res).toEqual({ id: 1 }) 49 | }) 50 | 51 | test('fetchOne with parameters', async () => { 52 | const res = await query(Systems) 53 | .select(Systems.include('id')) 54 | .where(({ eq }) => eq(Systems.id, 'id')) 55 | .fetchOne(client, { id: 2 }) 56 | 57 | expect(res).toEqual({ id: 2 }) 58 | }) 59 | 60 | test('empty fetchOne', async () => { 61 | const res = await query(Systems) 62 | .select(Systems.include('id')) 63 | .where(({ eq }) => eq(Systems.id, 'id')) 64 | .fetchOne(client, { id: 42 }) 65 | 66 | expect(res).toEqual(undefined) 67 | }) 68 | 69 | test('error when more than 1 row is returned', async () => { 70 | const q = query(Systems) 71 | .select(Systems.include('id')) 72 | .where(({ eq, or }) => or(eq(Systems.id, 'id1'), eq(Systems.id, 'id2'))) 73 | 74 | await expect(q.fetchOne(client, { id1: 1, id2: 1 })).resolves.toEqual({ 75 | id: 1, 76 | }) 77 | await expect(q.fetchOne(client, { id1: 1, id2: 2 })).rejects.toThrow( 78 | 'fetchOne: query returned more than 1 row (it returned 2 rows)', 79 | ) 80 | }) 81 | }) 82 | 83 | describe('.fetchExactlyOne()', () => { 84 | test('basic fetchExactlyOne', async () => { 85 | const res = await query(Systems) 86 | .select(Systems.include('id')) 87 | .where(({ eq, literal }) => eq(Systems.id, literal(1))) 88 | .fetchExactlyOne(client) 89 | 90 | expect(res).toEqual({ id: 1 }) 91 | }) 92 | 93 | test('fetchExactlyOne with parameters', async () => { 94 | const res = await query(Systems) 95 | .select(Systems.include('id')) 96 | .where(({ eq }) => eq(Systems.id, 'id')) 97 | .fetchExactlyOne(client, { id: 2 }) 98 | 99 | expect(res).toEqual({ id: 2 }) 100 | }) 101 | 102 | test('error unless a single row is returned', async () => { 103 | const q = query(Systems) 104 | .select(Systems.include('id')) 105 | .where(({ eq, or }) => or(eq(Systems.id, 'id1'), eq(Systems.id, 'id2'))) 106 | 107 | await expect( 108 | q.fetchExactlyOne(client, { id1: 1, id2: 1 }), 109 | ).resolves.toEqual({ id: 1 }) 110 | await expect( 111 | q.fetchExactlyOne(client, { id1: 42, id2: 43 }), 112 | ).rejects.toThrow('fetchExactlyOne: query returned 0 rows') 113 | await expect( 114 | q.fetchExactlyOne(client, { id1: 1, id2: 2 }), 115 | ).rejects.toThrow( 116 | 'fetchExactlyOne: query returned more than 1 row (it returned 2 rows)', 117 | ) 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /test/query/limitOffset.test.ts: -------------------------------------------------------------------------------- 1 | import { query, DatabaseClient } from '../../src' 2 | import { client, Systems } from '../helpers' 3 | 4 | describe('limit and offset', () => { 5 | const q = query(Systems).select(Systems.include('id')) 6 | const ROWS = 7 7 | 8 | describe('limit', () => { 9 | test('no limit', async () => { 10 | expect(await q.fetch(client)).toHaveLength(ROWS) 11 | }) 12 | 13 | test('large limit', async () => { 14 | expect(await q.limit(2 ** 32).fetch(client)).toHaveLength(ROWS) 15 | }) 16 | 17 | test.each([0, 1, 2, 3, 4, 5, 6, 7])('limit(%p)', async (l) => { 18 | expect(await q.limit(l).fetch(client)).toHaveLength(l) 19 | }) 20 | 21 | test.each([0, 1, 2, 3, 4, 5, 6, 7])("limit('l') + {l: %p}", async (l) => { 22 | expect(await q.limit('l').fetch(client, { l })).toHaveLength(l) 23 | }) 24 | }) 25 | 26 | describe('offset', () => { 27 | test.each([0, 1, 2, 3, 4, 5, 6, 7])('offset(%p)', async (o) => { 28 | expect(await q.offset(o).fetch(client)).toHaveLength(ROWS - o) 29 | }) 30 | 31 | test.each([0, 1, 2, 3, 4, 5, 6, 7])("offset('o') + {o: %p}", async (o) => { 32 | expect(await q.offset('o').fetch(client, { o })).toHaveLength(ROWS - o) 33 | }) 34 | 35 | test('large offset', async () => { 36 | expect(await q.offset(2 ** 32).fetch(client)).toEqual([]) 37 | }) 38 | }) 39 | 40 | describe('limit and offset together', () => { 41 | test('limit + offset (+ order by)', async () => { 42 | expect( 43 | await q.orderBy(Systems.id).limit(2).offset(3).fetch(client), 44 | ).toEqual([{ id: 4 }, { id: 5 }]) 45 | }) 46 | 47 | test('offset + limit (+ order by)', async () => { 48 | expect( 49 | await q.orderBy(Systems.id).offset(3).limit(2).fetch(client), 50 | ).toEqual([{ id: 4 }, { id: 5 }]) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/query/lock.test.ts: -------------------------------------------------------------------------------- 1 | import { query, QueryBuilderUsageError } from '../../src' 2 | import { Systems, client } from '../helpers' 3 | 4 | describe('row locking with query.lock()', () => { 5 | test.each([ 6 | ['forUpdate' as const, 'FOR UPDATE'], 7 | ['forNoKeyUpdate' as const, 'FOR NO KEY UPDATE'], 8 | ['forShare' as const, 'FOR SHARE'], 9 | ['forKeyShare' as const, 'FOR KEY SHARE'], 10 | ])('lock(%p)', (rowLockParam, sql) => { 11 | expect( 12 | query(Systems) 13 | .select(Systems.include('name')) 14 | .lock(rowLockParam) 15 | .sql(client), 16 | ).toMatch(new RegExp(sql + '$')) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /test/query/orderBy.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, Games } from '../helpers' 3 | 4 | describe('orderBy', () => { 5 | const orderdByTitle = [ 6 | { id: 6, title: 'Laser Blast' }, 7 | { id: 1, title: 'Sonic the Hedgehog' }, 8 | { id: 3, title: 'Super Mario Bros' }, 9 | { id: 2, title: 'Super Mario Land' }, 10 | { id: 4, title: 'Ultima IV' }, 11 | { id: 5, title: 'Virtua Racing' }, 12 | ] 13 | 14 | test('order by single column using default order', async () => { 15 | expect( 16 | await query(Games) 17 | .select(Games.include('id', 'title')) 18 | .orderBy(Games.title) 19 | .fetch(client), 20 | ).toEqual(orderdByTitle) 21 | }) 22 | 23 | test('order by single column ascending', async () => { 24 | expect( 25 | await query(Games) 26 | .select(Games.include('id', 'title')) 27 | .orderBy(Games.title) 28 | .fetch(client), 29 | ).toEqual(orderdByTitle) 30 | }) 31 | 32 | test('order by single column descending', async () => { 33 | expect( 34 | await query(Games) 35 | .select(Games.include('id', 'title')) 36 | .orderBy(Games.title, 'desc') 37 | .fetch(client), 38 | ).toEqual([...orderdByTitle].reverse()) 39 | }) 40 | 41 | test('order by single column descending and nulls first', async () => { 42 | expect( 43 | await query(Games) 44 | .select(Games.include('title', 'franchiseId')) 45 | .orderBy(Games.franchiseId, 'desc', 'nullsFirst') 46 | .fetch(client), 47 | ).toEqual([ 48 | { title: 'Virtua Racing', franchiseId: null }, 49 | { title: 'Laser Blast', franchiseId: null }, 50 | { title: 'Super Mario Land', franchiseId: 3 }, 51 | { title: 'Super Mario Bros', franchiseId: 3 }, 52 | { title: 'Sonic the Hedgehog', franchiseId: 2 }, 53 | { title: 'Ultima IV', franchiseId: 1 }, 54 | ]) 55 | }) 56 | 57 | test('order by single column descending and nulls last', async () => { 58 | expect( 59 | await query(Games) 60 | .select(Games.include('title', 'franchiseId')) 61 | .orderBy(Games.franchiseId, 'desc', 'nullsLast') 62 | .fetch(client), 63 | ).toEqual([ 64 | { title: 'Super Mario Land', franchiseId: 3 }, 65 | { title: 'Super Mario Bros', franchiseId: 3 }, 66 | { title: 'Sonic the Hedgehog', franchiseId: 2 }, 67 | { title: 'Ultima IV', franchiseId: 1 }, 68 | { title: 'Virtua Racing', franchiseId: null }, 69 | { title: 'Laser Blast', franchiseId: null }, 70 | ]) 71 | }) 72 | 73 | test('order by multiple columns', async () => { 74 | expect( 75 | await query(Games) 76 | .select(Games.include('title', 'franchiseId')) 77 | .orderBy(Games.franchiseId, 'desc', 'nullsLast') 78 | .orderBy(Games.title, 'asc') 79 | .fetch(client), 80 | ).toEqual([ 81 | { title: 'Super Mario Bros', franchiseId: 3 }, 82 | { title: 'Super Mario Land', franchiseId: 3 }, 83 | { title: 'Sonic the Hedgehog', franchiseId: 2 }, 84 | { title: 'Ultima IV', franchiseId: 1 }, 85 | { title: 'Laser Blast', franchiseId: null }, 86 | { title: 'Virtua Racing', franchiseId: null }, 87 | ]) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/select/select-checks.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { Manufacturers, Systems } from '../helpers' 3 | 4 | // not everything can be checked with the type system 5 | // 6 | // try to ensure that a query is properly constructed at runtime with 7 | // assertions though 8 | 9 | describe('select runtime tests', () => { 10 | test('duplicate selected columns', () => { 11 | expect(() => { 12 | query(Manufacturers) 13 | .join(Systems, ({ eq }) => eq(Systems.manufacturerId, Manufacturers.id)) 14 | .select(Manufacturers.include('id'), Systems.include('id')) 15 | }).toThrow('duplicate keys in selection: id') 16 | }) 17 | 18 | test('duplicate selected columns in different selections and renames', () => { 19 | expect(() => { 20 | query(Manufacturers) 21 | .join(Systems, ({ eq }) => eq(Systems.manufacturerId, Manufacturers.id)) 22 | .select(Manufacturers.include('name', 'id').rename({ id: 'year' })) 23 | .select(Systems.include('id', 'name', 'year')) 24 | }).toThrow('duplicate keys in selection: name, year') 25 | }) 26 | 27 | test('duplicate selected columns with aggregates', () => { 28 | expect(() => { 29 | query(Manufacturers) 30 | .join(Systems, ({ eq }) => eq(Systems.manufacturerId, Manufacturers.id)) 31 | .select(Manufacturers.include('name')) 32 | .selectJsonObject({ key: 'name' }, Systems.include('year')) 33 | }).toThrow('duplicate keys in selection: name') 34 | }) 35 | 36 | test('duplicate selected json object columns', () => { 37 | expect(() => { 38 | query(Manufacturers) 39 | .join(Systems, ({ eq }) => eq(Systems.manufacturerId, Manufacturers.id)) 40 | .selectJsonObject( 41 | { key: 'object' }, 42 | Manufacturers.all(), 43 | Systems.include('id'), 44 | ) 45 | }).toThrow('duplicate keys in select json object: id') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/select/select-subquery.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | Games, 4 | GamesSystems, 5 | Manufacturers, 6 | Systems, 7 | client, 8 | expectValuesUnsorted, 9 | } from '../helpers' 10 | 11 | describe('select: subselect', () => { 12 | test('correlated subselect', async () => { 13 | const res = await query(Manufacturers) 14 | .select(Manufacturers.include('name'), (subquery) => 15 | subquery(Systems) 16 | .select(Systems.include('name').rename({ name: 'firstConsole' })) 17 | .where(({ eq, and, literal, param, not }) => 18 | and( 19 | eq(Systems.manufacturerId, Manufacturers.id), 20 | not(param('blocker').boolean()), 21 | ), 22 | ) 23 | .orderBy(Systems.year, 'asc') 24 | .limit(1), 25 | ) 26 | .fetch(client, { blocker: false }) 27 | 28 | expectValuesUnsorted(res, [ 29 | { name: 'Sega', firstConsole: 'Master System' }, 30 | { name: 'Nintendo', firstConsole: 'NES' }, 31 | { name: 'Atari', firstConsole: 'Atari 2600' }, 32 | ]) 33 | }) 34 | 35 | test('correlated aggregated subselect', async () => { 36 | const q = await query(Manufacturers) 37 | .select((subquery) => 38 | subquery(Systems) 39 | .selectJsonArray({ key: 'systems' }, Systems.include('name')) 40 | .where(({ eq, and, literal, param, not }) => 41 | and( 42 | eq(Systems.manufacturerId, Manufacturers.id), 43 | not(param('blocker').boolean()), 44 | ), 45 | ), 46 | ) 47 | .where(({ eq }) => eq(Manufacturers.name, 'manufacturer')) 48 | 49 | // select an existing manufacturer 50 | const sega = await q.fetch(client, { manufacturer: 'Sega', blocker: false }) 51 | 52 | expect(sega).toEqual([{ systems: expect.any(Array) }]) 53 | expectValuesUnsorted(sega[0].systems, [ 54 | 'Master System', 55 | 'Genesis', 56 | 'Game Gear', 57 | ]) 58 | 59 | // empty select 60 | const nothing = await q.fetch(client, { 61 | manufacturer: 'does not exist', 62 | blocker: false, 63 | }) 64 | 65 | expect(nothing).toEqual([]) 66 | 67 | // force to not select any systems 68 | const noSystems = await q.fetch(client, { 69 | manufacturer: 'Sega', 70 | blocker: true, 71 | }) 72 | 73 | expect(noSystems).toEqual([{ systems: null }]) 74 | }) 75 | 76 | test('preserving Date objects in jsonArray', async () => { 77 | const q = await query(Games) 78 | .select(Games.include('title')) 79 | .select((subquery) => 80 | subquery(GamesSystems) 81 | .selectJsonArray( 82 | { key: 'releaseDates' }, 83 | GamesSystems.include('releaseDate'), 84 | ) 85 | .where(({ eq, and, literal, param, not }) => 86 | and( 87 | eq(GamesSystems.gameId, Games.id), 88 | // make the subquery return `null` for a specific game so that 89 | // we check that the result transformer deals with that case 90 | // correctly 91 | not(eq(GamesSystems.gameId, 'blockedGameId')), 92 | ), 93 | ), 94 | ) 95 | .where(({ isIn }) => isIn(Games.id, 'gameIds')) 96 | 97 | const res = await q.fetch(client, { gameIds: [1, 2], blockedGameId: 2 }) 98 | 99 | expectValuesUnsorted(res, [ 100 | { 101 | title: 'Sonic the Hedgehog', 102 | releaseDates: [ 103 | new Date('1991-10-25T00:00:00.000Z'), 104 | new Date('1991-07-26T00:00:00.000Z'), 105 | null, 106 | ], 107 | }, 108 | { 109 | title: 'Super Mario Land', 110 | releaseDates: null, 111 | }, 112 | ]) 113 | }) 114 | 115 | test('nested query and Date objects', async () => { 116 | // building nested json from subqueries 117 | const res = await query(Manufacturers) 118 | .select( 119 | Manufacturers.include('name').rename({ name: 'company' }), 120 | (subquery) => 121 | subquery(Systems) 122 | .selectJsonObjectArray( 123 | { key: 'systems', orderBy: Systems.year, direction: 'asc' }, 124 | Systems.include('name', 'id'), 125 | (subquery) => 126 | subquery(Games) 127 | .join(GamesSystems, ({ eq }) => 128 | eq(Games.id, GamesSystems.gameId), 129 | ) 130 | .selectJsonObjectArray( 131 | { key: 'games', orderBy: Games.title, direction: 'asc' }, 132 | Games.include('title'), 133 | GamesSystems.include('releaseDate'), 134 | ) 135 | .where(({ eq }) => eq(Systems.id, GamesSystems.systemId)), 136 | ) 137 | .where(({ eq }) => eq(Manufacturers.id, Systems.manufacturerId)), 138 | ) 139 | .where(({ eq }) => eq(Manufacturers.name, 'company')) 140 | .fetch(client, { company: 'Sega' }) 141 | 142 | expect(res).toEqual([ 143 | { 144 | company: 'Sega', 145 | systems: [ 146 | { 147 | name: 'Master System', 148 | id: 1, 149 | games: [ 150 | { 151 | title: 'Sonic the Hedgehog', 152 | releaseDate: new Date('1991-10-25T00:00:00.000Z'), 153 | }, 154 | { 155 | title: 'Ultima IV', 156 | releaseDate: new Date('1990-01-01T00:00:00.000Z'), 157 | }, 158 | ], 159 | }, 160 | { 161 | name: 'Genesis', 162 | id: 2, 163 | games: [ 164 | { 165 | title: 'Sonic the Hedgehog', 166 | releaseDate: new Date('1991-07-26T00:00:00.000Z'), 167 | }, 168 | { 169 | title: 'Virtua Racing', 170 | releaseDate: new Date('1994-08-18T00:00:00.000Z'), 171 | }, 172 | ], 173 | }, 174 | { 175 | name: 'Game Gear', 176 | id: 3, 177 | games: [{ title: 'Sonic the Hedgehog', releaseDate: null }], 178 | }, 179 | ], 180 | }, 181 | ]) 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /test/select/select.all.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, expectValuesUnsorted, Manufacturers } from '../helpers' 3 | 4 | // basic selection without joins, projections, subqueries 5 | describe('select.all', () => { 6 | test('all', async () => { 7 | const result = await query(Manufacturers) 8 | .select(Manufacturers.all()) 9 | .fetch(client) 10 | 11 | expectValuesUnsorted(result, [ 12 | { id: 1, name: 'Sega', country: 'Japan' }, 13 | { id: 2, name: 'Nintendo', country: 'Japan' }, 14 | { id: 3, name: 'Atari', country: 'USA' }, 15 | ]) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/select/select.exclude.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, expectValuesUnsorted, Manufacturers } from '../helpers' 3 | 4 | // basic selection without joins, projections, subqueries 5 | describe('select.exclude', () => { 6 | test('exclude', async () => { 7 | const result = await query(Manufacturers) 8 | .select(Manufacturers.exclude('name')) 9 | .fetch(client) 10 | 11 | expectValuesUnsorted(result, [ 12 | { id: 1, country: 'Japan' }, 13 | { id: 2, country: 'Japan' }, 14 | { id: 3, country: 'USA' }, 15 | ]) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/select/select.include.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | client, 4 | expectValuesUnsorted, 5 | Manufacturers, 6 | Systems, 7 | } from '../helpers' 8 | 9 | // basic selection without joins, projections, subqueries 10 | describe('select.include', () => { 11 | test('include', async () => { 12 | const result = await query(Manufacturers) 13 | .select(Manufacturers.include('name')) 14 | .fetch(client) 15 | 16 | expectValuesUnsorted(result, [ 17 | { name: 'Sega' }, 18 | { name: 'Nintendo' }, 19 | { name: 'Atari' }, 20 | ]) 21 | }) 22 | 23 | test('include a column that differs from the database name', async () => { 24 | const result = await query(Systems) 25 | .select(Systems.include('name', 'manufacturerId')) // it's manufacturer_id in the db 26 | .fetch(client) 27 | 28 | expectValuesUnsorted(result, [ 29 | { name: 'Master System', manufacturerId: 1 }, 30 | { name: 'Genesis', manufacturerId: 1 }, 31 | { name: 'Game Gear', manufacturerId: 1 }, 32 | { name: 'NES', manufacturerId: 2 }, 33 | { name: 'SNES', manufacturerId: 2 }, 34 | { name: 'Game Boy', manufacturerId: 2 }, 35 | { name: 'Atari 2600', manufacturerId: 3 }, 36 | ]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/select/select.rename.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, expectValuesUnsorted, Manufacturers } from '../helpers' 3 | 4 | describe('select.rename', () => { 5 | test('rename', async () => { 6 | const result = await query(Manufacturers) 7 | .select( 8 | Manufacturers.include('id', 'name').rename({ 9 | name: 'MANUFACTURER', 10 | }), 11 | ) 12 | .fetch(client) 13 | 14 | expectValuesUnsorted(result, [ 15 | { id: 1, MANUFACTURER: 'Sega' }, 16 | { id: 2, MANUFACTURER: 'Nintendo' }, 17 | { id: 3, MANUFACTURER: 'Atari' }, 18 | ]) 19 | }) 20 | 21 | test('escaping complex names', async () => { 22 | const result = await query(Manufacturers) 23 | .select( 24 | // escaping also works on schema column aliases but it's easier to 25 | // test with rename 26 | Manufacturers.include('id', 'name').rename({ 27 | name: `m A"'\\ \n u f ac 😅`, 28 | }), 29 | ) 30 | .fetch(client) 31 | 32 | expectValuesUnsorted(result, [ 33 | { id: 1, [`m A"'\\ \n u f ac 😅`]: 'Sega' }, 34 | { id: 2, [`m A"'\\ \n u f ac 😅`]: 'Nintendo' }, 35 | { id: 3, [`m A"'\\ \n u f ac 😅`]: 'Atari' }, 36 | ]) 37 | }) 38 | 39 | // error conditions that are not catched by the typesystem (either because 40 | // it is technically not possible or too complex) 41 | describe('rename errors', () => { 42 | test('renamed columns must be unique', async () => { 43 | expect(() => 44 | query(Manufacturers).select( 45 | Manufacturers.include('id', 'name').rename({ 46 | name: 'XXX', 47 | id: 'XXX', 48 | }), 49 | ), 50 | ).toThrow('mapped column "XXX" in `rename` is not unique') 51 | }) 52 | 53 | test('column names must exist', async () => { 54 | expect(() => 55 | query(Manufacturers).select( 56 | Manufacturers.include('id', 'name').rename({ 57 | name: 'XXX', 58 | foo: 'bar', 59 | }), 60 | ), 61 | ).toThrow( 62 | "renamed column 'foo' does not exist in this selection (table: 'classicgames.manufacturers')", 63 | ) 64 | }) 65 | 66 | test('column names must be selected', async () => { 67 | expect(() => 68 | query(Manufacturers).select( 69 | Manufacturers.include('id', 'name').rename({ 70 | name: 'XXX', 71 | country: 'bar', 72 | }), 73 | ), 74 | ).toThrow( 75 | "renamed column 'country' does not exist in this selection (table: 'classicgames.manufacturers')", 76 | ) 77 | }) 78 | 79 | test('rename must only be called once', async () => { 80 | expect(() => 81 | query(Manufacturers).select( 82 | Manufacturers.include('id', 'name') 83 | .rename({ 84 | name: 'M', 85 | }) 86 | .rename({ 87 | id: 'I', 88 | }), 89 | ), 90 | ).toThrow('`rename` has already been called on this selection') 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/select/selectJsonArray.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | GamesSystems, 4 | Manufacturers, 5 | client, 6 | expectValuesUnsorted, 7 | } from '../helpers' 8 | 9 | describe('selectJsonArray', () => { 10 | test('plain array', async () => { 11 | const result = await query(Manufacturers) 12 | .selectJsonArray({ key: 'ids' }, Manufacturers.include('id')) 13 | .fetch(client) 14 | 15 | expectValuesUnsorted(result, [{ ids: [1, 2, 3] }]) 16 | }) 17 | 18 | test('ordered default', async () => { 19 | const res = await query(Manufacturers) 20 | .selectJsonArray( 21 | { key: 'names', orderBy: Manufacturers.name }, 22 | Manufacturers.include('name'), 23 | ) 24 | .fetch(client) 25 | 26 | expect(res).toEqual([{ names: ['Atari', 'Nintendo', 'Sega'] }]) 27 | }) 28 | 29 | test('ordered ASC', async () => { 30 | const res = await query(Manufacturers) 31 | .selectJsonArray( 32 | { key: 'names', orderBy: Manufacturers.name, direction: 'asc' }, 33 | Manufacturers.include('name'), 34 | ) 35 | .fetch(client) 36 | 37 | expect(res).toEqual([{ names: ['Atari', 'Nintendo', 'Sega'] }]) 38 | }) 39 | 40 | test('ordered DESC', async () => { 41 | const res = await query(Manufacturers) 42 | .selectJsonArray( 43 | { key: 'names', orderBy: Manufacturers.name, direction: 'desc' }, 44 | Manufacturers.include('name'), 45 | ) 46 | .fetch(client) 47 | 48 | expect(res).toEqual([{ names: ['Sega', 'Nintendo', 'Atari'] }]) 49 | }) 50 | 51 | test('preserve Date objects in json through cast and result transformation', async () => { 52 | const res = await query(GamesSystems) 53 | .selectJsonArray({ key: 'dates' }, GamesSystems.include('releaseDate')) 54 | .fetch(client) 55 | 56 | // json_agg is an aggregate function 57 | expect(res).toEqual([expect.any(Object)]) 58 | expect(res[0].dates).toContainEqual(new Date('1991-10-25T00:00:00.000Z')) 59 | expect(res[0].dates).toContainEqual(null) 60 | }) 61 | 62 | describe('errors', () => { 63 | test('single selected column required ', async () => { 64 | expect(() => 65 | query(Manufacturers).selectJsonArray( 66 | { key: 'foo' }, 67 | Manufacturers.all(), 68 | ), 69 | ).toThrow( 70 | "table.selectJsonArray on table 'classicgames.manufacturers': a single column must be selected, not 3 ('id', 'name', 'country')", 71 | ) 72 | }) 73 | 74 | test('direction without order by ', async () => { 75 | expect(() => 76 | query(Manufacturers).selectJsonArray( 77 | { key: 'foo', direction: 'asc' }, 78 | Manufacturers.include('id'), 79 | ), 80 | ).toThrow( 81 | 'table.selectJsonArray: direction argument must be supplied along orderBy', 82 | ) 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/select/selectJsonObject.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | GamesSystems, 4 | Manufacturers, 5 | client, 6 | expectValuesUnsorted, 7 | } from '../helpers' 8 | 9 | describe('select.jsonObject', () => { 10 | test('all', async () => { 11 | const result = await query(Manufacturers) 12 | .selectJsonObject({ key: 'company' }, Manufacturers.all()) 13 | .fetch(client) 14 | 15 | expectValuesUnsorted(result, [ 16 | { company: { id: 1, name: 'Sega', country: 'Japan' } }, 17 | { company: { id: 2, name: 'Nintendo', country: 'Japan' } }, 18 | { company: { id: 3, name: 'Atari', country: 'USA' } }, 19 | ]) 20 | }) 21 | 22 | test('exclude + rename + escaping', async () => { 23 | const result = await query(Manufacturers) 24 | .selectJsonObject( 25 | { key: ' x ' }, 26 | Manufacturers.exclude('country').rename({ id: '# 😂😂😂 #' }), 27 | ) 28 | .fetch(client) 29 | 30 | expectValuesUnsorted(result, [ 31 | { ' x ': { '# 😂😂😂 #': 1, name: 'Sega' } }, 32 | { ' x ': { '# 😂😂😂 #': 2, name: 'Nintendo' } }, 33 | { ' x ': { '# 😂😂😂 #': 3, name: 'Atari' } }, 34 | ]) 35 | }) 36 | 37 | describe('cast & result transformation', () => { 38 | test('preserve the type of a date when selecting it through json', async () => { 39 | const result = await query(GamesSystems) 40 | .selectJsonObject( 41 | { key: 'game' }, 42 | GamesSystems.include('gameId', 'systemId', 'releaseDate').rename({ 43 | releaseDate: 'rd', 44 | }), 45 | ) 46 | .fetch(client) 47 | 48 | expect(result).toContainEqual({ 49 | game: { 50 | gameId: 1, 51 | systemId: 1, 52 | rd: new Date('1991-10-25T00:00:00.000Z'), 53 | }, 54 | }) 55 | 56 | // check transparent null handling 57 | expect(result).toContainEqual({ 58 | game: { 59 | gameId: 1, 60 | systemId: 3, 61 | rd: null, 62 | }, 63 | }) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/select/selectJsonObjectArray.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | GamesSystems, 4 | Manufacturers, 5 | client, 6 | expectValuesUnsorted, 7 | } from '../helpers' 8 | 9 | describe('select.jsonObjectArray', () => { 10 | test('all', async () => { 11 | const result = await query(Manufacturers) 12 | .selectJsonObjectArray({ key: 'companies' }, Manufacturers.all()) 13 | .fetch(client) 14 | 15 | expectValuesUnsorted(result, [ 16 | { 17 | companies: [ 18 | { id: 1, name: 'Sega', country: 'Japan' }, 19 | { id: 2, name: 'Nintendo', country: 'Japan' }, 20 | { id: 3, name: 'Atari', country: 'USA' }, 21 | ], 22 | }, 23 | ]) 24 | }) 25 | 26 | test('preserve Date objects in json through cast and result transformation', async () => { 27 | const res = await query(GamesSystems) 28 | .selectJsonObjectArray( 29 | { key: 'releases' }, 30 | GamesSystems.include('gameId', 'systemId', 'releaseDate').rename({ 31 | releaseDate: 'd', 32 | }), 33 | ) 34 | .fetch(client) 35 | 36 | // json_agg is an aggregate function 37 | expect(res).toEqual([expect.any(Object)]) 38 | expect(res[0].releases).toContainEqual({ 39 | gameId: 1, 40 | systemId: 1, 41 | d: new Date('1991-10-25T00:00:00.000Z'), 42 | }) 43 | expect(res[0].releases).toContainEqual({ gameId: 1, systemId: 3, d: null }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/table.test.ts: -------------------------------------------------------------------------------- 1 | import { table, column } from '../src' 2 | 3 | describe('creating tables', () => { 4 | // The fact that table creationg is successful is implicitly tested by all 5 | // the different query, select, where ... tests that operate on 6 | // well-defined tables from the test schema 7 | // 8 | // Here we run some tests to check that the runtime assertions in tables 9 | // and columns work as expected. 10 | describe('checks', () => { 11 | test('do not allow sql column name duplicates', () => { 12 | expect(() => { 13 | table('test_table', { 14 | id: column('id').integer(), 15 | name: column('name').string(), 16 | label: column('name').string(), 17 | }) 18 | }).toThrow( 19 | "table 'test_table' - found duplicate sql column names: 'name'", 20 | ) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/update.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../src' 2 | import { Manufacturers, Systems, client } from './helpers' 3 | 4 | describe('update', () => { 5 | beforeEach(async () => { 6 | await client.query('BEGIN') 7 | }) 8 | 9 | afterEach(async () => { 10 | await client.query('ROLLBACK') 11 | }) 12 | 13 | describe('update', () => { 14 | test('with column data', async () => { 15 | const res = await query 16 | .update(Systems) 17 | .data('data', Systems.include('name', 'year')) 18 | .execute(client, { data: { name: 'SEGA', year: 1990 } }) 19 | 20 | expect(res).toEqual(undefined) 21 | expect( 22 | await query(Systems) 23 | .select(Systems.include('name', 'year')) 24 | .fetch(client), 25 | ).toEqual([ 26 | { name: 'SEGA', year: 1990 }, 27 | { name: 'SEGA', year: 1990 }, 28 | { name: 'SEGA', year: 1990 }, 29 | { name: 'SEGA', year: 1990 }, 30 | { name: 'SEGA', year: 1990 }, 31 | { name: 'SEGA', year: 1990 }, 32 | { name: 'SEGA', year: 1990 }, 33 | ]) 34 | }) 35 | 36 | test('with column data and where and returning', async () => { 37 | const res = await query 38 | .update(Systems) 39 | .data('data', Systems.include('name', 'year', 'manufacturerId')) 40 | .where(({ eq }) => eq(Systems.name, 'syname')) 41 | .returning(Systems.all()) 42 | .execute(client, { 43 | syname: 'Sega', 44 | data: { name: 'SEGA', year: 1990, manufacturerId: 2 }, 45 | }) 46 | 47 | // new row contains 'SEGA' as the name, which does not match the 'Sega' 48 | // filter, hence an empty list 49 | expect(res).toEqual([]) 50 | }) 51 | 52 | test('with set and returning', async () => { 53 | const res = await query 54 | .update(Manufacturers) 55 | .set('name', ({ caseWhen, literal, eq }) => 56 | caseWhen( 57 | [eq(Manufacturers.name, literal('Sega')), literal('sega')], 58 | literal('non-sega'), 59 | ), 60 | ) 61 | .returning(Manufacturers.include('id', 'name')) 62 | .execute(client) 63 | 64 | // new row contains 'SEGA' as the name, which does not match the 'Sega' 65 | // filter, hence an empty list 66 | expect(res).toEqual([ 67 | { id: 1, name: 'sega' }, 68 | { id: 2, name: 'non-sega' }, 69 | { id: 3, name: 'non-sega' }, 70 | ]) 71 | }) 72 | 73 | test.each` 74 | ids | expected | error 75 | ${[1]} | ${1} | ${null} 76 | ${[1, 1, 1]} | ${1} | ${null} 77 | ${[1, 2]} | ${2} | ${null} 78 | ${[1, 2]} | ${1} | ${"query.update: table 'classicgames.systems' - expected to update exactly 1 rows but got 2 instead."} 79 | ${[]} | ${1} | ${"query.update: table 'classicgames.systems' - expected to update exactly 1 rows but got 0 instead."} 80 | ${[2]} | ${{ min: 1, max: 1 }} | ${null} 81 | ${[2, 4]} | ${{ min: 1, max: 1 }} | ${"query.update: table 'classicgames.systems' - expected to update no more than 1 rows but got 2 instead."} 82 | ${[1, 2, 3, 4]} | ${{ min: 3, max: 4 }} | ${null} 83 | ${[1, 2, 4]} | ${{ min: 3, max: 4 }} | ${null} 84 | ${[2, 4]} | ${{ min: 3, max: 4 }} | ${"query.update: table 'classicgames.systems' - expected to update no less than 3 rows but got 2 instead."} 85 | ${[2, 4, 1, 3, 5]} | ${{ min: 3 }} | ${null} 86 | ${[2, 4]} | ${{ min: 3 }} | ${"query.update: table 'classicgames.systems' - expected to update no less than 3 rows but got 2 instead."} 87 | ${[]} | ${{ max: 1 }} | ${null} 88 | ${[2, 4]} | ${{ max: 1 }} | ${"query.update: table 'classicgames.systems' - expected to update no more than 1 rows but got 2 instead."} 89 | `('expectUpdatedRowCount $expected', ({ ids, expected, error }) => { 90 | const q = query 91 | .update(Systems) 92 | .set('name', ({ literal }) => literal('an old console')) 93 | .where(({ isIn }) => isIn(Systems.id, 'ids')) 94 | .returning(Systems.include('id')) 95 | 96 | if (error === null) { 97 | expect( 98 | q.expectUpdatedRowCount(expected).execute(client, { ids }), 99 | ).resolves.toEqual( 100 | expect.arrayContaining(ids.map((id: number) => ({ id }))), 101 | ) 102 | } else { 103 | expect( 104 | q.expectUpdatedRowCount(expected).execute(client, { ids }), 105 | ).rejects.toThrow(error) 106 | } 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { omit, pick } from '../src/utils' 2 | 3 | describe('object utilities', () => { 4 | test('pick', () => { 5 | expect(pick({})).toEqual({}) 6 | expect(pick({ a: 1, b: undefined })).toEqual({}) 7 | expect(pick({ a: 1, b: undefined }, 'a')).toEqual({ a: 1 }) 8 | expect(pick({ a: 1, b: undefined }, 'a', 'b')).toEqual({ 9 | a: 1, 10 | b: undefined, 11 | }) 12 | }) 13 | 14 | test('omit', () => { 15 | expect(omit({})).toEqual({}) 16 | expect(omit({ a: 1, b: undefined })).toEqual({ a: 1, b: undefined }) 17 | expect(omit({ a: 1, b: undefined }, 'a')).toEqual({ b: undefined }) 18 | expect(omit({ a: 1, b: undefined }, 'a', 'b')).toEqual({}) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/where/where.eq.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | client, 4 | expectValuesUnsorted, 5 | Manufacturers, 6 | Systems, 7 | } from '../helpers' 8 | 9 | describe('where + eq', () => { 10 | test('column + literal', async () => { 11 | const q = query(Systems) 12 | .select(Systems.include('name')) 13 | .where(({ eq, literal }) => eq(Systems.id, literal(1))) 14 | 15 | expect(await q.fetch(client)).toEqual([{ name: 'Master System' }]) 16 | }) 17 | 18 | test('column + parameter', async () => { 19 | const q = query(Systems) 20 | .select(Systems.include('name')) 21 | .where(({ eq }) => eq(Systems.id, 'id')) 22 | 23 | expect(await q.fetch(client, { id: 1 })).toEqual([ 24 | { name: 'Master System' }, 25 | ]) 26 | expect(await q.fetch(client, { id: 2 })).toEqual([{ name: 'Genesis' }]) 27 | }) 28 | 29 | test('parameter + column', async () => { 30 | const q = query(Systems) 31 | .select(Systems.include('name')) 32 | .where(({ eq }) => eq('id', Systems.id)) 33 | 34 | expect(await q.fetch(client, { id: 1 })).toEqual([ 35 | { name: 'Master System' }, 36 | ]) 37 | }) 38 | 39 | test('column + subquery', async () => { 40 | const q = query(Systems) 41 | .select(Systems.include('name')) 42 | .where(({ eq, subquery }) => 43 | eq( 44 | Systems.manufacturerId, 45 | subquery(Manufacturers) 46 | .select(Manufacturers.include('id')) 47 | .where(({ eq }) => eq(Manufacturers.name, 'name')), 48 | ), 49 | ) 50 | 51 | expectValuesUnsorted(await q.fetch(client, { name: 'Sega' }), [ 52 | { name: 'Master System' }, 53 | { name: 'Game Gear' }, 54 | { name: 'Genesis' }, 55 | ]) 56 | }) 57 | 58 | test('subquery + column', async () => { 59 | const q = query(Systems) 60 | .select(Systems.include('name')) 61 | .where(({ eq, subquery }) => 62 | eq( 63 | subquery(Manufacturers) 64 | .select(Manufacturers.include('id')) 65 | .where(({ eq }) => eq(Manufacturers.name, 'name')), 66 | Systems.manufacturerId, 67 | ), 68 | ) 69 | 70 | expectValuesUnsorted(await q.fetch(client, { name: 'Sega' }), [ 71 | { name: 'Master System' }, 72 | { name: 'Game Gear' }, 73 | { name: 'Genesis' }, 74 | ]) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /test/where/where.exists.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { 3 | client, 4 | expectValuesUnsorted, 5 | Manufacturers, 6 | Systems, 7 | } from '../helpers' 8 | 9 | describe('where + exists', () => { 10 | test('correlated subselect', async () => { 11 | // manufacturers that have a console released in 1977 12 | const result = await query(Manufacturers) 13 | .select(Manufacturers.include('name')) 14 | .where(({ exists, subquery }) => 15 | exists( 16 | subquery(Systems) 17 | .select(Systems.include('id')) 18 | .where(({ eq, and }) => 19 | and( 20 | eq(Systems.manufacturerId, Manufacturers.id), 21 | eq(Systems.year, 'year'), 22 | ), 23 | ), 24 | ), 25 | ) 26 | .fetch(client, { year: 1977 }) 27 | 28 | expectValuesUnsorted(result, [{ name: 'Atari' }]) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/where/where.isIn.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, expectValuesUnsorted, Games, GamesSystems } from '../helpers' 3 | 4 | describe('where + isIn', () => { 5 | test('ids', async () => { 6 | const result = await query(Games) 7 | .select(Games.include('id', 'title')) 8 | .where(({ isIn }) => isIn(Games.id, 'ids')) 9 | .fetch(client, { 10 | ids: [5, 1], 11 | }) 12 | 13 | expectValuesUnsorted(result, [ 14 | { id: 1, title: 'Sonic the Hedgehog' }, 15 | { id: 5, title: 'Virtua Racing' }, 16 | ]) 17 | }) 18 | 19 | test('strings', async () => { 20 | const result = await query(Games) 21 | .select(Games.include('id', 'title')) 22 | .where(({ isIn }) => isIn(Games.title, 'titles')) 23 | .fetch(client, { 24 | titles: ['Super Mario Land', 'Sonic the Hedgehog'], 25 | }) 26 | 27 | expectValuesUnsorted(result, [ 28 | { id: 2, title: 'Super Mario Land' }, 29 | { id: 1, title: 'Sonic the Hedgehog' }, 30 | ]) 31 | }) 32 | 33 | test('empty', async () => { 34 | const result = await query(Games) 35 | .select(Games.include('id', 'title')) 36 | .where(({ isIn }) => isIn(Games.id, 'ids')) 37 | .fetch(client, { 38 | ids: [], 39 | }) 40 | 41 | expectValuesUnsorted(result, []) 42 | }) 43 | 44 | test('subselect', async () => { 45 | const result = await query(Games) 46 | .select(Games.include('title')) 47 | .where(({ isIn, subquery }) => 48 | isIn( 49 | Games.id, 50 | subquery(GamesSystems) 51 | .select(GamesSystems.include('gameId')) 52 | .where(({ eq }) => eq(GamesSystems.systemId, 'systemId')), 53 | ), 54 | ) 55 | .fetch(client, { systemId: 1 }) 56 | 57 | expectValuesUnsorted(result, [ 58 | { title: 'Ultima IV' }, 59 | { title: 'Sonic the Hedgehog' }, 60 | ]) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /test/where/where.isNull.test.ts: -------------------------------------------------------------------------------- 1 | import { query } from '../../src' 2 | import { client, expectValuesUnsorted, Franchises } from '../helpers' 3 | 4 | describe('where + isNull', () => { 5 | test('is null', async () => { 6 | const res = await query(Franchises) 7 | .select(Franchises.include('name')) 8 | .where(({ isNull }) => isNull(Franchises.manufacturerId)) 9 | .fetch(client) 10 | 11 | expectValuesUnsorted(res, [{ name: 'Ultima' }]) 12 | }) 13 | 14 | test('not is null', async () => { 15 | const res = await query(Franchises) 16 | .select(Franchises.include('name')) 17 | .where(({ isNull, not }) => not(isNull(Franchises.manufacturerId))) 18 | .fetch(client) 19 | 20 | expectValuesUnsorted(res, [{ name: 'Sonic' }, { name: 'Mario' }]) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "esnext", 6 | "lib": ["esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": false, 18 | "noUnusedParameters": false, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "baseUrl": ".", 23 | "paths": { 24 | "*": ["src/*", "node_modules/*"] 25 | }, 26 | "esModuleInterop": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------