├── spec ├── src ├── pnpm-workspace.yaml ├── .npmrc ├── generated │ ├── .gitignore │ ├── e2e │ │ ├── types.ts │ │ ├── index.ts │ │ └── tables.ts │ ├── test │ │ ├── data.dump │ │ └── schema.sql │ ├── tusken-plugin │ │ ├── package.json │ │ └── connection.ts │ ├── tusken.config.ts │ └── reset-e2e.sh ├── tsconfig.json ├── types │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── function.ts │ ├── insert.ts │ ├── typeCast.ts │ └── selection.ts ├── types.spec.ts ├── package.json ├── db.ts ├── pnpm-lock.yaml └── e2e.spec.ts ├── .npmrc ├── src ├── plugins │ ├── pg │ │ ├── package.json │ │ ├── client.ts │ │ ├── runtime.ts │ │ ├── runtime │ │ │ └── stream.ts │ │ ├── connection.ts │ │ └── pool.ts │ └── tsconfig.json ├── postgres │ ├── range.ts │ ├── interval.ts │ ├── props │ │ ├── union.ts │ │ ├── set.ts │ │ └── select.ts │ ├── typesBuiltin.d.ts │ ├── internal │ │ ├── type.ts │ │ ├── tuple.ts │ │ ├── query.ts │ │ └── token.ts │ ├── json.ts │ ├── stream.ts │ ├── join.ts │ ├── is.ts │ ├── typeCast.ts │ ├── selector.ts │ ├── query │ │ ├── count.ts │ │ ├── select.ts │ │ ├── delete.ts │ │ ├── union.ts │ │ ├── base │ │ │ └── set.ts │ │ ├── orderBy.ts │ │ ├── where.ts │ │ └── put.ts │ ├── array.ts │ ├── symbols.ts │ ├── expression.ts │ ├── connection.ts │ ├── row.ts │ ├── tableCast.ts │ ├── set.ts │ ├── typeChecks.ts │ ├── column.ts │ ├── selection.ts │ ├── function.ts │ ├── type.ts │ ├── database.ts │ ├── check.ts │ ├── table.ts │ └── query.ts ├── constants.ts ├── utils │ ├── isArray.ts │ ├── Variadic.ts │ ├── isObject.ts │ ├── toArray.ts │ ├── callProp.ts │ ├── escalade │ │ └── license.md │ └── narrow.ts ├── config │ ├── defineConfig.ts │ ├── definePlugin.ts │ ├── index.ts │ ├── loadProject.ts │ ├── loadRuntimePlugin.ts │ ├── escalade │ │ ├── sync.ts │ │ └── license.md │ ├── loadClient.ts │ ├── config.ts │ ├── loadModule.ts │ └── loadConfig.ts ├── prepare.sh ├── tsconfig.json ├── tsup.config.ts ├── dotenv.ts ├── dist │ └── plugins │ │ └── pg │ │ └── package.json ├── tusken.ts ├── definePlugin.ts ├── zod.ts └── package.json ├── pnpm-workspace.yaml ├── packages ├── tusken-schema │ ├── src │ │ ├── utils │ │ │ ├── syntax.ts │ │ │ ├── toTable.ts │ │ │ ├── imports.ts │ │ │ └── dataToEsm.ts │ │ ├── typescript │ │ │ ├── templateFunctions.ts │ │ │ ├── zodTypeMap.ts │ │ │ ├── datePart.ts │ │ │ ├── nativeTypeMap.ts │ │ │ ├── reservedWords.ts │ │ │ └── generateNativeTypes.ts │ │ ├── scripts │ │ │ └── fetch-docs.ts │ │ ├── extract.ts │ │ ├── extract │ │ │ ├── extractCasts.ts │ │ │ ├── extractTypes.ts │ │ │ └── extractFuncs.ts │ │ ├── docs.ts │ │ └── index.ts │ ├── LICENSE.md │ ├── tsconfig.json │ ├── build.config.ts │ └── package.json └── tusken-cli │ ├── bin │ └── tusken.js │ ├── tsup.config.ts │ ├── tsconfig.json │ ├── src │ ├── connectionString.ts │ ├── defer.ts │ ├── debounce.ts │ └── firstline │ │ ├── index.ts │ │ └── license.md │ ├── package.json │ └── readme.md ├── .gitignore ├── .indo.json ├── vitest.config.ts ├── release.sh ├── .vscode └── settings.json ├── release.config.js ├── package.json ├── LICENSE.md └── readme.md /spec/src: -------------------------------------------------------------------------------- 1 | ../src -------------------------------------------------------------------------------- /spec/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check = true 2 | -------------------------------------------------------------------------------- /spec/.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check = true 2 | -------------------------------------------------------------------------------- /src/plugins/pg/package.json: -------------------------------------------------------------------------------- 1 | ../../dist/plugins/pg/package.json -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'src' 3 | - 'packages/*' 4 | -------------------------------------------------------------------------------- /src/postgres/range.ts: -------------------------------------------------------------------------------- 1 | export { Range } from 'postgres-range' 2 | -------------------------------------------------------------------------------- /spec/generated/.gitignore: -------------------------------------------------------------------------------- 1 | postgres 2 | e2e/schema.sql 3 | test/*.ts 4 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/utils/syntax.ts: -------------------------------------------------------------------------------- 1 | export const __PURE__ = '/*#__PURE__*/' 2 | -------------------------------------------------------------------------------- /spec/generated/e2e/types.ts: -------------------------------------------------------------------------------- 1 | export * from './tables' 2 | export * from './primitives' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | docs 3 | dist 4 | vendor 5 | node_modules 6 | playground 7 | *.tsbuildinfo 8 | -------------------------------------------------------------------------------- /src/plugins/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /src/postgres/interval.ts: -------------------------------------------------------------------------------- 1 | export type Interval = import('postgres-interval').IPostgresInterval 2 | -------------------------------------------------------------------------------- /packages/tusken-cli/bin/tusken.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/index.js').default() 3 | -------------------------------------------------------------------------------- /spec/generated/test/data.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alloc/tusken/HEAD/spec/generated/test/data.dump -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const FunctionFlags = { 2 | isAggregate: 1 << 0, 3 | omitArgs: 1 << 1, 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/isArray.ts: -------------------------------------------------------------------------------- 1 | export const isArray = Array.isArray as (arg: unknown) => arg is readonly any[] 2 | -------------------------------------------------------------------------------- /packages/tusken-schema/LICENSE.md: -------------------------------------------------------------------------------- 1 | The `@tusken/schema` package shares a license with the `tusken` npm package. 2 | -------------------------------------------------------------------------------- /src/utils/Variadic.ts: -------------------------------------------------------------------------------- 1 | export type Variadic = T | readonly T[] 2 | export type RecursiveVariadic = T | readonly RecursiveVariadic[] 3 | -------------------------------------------------------------------------------- /src/utils/isObject.ts: -------------------------------------------------------------------------------- 1 | export function isObject(o: any): o is object { 2 | return !!o && typeof o == 'object' && !Array.isArray(o) 3 | } 4 | -------------------------------------------------------------------------------- /spec/generated/tusken-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "exports": { 4 | "./connection": "./connection.ts" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/toArray.ts: -------------------------------------------------------------------------------- 1 | export const toArray = ( 2 | arg: T 3 | ): (T extends readonly (infer U)[] ? U : T)[] => 4 | Array.isArray(arg) ? arg : ([arg] as any) 5 | -------------------------------------------------------------------------------- /src/config/defineConfig.ts: -------------------------------------------------------------------------------- 1 | import type { TuskenUserConfig } from './config' 2 | 3 | export function defineConfig(config: TuskenUserConfig) { 4 | return config 5 | } 6 | -------------------------------------------------------------------------------- /src/config/definePlugin.ts: -------------------------------------------------------------------------------- 1 | import { TuskenRuntimePlugin } from './config' 2 | 3 | export function defineRuntimePlugin(plugin: TuskenRuntimePlugin) { 4 | return plugin 5 | } 6 | -------------------------------------------------------------------------------- /.indo.json: -------------------------------------------------------------------------------- 1 | { 2 | "repos": { 3 | "docs": { 4 | "url": "https://github.com/alloc/tusken.git", 5 | "head": "docs", 6 | "optional": true 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/postgres/props/union.ts: -------------------------------------------------------------------------------- 1 | import { Select } from '../query/select' 2 | import { SetProps } from './set' 3 | 4 | export interface UnionProps extends SetProps { 5 | selects: Select[] 6 | } 7 | -------------------------------------------------------------------------------- /src/prepare.sh: -------------------------------------------------------------------------------- 1 | rm -rf dist 2 | git checkout HEAD -- dist 3 | mkdir -p dist/config 4 | echo 'export * from "../../config"' > dist/config/index.d.ts 5 | echo 'export * from "../tusken"' > dist/tusken.d.ts 6 | -------------------------------------------------------------------------------- /packages/tusken-cli/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | external: ['tusken/package.json'], 6 | target: 'node16', 7 | }) 8 | -------------------------------------------------------------------------------- /src/postgres/props/set.ts: -------------------------------------------------------------------------------- 1 | import type { SortSelection } from '../query/orderBy' 2 | 3 | export interface SetProps { 4 | limit?: number 5 | offset?: number 6 | orderBy?: SortSelection 7 | single?: boolean 8 | } 9 | -------------------------------------------------------------------------------- /src/plugins/pg/client.ts: -------------------------------------------------------------------------------- 1 | import { defineClientPlugin } from 'tusken' 2 | import { TuskenPool } from './pool' 3 | 4 | export default defineClientPlugin({ 5 | create(options) { 6 | return new TuskenPool(options) 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /packages/tusken-schema/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./"], 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node16", 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['spec/**/*.spec.ts'], 6 | globals: true, 7 | setupFiles: ['spec/db.ts'], 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /src/postgres/typesBuiltin.d.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from './type' 2 | 3 | export declare namespace t { 4 | export type bool = Type<'bool', boolean, never> 5 | 6 | type NULL = Type<'null', null, null> 7 | export { NULL as null } 8 | } 9 | -------------------------------------------------------------------------------- /spec/generated/tusken.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tusken/config' 2 | 3 | export default defineConfig({ 4 | dataDir: './postgres', 5 | schemaDir: './', 6 | connectionPlugin: './tusken-plugin', 7 | connection: { database: 'test' }, 8 | }) 9 | -------------------------------------------------------------------------------- /spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../src/tsconfig.json", 3 | "include": ["./"], 4 | "exclude": ["types"], 5 | "compilerOptions": { 6 | "module": "esnext", 7 | "moduleResolution": "node16", 8 | "types": ["vitest/globals"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /spec/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../src/tsconfig.json", 3 | "include": ["./"], 4 | "compilerOptions": { 5 | "incremental": true, 6 | "module": "esnext", 7 | "moduleResolution": "node16", 8 | "noEmit": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './defineConfig' 3 | export * from './definePlugin' 4 | export { default as escalade } from './escalade/sync' 5 | export * from './loadClient' 6 | export * from './loadProject' 7 | export * from './loadRuntimePlugin' 8 | -------------------------------------------------------------------------------- /packages/tusken-cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./", "../tusken-config/src/plugins/defaultConnection.ts"], 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node16", 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/templateFunctions.ts: -------------------------------------------------------------------------------- 1 | import { DatePart } from './datePart' 2 | 3 | // Customized SQL syntax for special functions 4 | export const templateFunctions: Record = { 5 | extract: `($1/${DatePart.join('|')}/ FROM $2)`, 6 | } 7 | -------------------------------------------------------------------------------- /src/postgres/internal/type.ts: -------------------------------------------------------------------------------- 1 | import { defineType } from '../type' 2 | import { t } from '../typesBuiltin' 3 | 4 | export const kUnknownType = defineType(0, 'unknown') 5 | export const kBoolType = defineType(-1, 'bool') 6 | export const kSetType = defineType(-2, 'setof') 7 | -------------------------------------------------------------------------------- /spec/generated/tusken-plugin/connection.ts: -------------------------------------------------------------------------------- 1 | import { defineConnectionPlugin } from 'tusken' 2 | 3 | export default defineConnectionPlugin({ 4 | defaults(options) { 5 | if (process.env.E2E) { 6 | options.database = 'e2e' 7 | } 8 | return options 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | pnpm -r build 3 | pnpm test:generate 4 | CI=1 pnpm test 5 | CI=1 pnpm test:types 6 | pnpm dotenv -- multi-semantic-release --deps.release inherit $@ 7 | pnpm install 8 | git add -u 9 | git commit -m "chore: update lockfile" --author "pnpm " 10 | git push 11 | -------------------------------------------------------------------------------- /src/utils/callProp.ts: -------------------------------------------------------------------------------- 1 | import { AnyFn } from '@alloc/types' 2 | 3 | export function callProp( 4 | value: T, 5 | ...args: AnyFn extends T ? Parameters> : unknown[] 6 | ): T extends AnyFn ? U : T { 7 | return typeof value == 'function' ? value(...args) : value 8 | } 9 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./plugins"], 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noEmitOnError": false, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "target": "esnext" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/plugins/pg/runtime.ts: -------------------------------------------------------------------------------- 1 | import { defineRuntimePlugin } from 'tusken/config' 2 | 3 | export default defineRuntimePlugin({ 4 | imports({ dependencies, config }) { 5 | if ( 6 | config.clientPlugin.id == 'tusken/plugins/pg' && 7 | dependencies['pg-query-stream'] 8 | ) { 9 | return ['./runtime/stream'] 10 | } 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /spec/types/vitest.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ['spec/types.spec.ts'], 7 | globals: true, 8 | forceRerunTriggers: [ 9 | '**/src/**/*.ts', 10 | '**/types/*.ts', 11 | '**/types/tsconfig.json', 12 | ], 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/zodTypeMap.ts: -------------------------------------------------------------------------------- 1 | export const jsTypeToZod: Record = { 2 | number: 'z.number', 3 | string: 'z.string', 4 | boolean: 'z.boolean', 5 | Date: 'z.coerce.date', 6 | Json: 'json', 7 | } 8 | 9 | // TODO: varchar to z.string().max(n) 10 | export const pgTypeToZod: Record = { 11 | uuid: 'z.string().uuid', 12 | } 13 | -------------------------------------------------------------------------------- /packages/tusken-schema/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: ['src/index'], 5 | externals: ['pg', 'tusken', 'tusken/constants'], 6 | declaration: true, 7 | rollup: { 8 | emitCJS: true, 9 | esbuild: { target: 'node16' }, 10 | dts: { 11 | respectExternal: true, 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /spec/generated/reset-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cd `dirname $BASH_SOURCE[0]` 5 | 6 | # 1. Generate the schema from "test" db 7 | tusken generate -d test 8 | cp test/schema.sql e2e 9 | 10 | # 2. Mirror the "test" db as "e2e" 11 | tusken wipe -d e2e -c tusken.config.ts 12 | pg_restore -d e2e test/data.dump 13 | 14 | # 3. Generate the client for "e2e" db 15 | tusken generate -d e2e -------------------------------------------------------------------------------- /spec/types.spec.ts: -------------------------------------------------------------------------------- 1 | import exec from '@cush/exec' 2 | 3 | test('TypeScript types', async () => { 4 | const typeChecker = exec('tsc --noEmit --project types/tsconfig.json', { 5 | cwd: __dirname, 6 | noThrow: true, 7 | }) 8 | const output = await typeChecker 9 | if (typeChecker.exitCode !== 0) { 10 | console.error(output) 11 | } 12 | expect(typeChecker.exitCode).toBe(0) 13 | }) 14 | -------------------------------------------------------------------------------- /src/postgres/json.ts: -------------------------------------------------------------------------------- 1 | import { Token } from './internal/token' 2 | 3 | export function tokenizeJson(json: any): Token { 4 | return { literal: JSON.stringify(json) } 5 | } 6 | 7 | export type Json = string | number | boolean | null | JsonObject | JsonArray 8 | 9 | export interface JsonObject { 10 | [property: string]: Json | undefined 11 | } 12 | 13 | export interface JsonArray extends Array {} 14 | -------------------------------------------------------------------------------- /src/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | './tusken.ts', 6 | './config/index.ts', 7 | './constants.ts', 8 | './dotenv.ts', 9 | './zod.ts', 10 | './postgres/array.ts', 11 | './plugins/*/**/*.ts', 12 | ], 13 | dts: true, 14 | format: ['esm', 'cjs'], 15 | target: 'node16', 16 | splitting: true, 17 | }) 18 | -------------------------------------------------------------------------------- /src/postgres/stream.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './query' 2 | 3 | export interface QueryStreamConfig {} 4 | 5 | export declare class QueryStream { 6 | constructor(text: string, ctx: Query.Context, config?: QueryStreamConfig) 7 | } 8 | 9 | export interface QueryStream 10 | extends Omit { 11 | [Symbol.asyncIterator](): AsyncIterableIterator 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "explicit" 4 | }, 5 | "files.exclude": { 6 | "**/.git": true, 7 | "**/dist": true 8 | }, 9 | "guides.active.color.dark": "rgba(100, 100, 100, 0.5)", 10 | "guides.normal.enabled": false, 11 | "guides.stack.enabled": false, 12 | "typescript.tsdk": "node_modules/typescript/lib", 13 | "[markdown]": { 14 | "rewrap.onSave": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/datePart.ts: -------------------------------------------------------------------------------- 1 | export const DatePart = [ 2 | 'century', 3 | 'day', 4 | 'decade', 5 | 'dow', 6 | 'doy', 7 | 'epoch', 8 | 'hour', 9 | 'isodow', 10 | 'isoyear', 11 | 'julian', 12 | 'microseconds', 13 | 'millennium', 14 | 'milliseconds', 15 | 'minute', 16 | 'month', 17 | 'quarter', 18 | 'second', 19 | 'timezone', 20 | 'timezone_hour', 21 | 'timezone_minute', 22 | 'week', 23 | 'year', 24 | ] 25 | -------------------------------------------------------------------------------- /src/dotenv.ts: -------------------------------------------------------------------------------- 1 | import escalade from './config/escalade/sync' 2 | 3 | export function findDotenvFile(load: (options: { path: string }) => void) { 4 | const envFiles = [ 5 | '.env.tusken', 6 | '.env.' + (process.env.NODE_ENV || 'development'), 7 | '.env', 8 | ] 9 | 10 | const envFile = escalade(process.cwd(), (_, files) => 11 | envFiles.find(f => files.includes(f)) 12 | ) 13 | 14 | if (envFile) { 15 | load({ path: envFile }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/scripts/fetch-docs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { fetchSummaries } from '../docs' 4 | import { extractTypes } from '../extract' 5 | 6 | async function main() { 7 | const { nativeFuncs } = await extractTypes() 8 | const docs = await fetchSummaries(nativeFuncs.map(fn => fn.name)) 9 | fs.writeFileSync( 10 | path.resolve(__dirname, '../../docs.json'), 11 | JSON.stringify(docs) 12 | ) 13 | } 14 | 15 | main() 16 | -------------------------------------------------------------------------------- /src/postgres/join.ts: -------------------------------------------------------------------------------- 1 | import type { Expression } from './expression' 2 | import type { Selectable } from './selection' 3 | import { t } from './typesBuiltin' 4 | 5 | export type JoinType = 'inner' | 'left' 6 | 7 | export class JoinRef { 8 | constructor( 9 | public type: JoinType, 10 | public from: Selectable, 11 | public where: Expression, 12 | /** Use this identifier to reference the join. */ 13 | public alias?: string | null 14 | ) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/postgres/props/select.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnRef } from '../column' 2 | import type { Expression } from '../expression' 3 | import type { JoinRef } from '../join' 4 | import type { Selectable } from '../selection' 5 | import { t } from '../typesBuiltin' 6 | import type { SetProps } from './set' 7 | 8 | export interface SelectProps extends SetProps { 9 | from: Selectable 10 | joins?: JoinRef[] 11 | where?: Expression | null 12 | groupBy?: ColumnRef[] 13 | } 14 | -------------------------------------------------------------------------------- /src/postgres/is.ts: -------------------------------------------------------------------------------- 1 | import { Variadic } from '../utils/Variadic' 2 | import { CheckList } from './check' 3 | import { Expression } from './expression' 4 | import { t } from './typesBuiltin' 5 | 6 | export function is(left: Variadic>): CheckList 7 | 8 | export function is( 9 | left: Variadic> 10 | ): CheckList 11 | 12 | export function is(left: Variadic>) { 13 | return new CheckList(left) 14 | } 15 | -------------------------------------------------------------------------------- /spec/generated/e2e/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "tusken" 2 | import clientPlugin from "tusken/plugins/pg/client" 3 | import connectionPlugin from "../tusken-plugin/connection" 4 | import "tusken/plugins/pg/runtime/stream" 5 | 6 | const db = new Database({ 7 | reserved: ["like", "user"], 8 | clientPlugin, 9 | connectionPlugin, 10 | connection: { 11 | database: "test" 12 | }, 13 | }) 14 | 15 | export { db as default } 16 | export * as t from './types' 17 | export * as pg from './functions' -------------------------------------------------------------------------------- /spec/types/function.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, test, _ } from 'spec.ts' 2 | import { Expression, Type } from 'tusken' 3 | import { t } from '../db' 4 | 5 | describe('function parameter types', () => { 6 | test('nullable parameter', () => { 7 | type ParamType = t.bool | t.null 8 | assert(_ as ParamType, _ as Extract) 9 | 10 | type ParamActual = t.param 11 | type ParamExpected = boolean | null | Expression 12 | assert(_ as ParamActual, _ as ParamExpected) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | { name: 'master' }, 4 | { name: 'alpha', prerelease: true }, 5 | { name: 'beta', prerelease: true }, 6 | ], 7 | plugins: [ 8 | [ 9 | '@semantic-release/commit-analyzer', 10 | { 11 | preset: 'conventionalcommits', 12 | }, 13 | ], 14 | [ 15 | '@semantic-release/release-notes-generator', 16 | { 17 | preset: 'conventionalcommits', 18 | } 19 | ], 20 | '@semantic-release/npm', 21 | '@semantic-release/github', 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/extract.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'tusken' 2 | import { extractTypeCasts } from './extract/extractCasts' 3 | import { extractNativeFuncs } from './extract/extractFuncs' 4 | import { extractNativeTypes } from './extract/extractTypes' 5 | 6 | export async function extractTypes(client: Client) { 7 | const nativeTypes = await extractNativeTypes(client) 8 | const nativeCasts = await extractTypeCasts(client, nativeTypes) 9 | const nativeFuncs = await extractNativeFuncs(client, nativeTypes) 10 | return { nativeTypes, nativeCasts, nativeFuncs } 11 | } 12 | -------------------------------------------------------------------------------- /src/config/loadProject.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { TuskenProject } from './config' 4 | import { loadConfig } from './loadConfig' 5 | 6 | export function loadProject(configPath?: string): TuskenProject { 7 | const [config, resolvedConfigPath] = loadConfig(configPath) 8 | const { dependencies, devDependencies } = JSON.parse( 9 | fs.readFileSync(path.join(config.rootDir, 'package.json'), 'utf8') 10 | ) 11 | 12 | return { 13 | dependencies: { ...devDependencies, ...dependencies }, 14 | configPath: resolvedConfigPath, 15 | config, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/postgres/typeCast.ts: -------------------------------------------------------------------------------- 1 | import { ExpressionRef } from './expression' 2 | import { tokenize } from './internal/tokenize' 3 | import type { Query } from './query' 4 | import type { RuntimeType, Type } from './type' 5 | 6 | type Props = { value: any; type: RuntimeType } 7 | 8 | export class TypeCast extends ExpressionRef { 9 | constructor(props: Props) { 10 | super(props.type, props, tokenizeTypeCast) 11 | } 12 | } 13 | 14 | function tokenizeTypeCast(props: Props, ctx: Query.Context) { 15 | return { concat: [tokenize(props.value, ctx), '::' + props.type.name] } 16 | } 17 | -------------------------------------------------------------------------------- /packages/tusken-cli/src/connectionString.ts: -------------------------------------------------------------------------------- 1 | import type { ConnectOptions } from 'tusken' 2 | 3 | export function toConnectionString({ 4 | user, 5 | password, 6 | host, 7 | port, 8 | database, 9 | connectionString, 10 | }: ConnectOptions) { 11 | if (connectionString) { 12 | return connectionString 13 | } 14 | const parts: any[] = ['postgres://'] 15 | if (user || password) { 16 | parts.push(user || '', password ? `:${password}` : '', '@') 17 | } 18 | parts.push(host, ':', port) 19 | if (database) { 20 | parts.push('/', database) 21 | } 22 | return parts.join('') 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/pg/runtime/stream.ts: -------------------------------------------------------------------------------- 1 | import QueryStream from 'pg-query-stream' 2 | import { TuskenPool } from '../pool' 3 | 4 | TuskenPool.prototype.stream = function (query, values, config) { 5 | const stream = new QueryStream(query, values as any[], config) 6 | this.query(stream) 7 | return stream 8 | } 9 | 10 | declare module 'tusken' { 11 | export interface QueryStreamConfig 12 | extends Exclude[2], void> {} 13 | } 14 | 15 | // To ensure the `declare module` statement above is merged into the 16 | // "tusken" package, we have to export something. 17 | export default QueryStream 18 | -------------------------------------------------------------------------------- /src/config/loadRuntimePlugin.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { 3 | TuskenProject, 4 | TuskenResolvedPlugin, 5 | TuskenRuntimePlugin, 6 | } from './config' 7 | import { loadModule } from './loadModule' 8 | 9 | export function loadRuntimePlugin( 10 | pluginData: TuskenResolvedPlugin, 11 | project: TuskenProject 12 | ) { 13 | const pluginModule = loadModule(pluginData.modulePath) 14 | const plugin = pluginModule.exports.default as TuskenRuntimePlugin 15 | 16 | return plugin.imports(project)?.map(id => { 17 | if (id[0] == '.') { 18 | return path.resolve(pluginModule.path, id) 19 | } 20 | return id 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/dist/plugins/pg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "exports": { 4 | "./client": { 5 | "types": "./client.d.ts", 6 | "import": "./client.mjs", 7 | "default": "./client.js" 8 | }, 9 | "./connection": { 10 | "types": "./connection.d.ts", 11 | "import": "./connection.mjs", 12 | "default": "./connection.js" 13 | }, 14 | "./runtime": { 15 | "types": "./runtime.d.ts", 16 | "import": "./runtime.mjs", 17 | "default": "./runtime.js" 18 | }, 19 | "./runtime/*": { 20 | "types": "./runtime/*.d.ts", 21 | "import": "./runtime/*.mjs", 22 | "default": "./runtime/*.js" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/postgres/selector.ts: -------------------------------------------------------------------------------- 1 | import { makeColumnRef } from './column' 2 | import { makeRowRef } from './row' 3 | import { Selection, SelectionSource } from './selection' 4 | 5 | export function makeSelector( 6 | from: T, 7 | onlyColumn?: () => string 8 | ): T { 9 | const selector = onlyColumn 10 | ? (select: any): any => { 11 | const column = makeColumnRef(from, onlyColumn()) 12 | return new Selection(select(column), from) 13 | } 14 | : (select: any): any => { 15 | const row = makeRowRef(from) 16 | return new Selection(select(row), from) 17 | } 18 | 19 | return Object.setPrototypeOf(selector, from) 20 | } 21 | -------------------------------------------------------------------------------- /packages/tusken-cli/src/defer.ts: -------------------------------------------------------------------------------- 1 | export type Deferred = PromiseLike & { 2 | resolve: undefined extends T 3 | ? (value?: T | PromiseLike) => void 4 | : (value: T | PromiseLike) => void 5 | reject: (error?: any) => void 6 | promise: Promise 7 | settled: boolean 8 | } 9 | 10 | export function defer() { 11 | const result = {} as Deferred 12 | const promise = new Promise((resolve, reject) => { 13 | result.resolve = resolve as any 14 | result.reject = reject 15 | }) 16 | promise.finally(() => { 17 | result.settled = true 18 | }) 19 | result.then = promise.then.bind(promise) as any 20 | result.promise = promise 21 | return result 22 | } 23 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/utils/toTable.ts: -------------------------------------------------------------------------------- 1 | export function toTable( 2 | list: readonly T[], 3 | identify: (item: T) => Id 4 | ): Record 5 | 6 | export function toTable( 7 | list: readonly T[], 8 | identify: (item: T) => Id, 9 | unwrap: (item: T) => U 10 | ): Record 11 | 12 | export function toTable( 13 | list: readonly any[], 14 | identify: (item: any) => string | number, 15 | unwrap: (item: any) => any = item => item 16 | ) { 17 | const table: any = {} 18 | list.forEach(item => { 19 | const id = identify(item) 20 | table[id] = unwrap(item) 21 | }) 22 | return table 23 | } 24 | -------------------------------------------------------------------------------- /spec/types/insert.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, test, _ } from 'spec.ts' 2 | import { ColumnInput, Expression, RowInsertion } from 'tusken' 3 | import { t } from '../db' 4 | 5 | describe('RowInsertion type', () => { 6 | test('nullable column', () => { 7 | type NewUser = RowInsertion 8 | type BioActual = NewUser['bio'] 9 | type BioExpected = ColumnInput | undefined 10 | /** 11 | * Ensure `bio` is an optional input for a text column. 12 | */ 13 | assert(_ as BioActual, _ as BioExpected) 14 | /** 15 | * Ensure the `ColumnInput` type is working right. 16 | */ 17 | const _1: BioActual = _ as Expression 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/postgres/query/count.ts: -------------------------------------------------------------------------------- 1 | import { SelectProps } from '../props/select' 2 | import { Query } from '../query' 3 | import { Selectable } from '../selection' 4 | import { SelectBase } from './base/select' 5 | import { Where } from './where' 6 | 7 | export class Count extends SelectBase { 8 | protected tokenize(props: SelectProps, ctx: Query.Context) { 9 | const tokens = super.tokenize(props, ctx) 10 | tokens[1] = 'COUNT(*)' 11 | ctx.resolvers.push(result => result.rows[0].count) 12 | return tokens 13 | } 14 | } 15 | 16 | export interface Count extends PromiseLike { 17 | innerJoin( 18 | from: Joined, 19 | on: Where<[...From, Joined]> 20 | ): Count<[...From, Joined]> 21 | } 22 | -------------------------------------------------------------------------------- /src/config/escalade/sync.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/lukeed/escalade/blob/2477005062cdbd8407afc90d3f48f4930354252b/src/sync.js 2 | import { readdirSync, statSync } from 'fs' 3 | import { dirname, resolve } from 'path' 4 | 5 | /** 6 | * Helper function for finding a file. 7 | */ 8 | export default function ( 9 | start: string, 10 | callback: (directory: string, files: string[]) => T | undefined 11 | ): string | undefined { 12 | let dir = resolve('.', start) 13 | let stats = statSync(dir) 14 | if (!stats.isDirectory()) { 15 | dir = dirname(dir) 16 | } 17 | let tmp: any 18 | while (true) { 19 | tmp = callback(dir, readdirSync(dir)) 20 | if (tmp !== undefined) return tmp 21 | dir = dirname((tmp = dir)) 22 | if (tmp === dir) break 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /spec/types/typeCast.ts: -------------------------------------------------------------------------------- 1 | import { assert, test, _ } from 'spec.ts' 2 | import { Expression, TypeCast } from 'tusken' 3 | import { t } from '../db' 4 | 5 | test('cast text to int', () => { 6 | const result = t.int4(_ as Expression) 7 | assert(result, _ as TypeCast) 8 | }) 9 | 10 | test('cast nullable text to int', () => { 11 | const result = t.int4(_ as Expression) 12 | assert(result, _ as TypeCast) 13 | }) 14 | 15 | test('cast JS string to bigint', () => { 16 | const result = t.int8(_ as string) 17 | assert(result, _ as TypeCast) 18 | }) 19 | 20 | test('cast nullable JS string to bigint', () => { 21 | const result = t.int8(_ as string | null | undefined) 22 | assert(result, _ as TypeCast) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/extract/extractCasts.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'tusken' 2 | import { NativeTypes } from './extractTypes' 3 | 4 | export type NativeCast = { 5 | id: number 6 | source: number 7 | target: number 8 | /** 9 | * `e` = explicit only 10 | * `i` = implicit anywhere 11 | * `a` = implicit in column assignment 12 | */ 13 | context: 'a' | 'i' | 'e' 14 | } 15 | 16 | export async function extractTypeCasts(client: Client, types: NativeTypes) { 17 | const { rows: nativeCasts } = await client.query( 18 | `select oid "id", castsource "source", casttarget "target", castcontext "context" from pg_cast where castsource = ANY ($1) and casttarget = ANY ($1)`, 19 | [types.map(t => [t.id, t.arrayId])] 20 | ) 21 | 22 | return nativeCasts 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/pg/connection.ts: -------------------------------------------------------------------------------- 1 | import { ConnectOptions, defineConnectionPlugin } from 'tusken' 2 | 3 | export default defineConnectionPlugin({ 4 | defaults({ connectionString, ...options }) { 5 | let result: ConnectOptions 6 | if (connectionString) { 7 | result = { connectionString } 8 | } else { 9 | const database = process.env.PGDATABASE 10 | if (database && database.startsWith('postgres://')) { 11 | result = { connectionString: database } 12 | } else { 13 | options.host ||= process.env.PGHOST || 'localhost' 14 | options.port ||= +(process.env.PGPORT || 5432) 15 | options.user ||= process.env.PGUSER 16 | options.database ||= database || 'main' 17 | result = options 18 | } 19 | } 20 | result.password ||= process.env.PGPASSWORD 21 | result.key = options.key 22 | return result 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/utils/imports.ts: -------------------------------------------------------------------------------- 1 | const SPACE = ' ' 2 | 3 | export type ImportDescriptorMap = { 4 | [source: string]: string | string[] 5 | } 6 | 7 | export function serializeImports(imports: ImportDescriptorMap | string[]) { 8 | return ( 9 | Array.isArray(imports) 10 | ? imports.map(source => [source, '']) 11 | : Object.entries(imports) 12 | ).map( 13 | ([source, spec]) => 14 | `import ${ 15 | typeof spec === 'string' 16 | ? spec 17 | ? spec + ' from ' 18 | : '' 19 | : spec.length == 0 20 | ? '' 21 | : '{' + 22 | SPACE + 23 | spec 24 | .map(spec => 25 | typeof spec === 'string' ? spec : spec[0] + ' as ' + spec[1] 26 | ) 27 | .join(',' + SPACE) + 28 | SPACE + 29 | '} from ' 30 | }"${source}"` 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/tusken.ts: -------------------------------------------------------------------------------- 1 | import './postgres/expression' 2 | 3 | export * from './config/defineConfig' 4 | export * from './definePlugin' 5 | export * from './postgres/check' 6 | export * from './postgres/column' 7 | export * from './postgres/connection' 8 | export * from './postgres/database' 9 | export * from './postgres/expression' 10 | export * from './postgres/function' 11 | export * from './postgres/interval' 12 | export * from './postgres/is' 13 | export * from './postgres/json' 14 | export * from './postgres/query' 15 | export * from './postgres/query/orderBy' 16 | export * from './postgres/query/select' 17 | export * from './postgres/query/where' 18 | export * from './postgres/range' 19 | export * from './postgres/row' 20 | export * from './postgres/selection' 21 | export * from './postgres/set' 22 | export * from './postgres/table' 23 | export * from './postgres/tableCast' 24 | export * from './postgres/type' 25 | export * from './postgres/typeCast' 26 | -------------------------------------------------------------------------------- /spec/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tusken", 3 | "private": true, 4 | "devDependencies": { 5 | "@tusken/cli": "link:../packages/tusken-cli", 6 | "pg": "^8.8.0", 7 | "pg-query-stream": "^4.2.4", 8 | "tusken": "link:./" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./src/dist/tusken.d.ts", 13 | "default": "./src/tusken.ts" 14 | }, 15 | "./array": { 16 | "types": "./src/dist/array.d.ts", 17 | "default": "./src/array.ts" 18 | }, 19 | "./config": { 20 | "types": "./src/dist/config/index.d.ts", 21 | "default": "./src/config/index.ts" 22 | }, 23 | "./constants": { 24 | "types": "./src/dist/constants.d.ts", 25 | "default": "./src/constants.ts" 26 | }, 27 | "./dotenv": { 28 | "types": "./src/dist/dotenv.d.ts", 29 | "default": "./src/dotenv.ts" 30 | }, 31 | "./plugins/*": "./src/plugins/*.ts", 32 | "./package.json": "./package.json" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/tusken-cli/src/debounce.ts: -------------------------------------------------------------------------------- 1 | // If the final call before the effect is triggered happens 2 | // to occur within 20ms of the call before it, we skip the 3 | // setTimeout and just trigger the effect 20ms earlier. 4 | const lenience = 20 5 | 6 | export function debounce(ms: number, effect: () => void) { 7 | let timeoutId: any = -1 8 | let lastCallTime = 0 9 | 10 | // This avoids calling clearTimeout and setTimeout as much 11 | // as possible, in hopes of better performance. 12 | const onTimeout = () => { 13 | if (lastCallTime) { 14 | const remainingDelay = ms - (Date.now() - lastCallTime) 15 | lastCallTime = 0 16 | 17 | if (remainingDelay > lenience) { 18 | timeoutId = setTimeout(onTimeout, remainingDelay) 19 | return 20 | } 21 | } 22 | timeoutId = -1 23 | effect() 24 | } 25 | 26 | return () => { 27 | if (timeoutId < 0) { 28 | timeoutId = setTimeout(onTimeout, ms) 29 | } else { 30 | lastCallTime = Date.now() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/definePlugin.ts: -------------------------------------------------------------------------------- 1 | import type { Client, ConnectOptions } from './postgres/connection' 2 | import type { Query } from './postgres/query' 3 | 4 | /** 5 | * Control the client object that's responsible for talking to the 6 | * Postgres server. 7 | */ 8 | export interface ClientPlugin { 9 | create: (options: ConnectOptions) => Client 10 | } 11 | 12 | /** 13 | * Provide default connection options. 14 | */ 15 | export interface ConnectionPlugin { 16 | /** 17 | * Resolve the connection options for a query. 18 | * 19 | * Return `null` to use the default connection. 20 | */ 21 | resolve?: ( 22 | context: Query.Context 23 | ) => ({ key: string } & ConnectOptions) | null 24 | /** 25 | * Manipulate the default connection options. 26 | */ 27 | defaults?: (options: ConnectOptions) => ConnectOptions 28 | } 29 | 30 | export function defineConnectionPlugin(plugin: ConnectionPlugin) { 31 | return plugin 32 | } 33 | 34 | export function defineClientPlugin(plugin: ClientPlugin) { 35 | return plugin 36 | } 37 | -------------------------------------------------------------------------------- /packages/tusken-cli/src/firstline/index.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/pensierinmusica/firstline/blob/1ddb45976bcc1fe3fb015201f47627cda807e926/index.js 2 | import fs from 'fs' 3 | 4 | export function firstLine( 5 | path: string, 6 | usrOpts: { encoding?: BufferEncoding; lineEnding?: string } = {} 7 | ) { 8 | const opts = { 9 | encoding: 'utf8' as BufferEncoding, 10 | lineEnding: '\n', 11 | } 12 | Object.assign(opts, usrOpts) 13 | return new Promise((resolve, reject) => { 14 | const rs = fs.createReadStream(path, { encoding: opts.encoding }) 15 | let acc = '' 16 | let pos = 0 17 | let index 18 | rs.on('data', chunk => { 19 | index = chunk.indexOf(opts.lineEnding) 20 | acc += chunk 21 | if (index === -1) { 22 | pos += chunk.length 23 | } else { 24 | pos += index 25 | rs.close() 26 | } 27 | }) 28 | .on('close', () => 29 | resolve(acc.slice(acc.charCodeAt(0) === 0xfeff ? 1 : 0, pos)) 30 | ) 31 | .on('error', err => reject(err)) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/postgres/query/select.ts: -------------------------------------------------------------------------------- 1 | import { Selectable, SelectResult, SelectResults } from '../selection' 2 | import { SetExpression } from '../set' 3 | import { QueryStream, QueryStreamConfig } from '../stream' 4 | import { SelectBase } from './base/select' 5 | import { Union } from './union' 6 | import { Where } from './where' 7 | 8 | export class Select // 9 | extends SelectBase 10 | { 11 | union(query: Select): Union { 12 | return this.query({ 13 | type: 'union', 14 | props: { selects: [this, query] }, 15 | query: new Union(this.db), 16 | }) 17 | } 18 | } 19 | 20 | export interface Select 21 | extends SetExpression>, 22 | PromiseLike> { 23 | innerJoin( 24 | from: Joined, 25 | on: Where<[...From, Joined]> 26 | ): Select<[...From, Joined]> 27 | 28 | stream(config?: QueryStreamConfig): QueryStream> 29 | 30 | [Symbol.asyncIterator](): AsyncIterableIterator> 31 | } 32 | -------------------------------------------------------------------------------- /src/postgres/array.ts: -------------------------------------------------------------------------------- 1 | import { kTypeArrayId } from './symbols' 2 | import { defineType, RuntimeType, Type } from './type' 3 | 4 | export const array = ((type: RuntimeType) => { 5 | const id = type[kTypeArrayId] 6 | if (id !== undefined) { 7 | return defineType(id, type.name + '[]', id) 8 | } 9 | throw Error('no array type is defined') 10 | }) as { 11 | (type: RuntimeType): RuntimeType> 12 | } 13 | 14 | export type array = Element extends Type< 15 | infer HostType, 16 | infer ClientType, 17 | infer ColumnInput 18 | > 19 | ? Type<`${HostType}[]`, ClientType[], ColumnInput[]> 20 | : never 21 | 22 | export type array2d = array> 23 | export type array3d = array> 24 | 25 | export const array2d = ( 26 | element: RuntimeType 27 | ): RuntimeType> => array(array(element) as any) 28 | 29 | export const array3d = ( 30 | element: RuntimeType 31 | ): RuntimeType> => array(array2d(element) as any) 32 | -------------------------------------------------------------------------------- /src/postgres/query/delete.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from '../expression' 2 | import { TokenArray } from '../internal/token' 3 | import { tokenizeWhere } from '../internal/tokenize' 4 | import { Query } from '../query' 5 | import { kTableName } from '../symbols' 6 | import { TableRef } from '../table' 7 | import { t } from '../typesBuiltin' 8 | import { buildWhereClause, Where } from './where' 9 | 10 | type Props = { 11 | from: TableRef 12 | where?: Expression | null 13 | } 14 | 15 | export class Delete extends Query { 16 | protected tokenize(props: Props, ctx: Query.Context) { 17 | const tokens: TokenArray = ['DELETE FROM', { id: props.from[kTableName] }] 18 | if (props.where) { 19 | tokens.push(tokenizeWhere(props.where, ctx)) 20 | } 21 | ctx.impure = true 22 | ctx.resolvers.push(result => result.rowCount) 23 | return tokens 24 | } 25 | 26 | where(filter: Where<[From]>) { 27 | this.props.where = buildWhereClause(this.props, filter) 28 | return this 29 | } 30 | } 31 | 32 | export interface Delete extends PromiseLike {} 33 | -------------------------------------------------------------------------------- /src/config/escalade/license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/utils/escalade/license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/plugins/pg/pool.ts: -------------------------------------------------------------------------------- 1 | import { Pool, PoolClient } from 'pg' 2 | import type { ConnectionLike } from 'tusken' 3 | 4 | export class TuskenPool extends Pool { 5 | declare stream?: ConnectionLike['stream'] 6 | 7 | override connect(): Promise 8 | override connect( 9 | callback: ( 10 | err: Error, 11 | client: PoolClient, 12 | done: (release?: any) => void 13 | ) => void 14 | ): void 15 | override connect( 16 | callback?: ( 17 | err: Error, 18 | client: PoolClient, 19 | done: (release?: any) => void 20 | ) => void 21 | ) { 22 | if (callback) { 23 | super.connect((err, client, done) => { 24 | callback(err, client && this._augmentClient(client), done) 25 | }) 26 | } else { 27 | return super.connect().then(client => { 28 | return this._augmentClient(client) 29 | }) 30 | } 31 | } 32 | 33 | protected _augmentClient(client: PoolClient) { 34 | const augmentedClient = client as PoolClient & { 35 | stream?: ConnectionLike['stream'] 36 | } 37 | augmentedClient.stream = this.stream?.bind(client) 38 | return augmentedClient 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/tusken-cli/src/firstline/license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Alessandro Zanardi 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. 22 | -------------------------------------------------------------------------------- /packages/tusken-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tusken/cli", 3 | "version": "1.0.0-alpha.17", 4 | "author": "Alec Larson", 5 | "license": "MIT", 6 | "keywords": [ 7 | "postgres", 8 | "typescript", 9 | "codegen" 10 | ], 11 | "module": "dist/index.mjs", 12 | "main": "dist/index.cjs", 13 | "bin": { 14 | "tusken": "bin/tusken.js" 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/alloc/tusken", 22 | "directory": "packages/tusken-cli" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^18.7.9", 26 | "@types/pg": "^8.6.5", 27 | "cac": "^6.7.14", 28 | "kleur": "^4.1.5", 29 | "misty": "^1.6.10", 30 | "pg": "^8.8.0", 31 | "tsup": "^6.2.2", 32 | "typescript": "^4.7.4" 33 | }, 34 | "dependencies": { 35 | "@cush/exec": "^1.8.0", 36 | "@tusken/schema": "1.0.0-alpha.17", 37 | "chokidar": "^3.5.3", 38 | "dotenv": "^16.0.2", 39 | "jiti": "^1.14.0", 40 | "tusken": "1.0.0-alpha.17" 41 | }, 42 | "peerDependencies": { 43 | "pg": ">=8.0.0" 44 | }, 45 | "scripts": { 46 | "dev": "tsup --sourcemap --watch", 47 | "build": "tsup --clean" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/postgres/internal/tuple.ts: -------------------------------------------------------------------------------- 1 | import * as pg from 'pg' 2 | import { kTypeId } from '../symbols' 3 | import { RuntimeType } from '../type' 4 | 5 | /** Provided by the `pg-types` package */ 6 | type PostgresTypes = { 7 | getTypeParser: (typeId: number) => (input: string) => any 8 | } 9 | 10 | export function getTupleParser(typeForIndex: (i: number) => RuntimeType) { 11 | // Taken from https://github.com/vitaly-t/pg-tuple/blob/350d256b8d9cfdc6899479c7f402a7ac0b2d4668/lib/single.js 12 | return (input: string) => { 13 | let i = 1 14 | let quotes = 0 15 | let startIdx = 1 16 | let values: any[] = [] 17 | while (i < input.length) { 18 | let a = input[i] 19 | if ((a === ',' || i === input.length - 1) && !(quotes % 2)) { 20 | let s = input 21 | .substr(startIdx, i - startIdx) 22 | .replace(/^"|"$/g, '') 23 | .replace(/"{2}/g, '"') 24 | .replace(/\\{4}/g, '\\') 25 | 26 | let type = typeForIndex(values.length) 27 | let value = pg.types.getTypeParser(type[kTypeId])(s) 28 | 29 | values.push(value) 30 | startIdx = i + 1 31 | quotes = 0 32 | } 33 | if (a === '"') { 34 | quotes++ 35 | } 36 | i++ 37 | } 38 | return values 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/postgres/symbols.ts: -------------------------------------------------------------------------------- 1 | export const kAliasName = Symbol.for('alias.name') 2 | export const kAliasOf = Symbol.for('alias.of') 3 | export const kColumnFrom = Symbol.for('column.from') 4 | export const kColumnName = Symbol.for('column.name') 5 | export const kExprProps = Symbol.for('expr.props') 6 | export const kExprTokens = Symbol.for('expr.tokens') 7 | export const kJoinCheck = Symbol.for('join.check') 8 | export const kJoinFrom = Symbol.for('join.from') 9 | export const kJoinRelation = Symbol.for('join.relation') 10 | export const kNullableColumns = Symbol.for('table.nullableColumns') 11 | export const kIdentityColumns = Symbol.for('table.identityColumns') 12 | export const kRuntimeType = Symbol.for('type.runtime') 13 | export const kSelectionArgs = Symbol.for('selection.args') 14 | export const kSelectionFrom = Symbol.for('selection.from') 15 | export const kSelectionType = Symbol.for('selection.type') 16 | export const kSetAlias = Symbol.for('set.alias') 17 | export const kTableCast = Symbol.for('tableCast.props') 18 | export const kTableColumns = Symbol.for('table.columns') 19 | export const kTableName = Symbol.for('table.name') 20 | export const kTableRef = Symbol.for('table.ref') 21 | export const kTypeArrayId = Symbol.for('type.arrayId') 22 | export const kTypeId = Symbol.for('type.id') 23 | export const kTypeTokenizer = Symbol.for('type.tokenizer') 24 | -------------------------------------------------------------------------------- /packages/tusken-schema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tusken/schema", 3 | "version": "1.0.0-alpha.17", 4 | "author": "Alec Larson", 5 | "license": "See LICENSE.md", 6 | "keywords": [ 7 | "postgres", 8 | "typescript", 9 | "codegen" 10 | ], 11 | "types": "./dist/index.d.ts", 12 | "module": "./dist/index.mjs", 13 | "main": "./dist/index.cjs", 14 | "exports": { 15 | "types": "./dist/index.d.ts", 16 | "import": "./dist/index.mjs", 17 | "default": "./dist/index.cjs" 18 | }, 19 | "files": [ 20 | "docs.json", 21 | "dist" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/alloc/tusken", 26 | "directory": "packages/tusken-schema" 27 | }, 28 | "devDependencies": { 29 | "@types/cheerio": "^0.22.31", 30 | "@types/node": "^18.7.9", 31 | "@types/pg": "^8.6.5", 32 | "cheerio": "1.0.0-rc.12", 33 | "sucrase": "^3.25.0", 34 | "typescript": "^4.7.4", 35 | "unbuild": "^0.8.9", 36 | "vitest": "^0.22.1" 37 | }, 38 | "dependencies": { 39 | "endent": "^2.1.0", 40 | "extract-pg-schema": "^4.0.2", 41 | "pg": "^8.8.0", 42 | "strict-event-emitter-types": "^2.0.0", 43 | "tusken": "1.0.0-alpha.17" 44 | }, 45 | "scripts": { 46 | "dev": "unbuild --stub", 47 | "build": "unbuild", 48 | "fetch:docs": "sucrase-node src/scripts/fetch-docs.ts" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/postgres/query/union.ts: -------------------------------------------------------------------------------- 1 | import { TokenArray } from '../internal/token' 2 | import { tokenizeSetProps } from '../internal/tokenize' 3 | import { UnionProps } from '../props/union' 4 | import type { Query } from '../query' 5 | import type { 6 | Selectable, 7 | SelectionSource, 8 | SelectResult, 9 | SelectResults, 10 | } from '../selection' 11 | import { SetExpression } from '../set' 12 | import { QueryStream, QueryStreamConfig } from '../stream' 13 | import { SetBase } from './base/set' 14 | import type { Select } from './select' 15 | 16 | export class Union // 17 | extends SetBase 18 | { 19 | protected get sources(): SelectionSource[] { 20 | return this.props.selects[0]['sources'] 21 | } 22 | protected tokenize(props: UnionProps, ctx: Query.Context): TokenArray { 23 | return [ 24 | { join: props.selects.map(query => ({ query })), with: ' UNION ' }, 25 | tokenizeSetProps(props, ctx), 26 | ] 27 | } 28 | 29 | union(query: Select) { 30 | this.props.selects.push(query) 31 | return this 32 | } 33 | } 34 | 35 | export interface Union 36 | extends SetExpression>, 37 | PromiseLike> { 38 | stream(config?: QueryStreamConfig): QueryStream> 39 | 40 | [Symbol.asyncIterator](): AsyncIterableIterator> 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tusken/~", 3 | "private": true, 4 | "author": "Alec Larson", 5 | "license": "See LICENSE.md", 6 | "scripts": { 7 | "build": "pnpm -r --no-bail build", 8 | "dev": "pnpm -r --no-bail --parallel dev", 9 | "release": "sh release.sh", 10 | "db:dump": "pg_dump -d test -Fc --data-only --file spec/generated/test/data.dump", 11 | "db:restore": "tusken wipe -d test -c spec/generated/tusken.config.ts && pg_restore -d test spec/generated/test/data.dump", 12 | "reset:e2e": "sh spec/generated/reset-e2e.sh", 13 | "test": "vitest sql", 14 | "test:e2e": "E2E=1 vitest e2e", 15 | "test:types": "vitest -c spec/types/vitest.config.ts", 16 | "test:generate": "cd spec/generated && tusken generate -d e2e" 17 | }, 18 | "prettier": "@alloc/prettier-config", 19 | "devDependencies": { 20 | "@alloc/prettier-config": "^1.0.0", 21 | "@cush/exec": "^1.8.0", 22 | "@tusken/cli": "workspace:*", 23 | "@types/pg": "^8.6.5", 24 | "@vitest/ui": "^0.23.2", 25 | "conventional-changelog-conventionalcommits": "^5.0.0", 26 | "dotenv-cli": "^6.0.0", 27 | "multi-semantic-release": "^3.0.0", 28 | "pg": "^8.8.0", 29 | "pg-query-stream": "^4.2.4", 30 | "prettier": "^2.7.1", 31 | "spec.ts": "^1.1.3", 32 | "tusken": "link:src", 33 | "typescript": "^4.9.4", 34 | "vitest": "0.24.x", 35 | "zod": "^3.20.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/postgres/expression.ts: -------------------------------------------------------------------------------- 1 | import { Any } from '@alloc/types' 2 | import type { CheckBuilder } from './check' 3 | import type { TokenProducer } from './internal/token' 4 | import { kExprProps, kExprTokens, kRuntimeType } from './symbols' 5 | import type { RuntimeType, Type } from './type' 6 | 7 | const emptyProps: any = Object.freeze({}) 8 | 9 | /** 10 | * An expression is used to represent a value in a query. 11 | * 12 | * This type is recommended for use in function signatures (rather than 13 | * the `ExpressionRef` class), since it allows for `T` to be a union 14 | * without breaking assignability. 15 | */ 16 | export declare abstract class Expression { 17 | protected [kRuntimeType]: RuntimeType 18 | } 19 | 20 | /** 21 | * An expression type contains runtime type information and it can 22 | * even tokenize itself. 23 | */ 24 | export class ExpressionRef { 25 | protected [kExprProps]: Props 26 | protected [kExprTokens]: TokenProducer 27 | protected get props(): Props { 28 | return this[kExprProps] 29 | } 30 | constructor( 31 | type: RuntimeType, 32 | props: ([Props] extends [Any] ? Record : Props) | null, 33 | tokens: TokenProducer 34 | ) { 35 | this[kExprProps] = props || emptyProps 36 | this[kExprTokens] = tokens 37 | this[kRuntimeType] = type 38 | } 39 | } 40 | 41 | export interface ExpressionRef extends Expression { 42 | is: CheckBuilder 43 | } 44 | -------------------------------------------------------------------------------- /src/postgres/connection.ts: -------------------------------------------------------------------------------- 1 | import type { QueryResponse } from './query' 2 | import type { QueryStream, QueryStreamConfig } from './stream' 3 | 4 | export type Client = Connection | ConnectionPool 5 | 6 | export type Connection = ConnectionLike & { 7 | end: () => Promise 8 | } 9 | 10 | export type ConnectionPool = ConnectionLike & { 11 | connect: () => Promise 12 | end: () => Promise 13 | } 14 | 15 | export type PooledConnection = ConnectionLike & { 16 | release: () => void 17 | } 18 | 19 | export type ConnectionLike = { 20 | query: >( 21 | query: string, 22 | values?: readonly any[] 23 | ) => Promise> 24 | stream?: ( 25 | query: string, 26 | values?: readonly any[], 27 | config?: QueryStreamConfig 28 | ) => QueryStream 29 | on: (event: 'error', listener: (e: Error) => void) => any 30 | } 31 | 32 | export type ConnectOptions = { 33 | /** 34 | * The `key` allows reuse of an existing client object and the 35 | * creation of multiple clients for distinct Postgres servers. 36 | * 37 | * This is usually defined by a plugin. 38 | * 39 | * @default "default" 40 | */ 41 | key?: string 42 | /** 43 | * If defined, the connection string will be the only option used when 44 | * connecting to the database. 45 | */ 46 | connectionString?: string 47 | host?: string 48 | port?: number 49 | user?: string 50 | password?: string 51 | database?: string 52 | ssl?: boolean 53 | } 54 | -------------------------------------------------------------------------------- /spec/db.ts: -------------------------------------------------------------------------------- 1 | import exec from '@cush/exec' 2 | import path from 'path' 3 | import { inspectQuery, Query } from 'tusken' 4 | import { afterEach, beforeEach, expect } from 'vitest' 5 | import db from './generated/e2e' 6 | 7 | export { pg, t } from './generated/e2e' 8 | 9 | let shouldLogQueries = false 10 | 11 | if (process.env.E2E) { 12 | const { query } = db.client 13 | db.client.query = (...args: any): any => { 14 | if (shouldLogQueries && typeof args[0] == 'string') { 15 | console.log('query: ', args[0]) 16 | console.log('params:', args[1]) 17 | } 18 | return query.apply(db.client, args) 19 | } 20 | 21 | beforeEach(async () => { 22 | await db.client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public') 23 | await exec('psql -d e2e -f', [ 24 | path.resolve(__dirname, 'generated/test/schema.sql'), 25 | ]) 26 | await exec('pg_restore -d e2e', [ 27 | path.resolve(__dirname, 'generated/test/data.dump'), 28 | ]) 29 | }) 30 | } 31 | 32 | export default db 33 | 34 | export const enableQueryLogging = () => { 35 | console.log('Query logging enabled.') 36 | shouldLogQueries = true 37 | } 38 | 39 | afterEach(() => { 40 | shouldLogQueries = false 41 | }) 42 | 43 | expect.addSnapshotSerializer({ 44 | test: val => val instanceof Query, 45 | print: val => inspectQuery(val as Query).sql, 46 | }) 47 | 48 | expect.addSnapshotSerializer({ 49 | test: val => Array.isArray(val) && val.length == 1 && val[0] instanceof Query, 50 | print: (val, print) => print(inspectQuery((val as [Query])[0]).tokens), 51 | }) 52 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/extract/extractTypes.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'tusken' 2 | import nativeTypeMap from '../typescript/nativeTypeMap' 3 | import { toTable } from '../utils/toTable' 4 | 5 | export type NativeType = { 6 | id: number 7 | name: string 8 | arrayId: number 9 | jsType: string 10 | } 11 | 12 | export type NativeTypes = NativeType[] & { 13 | byName: Record 14 | byId: Record 15 | any: string[] 16 | } 17 | 18 | export async function extractNativeTypes(client: Client) { 19 | const anyTypes = [ 20 | 'anyarray', 21 | 'anynonarray', 22 | 'anyelement', 23 | 'anycompatible', 24 | 'anycompatiblearray', 25 | 'anycompatiblenonarray', 26 | ] 27 | 28 | const nativeTypeNames = [ 29 | ...Object.keys(nativeTypeMap), 30 | 'any', 31 | 'void', 32 | ...anyTypes, 33 | ] 34 | 35 | const response = await client.query( 36 | `select oid "id", typname "name", typarray "arrayId" from pg_type where typname like 'reg%' or (typname not like '\\_%' escape '\\' and typname = ANY ($1))`, 37 | [nativeTypeNames] 38 | ) 39 | 40 | const nativeTypes = response.rows as NativeTypes 41 | 42 | nativeTypes.byName = toTable(nativeTypes, t => t.name) 43 | nativeTypes.byId = toTable(nativeTypes, t => t.id) 44 | 45 | for (const type of nativeTypes) { 46 | type.jsType = 't.' + type.name 47 | nativeTypes.byId[type.arrayId] = { 48 | ...type, 49 | name: type.name + '[]', 50 | jsType: `t.array<${type.jsType}>`, 51 | } 52 | } 53 | 54 | nativeTypes.any = anyTypes 55 | return nativeTypes 56 | } 57 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/nativeTypeMap.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | bit: 'number', 3 | bool: 'boolean', 4 | // box: '[[x1: number, y1: number], [x2: number, y2: number]]', 5 | bpchar: 'string', 6 | bytea: 'Buffer', 7 | char: 'string', 8 | cidr: 'string', 9 | circle: '{ radius: number, x: number, y: number }', 10 | date: 'Date', 11 | daterange: 'Range', 12 | float4: 'number', 13 | float8: 'number', 14 | inet: 'string', 15 | int2: 'number', 16 | int4: 'number', 17 | int4range: 'Range', 18 | int8: 'string', // 8 bytes, can't be represented as a FP number 19 | int8range: 'Range', 20 | interval: 'Interval', 21 | json: 'Json', 22 | jsonb: 'Json', 23 | // line: 'unknown', // ? 24 | // lseg: 'unknown', // ? 25 | macaddr: 'string', 26 | money: 'string', 27 | name: 'string', // https://www.postgresql.org/docs/13/datatype-character.html#DATATYPE-CHARACTER-SPECIAL-TABLE 28 | numeric: 'string', 29 | numrange: 'Range', 30 | oid: 'number', // unsigned four-byte integer 31 | // path: 'unknown', // ? 32 | point: '{ x: number, y: number }', 33 | // polygon: 'unknown', // ? 34 | text: 'string', 35 | time: 'string', 36 | timestamp: 'Date', 37 | timestamptz: 'Date', 38 | timetz: 'string', 39 | // tsquery: 'unknown', // ? 40 | tsrange: 'Range', 41 | tstzrange: 'Range', 42 | tsvector: 'string', // e.g. "'bird':1 'bore':4 'california':18 'dog':16 'face':14 'must':13 'perdit':2 'pioneer':11 'stori':5 'woman':8" 43 | uuid: 'string', // ? 44 | varbit: 'number', // bit string? 45 | varchar: 'string', 46 | xml: 'string', // ? 47 | } 48 | -------------------------------------------------------------------------------- /src/postgres/internal/query.ts: -------------------------------------------------------------------------------- 1 | import type { Query } from '../query' 2 | import { renderTokens, TokenArray } from './token' 3 | 4 | /** @ts-ignore */ 5 | export type QueryInternal = Pick< 6 | Query, 7 | // @ts-expect-error 8 | 'db' | 'position' | 'nodes' | 'tokenize' | 'trace' 9 | > 10 | 11 | export type Node = { 12 | readonly type: Type 13 | readonly query: T 14 | readonly props: T extends Query ? Props : never 15 | } 16 | 17 | export const createQueryContext = ( 18 | query: Query | QueryInternal, 19 | init?: Omit, 'query'> 20 | ): Query.Context => ({ 21 | query: query as any, 22 | values: init?.values || [], 23 | resolvers: init?.resolvers || [], 24 | mutators: init?.mutators || [], 25 | idents: init?.idents || new Set(), 26 | }) 27 | 28 | /** Render a SQL string. */ 29 | export function renderQuery(ctx: Query.Context): string { 30 | const tokens = tokenizeQuery(ctx) 31 | const rendered = renderTokens(tokens, ctx) 32 | return rendered.join(' ') 33 | } 34 | 35 | /** Convert the tree of query nodes into SQL tokens. */ 36 | export function tokenizeQuery(ctx: Query.Context): TokenArray { 37 | const { query } = ctx 38 | const nodes = 39 | query.position < query.nodes.length - 1 40 | ? query.nodes.slice(0, query.position + 1) 41 | : query.nodes 42 | 43 | return nodes.map(node => { 44 | return toQueryInternal(node.query).tokenize(node.props, ctx) 45 | }) 46 | } 47 | 48 | /** TypeScript helper for accessing private members */ 49 | export function toQueryInternal(query: Query): QueryInternal { 50 | return query as any 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/narrow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes types that can be narrowed 3 | */ 4 | type Narrowable = string | number | bigint | boolean 5 | 6 | /** 7 | * Similar to [[Cast]] but with a custom fallback `Catch`. If it fails, 8 | * it will enforce `Catch` instead of `A2`. 9 | * @param A1 to check against 10 | * @param A2 to try/test with 11 | * @param Catch to fallback to if the test failed 12 | * @returns `A1 | Catch` 13 | * @example 14 | * ```ts 15 | * import {A} from 'ts-toolbelt' 16 | * 17 | * type test0 = A.Try<'42', string> // '42' 18 | * type test1 = A.Try<'42', number> // never 19 | * type test1 = A.Try<'42', number, 'tried'> // 'tried' 20 | * ``` 21 | */ 22 | type Try = A1 extends A2 23 | ? A1 24 | : Catch 25 | 26 | /** 27 | * @hidden 28 | */ 29 | type NarrowRaw = 30 | | (A extends [] ? [] : never) 31 | | (A extends Narrowable ? A : never) 32 | | { 33 | [K in keyof A]: A[K] extends Function ? A[K] : NarrowRaw 34 | } 35 | 36 | /** 37 | * Prevent type widening on generic function parameters 38 | * @param A to narrow 39 | * @returns `A` 40 | * @example 41 | * ```ts 42 | * import {F} from 'ts-toolbelt' 43 | * 44 | * declare function foo(x: F.Narrow): A; 45 | * declare function bar(x: F.Narrow): A; 46 | * 47 | * const test0 = foo(['e', 2, true, {f: ['g', ['h']]}]) 48 | * // `A` inferred : ['e', 2, true, {f: ['g']}] 49 | * 50 | * const test1 = bar({a: 1, b: 'c', d: ['e', 2, true, {f: ['g']}]}) 51 | * // `A` inferred : {a: 1, b: 'c', d: ['e', 2, true, {f: ['g']}]} 52 | * ``` 53 | */ 54 | export type Narrow = Try> 55 | -------------------------------------------------------------------------------- /src/postgres/row.ts: -------------------------------------------------------------------------------- 1 | import { LoosePick, Remap } from '@alloc/types' 2 | import { ColumnInput, ColumnRefs, ColumnType, makeColumnRef } from './column' 3 | import { SelectionSource } from './selection' 4 | import { kSelectionFrom } from './symbols' 5 | import { IdentityColumns, RowType, TableRef } from './table' 6 | import { Type } from './type' 7 | 8 | export abstract class RowSelection { 9 | protected declare [kSelectionFrom]: From 10 | } 11 | 12 | export function makeRowRef( 13 | from: From 14 | ): RowRef { 15 | return new Proxy(Object.create(RowSelection.prototype), { 16 | get: (_, column: string | typeof kSelectionFrom) => 17 | column == kSelectionFrom ? from : makeColumnRef(from, column), 18 | }) 19 | } 20 | 21 | export type RowRef = RowSelection & 22 | ColumnRefs> 23 | 24 | type RowInput = RowType extends infer Row 25 | ? Row extends object 26 | ? { 27 | [Column in keyof Row]: ColumnType extends infer T 28 | ? [Extract>] extends [never] 29 | ? ColumnInput 30 | : any 31 | : never 32 | } 33 | : never 34 | : never 35 | 36 | export type RowInsertion = ( 37 | T extends TableRef 38 | ? Omit, Option> & Partial, Option>> 39 | : never 40 | ) extends infer Props 41 | ? Remap 42 | : never 43 | 44 | export type RowUpdate = Partial> 45 | export type RowKeyedUpdate = {} & Remap< 46 | IdentityColumns extends (infer Id extends keyof RowType)[] 47 | ? Pick, Id> & Omit, Id> 48 | : never 49 | > 50 | -------------------------------------------------------------------------------- /src/config/loadClient.ts: -------------------------------------------------------------------------------- 1 | import type { ClientPlugin, ConnectionPlugin } from '../definePlugin' 2 | import type { Client, ConnectOptions } from '../postgres/connection' 3 | import { TuskenProject } from './config' 4 | import { loadModule } from './loadModule' 5 | 6 | export function loadClient( 7 | project: TuskenProject, 8 | options?: ConnectOptions 9 | ): [Client, ConnectOptions] { 10 | const { config } = project 11 | 12 | const connectionPluginPath = config.connectionPlugin.modulePath 13 | const connectionPlugin = loadModule(connectionPluginPath).exports 14 | .default as ConnectionPlugin 15 | 16 | const shouldUseProjectDefaults = 17 | !options || 18 | (onlyKeys(options, ['database', 'password']) && 19 | !options.database?.startsWith('postgres://')) 20 | 21 | if (shouldUseProjectDefaults) { 22 | options = { ...config.connection, ...options } 23 | } else { 24 | options = { ...options } 25 | } 26 | 27 | const connection = connectionPlugin.defaults?.(options) || options 28 | 29 | if (connection.connectionString) { 30 | const url = new URL(connection.connectionString) 31 | if (!options.database) { 32 | // Needs to be defined for `tusken generate` command. 33 | connection.database ||= url.pathname.slice(1) 34 | } 35 | // Override the database path if necessary. 36 | else if (options.database != connection.database) { 37 | url.pathname = '/' + options.database 38 | connection.connectionString = url.toString() 39 | } 40 | } 41 | 42 | const clientPluginPath = config.clientPlugin.modulePath 43 | const clientPlugin = loadModule(clientPluginPath).exports 44 | .default as ClientPlugin 45 | 46 | return [clientPlugin.create(connection), connection] 47 | } 48 | 49 | function onlyKeys(obj: any, allowedKeys: string[]) { 50 | return Object.keys(obj).every(key => allowedKeys.includes(key)) 51 | } 52 | -------------------------------------------------------------------------------- /src/postgres/tableCast.ts: -------------------------------------------------------------------------------- 1 | import { Any } from '@alloc/types' 2 | import { toArray } from '../utils/toArray' 3 | import { array } from './array' 4 | import { ColumnRef } from './column' 5 | import { RawColumnSelection, Selectable } from './selection' 6 | import { kSelectionArgs, kTableCast } from './symbols' 7 | import { IdentityColumns, RowType } from './table' 8 | import { isSelection } from './typeChecks' 9 | 10 | type TableKeyType = [T] extends [Any] 11 | ? any // avoid never for "T = any" 12 | : [IdentityColumns, RowType] extends [infer Keys, infer Values] 13 | ? Keys extends [] 14 | ? never 15 | : Keys extends [keyof Values] 16 | ? Values[Keys[0]] 17 | : Keys extends (keyof Values)[] 18 | ? any // FIXME: composite keys not yet supported 19 | : Keys extends string[] 20 | ? any // avoid never for "T = Selection" 21 | : never 22 | : never 23 | 24 | /** Can reference one or multiple foreign keys */ 25 | export type ForeignKeyRef = ColumnRef< 26 | TableKeyType | array> 27 | > 28 | 29 | interface Props> { 30 | pk: PK 31 | from: T 32 | selected?: RawColumnSelection[] 33 | distinct?: boolean 34 | } 35 | 36 | export class TableCast< 37 | T extends Selectable = any, 38 | PK extends ForeignKeyRef = ForeignKeyRef 39 | > { 40 | protected [kTableCast]: Props 41 | constructor(pk: PK, from: T) { 42 | this[kTableCast] = { 43 | pk, 44 | from, 45 | // Returning a SetRef or an array of column names from 46 | // the selector is not supported. 47 | selected: isSelection(from) 48 | ? (toArray(from[kSelectionArgs]) as RawColumnSelection[]) 49 | : undefined, 50 | } 51 | } 52 | 53 | distinct(flag?: boolean) { 54 | this[kTableCast].distinct = flag != false 55 | return this 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/docs.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio' 2 | 3 | type Selection = ReturnType> 4 | 5 | export async function fetchSummaries(names: string[]) { 6 | const urls: Record = {} 7 | await Promise.all( 8 | Array.from(new Array(26), async (_, i) => { 9 | const letter = String.fromCharCode(97 + i) 10 | const linkElems = await fetchAndExtract( 11 | `https://pgpedia.info/${letter}/index.html`, 12 | 'a.pgpedia_pagelink' 13 | ) 14 | linkElems?.each(i => { 15 | let title = linkElems.eq(i).attr('title') 16 | if (title) { 17 | if (title.endsWith('()')) { 18 | title = title.slice(0, -2) 19 | } 20 | if (names.includes(title)) { 21 | urls[title] = linkElems.eq(i).attr('href')! 22 | } 23 | } 24 | }) 25 | }) 26 | ) 27 | 28 | const summaries: Record = {} 29 | await Promise.all( 30 | names.map(async name => { 31 | const url = urls[name] 32 | if (url) { 33 | const elem = await fetchAndExtract(url, '#entry-summary') 34 | const summary = elem?.text().trim() 35 | if (summary) { 36 | summaries[name] = summary + '\n\n@see ' + url 37 | } 38 | } 39 | }) 40 | ) 41 | return summaries 42 | } 43 | 44 | export async function fetchAndExtract( 45 | url: string, 46 | selector: string 47 | ): Promise { 48 | try { 49 | const resp = await fetch(url) 50 | console.log(resp.status, url) 51 | if (resp.status == 500) { 52 | return new Promise(resolve => { 53 | setTimeout(() => resolve(fetchAndExtract(url, selector)), 1000) 54 | }) 55 | } 56 | if (resp.status == 200) { 57 | const $ = cheerio.load(await resp.text()) 58 | return $(selector) 59 | } 60 | } catch (e: any) { 61 | if (e.code == 'ECONNRESET') { 62 | console.log(e.code, url) 63 | return fetchAndExtract(url, selector) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/postgres/query/base/set.ts: -------------------------------------------------------------------------------- 1 | import { createQueryContext, renderQuery } from '../../internal/query' 2 | import { SetProps } from '../../props/set' 3 | import { Query } from '../../query' 4 | import type { Selectable, SelectionSource } from '../../selection' 5 | import type { QueryStreamConfig } from '../../stream' 6 | import { orderBy, SortSelection, SortSelector } from '../orderBy' 7 | 8 | const kSelectFrom = Symbol() 9 | 10 | export abstract class SetBase< 11 | From extends Selectable[] = any, 12 | Props extends SetProps = any 13 | > extends Query { 14 | protected declare [kSelectFrom]: From 15 | protected declare abstract sources: SelectionSource[] 16 | 17 | /** 18 | * Resolve with a single row at the given offset. 19 | * - Negative offset is treated as zero. 20 | * - Multiple calls are not supported. 21 | */ 22 | at(offset: number) { 23 | const self = this.clone() 24 | self.props.single = true 25 | self.props.limit = 1 26 | self.props.offset = offset > 0 ? offset : undefined 27 | return self 28 | } 29 | 30 | limit(length: number, page?: number) { 31 | const self = this.clone() 32 | self.props.limit = length 33 | if (page && page > 1) { 34 | self.props.offset = length * (page - 1) 35 | } 36 | return self 37 | } 38 | 39 | orderBy(selector: SortSelection | SortSelector) { 40 | const self = this.clone() 41 | self.props.orderBy = orderBy(self.sources, selector) 42 | return self 43 | } 44 | 45 | stream(config?: QueryStreamConfig) { 46 | const ctx = createQueryContext(this) 47 | 48 | // TODO: apply mutators to stream 49 | const query = renderQuery(ctx) 50 | const client = this.db['getClient'](ctx) 51 | if (client.stream) { 52 | return client.stream(query, ctx.values, config) 53 | } 54 | throw Error('Streaming not supported by your Postgres client') 55 | } 56 | 57 | [Symbol.asyncIterator]() { 58 | const stream = this.stream() 59 | stream.resume() 60 | return stream[Symbol.asyncIterator]() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/reservedWords.ts: -------------------------------------------------------------------------------- 1 | // This list is taken from https://www.postgresql.org/docs/12/sql-keywords-appendix.html. Only the 2 | // Postgres specific reserved keywords are included below. Please do note that these keywords are 3 | // reserved for column and table names, but not for column labels (e.g. alias such as 4 | // `select 1 as user`) 5 | export const reservedWords = [ 6 | 'all', 7 | 'analyse', 8 | 'analyze', 9 | 'and', 10 | 'any', 11 | 'array', 12 | 'as', 13 | 'asc', 14 | 'asymmetric', 15 | 'authorization', 16 | 'binary', 17 | 'both', 18 | 'case', 19 | 'cast', 20 | 'check', 21 | 'collate', 22 | 'collation', 23 | 'column', 24 | 'concurrently', 25 | 'constraint', 26 | 'create', 27 | 'cross', 28 | 'current_catalog', 29 | 'current_date', 30 | 'current_role', 31 | 'current_schema', 32 | 'current_time', 33 | 'current_timestamp', 34 | 'current_user', 35 | 'default', 36 | 'deferrable', 37 | 'desc', 38 | 'distinct', 39 | 'do', 40 | 'else', 41 | 'end', 42 | 'except', 43 | 'false', 44 | 'fetch', 45 | 'for', 46 | 'foreign', 47 | 'freeze', 48 | 'from', 49 | 'full', 50 | 'grant', 51 | 'group', 52 | 'having', 53 | 'ilike', 54 | 'in', 55 | 'initially', 56 | 'inner', 57 | 'intersect', 58 | 'into', 59 | 'is', 60 | 'isnull', 61 | 'join', 62 | 'lateral', 63 | 'leading', 64 | 'left', 65 | 'like', 66 | 'limit', 67 | 'localtime', 68 | 'localtimestamp', 69 | 'natural', 70 | 'not', 71 | 'notnull', 72 | 'null', 73 | 'offset', 74 | 'on', 75 | 'only', 76 | 'or', 77 | 'order', 78 | 'outer', 79 | 'overlaps', 80 | 'placing', 81 | 'primary', 82 | 'references', 83 | 'returning', 84 | 'right', 85 | 'select', 86 | 'session_user', 87 | 'similar', 88 | 'some', 89 | 'symmetric', 90 | 'table', 91 | 'tablesample', 92 | 'then', 93 | 'to', 94 | 'trailing', 95 | 'true', 96 | 'union', 97 | 'unique', 98 | 'user', 99 | 'using', 100 | 'variadic', 101 | 'verbose', 102 | 'when', 103 | 'where', 104 | 'window', 105 | 'with', 106 | ] 107 | -------------------------------------------------------------------------------- /packages/tusken-cli/readme.md: -------------------------------------------------------------------------------- 1 | # @tusken/cli 2 | 3 | > The CLI for [tusken](https://github.com/alloc/tusken) Postgres clients 4 | 5 | ## Configuration 6 | 7 | If no `tusken.config.ts` file is found, a default connection to `localhost:5432` is used. The [environment variables](https://node-postgres.com/features/connecting#environment-variables) used by `node-postgres` are respected. 8 | 9 | ### `tusken.config.ts` 10 | 11 | ```ts 12 | import { defineConfig } from 'tusken/config' 13 | 14 | export default defineConfig({ 15 | // The directory where the Postgres data directory is found. 16 | dataDir: './postgres', 17 | // The directory where the generated client is emitted. 18 | schemaDir: './src/generated', 19 | // The Postgres connection options. 20 | connection: { 21 | host: 'localhost', 22 | port: 5432, 23 | user: 'postgres', 24 | password: ' ', 25 | }, 26 | // The Postgres connection pool options. Same as node-postgres. 27 | pool: {...}, 28 | }) 29 | ``` 30 | 31 | ## Commands 32 | 33 | ### `tusken generate` 34 | 35 | Generate a database client from a Postgres database. 36 | 37 | ``` 38 | Usage: 39 | $ tusken generate 40 | 41 | Options: 42 | -c, --config Path to config file 43 | -d, --database The database to generate types for 44 | -w, --watch Enable watch mode 45 | -h, --help Display this message 46 | ``` 47 | 48 | ### `tusken wipe` 49 | 50 | Delete all rows in a database's `public` schema. 51 | 52 | ``` 53 | Usage: 54 | $ tusken wipe 55 | 56 | Options: 57 | -c, --config Path to config file 58 | -d, --database The database to wipe 59 | -h, --help Display this message 60 | ``` 61 | 62 | The `--config` argument is **required**, in order to prevent mistakes. 63 | 64 | ### `tusken import` 65 | 66 | Import one or more CSV files into a database. 67 | 68 | ``` 69 | Usage: 70 | $ tusken import [...files] 71 | 72 | Options: 73 | -c, --config Path to config file 74 | -d, --database The database to import into 75 | -t, --table The table to import into 76 | --noConflicts Fail if a row conflict is found 77 | -h, --help Display this message 78 | ``` 79 | -------------------------------------------------------------------------------- /src/postgres/set.ts: -------------------------------------------------------------------------------- 1 | import { Narrow } from '../utils/Narrow' 2 | import { ColumnRef } from './column' 3 | import { Expression } from './expression' 4 | import { CallExpression } from './function' 5 | import { kSetType } from './internal/type' 6 | import { RawSelection, ResolveSelection, Selection } from './selection' 7 | import { makeSelector } from './selector' 8 | import { kSetAlias } from './symbols' 9 | import { SetType, Type } from './type' 10 | 11 | /** A function that returns a set of rows. */ 12 | export function defineSetFunction(callee: string): any { 13 | return (...args: any[]) => makeSetRef(callee, args) 14 | } 15 | 16 | /** 17 | * An expression that results in a set of rows. 18 | */ 19 | export declare abstract class SetExpression< 20 | T extends object = any 21 | > extends Expression> {} 22 | 23 | export function makeSetRef( 24 | callee: Callee, 25 | args: any[] 26 | ): SetRef { 27 | const type = new (SetRef as new ( 28 | callee: Callee, // @prettier-ignore 29 | args: any[] 30 | ) => SetRef)(callee, args) 31 | 32 | return makeSelector(type, () => type[kSetAlias]) 33 | } 34 | 35 | /** 36 | * A function call that returns a set of rows. 37 | */ 38 | export abstract class SetRef< 39 | T extends Type = any, 40 | Callee extends string = any 41 | > extends CallExpression, Callee> { 42 | protected [kSetAlias]: string 43 | constructor(callee: Callee, args: any[]) { 44 | super(kSetType, { callee, args }) 45 | this[kSetAlias] = callee 46 | } 47 | 48 | /** Set an alias for use in future `.where` calls */ 49 | as(alias: Alias): SetRef { 50 | this.props.alias = this[kSetAlias] = alias 51 | return this as any 52 | } 53 | } 54 | 55 | export interface SetRef 56 | extends SetExpression<{ [P in Callee]: T }> { 57 | /** 58 | * Define a selection of columns from this set. 59 | */ 60 | ( 61 | selector: (value: ColumnRef) => Narrow 62 | ): Selection, SetRef> 63 | } 64 | 65 | export function getSetAlias(val: SetRef): string 66 | export function getSetAlias(val: any): string | undefined 67 | export function getSetAlias(val: any) { 68 | return val ? val[kSetAlias] : undefined 69 | } 70 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Depending on the type of your legal entity, you are granted permission to use this software in your project. Individuals and small companies are allowed to use this software for free (even for commercial purposes), while a company license is required for for-profit organisations of a certain size. This two-tier system was designed to ensure funding for this project while still allowing the source code to be available and the software to be free for most. Read below for the exact terms of use. 2 | 3 | - [Free license](#free-license) 4 | - [Company license](#company-license) 5 | 6 | ## Free license 7 | 8 | Copyright © 2022-present [Alec Larson](https://github.com/aleclarson) 9 | 10 | ### Eligibility 11 | 12 | You are eligible to use this software for free if you are: 13 | 14 | - an individual 15 | - a for-profit organisation with up to 3 employees 16 | - a non-profit or not-for-profit organisation 17 | - evaluating the software, and are not yet using it for a commercial purpose 18 | 19 | ### Commercial use 20 | 21 | Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, or hobby projects, doesn't count as use for a commercial purpose. 22 | 23 | ### Warranty notice 24 | 25 | The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the author or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software. 26 | 27 | ### Support 28 | 29 | Support is provided on a best-we-can-do basis via Github Issues. 30 | 31 | ## Company license 32 | 33 | You are required to obtain a company license if you are not within the group of entities eligible for a free license. This license allows you to use this software in any project with a commercial purpose. 34 | 35 | ### Support 36 | 37 | Prioritized support is provided on a best-we-can-do basis via Github Issues. 38 | 39 | ### Acquisition 40 | 41 | You can inquire a company license by emailing [alec.stanford.larson@gmail.com](mailto:alec.stanford.larson@gmail.com). 42 | 43 | ### Pricing 44 | 45 | We will do our best to find a fair price based on the size, location and stage of the company. 46 | -------------------------------------------------------------------------------- /src/zod.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | type JsonPrimitive = z.infer 4 | 5 | const jsonPrimitive = /*#__PURE__*/ z.union([ 6 | z.string(), 7 | z.number(), 8 | z.boolean(), 9 | z.null(), 10 | ]) 11 | 12 | export type Json = JsonPrimitive | { [key: string]: Json } | Json[] 13 | 14 | export type ZodJson = z.ZodType 15 | 16 | export const json: ZodJson = /*#__PURE__*/ z.lazy(() => 17 | z.union([jsonPrimitive, z.array(json), z.record(json)]) 18 | ) 19 | 20 | export type ZodTable = 21 | | z.ZodRecord 22 | | z.ZodObject> 23 | | z.ZodUnion< 24 | [z.ZodObject>, ...z.ZodObject>[]] 25 | > 26 | 27 | export type ZodTableColumn = T extends z.ZodRecord 28 | ? Key 29 | : T extends z.ZodObject> 30 | ? ReturnType 31 | : T extends z.ZodUnion 32 | ? z.ZodUnion<{ 33 | [P in keyof U]: U[P] extends z.ZodObject> 34 | ? ReturnType 35 | : never 36 | }> 37 | : never 38 | 39 | export type ZodWhereClause = z.ZodUnion< 40 | [ 41 | z.ZodTuple<[ZodTableColumn, typeof rangeOperator, z.ZodAny]>, 42 | z.ZodTuple< 43 | [ZodTableColumn, z.ZodLiteral<'not'>, typeof rangeOperator, any] 44 | >, 45 | z.ZodTuple<[ZodWhereClause, typeof logicOperator, ZodWhereClause]> 46 | ] 47 | > 48 | 49 | export const tableColumn = (table: T): ZodTableColumn => 50 | table instanceof z.ZodRecord 51 | ? (table.keySchema as any) 52 | : table instanceof z.ZodObject 53 | ? table.keyof() 54 | : z.union(table.options.map(tableColumn) as any) 55 | 56 | /** Postgres WHERE clause in serializable form. */ 57 | export const where = >( 58 | table = z.record(z.any()) as unknown as T 59 | ): ZodWhereClause => { 60 | const self: any = z.lazy(() => { 61 | const column = tableColumn(table) 62 | return z.union([ 63 | z.tuple([column, rangeOperator, z.any()]), 64 | z.tuple([column, z.literal('not'), rangeOperator, z.any()]), 65 | z.tuple([self, logicOperator, self]), 66 | ]) 67 | }) 68 | return self 69 | } 70 | 71 | export const rangeOperator = /*#__PURE__*/ z.enum([ 72 | 'eq', 73 | 'gt', 74 | 'gte', 75 | 'lt', 76 | 'lte', 77 | 'like', 78 | 'ilike', 79 | 'in', 80 | 'between', 81 | ]) 82 | 83 | export const logicOperator = /*#__PURE__*/ z.enum([ 84 | 'and', 85 | 'or', 86 | 'nand', 87 | 'nor', 88 | 'xor', 89 | ]) 90 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tusken", 3 | "version": "1.0.0-alpha.17", 4 | "author": "Alec Larson", 5 | "license": "See LICENSE.md", 6 | "keywords": [ 7 | "postgres", 8 | "client", 9 | "query", 10 | "builder", 11 | "typescript", 12 | "codegen" 13 | ], 14 | "types": "dist/tusken.d.ts", 15 | "module": "dist/tusken.mjs", 16 | "main": "dist/tusken.js", 17 | "exports": { 18 | ".": { 19 | "types": "./dist/tusken.d.ts", 20 | "import": "./dist/tusken.mjs", 21 | "default": "./dist/tusken.js" 22 | }, 23 | "./array": { 24 | "types": "./dist/postgres/array.d.ts", 25 | "import": "./dist/postgres/array.mjs", 26 | "default": "./dist/postgres/array.js" 27 | }, 28 | "./config": { 29 | "types": "./dist/config/index.d.ts", 30 | "import": "./dist/config/index.mjs", 31 | "default": "./dist/config/index.js" 32 | }, 33 | "./constants": { 34 | "types": "./dist/constants.d.ts", 35 | "import": "./dist/constants.mjs", 36 | "default": "./dist/constants.js" 37 | }, 38 | "./zod": { 39 | "types": "./dist/zod.d.ts", 40 | "import": "./dist/zod.mjs", 41 | "default": "./dist/zod.js" 42 | }, 43 | "./dotenv": { 44 | "types": "./dist/dotenv.d.ts", 45 | "import": "./dist/dotenv.mjs", 46 | "default": "./dist/dotenv.js" 47 | }, 48 | "./plugins/*": { 49 | "types": "./dist/plugins/*.d.ts", 50 | "import": "./dist/plugins/*.mjs", 51 | "default": "./dist/plugins/*.js" 52 | }, 53 | "./package.json": "./package.json" 54 | }, 55 | "sideEffects": [ 56 | "./postgres/expression.ts" 57 | ], 58 | "files": [ 59 | "dist" 60 | ], 61 | "repository": { 62 | "type": "git", 63 | "url": "https://github.com/alloc/tusken" 64 | }, 65 | "scripts": { 66 | "dev": "sh prepare.sh && tsup-node --watch --sourcemap", 67 | "build": "sh prepare.sh && tsup-node" 68 | }, 69 | "devDependencies": { 70 | "@types/node": "^18.7.9", 71 | "pg": ">=8.0.0", 72 | "postgres-array": "^3.0.1", 73 | "postgres-interval": "^4.0.0", 74 | "postgres-range": "^1.1.3", 75 | "tsup": "^6.2.2" 76 | }, 77 | "dependencies": { 78 | "@alloc/resolve.exports": "^1.0.3", 79 | "@alloc/types": "^2.2.2", 80 | "@types/pg": "^8.6.5", 81 | "callsites": "^3.0.0", 82 | "find-dependency": "^1.3.3", 83 | "sucrase": "^3.25.0", 84 | "tusken": "1.0.0-alpha.17" 85 | }, 86 | "peerDependencies": { 87 | "pg": ">=8.0.0", 88 | "postgres-array": "*", 89 | "postgres-interval": "*", 90 | "postgres-range": "*" 91 | }, 92 | "optionalDependencies": { 93 | "pg-query-stream": "*" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/postgres/query/orderBy.ts: -------------------------------------------------------------------------------- 1 | import { Exclusive, Intersect } from '@alloc/types' 2 | import type { ColumnRefs } from '../column' 3 | import { Expression } from '../expression' 4 | import { makeRowRef } from '../row' 5 | import { Selectable, Selection, SelectionSource } from '../selection' 6 | import { RowType, toTableName } from '../table' 7 | import type { SourceRefId } from './where' 8 | 9 | export function orderBy( 10 | sources: SelectionSource[], 11 | selector: SortSelection | SortSelector 12 | ): SortSelection { 13 | if (typeof selector !== 'function') { 14 | return selector 15 | } 16 | if (sources.length == 1) { 17 | return selector(makeRowRef(sources[0])) 18 | } 19 | const refs: any = {} 20 | for (const source of sources) { 21 | const table = toTableName(source) 22 | if (table) { 23 | refs[table] = makeRowRef(source) 24 | } 25 | } 26 | return selector(refs) 27 | } 28 | 29 | export type SortSelector = ( 30 | refs: [From] extends [[infer Source]] 31 | ? ColumnRefs> 32 | : SortRefs 33 | ) => SortSelectorResult 34 | 35 | export type SortSelection = 36 | | SortSelectorResult 37 | | SortColumn 38 | | SortOrder> 39 | 40 | export type SortSelectorResult = 41 | | (SortExpression | SortOrder>)[] 42 | | SortOrder> 43 | | SortExpression 44 | 45 | export type SortColumn = string & 46 | keyof RowType 47 | 48 | export type SortExpression = 49 | | Expression 50 | | SortColumn 51 | 52 | export type SortOrder = Exclusive< 53 | | { 54 | /** The row whose value is `>` will come first. */ 55 | desc: T 56 | /** 57 | * The rows whose value is `NULL` may come first or last. 58 | * @default "first" 59 | */ 60 | nulls?: 'first' | 'last' 61 | } 62 | | { 63 | /** The row whose value is `<` will come first. */ 64 | asc: T 65 | /** 66 | * The rows whose value is `NULL` may come first or last. 67 | * @default "last" 68 | */ 69 | nulls?: 'first' | 'last' 70 | } 71 | > 72 | 73 | export type SortRefs = Intersect< 74 | T[number] extends infer From extends Selectable 75 | ? From extends Selection 76 | ? SortSource 77 | : SortSource> 78 | : never 79 | > 80 | 81 | type SortSource = { 82 | [P in SourceRefId]: ColumnRefs> 83 | } 84 | -------------------------------------------------------------------------------- /src/postgres/typeChecks.ts: -------------------------------------------------------------------------------- 1 | import type { CheckBuilder } from './check' 2 | import type { ColumnExpression, ColumnRef } from './column' 3 | import type { Expression, ExpressionRef } from './expression' 4 | import type { CallExpression } from './function' 5 | import type { Selectable, Selection } from './selection' 6 | import { SetExpression } from './set' 7 | import { 8 | kColumnFrom, 9 | kColumnName, 10 | kExprProps, 11 | kRuntimeType, 12 | kSelectionFrom, 13 | kTableCast, 14 | kTableName, 15 | kTypeId, 16 | } from './symbols' 17 | import type { TableRef } from './table' 18 | import { TableCast } from './tableCast' 19 | import { RuntimeType } from './type' 20 | import { t } from './typesBuiltin' 21 | 22 | export function isTableRef(val: object): val is TableRef { 23 | return kTableName in val 24 | } 25 | 26 | export function isColumnRef(val: object): val is ColumnRef { 27 | return kColumnFrom in val 28 | } 29 | 30 | export function isSelection(val: object): val is Selection { 31 | return kSelectionFrom in val 32 | } 33 | 34 | export function isExpression(val: object): val is Expression { 35 | return kRuntimeType in val 36 | } 37 | 38 | /** Is this an expression that can tokenize itself? */ 39 | export function isExpressionRef(val: object): val is ExpressionRef { 40 | return kExprProps in val 41 | } 42 | 43 | export function isBoolExpression( 44 | val: object 45 | ): val is Expression { 46 | const exprType = isExpression(val) && val[kRuntimeType] 47 | return !!exprType && exprType.name == 'bool' 48 | } 49 | 50 | export function isCallExpression( 51 | val: object, 52 | callee?: string 53 | ): val is CallExpression { 54 | const props = isExpressionRef(val) && val[kExprProps] 55 | return props ? !callee || (props as any).callee == callee : false 56 | } 57 | 58 | /** 59 | * Only use this over `isColumnRef` if your value is never a `ColumnRef` 60 | * but it might be a `ColumnExpression` (which is more common in libraries 61 | * than in applications). 62 | */ 63 | export function isColumnExpression(val: object): val is ColumnExpression { 64 | return kColumnName in val 65 | } 66 | 67 | export function isSetExpression(val: object): val is SetExpression { 68 | return isExpression(val) && val[kRuntimeType].name == 'setof' 69 | } 70 | 71 | export function isArrayExpression(val: object): val is Expression { 72 | return isExpression(val) && val[kRuntimeType].name.endsWith('[]') 73 | } 74 | 75 | export function isArrayType(val: object): val is RuntimeType { 76 | return kTypeId in val && (val as unknown as RuntimeType).name.endsWith('[]') 77 | } 78 | 79 | // TODO: use symbol checking instead of duck typing 80 | export function isCheckBuilder(val: object): val is CheckBuilder { 81 | return 'left' in val && 'negated' in val && 'wrap' in val 82 | } 83 | 84 | export function isTableCast( 85 | val: object 86 | ): val is TableCast { 87 | return kTableCast in val 88 | } 89 | -------------------------------------------------------------------------------- /spec/generated/e2e/tables.ts: -------------------------------------------------------------------------------- 1 | import { makeTableRef, RowResult, RowType, TableRef } from "tusken" 2 | import * as t from "./primitives" 3 | 4 | export const featureFlag: TableRef<{ 5 | id: t.int4 6 | enabled: t.bool | t.null 7 | }, "featureFlag", [ 8 | "id" 9 | ], "enabled"> = /*#__PURE__*/ makeTableRef("featureFlag", [ 10 | "id" 11 | ], { 12 | id: t.int4, 13 | enabled: t.option(t.bool), 14 | }) 15 | 16 | export const follow: TableRef<{ 17 | id: t.int4 18 | follower: t.int4 19 | author: t.int4 20 | }, "follow", [ 21 | "id" 22 | ], "id"> = /*#__PURE__*/ makeTableRef("follow", [ 23 | "id" 24 | ], { 25 | id: t.option(t.int4), 26 | follower: t.int4, 27 | author: t.int4, 28 | }) 29 | 30 | export const foo: TableRef<{ 31 | id: t.int4 32 | id2: t.int4 33 | json: t.json | t.null 34 | jsonb: t.jsonb | t.null 35 | }, "foo", [ 36 | "id", 37 | "id2" 38 | ], "json" | "jsonb"> = /*#__PURE__*/ makeTableRef("foo", [ 39 | "id", 40 | "id2" 41 | ], { 42 | id: t.int4, 43 | id2: t.int4, 44 | json: t.option(t.json), 45 | jsonb: t.option(t.jsonb), 46 | }) 47 | 48 | export const like: TableRef<{ 49 | id: t.int4 50 | tweet: t.int4 51 | author: t.int4 52 | }, "like", [ 53 | "id" 54 | ], "id"> = /*#__PURE__*/ makeTableRef("like", [ 55 | "id" 56 | ], { 57 | id: t.option(t.int4), 58 | tweet: t.int4, 59 | author: t.int4, 60 | }) 61 | 62 | export const tweet: TableRef<{ 63 | id: t.int4 64 | author: t.int4 65 | text: t.text 66 | replies: t.array 67 | }, "tweet", [ 68 | "id" 69 | ], "id" | "replies"> = /*#__PURE__*/ makeTableRef("tweet", [ 70 | "id" 71 | ], { 72 | id: t.option(t.int4), 73 | author: t.int4, 74 | text: t.text, 75 | replies: t.option(t.array(t.int4)), 76 | }) 77 | 78 | export const user: TableRef<{ 79 | id: t.int4 80 | name: t.text 81 | joinedAt: t.timestamptz 82 | bio: t.text | t.null 83 | featureFlags: t.array 84 | }, "user", [ 85 | "id" 86 | ], "id" | "joinedAt" | "bio" | "featureFlags"> = /*#__PURE__*/ makeTableRef("user", [ 87 | "id" 88 | ], { 89 | id: t.option(t.int4), 90 | name: t.text, 91 | joinedAt: t.option(t.timestamptz), 92 | bio: t.option(t.text), 93 | featureFlags: t.option(t.array(t.int4)), 94 | }) 95 | 96 | // Materialized row types 97 | export interface featureFlag extends RowResult> {} 98 | export interface follow extends RowResult> {} 99 | export interface foo extends RowResult> {} 100 | export interface like extends RowResult> {} 101 | export interface tweet extends RowResult> {} 102 | export interface user extends RowResult> {} 103 | 104 | /** Use this instead of `t[name]` if you want tree-shaking. */ 105 | export const ref = { 106 | featureFlag, 107 | follow, 108 | foo, 109 | like, 110 | tweet, 111 | user, 112 | } -------------------------------------------------------------------------------- /src/postgres/column.ts: -------------------------------------------------------------------------------- 1 | import type { Any } from '@alloc/types' 2 | import { Expression, ExpressionRef } from './expression' 3 | import { CallExpression } from './function' 4 | import { tokenizeColumn } from './internal/tokenize' 5 | import type { Selectable } from './selection' 6 | import { kColumnFrom, kColumnName } from './symbols' 7 | import { getColumnType, RowType, toTableName, toTableRef } from './table' 8 | import type { QueryInput, RuntimeType, Type } from './type' 9 | import { t } from './typesBuiltin' 10 | 11 | export type ColumnOf = string & keyof RowType 12 | 13 | /** 14 | * Which values can be used (without an explicit cast) when 15 | * assigning to a column with type `T`. 16 | */ 17 | export type ColumnInput = 18 | | QueryInput ? T | U : never> 19 | | (T extends undefined ? undefined : never) 20 | 21 | /** 22 | * Get the native Postgres type of a column. 23 | * 24 | * Optional columns have `t.null` included. 25 | */ 26 | export type ColumnType = unknown & 27 | (Row[Column & keyof Row] extends infer Value 28 | ? Extract | (undefined extends Value ? t.null : never) 29 | : never) 30 | 31 | export type ColumnRefs = unknown & 32 | ([T] extends [Any] 33 | ? any 34 | : { 35 | [Column in string & keyof T]-?: ColumnRef, Column> 36 | }) 37 | 38 | /** 39 | * An expression is used to represent a table column in a query. 40 | * 41 | * This type is recommended for use in function signatures (rather than 42 | * the `ColumnRef` class), since it allows for `T` to be a union 43 | * without breaking assignability. 44 | */ 45 | export declare abstract class ColumnExpression< 46 | T extends Type = any, 47 | Name extends string = any 48 | > extends Expression { 49 | protected [kColumnName]: Name 50 | } 51 | 52 | export function makeColumnRef( 53 | from: Selectable, 54 | name: Name 55 | ): ColumnRef { 56 | const table = toTableRef(from) 57 | const type = getColumnType(table, name) 58 | return new (ColumnRef as new ( 59 | from: Selectable, 60 | name: Name, 61 | type: RuntimeType 62 | ) => any)(from, name, type) as any 63 | } 64 | 65 | export abstract class ColumnRef< 66 | T extends Type = any, 67 | Name extends string = any 68 | > extends ExpressionRef { 69 | protected [kColumnFrom]: Selectable 70 | protected [kColumnName]: Name 71 | 72 | constructor(from: Selectable, name: Name, type: RuntimeType) { 73 | super(type, null, (_, ctx) => [ 74 | tokenizeColumn( 75 | this[kColumnName], 76 | // Omit the table name if no joins exist. 77 | !!ctx.joins && toTableName(this[kColumnFrom], ctx) 78 | ), 79 | ]) 80 | this[kColumnFrom] = from 81 | this[kColumnName] = name 82 | } 83 | } 84 | 85 | export interface ColumnRef 86 | extends ColumnExpression {} 87 | 88 | const kAggregate = Symbol() 89 | 90 | /** 91 | * Aggregate columns cannot be selected alongside normal columns. 92 | * 93 | * You must use `GROUP BY` on the normal columns. 94 | */ 95 | export abstract class Aggregate< 96 | T extends Type = any, 97 | Callee extends string = any 98 | > extends CallExpression { 99 | protected declare [kAggregate]: true 100 | } 101 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/extract/extractFuncs.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'tusken' 2 | import { NativeTypes } from './extractTypes' 3 | 4 | export type NativeFunc = { 5 | name: string 6 | args: string[] | null 7 | argTypes: string[] 8 | isVariadic: 1 | 0 9 | optionalArgCount: number 10 | returnSet: boolean 11 | returnType: string 12 | kind: 'a' | 'f' | 'p' | 'w' 13 | strict: boolean 14 | typeParams?: string 15 | } 16 | 17 | export async function extractNativeFuncs(client: Client, types: NativeTypes) { 18 | const { rows: nativeFuncs } = await client.query( 19 | `select proname "name", proargnames "args", proargtypes "argTypes", provariadic "isVariadic", pronargdefaults "optionalArgCount", prorettype "returnType", proretset "returnSet", prokind "kind", proisstrict "strict" from pg_proc where proname not like '\\_%' escape '\\' and prorettype = ANY ($1)`, 20 | [types.map(t => [t.id, t.arrayId])] 21 | ) 22 | 23 | // TODO: need special implementations for these functions 24 | const hypotheticalSetFuncs = [ 25 | 'cume_dist', 26 | 'dense_rank', 27 | 'percent_rank', 28 | 'rank', 29 | ] 30 | const orderedSetFuncs = ['mode', 'percentile_cont', 'percentile_disc'] 31 | const ignoredFuncs = [...hypotheticalSetFuncs, ...orderedSetFuncs] 32 | 33 | const anyArrayTypes = ['anyarray', 'anycompatiblearray'] 34 | const elementTypes = ['anyelement', 'anycompatible'] 35 | 36 | return nativeFuncs.filter(fn => { 37 | if (fn.kind == 'w' || ignoredFuncs.includes(fn.name)) { 38 | return 39 | } 40 | 41 | const argTypes = fn.argTypes 42 | ? ((fn as any).argTypes as string) 43 | .trim() 44 | .split(' ') 45 | .map(t => types.byId[+t]) 46 | : [] 47 | 48 | // Just need to check argTypes, since returnType is checked 49 | // in the SQL query above. 50 | if (argTypes.some(t => t == null)) { 51 | return 52 | } 53 | 54 | fn.argTypes = argTypes.map(t => t.jsType) 55 | const returnType = types.byId[+fn.returnType] 56 | fn.returnType = returnType.jsType 57 | 58 | const hasGenericReturn = types.any.includes(returnType.name) 59 | const genericArrayArg = argTypes.find(argType => 60 | anyArrayTypes.includes(argType.name) 61 | ) 62 | 63 | const newArgTypes = [...fn.argTypes] 64 | const genericArgTypes = argTypes.filter((argType, i) => { 65 | if (types.any.includes(argType.name)) { 66 | newArgTypes[i] = elementTypes.includes(argType.name) 67 | ? genericArrayArg 68 | ? 't.elementof' 69 | : 'T' 70 | : 'T' 71 | 72 | return true 73 | } 74 | }) 75 | 76 | const needsTypeParam = 77 | genericArgTypes.length > 1 || 78 | (genericArgTypes.length == 1 && hasGenericReturn) 79 | 80 | if (needsTypeParam) { 81 | fn.argTypes = newArgTypes 82 | if (genericArrayArg) { 83 | fn.typeParams = `` 84 | if (hasGenericReturn) { 85 | fn.returnType = elementTypes.includes(fn.returnType) 86 | ? 't.elementof' 87 | : 'T' 88 | } 89 | } else { 90 | fn.typeParams = '' 91 | if (hasGenericReturn) { 92 | fn.returnType = elementTypes.includes(fn.returnType) 93 | ? 'T' 94 | : 't.array' 95 | } 96 | } 97 | } 98 | 99 | return true 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /spec/types/selection.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, test, _ } from 'spec.ts' 2 | import { 3 | ColumnRef, 4 | ResolveSelection, 5 | Select, 6 | Selectable, 7 | SelectedRow, 8 | Selection, 9 | } from 'tusken' 10 | import db, { t } from '../db' 11 | 12 | test('db.select => asyncIterator', async () => { 13 | for await (const user of db.select(t.user)) { 14 | assert(user, _ as t.user) 15 | } 16 | 17 | for await (const user of db.select(t.user(u => u.id))) { 18 | /** 19 | * The return type of the selector function. 20 | */ 21 | type Step1 = ColumnRef 22 | /** 23 | * An object type whose keys are inferred from the selection 24 | * and whose values are Postgres types. 25 | */ 26 | type Step2 = ResolveSelection 27 | /** 28 | * The return type of the `t.user` function call. 29 | */ 30 | type Step3 = Selection 31 | /** 32 | * The `db.select` call uses the `SelectedRow` type to merge 33 | * the union of various selection types (declared by a query) 34 | * into one object type. 35 | * 36 | * In this case, the union has only 1 member (UserSelection). 37 | */ 38 | type Result = SelectedRow 39 | /** 40 | * If there's no bugs in the above walkthrough, 41 | * then this assertion will pass. 42 | */ 43 | assert(user, _ as Result) 44 | /** 45 | * This is the real assertion. The developer expects the 46 | * `user` variable to be the following: 47 | */ 48 | assert(user, _ as { id: number }) 49 | } 50 | 51 | for await (const user of db.select(t.user(u => [u.id, u.name]))) { 52 | assert(user, _ as { id: number; name: string }) 53 | } 54 | 55 | for await (const user of db.select( 56 | t.user(u => ({ id: u.id, name: u.name })) 57 | )) { 58 | assert(user, _ as { id: number; name: string }) 59 | } 60 | }) 61 | 62 | describe('table selection', () => { 63 | test('nullable column', () => { 64 | const selection = t.user(u => [u.bio]) 65 | /** 66 | * The returned array should've been resolved 67 | * into an object type like this. 68 | */ 69 | type Columns = { 70 | bio: t.text | t.null 71 | } 72 | /** 73 | * The `selection` object should be this type. 74 | */ 75 | type Result = Selection 76 | assert(selection, _ as Result) 77 | }) 78 | }) 79 | 80 | test('table casting', async () => { 81 | const selection = t.user(u => { 82 | const featureFlags = t.featureFlag(u.featureFlags) 83 | assert( 84 | _ as ResolveSelection, 85 | _ as { 86 | featureFlags: { 87 | id: t.int4 88 | enabled: t.NULL | t.bool 89 | }[] 90 | } 91 | ) 92 | return [u.id, u.name, featureFlags] 93 | }) 94 | 95 | const query = db.select(selection) 96 | 97 | type Selected = typeof query extends Select< 98 | [infer Selected extends Selectable] 99 | > 100 | ? Selected 101 | : never 102 | 103 | type Result = SelectedRow 104 | 105 | const results = await query 106 | assert(results, _ as Result[]) 107 | assert( 108 | results, 109 | _ as { 110 | id: number 111 | name: string 112 | featureFlags: { id: number; enabled: boolean | null }[] 113 | }[] 114 | ) 115 | }) 116 | 117 | test('orderBy with unselected column', () => { 118 | // Sort by a column that wasn't selected. 119 | db.select(t.tweet(tweet => [tweet.text, tweet.author])).orderBy({ 120 | desc: 'id', 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /spec/generated/test/schema.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | ALTER SCHEMA public OWNER TO alec; 12 | COMMENT ON SCHEMA public IS ''; 13 | SET default_tablespace = ''; 14 | SET default_table_access_method = heap; 15 | CREATE TABLE public."featureFlag" ( 16 | id integer NOT NULL, 17 | enabled boolean DEFAULT false 18 | ); 19 | ALTER TABLE public."featureFlag" OWNER TO postgres; 20 | CREATE TABLE public.follow ( 21 | id integer NOT NULL, 22 | follower integer NOT NULL, 23 | author integer NOT NULL 24 | ); 25 | ALTER TABLE public.follow OWNER TO postgres; 26 | ALTER TABLE public.follow ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY ( 27 | SEQUENCE NAME public.follow_id_seq 28 | START WITH 1 29 | INCREMENT BY 1 30 | NO MINVALUE 31 | NO MAXVALUE 32 | CACHE 1 33 | ); 34 | CREATE TABLE public.foo ( 35 | id integer NOT NULL, 36 | id2 integer NOT NULL, 37 | json json, 38 | jsonb jsonb 39 | ); 40 | ALTER TABLE public.foo OWNER TO postgres; 41 | CREATE SEQUENCE public.foo_id_seq 42 | AS integer 43 | START WITH 1 44 | INCREMENT BY 1 45 | NO MINVALUE 46 | NO MAXVALUE 47 | CACHE 1; 48 | ALTER TABLE public.foo_id_seq OWNER TO postgres; 49 | CREATE TABLE public."like" ( 50 | id integer NOT NULL, 51 | tweet integer NOT NULL, 52 | author integer NOT NULL 53 | ); 54 | ALTER TABLE public."like" OWNER TO postgres; 55 | ALTER TABLE public."like" ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY ( 56 | SEQUENCE NAME public.like_id_seq 57 | START WITH 1 58 | INCREMENT BY 1 59 | NO MINVALUE 60 | NO MAXVALUE 61 | CACHE 1 62 | ); 63 | CREATE TABLE public.tweet ( 64 | id integer NOT NULL, 65 | author integer NOT NULL, 66 | text text NOT NULL, 67 | replies integer[] DEFAULT '{}'::integer[] NOT NULL 68 | ); 69 | ALTER TABLE public.tweet OWNER TO postgres; 70 | ALTER TABLE public.tweet ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY ( 71 | SEQUENCE NAME public.tweet_id_seq 72 | START WITH 1 73 | INCREMENT BY 1 74 | NO MINVALUE 75 | NO MAXVALUE 76 | CACHE 1 77 | ); 78 | CREATE TABLE public."user" ( 79 | id integer NOT NULL, 80 | name text NOT NULL, 81 | "joinedAt" timestamp with time zone DEFAULT now() NOT NULL, 82 | bio text, 83 | "featureFlags" integer[] DEFAULT '{}'::integer[] NOT NULL 84 | ); 85 | ALTER TABLE public."user" OWNER TO alec; 86 | CREATE SEQUENCE public.user_id_seq 87 | AS integer 88 | START WITH 1 89 | INCREMENT BY 1 90 | NO MINVALUE 91 | NO MAXVALUE 92 | CACHE 1; 93 | ALTER TABLE public.user_id_seq OWNER TO alec; 94 | ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; 95 | ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); 96 | ALTER TABLE ONLY public."featureFlag" 97 | ADD CONSTRAINT "featureFlag_pkey" PRIMARY KEY (id); 98 | ALTER TABLE ONLY public.follow 99 | ADD CONSTRAINT follow_pkey PRIMARY KEY (id); 100 | ALTER TABLE ONLY public.foo 101 | ADD CONSTRAINT foo_pkey PRIMARY KEY (id, id2); 102 | ALTER TABLE ONLY public."like" 103 | ADD CONSTRAINT like_pkey PRIMARY KEY (id); 104 | ALTER TABLE ONLY public.tweet 105 | ADD CONSTRAINT tweet_pkey PRIMARY KEY (id); 106 | ALTER TABLE ONLY public."user" 107 | ADD CONSTRAINT user_pkey PRIMARY KEY (id); 108 | REVOKE USAGE ON SCHEMA public FROM PUBLIC; 109 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import type { ConnectOptions } from '../postgres/connection' 2 | 3 | interface OptionsWithDefaults { 4 | /** 5 | * Where the generated Tusken schema is stored. 6 | * @default "./src/generated" 7 | */ 8 | schemaDir?: string 9 | /** 10 | * Where the Postgres database is stored. 11 | * @default "./postgres" 12 | */ 13 | dataDir?: string 14 | } 15 | 16 | type PluginOption = string | false | undefined | PluginOption[] 17 | 18 | /** 19 | * The JS object exported by a `tusken.config.ts` file. 20 | */ 21 | export interface TuskenUserConfig extends OptionsWithDefaults { 22 | /** 23 | * Static values to use as default connection options. 24 | * 25 | * ⚠️ Do not use `process.env` or other dynamic values here. 26 | */ 27 | connection?: ConnectOptions 28 | /** 29 | * List of package names to load plugins from. 30 | */ 31 | plugins?: PluginOption[] 32 | /** 33 | * Path to a package with a `./client` entry module whose default 34 | * export is a runtime plugin function that returns a client object 35 | * responsible for talking to the Postgres server. 36 | * 37 | * If this option is undefined, the first plugin in `plugins` with a 38 | * `./client` entry module will be used. If none is found, then 39 | * `tusken/plugins/pg` is used by default. 40 | */ 41 | clientPlugin?: string 42 | /** 43 | * Path to a package with a `./connection` entry module whose default 44 | * export is a runtime plugin function that returns a connection 45 | * options object. 46 | * 47 | * If this option is undefined, the first plugin in `plugins` with a 48 | * `./connection` entry module will be used. If none is found, then 49 | * `tusken/plugins/pg` is used by default. 50 | */ 51 | connectionPlugin?: string 52 | } 53 | 54 | export type TuskenResolvedPlugin = { 55 | /** The plugin's package ID. */ 56 | id: string 57 | /** The subpath for within the plugin package. */ 58 | subPath: string 59 | /** Absolute path to the plugin module. */ 60 | modulePath: string 61 | } 62 | 63 | /** 64 | * Configuration loaded from `tusken.config.ts` file. 65 | */ 66 | export interface TuskenConfig extends Required { 67 | /** 68 | * The directory where nearest `package.json` lives. 69 | */ 70 | rootDir: string 71 | clientPlugin: TuskenResolvedPlugin 72 | connectionPlugin: TuskenResolvedPlugin 73 | connection?: ConnectOptions 74 | /** 75 | * Runtime plugins are loaded by the `tusken generate` command. Their 76 | * default export must be a `TuskenRuntimePlugin` object. 77 | * 78 | * To provide a runtime plugin, add a `./runtime` entry module to your 79 | * plugin package. 80 | */ 81 | runtimePlugins: TuskenResolvedPlugin[] 82 | /** 83 | * Schema plugins are loaded by the `tusken generate` command. They 84 | * typically declare tables, indexes, and other schema objects. 85 | * 86 | * To provide a schema plugin, add a `./schema` entry module to your 87 | * plugin package. 88 | * 89 | * @experimental 90 | */ 91 | schemaPlugins: TuskenResolvedPlugin[] 92 | } 93 | 94 | export interface TuskenRuntimePlugin { 95 | /** 96 | * The returns list of module IDs can be absolute or relative. When 97 | * relative, the path is resolved with the `__dirname` of the plugin 98 | * module. 99 | * 100 | * The runtime modules are typically used to declare type definitions 101 | * and add new methods to classes like `Database` and `Query`, for 102 | * example. 103 | */ 104 | imports: (project: TuskenProject) => string[] | undefined 105 | } 106 | 107 | /** 108 | * Used by runtime plugins for conditional imports. 109 | */ 110 | export interface TuskenProject { 111 | dependencies: Record 112 | config: TuskenConfig 113 | configPath?: string 114 | } 115 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/utils/dataToEsm.ts: -------------------------------------------------------------------------------- 1 | const varDeclRE = /^(const|let|var) / 2 | const INDENT = ' ' 3 | const RETURN = '\n' 4 | const SPACE = ' ' 5 | 6 | type Replacer = (this: any, key: string, value: any) => string | void 7 | 8 | const seen = new Set() 9 | 10 | /** 11 | * Convert almost any kind of data to ESM code. 12 | * 13 | * By default, `export default` is prepended to the generated code, 14 | * but the `variable` argument lets you change that. For example, 15 | * you could pass `"foo"` to prepend `const foo =`, pass an empty 16 | * string to prepend nothing, or `"let foo"` for a `let foo =` prefix. 17 | * 18 | * The `replacer` argument lets you selectively override which code 19 | * is generated from a specific value. 20 | */ 21 | export function dataToEsm( 22 | data: unknown, 23 | variable?: string | null, 24 | replacer: Replacer = () => {} 25 | ) { 26 | if (variable && !varDeclRE.test(variable)) { 27 | variable = 'const ' + variable 28 | } 29 | 30 | const prefix = variable 31 | ? variable + ' = ' 32 | : variable !== '' 33 | ? 'export default ' 34 | : '' 35 | 36 | return prefix + serialize(data, [], replacer) 37 | } 38 | 39 | function serialize( 40 | value: any, 41 | keyPath: string[], 42 | replacer: Replacer, 43 | context?: any 44 | ): string { 45 | const key = keyPath.length ? keyPath[keyPath.length - 1] : '' 46 | const replacement = replacer.call(context, key, value) 47 | if (typeof replacement === 'string') { 48 | return replacement 49 | } 50 | if ( 51 | value == null || 52 | value === Infinity || 53 | value === -Infinity || 54 | Number.isNaN(value) || 55 | value instanceof RegExp 56 | ) { 57 | return String(value) 58 | } 59 | if (value === 0 && 1 / value === -Infinity) { 60 | return '-0' 61 | } 62 | if (value instanceof Date) { 63 | return `new Date(${value.getTime()})` 64 | } 65 | if (Array.isArray(value)) { 66 | if (seen.has(value)) { 67 | return '' 68 | } 69 | seen.add(value) 70 | const serialized = serializeArray(value, keyPath, replacer) 71 | seen.delete(value) 72 | return serialized 73 | } 74 | if (typeof value === 'object') { 75 | if (seen.has(value)) { 76 | return '' 77 | } 78 | seen.add(value) 79 | const serialized = serializeObject(value, keyPath, replacer) 80 | seen.delete(value) 81 | return serialized 82 | } 83 | return stringify(value) 84 | } 85 | 86 | function stringify(obj: unknown): string { 87 | return JSON.stringify(obj).replace( 88 | /[\u2028\u2029]/g, 89 | char => `\\u${`000${char.charCodeAt(0).toString(16)}`.slice(-4)}` 90 | ) 91 | } 92 | 93 | function serializeArray( 94 | arr: T[], 95 | keyPath: string[], 96 | replacer: Replacer 97 | ): string { 98 | let output = '[' 99 | const baseIndent = INDENT.repeat(keyPath.length) 100 | const separator = RETURN + baseIndent + INDENT 101 | for (let i = 0; i < arr.length; i++) { 102 | output += `${i > 0 ? ',' : ''}${separator}${serialize( 103 | arr[i], 104 | keyPath.concat(String(i)), 105 | replacer, 106 | arr 107 | )}` 108 | } 109 | return output + RETURN + baseIndent + ']' 110 | } 111 | 112 | function serializeObject( 113 | obj: object, 114 | keyPath: string[], 115 | replacer: Replacer 116 | ): string { 117 | let output = '{' 118 | const baseIndent = INDENT.repeat(keyPath.length) 119 | const separator = RETURN + baseIndent + INDENT 120 | 121 | const definedEntries = Object.entries(obj).filter(entry => { 122 | const [key, value] = entry 123 | // Ignore undefined property values like JSON.stringify does 124 | if (value !== undefined) { 125 | entry[1] = serialize(value, keyPath.concat(String(key)), replacer, obj) 126 | return entry[1] !== '' 127 | } 128 | }) 129 | 130 | definedEntries.forEach(([key, value], i) => { 131 | const legalName = /^[$_a-z0-9]+$/i.test(key) ? key : stringify(key) 132 | output += (i > 0 ? ',' : '') + separator + legalName + ':' + SPACE + value 133 | }) 134 | 135 | return output + RETURN + baseIndent + '}' 136 | } 137 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import EventEmitter from 'events' 3 | import fs, { mkdirSync, writeFileSync } from 'fs' 4 | import path from 'path' 5 | import { StrictEventEmitter } from 'strict-event-emitter-types' 6 | import type { Client, ConnectOptions } from 'tusken' 7 | import { loadClient, loadProject, TuskenProject } from 'tusken/config' 8 | import { promisify } from 'util' 9 | import { extractTypes } from './extract' 10 | import { generateNativeFuncs } from './typescript/generateNativeFuncs' 11 | import { generateNativeTypes } from './typescript/generateNativeTypes' 12 | import { generateTypeSchema } from './typescript/generateSchema' 13 | 14 | type Events = { 15 | extractStart: () => void 16 | generateStart: () => void 17 | generateEnd: () => void 18 | write: () => void 19 | error: (e: any) => void 20 | } 21 | 22 | export interface Generator extends StrictEventEmitter { 23 | client: Client 24 | connection: ConnectOptions 25 | /** Generate and write the files. */ 26 | update(): Promise 27 | } 28 | 29 | export function generate(options: { 30 | project?: TuskenProject 31 | database?: string 32 | host?: string 33 | port?: number 34 | configPath?: string 35 | tuskenId?: string 36 | }): Generator { 37 | const project = loadProject(options.configPath) 38 | const [client, connection] = loadClient(project, { 39 | database: options.database, 40 | host: options.host, 41 | port: options.port, 42 | }) 43 | if (!connection.database) { 44 | throw Error('No database specified') 45 | } 46 | const outDir = path.join(project.config.schemaDir, connection.database) 47 | const docs = JSON.parse( 48 | fs.readFileSync(path.resolve(__dirname, '../docs.json'), 'utf8') 49 | ) 50 | const generator = new EventEmitter() as Generator 51 | generator.client = client 52 | generator.connection = connection 53 | generator.update = async () => { 54 | try { 55 | generator.emit('extractStart') 56 | 57 | const { nativeTypes, nativeCasts, nativeFuncs } = await extractTypes( 58 | client 59 | ) 60 | 61 | generator.emit('generateStart') 62 | 63 | const tuskenId = options.tuskenId || 'tusken' 64 | const files = await generateTypeSchema( 65 | project, 66 | connection, 67 | generateNativeTypes(nativeTypes, nativeCasts, tuskenId), 68 | tuskenId, 69 | client 70 | ) 71 | files.push({ 72 | name: 'functions.ts', 73 | content: generateNativeFuncs(nativeFuncs, docs), 74 | }) 75 | files.push({ 76 | name: 'schema.sql', 77 | content: await dumpSqlSchema(connection), 78 | }) 79 | 80 | generator.emit('generateEnd') 81 | 82 | mkdirSync(outDir, { recursive: true }) 83 | for (const file of files) { 84 | writeFileSync(path.join(outDir, file.name), file.content) 85 | } 86 | 87 | generator.emit('write') 88 | } catch (e: any) { 89 | generator.emit('error', e) 90 | } 91 | } 92 | process.nextTick(() => { 93 | generator.update().catch(e => { 94 | generator.emit('error', e) 95 | }) 96 | }) 97 | return generator 98 | } 99 | 100 | async function dumpSqlSchema(opts: ConnectOptions) { 101 | const env = await getClientEnv(opts) 102 | const { stdout, stderr } = await promisify(exec)( 103 | `pg_dump --schema-only -E utf8` + 104 | (env.PGDATABASE?.startsWith('postgres://') 105 | ? ` -d ${env.PGDATABASE}` 106 | : ''), 107 | { encoding: 'utf8', env } 108 | ) 109 | if (stderr) { 110 | console.error(stderr) 111 | } 112 | return stdout.replace(/^(--.*?|)\n/gm, '') 113 | } 114 | 115 | export async function getClientEnv(opts: ConnectOptions) { 116 | const env: any = { 117 | ...process.env, 118 | PGPASSWORD: opts.password, 119 | PGDATABASE: opts.connectionString, 120 | } 121 | if (!env.PGDATABASE) { 122 | env.PGDATABASE = opts.database 123 | env.PGUSER = opts.user 124 | env.PGHOST = opts.host 125 | env.PGPORT = opts.port 126 | } 127 | return env 128 | } 129 | -------------------------------------------------------------------------------- /src/postgres/query/where.ts: -------------------------------------------------------------------------------- 1 | import { Any, Intersect } from '@alloc/types' 2 | import { isObject } from '../../utils/isObject' 3 | import { RecursiveVariadic } from '../../utils/Variadic' 4 | import { reduceChecks } from '../check' 5 | import { ColumnRef, ColumnType, makeColumnRef } from '../column' 6 | import { Expression } from '../expression' 7 | import { CallExpression } from '../function' 8 | import { JoinRef } from '../join' 9 | import { Selectable, Selection } from '../selection' 10 | import { getSetAlias, SetRef } from '../set' 11 | import { kIdentityColumns, kTableName } from '../symbols' 12 | import { RowType, TableRef, toTableName, toTableRef } from '../table' 13 | import { t } from '../typesBuiltin' 14 | 15 | export function wherePrimaryKeyEquals( 16 | pk: any, 17 | from: TableRef 18 | ): Where<[TableRef]> { 19 | const pkColumns = from[kIdentityColumns] as string[] 20 | if (isObject(pk)) { 21 | const keys = Object.keys(pk) 22 | if (keys.length !== pkColumns.length) { 23 | const missingKey = pkColumns.find(key => !keys.includes(key)) 24 | throw Error( 25 | `"${missingKey}" is a primary key column of "${from[kTableName]}" but was not defined` 26 | ) 27 | } 28 | const invalidKey = keys.find(key => !pkColumns.includes(key)) 29 | if (invalidKey) { 30 | throw Error( 31 | `"${invalidKey}" is not a primary key column of "${toTableName(from)}"` 32 | ) 33 | } 34 | return from => keys.map(key => from[key].is.eq(pk[key as keyof typeof pk])) 35 | } 36 | if (pkColumns.length > 1) { 37 | throw Error(`Primary key of "${toTableName(from)}" is composite`) 38 | } 39 | return from => from[pkColumns[0]].is.eq(pk) 40 | } 41 | 42 | export function buildWhereClause( 43 | props: { 44 | from: Selectable 45 | joins?: JoinRef[] 46 | }, 47 | filter: Where, 48 | where?: Expression | null 49 | ) { 50 | const joined = props.joins?.map(join => join.from) 51 | const sources = [props.from].concat(joined || []) 52 | 53 | const refs = {} as WhereRefs 54 | sources.forEach(from => { 55 | const setAlias = getSetAlias(from) 56 | if (setAlias) { 57 | refs[setAlias] = makeColumnRef(from as SetRef, setAlias) 58 | } else { 59 | const table = toTableRef(from) 60 | if (table) { 61 | refs[table[kTableName]] = new Proxy(from, { 62 | get: (_, column: string) => makeColumnRef(from, column), 63 | }) as any 64 | } 65 | } 66 | }) 67 | 68 | const cond = filter(joined ? refs : Object.values(refs)[0]) 69 | return reduceChecks(where ? [where, cond] : cond) 70 | } 71 | 72 | export type FindWhere = ( 73 | ref: WhereRef 74 | ) => RecursiveVariadic | false | null> 75 | 76 | export type Where = ( 77 | refs: WhereRefs 78 | ) => RecursiveVariadic | false | null> 79 | 80 | export type WhereRefs = [From] extends [Any] 81 | ? Record 82 | : From extends [Selectable] 83 | ? WhereRef 84 | : Intersect> 85 | 86 | type WhereRefsObject = 87 | From[number] extends infer Source 88 | ? Source extends Selection 89 | ? WhereRefsObject<[From]> 90 | : Source extends Selectable 91 | ? { [P in SourceRefId]: WhereRef } 92 | : never 93 | : never 94 | 95 | export type SourceRefId = Source extends SetRef 96 | ? Alias 97 | : Source extends CallExpression 98 | ? Callee 99 | : Source extends TableRef 100 | ? Name 101 | : never 102 | 103 | type WhereRef = [From] extends [Any] 104 | ? WhereRef 105 | : From extends SetRef 106 | ? ColumnRef 107 | : RowType extends infer Values 108 | ? Values extends object 109 | ? { 110 | [K in string & keyof Values]-?: ColumnRef, K> 111 | } 112 | : never 113 | : never 114 | -------------------------------------------------------------------------------- /src/postgres/selection.ts: -------------------------------------------------------------------------------- 1 | import type { Any, CombineObjects, Intersect, Pick } from '@alloc/types' 2 | import type { ColumnExpression, ColumnRef } from './column' 3 | import type { CallExpression } from './function' 4 | import type { JoinRef } from './join' 5 | import type { SetExpression } from './set' 6 | import { kSelectionArgs, kSelectionFrom, kSelectionType } from './symbols' 7 | import type { TableRef } from './table' 8 | import type { TableCast } from './tableCast' 9 | import type { RowResult, SetType, Type } from './type' 10 | 11 | /** Selection sources have a default selection of all columns. */ 12 | export type SelectionSource = SetExpression | TableRef | JoinRef 13 | 14 | export class Selection< 15 | T extends object = any, 16 | From extends SelectionSource = any 17 | > { 18 | protected declare [kSelectionType]: T 19 | protected [kSelectionArgs]: RawSelection 20 | protected [kSelectionFrom]: From 21 | 22 | constructor(args: RawSelection, from: From) { 23 | this[kSelectionArgs] = args 24 | this[kSelectionFrom] = from 25 | } 26 | } 27 | 28 | export interface Selection extends SetType {} 29 | 30 | /** 31 | * Get the sources of one or more selection types. 32 | */ 33 | export type SelectionSources = T extends readonly any[] 34 | ? { [I in keyof T]: SelectionSources } 35 | : T extends Selection 36 | ? From 37 | : Extract 38 | 39 | /** Object types compatible with `SELECT` command */ 40 | export type Selectable = SelectionSource | Selection 41 | 42 | export type SelectResult = SelectedRow< 43 | From[number] 44 | > extends infer Result 45 | ? Extract 46 | : never 47 | 48 | export type SelectResults = 49 | SelectResult[] extends infer Result ? Extract : never 50 | 51 | /** Note that `T` must be a union, not an array type. */ 52 | export type SelectedRow = unknown & 53 | ([T] extends [Any] 54 | ? Record 55 | : Intersect ? Row : never> extends infer Row 56 | ? RowResult> 57 | : never) 58 | 59 | export type AliasMapping = { 60 | [alias: string]: ColumnExpression | CallExpression | TableCast 61 | } 62 | 63 | export type RawSelection = string[] | RawColumnSelection | RawColumnSelection[] 64 | 65 | export type RawColumnSelection = 66 | | AliasMapping 67 | | ColumnExpression 68 | | CallExpression 69 | | TableCast 70 | 71 | /** Coerce a `RawSelection` into an object type. */ 72 | export type ResolveSelection = T extends (infer U)[] 73 | ? ResolveSingleSelection> 74 | : ResolveSingleSelection 75 | 76 | /** 77 | * Unlike the `ResolveSelection` type, this type avoids separating the `T` union, 78 | * in case there are multiple `ColumnExpression` with the same name that need to 79 | * be merged. 80 | */ 81 | type ResolveSingleSelection = Intersect< 82 | | ResolveColumns 83 | | (T extends CallExpression 84 | ? { [P in Callee]: ReturnType } 85 | : T extends (infer E)[] 86 | ? ResolveAliasMapping | ResolveColumns | ResolveTableCast 87 | : ResolveAliasMapping | ResolveTableCast) 88 | > extends infer Resolved 89 | ? Pick 90 | : never 91 | 92 | type ResolveAliasMapping = T extends AliasMapping 93 | ? { [P in keyof T]: ResolveAliasedValue } 94 | : never 95 | 96 | type ResolveAliasedValue = T extends CallExpression 97 | ? ReturnType 98 | : T extends ColumnExpression 99 | ? ColumnValue 100 | : T extends TableCast | TableRef> 101 | ? Values 102 | : never 103 | 104 | type ResolveTableCast = T extends TableCast< 105 | Selection | TableRef, 106 | ColumnRef, infer Key> 107 | > 108 | ? { [P in Key]: ClientType extends any[] ? Values[] : Values } 109 | : never 110 | 111 | /** 112 | * Convert a union of column refs into an object type. 113 | * 114 | * This is used when a selector returns an array that 115 | * includes at least one column ref. 116 | */ 117 | type ResolveColumns = unknown & 118 | ([Extract] extends [ColumnExpression] 119 | ? Column extends string 120 | ? CombineObjects< 121 | T extends ColumnExpression 122 | ? { [P in Column]: ColumnValue } 123 | : never 124 | > 125 | : never 126 | : never) 127 | -------------------------------------------------------------------------------- /src/postgres/function.ts: -------------------------------------------------------------------------------- 1 | import { FunctionFlags as f } from '../constants' 2 | import { isObject } from '../utils/isObject' 3 | import { ExpressionRef } from './expression' 4 | import { Token, TokenArray } from './internal/token' 5 | import { tokenize } from './internal/tokenize' 6 | import { kUnknownType } from './internal/type' 7 | import type { Query } from './query' 8 | import { kExprProps } from './symbols' 9 | import type { RuntimeType, Type } from './type' 10 | import { isCallExpression } from './typeChecks' 11 | 12 | export const defineFunction = 13 | (callee: string, flags = 0, returnType = kUnknownType): any => 14 | (...args: any[]) => 15 | new CallExpression(returnType, { 16 | callee, 17 | args: flags & f.omitArgs ? undefined : args, 18 | isAggregate: (flags & f.isAggregate) !== 0, 19 | }) 20 | 21 | /** 22 | * Functions with unique syntax requirements can use this. 23 | */ 24 | export const defineTemplate = ( 25 | callee: string, 26 | template: string, 27 | returnType = kUnknownType 28 | ): any => { 29 | const tokenize = parseTemplate(template, callee) 30 | return (...args: any[]) => 31 | new CallExpression(returnType, { 32 | callee, 33 | args, 34 | tokenize, 35 | }) 36 | } 37 | 38 | function parseTemplate(template: string, callee: string) { 39 | return (args: Token[], ctx: Query.Context) => { 40 | const tokens: TokenArray = [callee] 41 | const lexer = /(\$\d+)(?:\/([^/]+)\/)?/gi 42 | let match: RegExpExecArray | null 43 | let lastIndex = 0 44 | while ((match = lexer.exec(template))) { 45 | if (match.index > lastIndex) { 46 | tokens.push(template.slice(lastIndex, match.index)) 47 | } 48 | lastIndex = match.index + match[0].length 49 | if (match[1]) { 50 | // The number after the $ is the 1-based argument index. 51 | const argIndex = Number(match[1].slice(1)) - 1 52 | if (args[argIndex]) { 53 | const arg = args[argIndex] 54 | // The 2nd capture group is a regular expression. 55 | if (match[2]) { 56 | const literal: unknown = Array.isArray(arg) 57 | ? undefined 58 | : typeof arg == 'string' 59 | ? arg 60 | : arg.literal ?? arg.number ?? arg.value 61 | 62 | if (literal === undefined || typeof literal == 'object') { 63 | throw Error( 64 | `Argument ${argIndex} for function "${callee}" could not be validated. Try using a literal value.` 65 | ) 66 | } 67 | 68 | const validator = new RegExp('^(' + match[2] + ')$', 'i') 69 | if (!validator.test(String(literal))) { 70 | throw new Error( 71 | `Argument ${argIndex} for function "${callee}" must match ${validator}` 72 | ) 73 | } 74 | tokens.push( 75 | typeof literal == 'number' 76 | ? { number: literal } 77 | : typeof literal == 'string' 78 | ? literal 79 | : { value: literal } 80 | ) 81 | } else { 82 | tokens.push(arg) 83 | } 84 | } 85 | } 86 | } 87 | if (lastIndex < template.length) { 88 | tokens.push(template.slice(lastIndex)) 89 | } 90 | return { concat: tokens } 91 | } 92 | } 93 | 94 | interface Props { 95 | alias?: string 96 | args?: any[] 97 | callee: Callee 98 | tokenize?: (args: any[], ctx: Query.Context) => Token 99 | isAggregate?: boolean 100 | } 101 | 102 | export class CallExpression< 103 | T extends Type = any, 104 | Callee extends string = any 105 | > extends ExpressionRef> { 106 | constructor(returnType: RuntimeType, props: Props) { 107 | super(returnType, props, tokenizeCallExpression) 108 | 109 | props.isAggregate ||= props.args?.some( 110 | arg => isObject(arg) && isCallExpression(arg) && arg.props.isAggregate 111 | ) 112 | } 113 | } 114 | 115 | function tokenizeCallExpression(props: Props, ctx: Query.Context): TokenArray { 116 | const args = props.args?.map(arg => tokenize(arg, ctx)) 117 | return [ 118 | props.tokenize 119 | ? props.tokenize(args!, ctx) 120 | : { 121 | callee: props.callee, 122 | args, 123 | }, 124 | props.alias ? ['AS', { id: props.alias }] : '', 125 | ] 126 | } 127 | 128 | export function getCallee(val: CallExpression): string 129 | export function getCallee(val: any): string | undefined 130 | export function getCallee(val: any) { 131 | return val instanceof CallExpression ? val[kExprProps].callee : undefined 132 | } 133 | -------------------------------------------------------------------------------- /src/config/loadModule.ts: -------------------------------------------------------------------------------- 1 | import { resolveExports } from '@alloc/resolve.exports' 2 | import findDependency from 'find-dependency' 3 | import fs from 'fs' 4 | import { Module } from 'module' 5 | import path from 'path' 6 | import vm from 'vm' 7 | 8 | const nodeRequire = require 9 | const moduleCache = (Module as any)._cache as Record< 10 | string, 11 | InstanceType 12 | > 13 | 14 | /** 15 | * Resolve a file suffix for the given `filePath` and load the module 16 | * after transforming its ESM syntax and stripping TS definitions. 17 | * 18 | * To reload a module, you must remove it from `require.cache` first. 19 | */ 20 | export function loadModule( 21 | id: string, 22 | code?: string | null, 23 | env?: Record | null, 24 | parent?: InstanceType 25 | ) { 26 | const filePath = resolveFileSuffix(id) 27 | if (!filePath) { 28 | throw Error('File not found: ' + id) 29 | } 30 | 31 | if (moduleCache[filePath]) { 32 | return moduleCache[filePath] 33 | } 34 | 35 | if (filePath.endsWith('.cjs')) { 36 | nodeRequire(filePath) 37 | return moduleCache[filePath] 38 | } 39 | 40 | if (!code) { 41 | code = fs.readFileSync(filePath, 'utf8') 42 | } 43 | 44 | const sucrase = nodeRequire('sucrase') as typeof import('sucrase') 45 | const transformed = sucrase.transform(code, { 46 | filePath, 47 | transforms: filePath.endsWith('.ts') 48 | ? ['imports', 'typescript'] 49 | : ['imports'], 50 | sourceMapOptions: { 51 | compiledFilename: path.basename(filePath), 52 | }, 53 | }) 54 | 55 | const mod = new Module(filePath, parent) 56 | mod.require = Module.createRequire(filePath) 57 | moduleCache[filePath] = mod 58 | 59 | const require = (id: string) => { 60 | if (id[0] == '.') { 61 | id = path.resolve(mod.path, id) 62 | } 63 | const resolvedId = resolveFileSuffix(id, mod.path) 64 | if (resolvedId?.endsWith('.ts')) { 65 | const loaded = loadModule(resolvedId, null, null, mod) 66 | return loaded.exports 67 | } 68 | return mod.require(resolvedId || id) 69 | } 70 | 71 | env ||= {} 72 | env.module = mod 73 | env.exports = mod.exports 74 | env.require = Object.assign(require, mod.require) 75 | 76 | const script = new vm.Script( 77 | `(function(${Object.keys(env)}) { ${transformed.code}\n})` + 78 | toInlineSourceMap(transformed.sourceMap), 79 | { filename: filePath } 80 | ) 81 | 82 | const moduleFactory = script.runInThisContext() as Function 83 | moduleFactory(...Object.values(env)) 84 | 85 | return mod 86 | } 87 | 88 | const suffixes = ['.js', '.cjs', '.ts'] 89 | const dirIndex = path.sep + 'index' 90 | 91 | function resolveFileSuffix(filePath: string, cwd?: string): string | null { 92 | if (filePath[0] != '.' && !path.isAbsolute(filePath[0])) { 93 | const fileParts = filePath.split('/') 94 | const pkgName = 95 | filePath[0] == '@' ? fileParts.slice(0, 2).join('/') : fileParts[0] 96 | const pkgRoot = findDependency(pkgName, { cwd, skipGlobal: true }) 97 | if (!pkgRoot) { 98 | return null 99 | } 100 | const fileName = fileParts.slice(pkgName[0] == '@' ? 2 : 1) 101 | const pkg = JSON.parse( 102 | fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8') 103 | ) 104 | if (pkg.exports) { 105 | const subPath = ['.', ...fileName].join('/') 106 | const possibleFiles = resolveExports(pkg, subPath, { 107 | isRequire: true, 108 | conditions: ['node'], 109 | }) 110 | for (const file of possibleFiles) { 111 | const resolved = resolveFileSuffix(path.join(pkgRoot, file)) 112 | if (resolved) { 113 | return resolved 114 | } 115 | } 116 | return null 117 | } 118 | filePath = path.join(pkgRoot, ...fileName) 119 | } 120 | 121 | let suffix = suffixes.find(suffix => fs.existsSync(filePath + suffix)) 122 | if (!suffix) { 123 | try { 124 | if (fs.statSync(filePath).isFile()) { 125 | suffix = '' 126 | } else { 127 | // Try directory index when all else fails. 128 | suffix = suffixes.find(suffix => 129 | fs.existsSync(filePath + dirIndex + suffix) 130 | ) 131 | if (suffix) { 132 | suffix = dirIndex + suffix 133 | } 134 | } 135 | } catch (e: any) { 136 | if (e.code != 'ENOENT') { 137 | throw e 138 | } 139 | } 140 | } 141 | 142 | if (suffix != null) { 143 | return filePath + suffix 144 | } 145 | return null 146 | } 147 | 148 | function toInlineSourceMap(map: any) { 149 | return ( 150 | '\n//# ' + 151 | 'sourceMappingURL=data:application/json;charset=utf-8;base64,' + 152 | Buffer.from(JSON.stringify(map), 'utf8').toString('base64') 153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /spec/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.4 2 | 3 | importers: 4 | 5 | .: 6 | specifiers: 7 | '@tusken/cli': link:../packages/tusken-cli 8 | pg: ^8.8.0 9 | pg-query-stream: ^4.2.4 10 | tusken: link:./ 11 | devDependencies: 12 | '@tusken/cli': link:../packages/tusken-cli 13 | pg: 8.8.0 14 | pg-query-stream: 4.2.4_pg@8.8.0 15 | tusken: 'link:' 16 | 17 | generated/tusken-plugin: 18 | specifiers: {} 19 | 20 | packages: 21 | 22 | /buffer-writer/2.0.0: 23 | resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} 24 | engines: {node: '>=4'} 25 | dev: true 26 | 27 | /packet-reader/1.0.0: 28 | resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} 29 | dev: true 30 | 31 | /pg-connection-string/2.5.0: 32 | resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} 33 | dev: true 34 | 35 | /pg-cursor/2.7.4_pg@8.8.0: 36 | resolution: {integrity: sha512-CNWwOzTTZ9QvphoOL+Wg/7pmVr9GnAWBjPbuK2FRclrB4A/WRO/ssCJ9BlkzIGmmofK2M/LyokNHgsLSn+fMHA==} 37 | peerDependencies: 38 | pg: ^8 39 | dependencies: 40 | pg: 8.8.0 41 | dev: true 42 | 43 | /pg-int8/1.0.1: 44 | resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} 45 | engines: {node: '>=4.0.0'} 46 | dev: true 47 | 48 | /pg-pool/3.5.2_pg@8.8.0: 49 | resolution: {integrity: sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==} 50 | peerDependencies: 51 | pg: '>=8.0' 52 | dependencies: 53 | pg: 8.8.0 54 | dev: true 55 | 56 | /pg-protocol/1.5.0: 57 | resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==} 58 | dev: true 59 | 60 | /pg-query-stream/4.2.4_pg@8.8.0: 61 | resolution: {integrity: sha512-Et3gTrWn4C2rj4LVioNq1QDd7aH/3mSJcBm79jZALv3wopvx9bWENtbOYZbHQ6KM+IkfFxs0JF1ZLjMDJ9/N6Q==} 62 | dependencies: 63 | pg-cursor: 2.7.4_pg@8.8.0 64 | transitivePeerDependencies: 65 | - pg 66 | dev: true 67 | 68 | /pg-types/2.2.0: 69 | resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} 70 | engines: {node: '>=4'} 71 | dependencies: 72 | pg-int8: 1.0.1 73 | postgres-array: 2.0.0 74 | postgres-bytea: 1.0.0 75 | postgres-date: 1.0.7 76 | postgres-interval: 1.2.0 77 | dev: true 78 | 79 | /pg/8.8.0: 80 | resolution: {integrity: sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==} 81 | engines: {node: '>= 8.0.0'} 82 | peerDependencies: 83 | pg-native: '>=3.0.1' 84 | peerDependenciesMeta: 85 | pg-native: 86 | optional: true 87 | dependencies: 88 | buffer-writer: 2.0.0 89 | packet-reader: 1.0.0 90 | pg-connection-string: 2.5.0 91 | pg-pool: 3.5.2_pg@8.8.0 92 | pg-protocol: 1.5.0 93 | pg-types: 2.2.0 94 | pgpass: 1.0.5 95 | dev: true 96 | 97 | /pgpass/1.0.5: 98 | resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} 99 | dependencies: 100 | split2: 4.1.0 101 | dev: true 102 | 103 | /postgres-array/2.0.0: 104 | resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} 105 | engines: {node: '>=4'} 106 | dev: true 107 | 108 | /postgres-bytea/1.0.0: 109 | resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} 110 | engines: {node: '>=0.10.0'} 111 | dev: true 112 | 113 | /postgres-date/1.0.7: 114 | resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} 115 | engines: {node: '>=0.10.0'} 116 | dev: true 117 | 118 | /postgres-interval/1.2.0: 119 | resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} 120 | engines: {node: '>=0.10.0'} 121 | dependencies: 122 | xtend: 4.0.2 123 | dev: true 124 | 125 | /split2/4.1.0: 126 | resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==} 127 | engines: {node: '>= 10.x'} 128 | dev: true 129 | 130 | /xtend/4.0.2: 131 | resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} 132 | engines: {node: '>=0.4'} 133 | dev: true 134 | -------------------------------------------------------------------------------- /src/config/loadConfig.ts: -------------------------------------------------------------------------------- 1 | import { resolveExports } from '@alloc/resolve.exports' 2 | import findDependency from 'find-dependency' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { TuskenConfig, TuskenUserConfig } from './config' 6 | import escalade from './escalade/sync' 7 | import { loadModule } from './loadModule' 8 | 9 | export function loadConfig( 10 | configPath?: string 11 | ): [config: TuskenConfig, configPath: string | undefined] { 12 | let configDir = process.cwd() 13 | let userConfig: TuskenUserConfig 14 | 15 | configPath ||= escalade(configDir, (dir, files) => { 16 | const file = files.find(f => /^tusken\.config\.[jt]s$/.test(f)) 17 | if (file) { 18 | return path.join(dir, file) 19 | } 20 | }) 21 | 22 | if (configPath) { 23 | configPath = path.resolve(configPath) 24 | userConfig = loadModule(configPath).exports.default 25 | if (!userConfig) { 26 | throw Error('Config must be the default export: ' + configPath) 27 | } 28 | configDir = path.dirname(configPath) 29 | configPath = fs.realpathSync(configPath) 30 | } else { 31 | userConfig = {} 32 | } 33 | 34 | const rootDir = escalade(configDir, (dir, files) => { 35 | if (files.includes('package.json')) { 36 | return dir 37 | } 38 | if (files.includes('.git')) { 39 | return false 40 | } 41 | }) 42 | 43 | if (!rootDir) { 44 | throw Error('Could not find package.json') 45 | } 46 | 47 | const pluginSubPaths = [ 48 | './client', 49 | './connection', 50 | './runtime', 51 | './schema', 52 | ] as const 53 | 54 | type PluginSubPath = typeof pluginSubPaths[number] 55 | 56 | const resolvePluginRoot = (id: string) => { 57 | if (id[0] != '.') { 58 | const resolved = findDependency(id, { cwd: configDir, skipGlobal: true }) 59 | if (resolved) { 60 | return resolved 61 | } 62 | } 63 | return path.resolve(configDir, id) 64 | } 65 | 66 | const loadPluginData = (id: string) => { 67 | const root = resolvePluginRoot(id) 68 | const pkg = JSON.parse( 69 | fs.readFileSync(path.join(root, 'package.json'), 'utf8') 70 | ) 71 | return { 72 | id, 73 | root, 74 | subPaths: pluginSubPaths.filter( 75 | subPath => resolveExports(pkg, subPath).length > 0 76 | ), 77 | } 78 | } 79 | 80 | type PluginData = ReturnType 81 | 82 | let clientPlugin: PluginData | undefined 83 | if (userConfig.clientPlugin) { 84 | clientPlugin = loadPluginData(userConfig.clientPlugin) 85 | } 86 | 87 | let connectionPlugin: PluginData | undefined 88 | if (userConfig.connectionPlugin) { 89 | connectionPlugin = loadPluginData(userConfig.connectionPlugin) 90 | } 91 | 92 | const pluginPaths = userConfig.plugins ?? [] 93 | const plugins = Array.from( 94 | new Set(pluginPaths.flat().filter(Boolean) as string[]), 95 | loadPluginData 96 | ) 97 | 98 | // This package provides default implementations of the clientPlugin 99 | // and connectionPlugin. 100 | const fallback = loadPluginData('tusken/plugins/pg') 101 | 102 | const resolvePluginModule = ( 103 | plugin: PluginData, 104 | subPath: PluginSubPath, 105 | assumeValid?: boolean 106 | ) => { 107 | if (!assumeValid && !plugin.subPaths.includes(subPath)) { 108 | throw Error(`Plugin missing a "${subPath}" module: ${plugin.id}`) 109 | } 110 | return { 111 | id: plugin.id, 112 | subPath, 113 | modulePath: path.join(plugin.root, subPath), 114 | } 115 | } 116 | 117 | const matchFirstPlugin = (subPath: PluginSubPath) => 118 | resolvePluginModule( 119 | plugins.find(p => p.subPaths.includes(subPath)) || fallback, 120 | subPath, 121 | true 122 | ) 123 | 124 | const matchPlugins = (subPath: PluginSubPath) => 125 | plugins 126 | .filter(p => p.subPaths.includes(subPath)) 127 | .map(p => resolvePluginModule(p, subPath, true)) 128 | 129 | const config: TuskenConfig = { 130 | rootDir, 131 | dataDir: userConfig.dataDir 132 | ? path.resolve(configDir, userConfig.dataDir) 133 | : path.join(rootDir, 'postgres'), 134 | schemaDir: userConfig.schemaDir 135 | ? path.resolve(configDir, userConfig.schemaDir) 136 | : path.join(rootDir, 'src/generated'), 137 | connection: userConfig.connection, 138 | // 139 | // Plugins 140 | // 141 | clientPlugin: clientPlugin 142 | ? resolvePluginModule(clientPlugin, './client') 143 | : matchFirstPlugin('./client'), 144 | connectionPlugin: connectionPlugin 145 | ? resolvePluginModule(connectionPlugin, './connection') 146 | : matchFirstPlugin('./connection'), 147 | runtimePlugins: matchPlugins('./runtime').concat( 148 | // Always include the fallback runtime plugin. 149 | resolvePluginModule(fallback, './runtime', true) 150 | ), 151 | schemaPlugins: matchPlugins('./schema'), 152 | } 153 | 154 | return [config, configPath] 155 | } 156 | -------------------------------------------------------------------------------- /src/postgres/type.ts: -------------------------------------------------------------------------------- 1 | import { Intersect, Remap } from '@alloc/types' 2 | import type { Expression } from './expression' 3 | import type { Token, TokenArray } from './internal/token' 4 | import { kRuntimeType, kTypeArrayId, kTypeId, kTypeTokenizer } from './symbols' 5 | import { TypeCast } from './typeCast' 6 | import { t } from './typesBuiltin' 7 | 8 | const kHostType = Symbol() 9 | const kClientType = Symbol() 10 | const kColumnInput = Symbol() 11 | 12 | /** 13 | * Used to map a Postgres type to the types it can be implicitly 14 | * down-casted to. The generated client adds to this interface. 15 | */ 16 | export interface ImplicitTypeCoercion {} 17 | 18 | /** Postgres data type */ 19 | export abstract class Type< 20 | HostType extends string = any, 21 | ClientType = any, 22 | ColumnInput = any 23 | > { 24 | protected declare [kHostType]: HostType 25 | protected declare [kClientType]: ClientType 26 | protected declare [kColumnInput]: ColumnInput 27 | protected declare [kRuntimeType]: RuntimeType 28 | } 29 | 30 | /** 31 | * Runtime types are plain objects with hidden properties 32 | * that describe the type of an expression. 33 | */ 34 | export declare class RuntimeType { 35 | readonly name: string 36 | readonly isOptional: boolean 37 | protected [kTypeId]: number 38 | protected [kTypeArrayId]: number | undefined 39 | protected [kTypeTokenizer]: ValueTokenizer | undefined 40 | /** Exists for type inference */ 41 | protected declare compilerType: T 42 | } 43 | 44 | export interface RuntimeType { 45 | (input: Input): TypeCast< 46 | Input extends null | undefined 47 | ? t.null 48 | : T | (Input extends Expression ? ExtractNull : never) 49 | > 50 | } 51 | 52 | export type ValueTokenizer = (value: any) => Token | TokenArray | undefined 53 | 54 | export const defineType = ( 55 | id: number, 56 | name: string, 57 | arrayId?: number, 58 | tokenizer?: ValueTokenizer 59 | ): RuntimeType => { 60 | const type: any = (value: any) => new TypeCast({ value, type }) 61 | Object.defineProperty(type, 'name', { value: name }) 62 | type.isOptional = false 63 | type[kTypeId] = id 64 | type[kTypeArrayId] = arrayId 65 | type[kTypeTokenizer] = tokenizer 66 | return type 67 | } 68 | 69 | /** 70 | * An optional type is not the same as a nullable type. \ 71 | * The former may be non-nullable but still be generated or set 72 | * to a default value if omitted from an insertion. 73 | */ 74 | export function defineOptionalType( 75 | arg: RuntimeType 76 | ): typeof arg { 77 | if (arg.isOptional) { 78 | return arg 79 | } 80 | const type: any = Object.assign(arg.bind(null), arg) 81 | Object.defineProperty(type, 'name', { value: arg.name }) 82 | type.isOptional = true 83 | return type 84 | } 85 | 86 | /** Convert a Postgres row to a JavaScript object */ 87 | export type RowResult = Intersect< 88 | keyof T extends infer Column 89 | ? Column extends keyof T 90 | ? { [P in Column]: ColumnResult } 91 | : never 92 | : never 93 | > extends infer Values 94 | ? Remap 95 | : never 96 | 97 | type ColumnResult = T extends Type 98 | ? Value 99 | : T extends object 100 | ? T extends (infer Element)[] 101 | ? ColumnResult[] 102 | : { [P in keyof T]: ColumnResult } 103 | : never 104 | 105 | /** Allow both the Postgres type and its JavaScript type */ 106 | export type QueryInput = 107 | | (T extends Type ? ClientType : never) 108 | | Expression> extends infer Result 109 | ? Result 110 | : never 111 | 112 | /** Cast a Postgres type name into its implicit coercion types */ 113 | export type ImplicitCast = 114 | HostType extends keyof ImplicitTypeCoercion 115 | ? ImplicitTypeCoercion[HostType] 116 | : never 117 | 118 | /** Similar to `QueryInput` but implicit type coercion is allowed */ 119 | export type QueryParam = QueryInput< 120 | T extends Type ? T | ImplicitCast : never 121 | > 122 | 123 | /** 124 | * Aggregate functions must be given Postgres expressions. For any 125 | * aggregate function, these expressions can evaluate to null, since 126 | * that merely results in an empty result set. 127 | */ 128 | export type AggregateParam = Expression< 129 | T extends Type ? T | ImplicitCast | t.null : never 130 | > 131 | 132 | /** Returns the Postgres `NULL` type if `T` is ever nullable */ 133 | export type ExtractNull = T extends Type 134 | ? 'null' extends HostType 135 | ? t.null 136 | : never 137 | : never 138 | 139 | export type StringInput = Extract, string | null> 140 | 141 | export type ArrayInput = 142 | | QueryInput[] 143 | | (T extends Type 144 | ? QueryInput> 145 | : never) 146 | 147 | export type ArrayParam = ArrayInput< 148 | T extends Type ? T | ImplicitCast : never 149 | > 150 | 151 | export abstract class SetType // 152 | extends Type<`setof`, T[], T[]> {} 153 | -------------------------------------------------------------------------------- /spec/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import db, { t } from './db' 2 | 3 | test('equal check with array of ids', async () => { 4 | expect(await db.select(t.user(u => u.name)).where(u => u.id.is.eq([1, 2]))) 5 | .toMatchInlineSnapshot(` 6 | [ 7 | { 8 | "name": "alec", 9 | }, 10 | { 11 | "name": "anakin", 12 | }, 13 | ] 14 | `) 15 | }) 16 | 17 | describe('composite keys', () => { 18 | const createRows = async () => { 19 | await db.put(t.foo, { id: 1, id2: 1, json: 'hey' }) 20 | await db.put(t.foo, [ 21 | { id: 1, id2: 2, json: 'there' }, 22 | { id: 1, id2: 3, json: 'friend' }, 23 | ]) 24 | } 25 | test('put', async () => { 26 | let error: any 27 | await createRows().catch(e => { 28 | error = e 29 | }) 30 | expect(error).toBeUndefined() 31 | }) 32 | test('get', async () => { 33 | // [Bad] Key argument should be an object. 34 | // @ts-expect-error 2345 35 | expect(() => db.get(t.foo, 1)).toThrowErrorMatchingInlineSnapshot( 36 | '"Primary key of \\"foo\\" is composite"' 37 | ) 38 | 39 | // [Bad] Incomplete key object. 40 | // @ts-expect-error 2345 41 | expect(() => db.get(t.foo, { id: 1 })).toThrowErrorMatchingInlineSnapshot( 42 | '"\\"id2\\" is a primary key column of \\"foo\\" but was not defined"' 43 | ) 44 | 45 | await createRows() 46 | expect(await db.get(t.foo, { id: 100, id2: 1 })).toBeNull() 47 | expect(await db.get(t.foo, { id: 1, id2: 1 })).toMatchInlineSnapshot(` 48 | { 49 | "id": 1, 50 | "id2": 1, 51 | "json": "hey", 52 | "jsonb": null, 53 | } 54 | `) 55 | }) 56 | test('delete', () => {}) 57 | }) 58 | 59 | describe('resolve a TableCast selection', () => { 60 | test('single id', async () => { 61 | expect( 62 | await db 63 | .select(t.tweet(tweet => [tweet.text, t.user(tweet.author)])) 64 | .limit(1) 65 | ).toMatchInlineSnapshot(` 66 | [ 67 | { 68 | "author": { 69 | "bio": "You underestimate my power!", 70 | "featureFlags": [ 71 | 1, 72 | ], 73 | "id": 2, 74 | "joinedAt": 2022-09-17T17:47:45.350Z, 75 | "name": "anakin", 76 | }, 77 | "text": "I've got a bad feeling about this.", 78 | }, 79 | ] 80 | `) 81 | }) 82 | 83 | test('array of ids', async () => { 84 | expect( 85 | await db 86 | .select(t.user(u => [u.id, u.name, t.featureFlag(u.featureFlags)])) 87 | .limit(1) 88 | ).toMatchInlineSnapshot(` 89 | [ 90 | { 91 | "featureFlags": [ 92 | { 93 | "enabled": true, 94 | "id": 1, 95 | }, 96 | ], 97 | "id": 2, 98 | "name": "anakin", 99 | }, 100 | ] 101 | `) 102 | }) 103 | 104 | test('with selector', async () => { 105 | // One selected columns 106 | expect( 107 | await db 108 | .select(t.tweet(tweet => t.user(tweet.author, author => author.name))) 109 | .limit(1) 110 | ).toMatchInlineSnapshot(` 111 | [ 112 | { 113 | "author": { 114 | "name": "anakin", 115 | }, 116 | }, 117 | ] 118 | `) 119 | 120 | // Many selected columns 121 | expect( 122 | await db 123 | .select( 124 | t.tweet(tweet => 125 | t.user(tweet.author, author => [author.id, author.name]) 126 | ) 127 | ) 128 | .limit(1) 129 | ).toMatchInlineSnapshot(` 130 | [ 131 | { 132 | "author": { 133 | "id": 2, 134 | "name": "anakin", 135 | }, 136 | }, 137 | ] 138 | `) 139 | 140 | // Alias mapping 141 | expect( 142 | await db 143 | .select( 144 | t.tweet(tweet => 145 | t.user(tweet.author, author => ({ 146 | _id: author.id, 147 | alias: author.name, 148 | })) 149 | ) 150 | ) 151 | .limit(1) 152 | ).toMatchInlineSnapshot(` 153 | [ 154 | { 155 | "author": { 156 | "_id": 2, 157 | "alias": "anakin", 158 | }, 159 | }, 160 | ] 161 | `) 162 | }) 163 | 164 | test('self-join', async () => { 165 | expect( 166 | await db 167 | .select( 168 | t.tweet(tweet => [ 169 | tweet.id, 170 | t.tweet(tweet.replies, reply => reply.id), 171 | ]) 172 | ) 173 | .limit(1) 174 | ).toMatchInlineSnapshot(` 175 | [ 176 | { 177 | "id": 2, 178 | "replies": [ 179 | { 180 | "id": 1, 181 | }, 182 | ], 183 | }, 184 | ] 185 | `) 186 | }) 187 | }) 188 | 189 | test('query stream', async () => { 190 | const users: any[] = [] 191 | for await (const user of db.select(t.user(u => u.id)).limit(2)) { 192 | users.push(user) 193 | } 194 | expect(users).toMatchInlineSnapshot(` 195 | [ 196 | { 197 | "id": 1, 198 | }, 199 | { 200 | "id": 2, 201 | }, 202 | ] 203 | `) 204 | }) 205 | -------------------------------------------------------------------------------- /src/postgres/internal/token.ts: -------------------------------------------------------------------------------- 1 | import { Exclusive } from '@alloc/types' 2 | import type { Query } from '../query' 3 | import { createQueryContext, renderQuery } from './query' 4 | import { tokenize } from './tokenize' 5 | 6 | /** Coerce into a string, buffer, or null */ 7 | type Value = { value: any } 8 | 9 | /** Format with `%I` like sprintf */ 10 | type Identifier = { id: any } 11 | 12 | /** Format with `%L` like sprintf */ 13 | type Literal = { literal: any } 14 | 15 | /** Coerce into a number, throw if `NaN` */ 16 | type Numeric = { number: any } 17 | 18 | /** Join tokens with an empty string */ 19 | type Concat = { concat: TokenArray } 20 | 21 | /** Postgres array literal */ 22 | type ArrayLiteral = { array: any[] } 23 | 24 | /** A comma-separated list */ 25 | type List = { list: TokenArray } 26 | 27 | /** Join tokens with another token */ 28 | type Join = { join: TokenArray; with: Token } 29 | 30 | /** A comma-separated list with parentheses around it */ 31 | type Tuple = { tuple: TokenArray } 32 | 33 | type Call = { callee: string; args?: TokenArray } 34 | 35 | type SubQuery = { query: Query } 36 | 37 | export type Token = 38 | | string 39 | | Exclusive< 40 | | Value 41 | | Identifier 42 | | Literal 43 | | Numeric 44 | | ArrayLiteral 45 | | Concat 46 | | List 47 | | Join 48 | | Tuple 49 | | Call 50 | | SubQuery 51 | > 52 | 53 | export type TokenArray = (Token | TokenArray)[] 54 | export type TokenProducer = ( 55 | props: Props, 56 | ctx: Query.Context 57 | ) => Token | TokenArray 58 | 59 | export function renderTokens( 60 | tokens: TokenArray, 61 | ctx: Query.Context, 62 | sql: string[] = [] 63 | ): string[] { 64 | for (const token of tokens) { 65 | if (Array.isArray(token)) { 66 | renderTokens(token, ctx, sql) 67 | } else { 68 | const rendered = renderToken(token, ctx) 69 | if (rendered) { 70 | sql.push(rendered) 71 | } 72 | } 73 | } 74 | return sql 75 | } 76 | 77 | function renderToken(token: Token, ctx: Query.Context): string { 78 | return typeof token == 'string' 79 | ? token 80 | : 'value' in token 81 | ? '$' + ctx.values.push(token.value) 82 | : 'id' in token 83 | ? Array.isArray(token.id) 84 | ? token.id.map(mapStringToIdentifier, ctx).join('.') 85 | : toIdentifier(token.id, ctx) 86 | : 'callee' in token 87 | ? toIdentifier(token.callee, ctx) + 88 | (token.args ? `(${renderTokens(token.args, ctx).join(', ')})` : ``) 89 | : 'literal' in token 90 | ? toLiteral(token.literal) 91 | : 'number' in token 92 | ? toNumber(token.number) 93 | : token.query 94 | ? `(${renderQuery( 95 | createQueryContext(token.query, { 96 | values: ctx.values, 97 | }) 98 | )})` 99 | : token.array 100 | ? `'${renderArrayLiteral(token.array, ctx)}'` 101 | : renderList(token, ctx) 102 | } 103 | 104 | function renderArrayLiteral(values: any[], ctx: Query.Context): string { 105 | const sql = values.map(value => 106 | Array.isArray(value) 107 | ? renderArrayLiteral(value, ctx) 108 | : mapTokensToSql.call(ctx, tokenize(value, ctx)) 109 | ) 110 | return `{${sql.join(', ')}}` 111 | } 112 | 113 | function renderList( 114 | token: Exclusive, 115 | ctx: Query.Context 116 | ): string { 117 | const list = token.list || token.concat || token.tuple || token.join 118 | const sql = list.length 119 | ? list 120 | .map(mapTokensToSql, ctx) 121 | .join( 122 | token.concat ? '' : token.join ? renderToken(token.with, ctx) : ', ' 123 | ) 124 | : 'NULL' 125 | 126 | return token.tuple ? `(${sql})` : sql 127 | } 128 | 129 | function mapTokensToSql(this: Query.Context, arg: Token | TokenArray) { 130 | return Array.isArray(arg) 131 | ? renderTokens(arg, this).join(' ') 132 | : renderToken(arg, this) 133 | } 134 | 135 | function mapStringToIdentifier(this: Query.Context, arg: any) { 136 | return toIdentifier(arg, this) 137 | } 138 | 139 | // https://github.com/segmentio/pg-escape/blob/780350b461f4f2ab50ca8b5aafcbb57433835f6b/index.js 140 | function toLiteral(val: any): string { 141 | if (val == null) { 142 | return 'NULL' 143 | } 144 | if (Array.isArray(val)) { 145 | const vals = val.map(toLiteral) 146 | return '(' + vals.join(', ') + ')' 147 | } 148 | val = String(val) 149 | return ( 150 | (val.includes('\\') ? 'E' : '') + 151 | "'" + 152 | val.replace(/'/g, "''").replace(/\\/g, '\\\\') + 153 | "'" 154 | ) 155 | } 156 | 157 | const capitalOrSpace = /[A-Z\s]/ 158 | 159 | function toIdentifier(val: any, { query: { db } }: Query.Context): string { 160 | val = String(val).replace(/"/g, '""') 161 | return capitalOrSpace.test(val) || db.config.reserved.includes(val) 162 | ? `"${val}"` 163 | : val 164 | } 165 | 166 | function toNumber(val: any) { 167 | const type = typeof val 168 | if (type !== 'number' || isNaN(val) || !isFinite(val)) { 169 | throw Error( 170 | `Expected a number, got ${ 171 | type == 'number' 172 | ? val 173 | : type == 'object' 174 | ? Object.prototype.toString.call(val) 175 | : type 176 | }` 177 | ) 178 | } 179 | return String(val) 180 | } 181 | -------------------------------------------------------------------------------- /packages/tusken-schema/src/typescript/generateNativeTypes.ts: -------------------------------------------------------------------------------- 1 | import endent from 'endent' 2 | import { NativeCast } from '../extract/extractCasts' 3 | import { NativeTypes } from '../extract/extractTypes' 4 | import { ImportDescriptorMap } from '../utils/imports' 5 | import { __PURE__ } from '../utils/syntax' 6 | import nativeTypeMap from './nativeTypeMap' 7 | 8 | const toExport = (stmt: string) => `export ${stmt}` 9 | 10 | export type GeneratedLines = { 11 | imports: ImportDescriptorMap 12 | lines: string[] 13 | } 14 | 15 | // TODO: include user-defined enum/composite types 16 | export function generateNativeTypes( 17 | nativeTypes: NativeTypes, 18 | nativeCasts: NativeCast[], 19 | tuskenId: string 20 | ): GeneratedLines { 21 | const regTypes = new Set() 22 | const implicitCastMap: Record = {} 23 | const columnCastMap: Record = {} 24 | 25 | for (const cast of nativeCasts) { 26 | const source = nativeTypes.byId[cast.source] 27 | const target = nativeTypes.byId[cast.target] 28 | if (source == target || target.name.startsWith('reg')) { 29 | continue 30 | } 31 | if (source.name.startsWith('reg')) { 32 | regTypes.add(source.name) 33 | continue 34 | } 35 | if (cast.context == 'i') { 36 | implicitCastMap[target.name] ||= [] 37 | implicitCastMap[target.name].push(source.name) 38 | } else if (cast.context == 'a') { 39 | columnCastMap[target.name] ||= [] 40 | columnCastMap[target.name].push(source.name) 41 | } 42 | } 43 | 44 | const castTypes = endent` 45 | // Inject rules for implicit type coercion. 46 | declare module 'tusken' { 47 | export interface ImplicitTypeCoercion { 48 | ${Object.entries(implicitCastMap) 49 | .map(([source, targets]) => { 50 | return endent` 51 | "${source}": ${targets.join(' | ')} 52 | ` 53 | }) 54 | .join('\n')} 55 | } 56 | } 57 | 58 | /** Some implicit casts only take place during column assignment. */ 59 | type ColumnCast = ImplicitCast | 60 | (${renderCastLogic(columnCastMap, ' ')} 61 | : never) 62 | ` 63 | 64 | const JSON_TYPES = ['json', 'jsonb'] 65 | 66 | const types: string[] = [] 67 | const runtimeTypes: string[] = ['const option = defineOptionalType'] 68 | 69 | for (const [nativeType, mappedType] of Object.entries(nativeTypeMap)) { 70 | types.push( 71 | `type ${nativeType} = Type<"${nativeType}", ${mappedType}, ColumnCast<"${nativeType}">>` 72 | ) 73 | const { id, arrayId } = nativeTypes.byName[nativeType] 74 | const runtimeArgs = [`${id}, "${nativeType}", ${arrayId}`] 75 | if (JSON_TYPES.includes(nativeType)) { 76 | runtimeArgs.push('tokenizeJson') 77 | } 78 | runtimeTypes.push( 79 | `const ${nativeType}: RuntimeType<${nativeType}> = ${__PURE__} defineType(${runtimeArgs.join( 80 | ', ' 81 | )})` 82 | ) 83 | } 84 | 85 | // These pseudo types are conflicting with TypeScript reserved keywords. 86 | const pseudoConflicts = { 87 | ANY: 'anynonarray | anyarray', 88 | NULL: 'Type<"null", null, null>', 89 | VOID: 'Type<"void", void, void>', 90 | } 91 | 92 | const pseudoTypes = Object.entries({ 93 | ...pseudoConflicts, 94 | anyarray: 95 | 'array | array2d | array3d', 96 | anycompatiblearray: 'anyarray', 97 | anynonarray: Object.keys(nativeTypeMap).join(' | '), 98 | anyelement: 'anynonarray | anyarray', 99 | }).map(([name, type]) => `type ${name} = ${type}`) 100 | 101 | const specialTypes = [ 102 | 'type elementof = T extends array ? E : anyelement', 103 | 'type param = QueryParam', 104 | 'type aggParam = AggregateParam', 105 | 'type record = Type<"record", { [key: string]: any }, never>', 106 | 'type typeLike = Type', 107 | 'type numberLike = typeLike', 108 | 'type stringLike = typeLike', 109 | 'type dateLike = typeLike', 110 | ] 111 | 112 | return { 113 | imports: { 114 | [tuskenId]: [ 115 | 'defineOptionalType', 116 | 'defineType', 117 | 'tokenizeJson', 118 | 'AggregateParam', 119 | 'ImplicitCast', 120 | 'Interval', 121 | 'Json', 122 | 'QueryParam', 123 | 'Range', 124 | 'RuntimeType', 125 | 'Type', 126 | ], 127 | [tuskenId + '/array']: ['array', 'array2d', 'array3d'], 128 | }, 129 | lines: [ 130 | '// Primitive types', 131 | ...types.map(toExport), 132 | '', 133 | ...runtimeTypes.map(toExport), 134 | '\n// Array types', 135 | 'export { array, array2d, array3d }', 136 | '\n// Pseudo types', 137 | ...pseudoTypes.map(toExport), 138 | toExport( 139 | 'type { ' + 140 | Object.keys(pseudoConflicts) 141 | .map(name => `${name} as ${name.toLowerCase()}`) 142 | .join(', ') + 143 | ' }' 144 | ), 145 | '\n// Registry types', 146 | toExport( 147 | 'type { ' + Array.from(regTypes, t => `oid as ${t}`).join(', ') + ' }' 148 | ), 149 | '', 150 | ...specialTypes.map(toExport), 151 | '', 152 | castTypes, 153 | ], 154 | } 155 | } 156 | 157 | function renderCastLogic(castMap: Record, indent = '') { 158 | return Object.entries(castMap) 159 | .map( 160 | ([source, targets]) => 161 | `T extends "${source}"\n${indent} ? ${targets.join(' | ')}` 162 | ) 163 | .join('\n' + indent + ' : ') 164 | } 165 | -------------------------------------------------------------------------------- /src/postgres/database.ts: -------------------------------------------------------------------------------- 1 | import { ClientPlugin, ConnectionPlugin } from '../definePlugin' 2 | import { Client, ConnectOptions } from './connection' 3 | import { Query, QueryPromise } from './query' 4 | import { Count } from './query/count' 5 | import { Delete } from './query/delete' 6 | import { Put } from './query/put' 7 | import { Select } from './query/select' 8 | import { FindWhere, wherePrimaryKeyEquals } from './query/where' 9 | import { RowInsertion, RowKeyedUpdate, RowUpdate } from './row' 10 | import { 11 | Selectable, 12 | SelectedRow, 13 | Selection, 14 | SelectionSources, 15 | } from './selection' 16 | import { RowIdentity, TableRef, toTableRef } from './table' 17 | 18 | export interface DatabaseConfig { 19 | clientPlugin: ClientPlugin 20 | connectionPlugin: ConnectionPlugin 21 | connection?: ConnectOptions 22 | reserved: string[] 23 | } 24 | 25 | export class Database { 26 | constructor(public config: DatabaseConfig) {} 27 | 28 | /** 29 | * Backing clients are created when the `ConnectionPlugin` assigns a 30 | * previously unseen `key` to the connection options for a query. 31 | */ 32 | protected clients: Record = Object.create(null) 33 | 34 | protected createClient(opts?: ConnectOptions) { 35 | const config = this.config 36 | opts ||= config.connection || {} 37 | opts = config.connectionPlugin.defaults?.(opts) || opts 38 | return config.clientPlugin.create(opts) 39 | } 40 | 41 | protected getClient(ctx?: Query.Context): Client { 42 | const config = this.config 43 | const resolved = ctx && config.connectionPlugin.resolve?.(ctx) 44 | if (resolved) { 45 | return (this.clients[resolved.key] ||= this.createClient(resolved)) 46 | } 47 | return (this.clients.default ||= this.createClient()) 48 | } 49 | 50 | /** The default backing client */ 51 | get client() { 52 | return this.getClient() 53 | } 54 | 55 | setDefaultConnection(opts: ConnectOptions) { 56 | this.clients.default = this.createClient(opts) 57 | } 58 | 59 | /** 60 | * Create a separate `Database` object that's connected to another 61 | * Postgres server. Useful for transferring data between two servers. 62 | * 63 | * If you only have one server, you should call `setDefaultConnection` 64 | * instead of creating a new `Database` with this method. 65 | */ 66 | connect(opts: ConnectOptions) { 67 | const db = new Database(this.config) 68 | db.setDefaultConnection(opts) 69 | return db 70 | } 71 | 72 | /** 73 | * Count the number of rows in a selection. You can use the 74 | * `where` and `innerJoin` methods to be more specific. 75 | * 76 | * You need to use `pg.count` instead if you want to check 77 | * a specific column for `NULL` before counting a row. 78 | */ 79 | count(from: From) { 80 | return this.query({ 81 | type: 'count', 82 | query: new Count(this), 83 | props: { from }, 84 | }) 85 | } 86 | 87 | delete(from: From): Delete 88 | delete( 89 | from: From, 90 | pk: RowIdentity 91 | ): QueryPromise 92 | delete(from: TableRef, pk?: any) { 93 | const query = this.query({ 94 | type: 'delete', 95 | props: { from }, 96 | query: new Delete(this), 97 | }) 98 | if (arguments.length > 1) { 99 | return query.where(wherePrimaryKeyEquals(pk, from)) 100 | } 101 | return query 102 | } 103 | 104 | /** 105 | * Same as `select` but only one row (or null) is returned. 106 | */ 107 | find( 108 | from: T, 109 | filter: FindWhere> 110 | ): QueryPromise | null> { 111 | return this.select(from).where(filter).at(0) as any 112 | } 113 | 114 | /** 115 | * Get a row by its primary key. 116 | * 117 | * To get a row by any other column, use the `db.find` method instead. 118 | */ 119 | get>( 120 | from: T, 121 | pk: RowIdentity 122 | ): QueryPromise | null> { 123 | return this.find(from, wherePrimaryKeyEquals(pk, toTableRef(from))) 124 | } 125 | 126 | /** 127 | * Insert 1+ rows into a table. 128 | */ 129 | put( 130 | table: T, 131 | row: RowInsertion | readonly RowInsertion[] 132 | ): Put 133 | 134 | /** 135 | * Update 1+ rows in a table. 136 | */ 137 | put( 138 | table: T, 139 | row: RowKeyedUpdate | readonly RowKeyedUpdate[] 140 | ): Put 141 | 142 | /** 143 | * Update or delete a row by its primary key. 144 | */ 145 | put( 146 | table: T, 147 | pk: RowIdentity, 148 | row: RowUpdate | null 149 | ): Put 150 | 151 | put(table: TableRef, pk: any, data?: any) { 152 | if (arguments.length == 2) { 153 | data = pk 154 | pk = undefined 155 | } else if (data === null) { 156 | return this.delete(table, pk) 157 | } 158 | return this.query({ 159 | type: 'put', 160 | query: new Put(this), 161 | props: { table, data, pk }, 162 | }) 163 | } 164 | 165 | select(from: T) { 166 | return this.query({ 167 | type: 'select', 168 | query: new Select<[T]>(this), 169 | props: { from }, 170 | }) 171 | } 172 | 173 | protected query(node: { 174 | type: string 175 | query: T 176 | props: T extends Query ? Props : never 177 | }): T 178 | 179 | protected query(node: any) { 180 | node.query.nodes.push(node) 181 | return node.query 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ⚠️ This library is currently in alpha. Contributors wanted! 2 | 3 | --- 4 | 5 | # tusken 6 | 7 | Postgres client from a galaxy far, far away. 8 | 9 | - your database is the source-of-truth for TypeScript generated types 10 | - type safety for all queries (even subqueries) 11 | - all built-in Postgres functions are available and type-safe 12 | - implicit type casts are accounted for 13 | - minimal, intuitive SQL building 14 | - shortcuts for common tasks (eg: `get`, `put`, and more) 15 | - identifiers are case-sensitive 16 | - lightweight, largely tree-shakeable 17 | - works with [`@tusken/cli`] to easily import CSV files, wipe data, generate a type-safe client, dump the schema for migrations, and more 18 | - you control the [`pg`] version as a peer dependency 19 | - query streaming with the `.stream` method (just install [`pg-query-stream`] and run `tusken generate`) 20 | 21 | [`pg`]: https://www.npmjs.com/package/pg 22 | [`pg-query-stream`]: https://www.npmjs.com/package/pg-query-stream 23 | [`@tusken/cli`]: https://github.com/alloc/tusken/tree/master/packages/tusken-cli 24 | 25 | ### Migrations? 26 | 27 | Use [graphile-migrate](https://github.com/graphile/migrate). 28 | 29 | ## Install 30 | 31 | ```sh 32 | pnpm i tusken@alpha pg postgres-range postgres-interval 33 | pnpm i @tusken/cli@alpha -D 34 | ``` 35 | 36 | ## Usage 37 | 38 | First, you need a `tusken.config.ts` file in your project root, unless you plan on using the default config. By default, the Postgres database is assumed to exist at `./postgres` relative to the working directory (customize with `dataDir` in your config) and the generated types are emitted into the `./src/generated` folder (customize with `schemaDir` in your config). 39 | 40 | ```ts 41 | import { defineConfig } from 'tusken/config' 42 | 43 | export default defineConfig({ 44 | dataDir: './postgres', 45 | schemaDir: './src/generated', 46 | connection: { 47 | host: 'localhost', 48 | port: 5432, 49 | user: 'postgres', 50 | password: ' ', 51 | }, 52 | pool: { 53 | /* node-postgres pooling options */ 54 | }, 55 | }) 56 | ``` 57 | 58 | After running `pnpm tusken generate -d ` in your project root, you can import the database client from `./src/db/` as **the default export.** 59 | 60 | ```ts 61 | import db, { t, pg } from './db/' 62 | ``` 63 | 64 | The `t` export contains your user-defined Postgres tables and many native types. The `pg` export contains your user-defined Postgres functions and many built-in functions. 65 | 66 | ### Creating, updating, deleting one row 67 | 68 | Say we have a basic `user` table like this… 69 | 70 | ```sql 71 | create table "user" ( 72 | "id" serial primary key, 73 | "name" text, 74 | "password" text 75 | ) 76 | ``` 77 | 78 | To create a user, use the `put` method… 79 | 80 | ```ts 81 | // Create a user 82 | await db.put(t.user, { name: 'anakin', password: 'padme4eva' }) 83 | 84 | // Update a user (merge, not replace) 85 | await db.put(t.user, 1, { name: 'vader', password: 'darkside4eva' }) 86 | 87 | // Delete a user 88 | await db.put(t.user, 1, null) 89 | ``` 90 | 91 | ### Getting a row by primary key 92 | 93 | Here we can use the `get` method… 94 | 95 | ```ts 96 | await db.get(t.user, 1) 97 | ``` 98 | 99 | Selections are supported… 100 | 101 | ```ts 102 | await db.get( 103 | t.user(u => [u.name]), 104 | 1 105 | ) 106 | ``` 107 | 108 | Selections can have aliases… 109 | 110 | ```ts 111 | await db.get( 112 | t.user(u => [{ n: u.name }]), 113 | 1 114 | ) 115 | 116 | // You can omit the array if you don't mind giving 117 | // everything an alias. 118 | await db.get( 119 | t.user(u => ({ n: u.name })), 120 | 1 121 | ) 122 | ``` 123 | 124 | Selections can contain function calls… 125 | 126 | ```ts 127 | await db.get( 128 | t.user(u => ({ 129 | name: pg.upper(u.name), 130 | })), 131 | 1 132 | ) 133 | ``` 134 | 135 | To select all but a few columns… 136 | 137 | ```ts 138 | await db.get(t.user.omit('id', 'password'), 1) 139 | ``` 140 | 141 | ### Inner joins 142 | 143 | ```ts 144 | // Find all books with >= 100 likes and also get the author of each. 145 | await db.select(t.author).innerJoin( 146 | t.book.where(b => b.likes.gte(100)), 147 | t => t.author.id.eq(t.book.authorId) 148 | ) 149 | ``` 150 | 151 |   152 | 153 | ## What's planned? 154 | 155 | This is a vague roadmap. Nothing here is guaranteed to be implemented soon, but they will be at some point (contributors welcome). 156 | 157 | - math operators 158 | - enum types 159 | - domain types 160 | - composite types 161 | - more geometry types 162 | - array-based primary key 163 | - `ANY` and `SOME` operators 164 | - transactions 165 | - explicit [locking](https://www.postgresql.org/docs/current/explicit-locking.html) 166 | - views & [materialized views](https://www.postgresql.org/docs/14/rules-materializedviews.html) 167 | - table [inheritance](https://www.postgresql.org/docs/current/tutorial-inheritance.html) 168 | - window functions 169 | - plugin packages 170 | - these plugins can do any of: 171 | - alter your schema 172 | - seed your database 173 | - extend the runtime API 174 | - auto-loading of packages with `tusken-plugin-abc` or `@xyz/tusken-plugin-abc` naming scheme 175 | - add some new commands 176 | - `tusken install` (merge plugin schemas into your database) 177 | - `tusken seed` (use plugins to seed your database) 178 | - `NOTIFY`/`LISTEN` support (just copy `pg-pubsub`?) 179 | - define Postgres functions with TypeScript 180 | - more shortcuts for common tasks 181 | 182 | ## What could be improved? 183 | 184 | This is a list of existing features that aren't perfect yet. If you find a good candidate for this list, please add it and open a PR. 185 | 186 | Contributions are extra welcome in these places: 187 | 188 | - comprehensive "playground" example 189 | - subquery support is incomplete 190 | - bug: selectors cannot treat single-column set queries like an array of scalars 191 | - type safety of comparison operators 192 | - all operators are allowed, regardless of data type 193 | - see `.where` methods and `is` function 194 | - the `jsonb` type should be generic 195 | - with option to infer its subtype at build-time from current row data 196 | - missing SQL commands 197 | - `WITH` 198 | - `GROUP BY` 199 | - `UPDATE` 200 | - `MERGE` 201 | - `USING` 202 | - `HAVING` 203 | - `DISTINCT ON` 204 | - `INTERSECT` 205 | - `CASE` 206 | - etc 207 | -------------------------------------------------------------------------------- /src/postgres/check.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from '../utils/isArray' 2 | import { RecursiveVariadic, Variadic } from '../utils/Variadic' 3 | import { Expression, ExpressionRef } from './expression' 4 | import { 5 | tokenizeCheck, 6 | tokenizeExpression, 7 | tokenizeLogicalAnd, 8 | } from './internal/tokenize' 9 | import { kBoolType } from './internal/type' 10 | import type { Query } from './query' 11 | import type { 12 | ArrayParam, 13 | ExtractNull, 14 | QueryParam, 15 | StringInput, 16 | Type, 17 | } from './type' 18 | import { isBoolExpression } from './typeChecks' 19 | import { t } from './typesBuiltin' 20 | 21 | interface Props { 22 | check: Check | Variadic> 23 | } 24 | 25 | export class CheckList // 26 | extends ExpressionRef 27 | { 28 | constructor(check: Check | Variadic>) { 29 | super(kBoolType as any, { check }, tokenizeCheckList) 30 | } 31 | 32 | and(right: RecursiveVariadic>): this 33 | and( 34 | right: RecursiveVariadic> 35 | ): CheckList 36 | and(right: any): any { 37 | const { props } = this 38 | props.check = new Check(props.check, 'AND', reduceChecks(right)) 39 | return this 40 | } 41 | 42 | or(right: RecursiveVariadic>): this 43 | or( 44 | right: RecursiveVariadic> 45 | ): CheckList 46 | or(right: any): any { 47 | const { props } = this 48 | props.check = new Check(props.check, 'OR', reduceChecks(right)) 49 | return this 50 | } 51 | 52 | nand(right: RecursiveVariadic>): this 53 | nand( 54 | right: RecursiveVariadic> 55 | ): CheckList 56 | nand(right: any): any { 57 | const { props } = this.and(right) 58 | props.check = new Check(props.check, 'NOT') 59 | return this 60 | } 61 | 62 | nor(right: RecursiveVariadic>): this 63 | nor( 64 | right: RecursiveVariadic> 65 | ): CheckList 66 | nor(right: any): any { 67 | const { props } = this.or(right) 68 | props.check = new Check(props.check, 'NOT') 69 | return this 70 | } 71 | 72 | xor(right: RecursiveVariadic>): this 73 | xor( 74 | right: RecursiveVariadic> 75 | ): CheckList 76 | xor(right: any): any { 77 | const { props } = this 78 | const left = props.check 79 | right = reduceChecks(right) 80 | props.check = new Check( 81 | new Check(left, 'AND', right, true), 82 | 'OR', 83 | new Check(right, 'AND', left, true) 84 | ) 85 | return this 86 | } 87 | } 88 | 89 | export function reduceChecks( 90 | checks: RecursiveVariadic | false | null> 91 | ): Expression | null { 92 | if (isArray(checks)) { 93 | const reduced = checks.map(reduceChecks).filter(Boolean) as Expression[] 94 | return reduced ? new CheckList(reduced) : null 95 | } 96 | return checks || null 97 | } 98 | 99 | function tokenizeCheckList({ check }: Props, ctx: Query.Context) { 100 | return isArray(check) 101 | ? tokenizeLogicalAnd(check, ctx) 102 | : isBoolExpression(check) 103 | ? tokenizeExpression(check, ctx) 104 | : tokenizeCheck(check, ctx) 105 | } 106 | 107 | export class Check { 108 | constructor( 109 | readonly left: unknown, 110 | readonly op: string, 111 | readonly right?: unknown, 112 | readonly isNot?: boolean 113 | ) {} 114 | } 115 | 116 | export class CheckBuilder { 117 | constructor( 118 | protected wrap: (check: Check) => CheckList, 119 | protected left: any, 120 | protected isNot?: boolean 121 | ) {} 122 | 123 | protected check(op: string, right: any) { 124 | return this.wrap(new Check(this.left, op, right, this.isNot)) 125 | } 126 | 127 | get not() { 128 | return new CheckBuilder(this.wrap, this.left, !this.isNot) 129 | } 130 | 131 | /** Inclusive range matching */ 132 | between( 133 | min: QueryParam, 134 | max: QueryParam 135 | ): CheckList> { 136 | return this.check('BETWEEN', [min, max]) 137 | } 138 | 139 | in(arr: readonly QueryParam[]): CheckList> { 140 | return this.check('IN', arr) 141 | } 142 | 143 | like(pattern: StringInput): CheckList> { 144 | return this.check('LIKE', pattern) 145 | } 146 | 147 | ilike(pattern: StringInput): CheckList> { 148 | return this.check('ILIKE', pattern) 149 | } 150 | } 151 | 152 | export interface CheckBuilder 153 | extends CheckMethods, 154 | CheckAliases, 155 | ConstantChecks {} 156 | 157 | // TODO: let right be null here 158 | type CheckMethods = { 159 | [P in keyof typeof checkMapping]: ( 160 | right: QueryParam | ArrayParam 161 | ) => CheckList> 162 | } 163 | 164 | type CheckAliases = { 165 | [P in keyof typeof checkAliases]: ( 166 | right: QueryParam | ArrayParam 167 | ) => CheckList> 168 | } 169 | 170 | type ConstantChecks = { 171 | [P in keyof typeof constantChecks]: () => CheckList 172 | } 173 | 174 | const checkMapping = { 175 | equalTo: '=', 176 | greaterThan: '>', 177 | greaterThanOrEqualTo: '>=', 178 | lessThan: '<', 179 | lessThanOrEqualTo: '<=', 180 | } as const 181 | 182 | Object.entries(checkMapping).forEach(([key, op]) => 183 | Object.defineProperty(CheckBuilder.prototype, key, { 184 | value(this: CheckBuilder, right: any) { 185 | return this.wrap(new Check(this.left, op, right, this.isNot)) 186 | }, 187 | }) 188 | ) 189 | 190 | const checkAliases = { 191 | eq: 'equalTo', 192 | gt: 'greaterThan', 193 | gte: 'greaterThanOrEqualTo', 194 | lt: 'lessThan', 195 | lte: 'lessThanOrEqualTo', 196 | } as const 197 | 198 | Object.entries(checkAliases).forEach(([key, alias]) => 199 | Object.defineProperty(CheckBuilder.prototype, key, { 200 | value: CheckBuilder.prototype[alias], 201 | }) 202 | ) 203 | 204 | const constantChecks = { 205 | true: true, 206 | false: false, 207 | null: null, 208 | } as const 209 | 210 | Object.entries(constantChecks).forEach(([key, constantValue]) => 211 | Object.defineProperty(CheckBuilder.prototype, key, { 212 | value() { 213 | return this.check('IS', constantValue) 214 | }, 215 | }) 216 | ) 217 | 218 | // We have to define the `ExpressionRef#is` method here 219 | // or else a circular dependency is created. 220 | Object.defineProperty(ExpressionRef.prototype, 'is', { 221 | get(this: ExpressionRef) { 222 | return new CheckBuilder(check => { 223 | return new CheckList(check) 224 | }, this) 225 | }, 226 | }) 227 | -------------------------------------------------------------------------------- /src/postgres/query/put.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '../../utils/isObject' 2 | import { toArray } from '../../utils/toArray' 3 | import { Token, TokenArray } from '../internal/token' 4 | import { tokenizeTyped } from '../internal/tokenize' 5 | import { Query } from '../query' 6 | import { kIdentityColumns, kTableColumns } from '../symbols' 7 | import { getColumnType, TableRef, toTableName } from '../table' 8 | import { RuntimeType } from '../type' 9 | 10 | type Props = { 11 | table: T 12 | data: Record | readonly Record[] 13 | pk?: any 14 | } 15 | 16 | export class Put extends Query> { 17 | protected tokenize(props: Props, ctx: Query.Context) { 18 | let { table, data, pk } = props 19 | 20 | const [columns, rows, nulls] = tokenizeRows(data, table, ctx) 21 | if (!rows.length) { 22 | throw Error('no rows to insert') 23 | } 24 | 25 | const pkColumns = table[kIdentityColumns] as string[] 26 | const pkValues: Record = isObject(pk) 27 | ? pk 28 | : { [pkColumns[0]]: pk } 29 | 30 | /** 31 | * These columns are not defined in the `data` object. 32 | * 33 | * If this is empty, we are either inserting new rows or updating 34 | * the primary key of existing rows. 35 | */ 36 | const pkUnusedColumns = pkColumns.filter( 37 | column => !columns.includes(column) 38 | ) 39 | 40 | /** 41 | * An `UPDATE` query is needed if a specific row is being targeted 42 | * and the given `data` is missing required columns. 43 | */ 44 | const isUpdate = 45 | pk !== undefined || 46 | Object.entries(table[kTableColumns]).some( 47 | ([column, type]) => !type.isOptional && !columns.includes(column) 48 | ) 49 | 50 | const mayConflict = !isUpdate && !pkUnusedColumns.length 51 | 52 | if (isUpdate && pkUnusedColumns.length) { 53 | for (const pkColumn of pkUnusedColumns) { 54 | const key = pkValues[pkColumn] 55 | if (key === undefined) { 56 | throw Error(`Missing primary key column "${pkColumn}"`) 57 | } 58 | columns.unshift(pkColumn) 59 | rows[0].tuple.unshift( 60 | tokenizeTyped(key, getColumnType(table, pkColumn), ctx) 61 | ) 62 | } 63 | } 64 | 65 | const targetId = toTableName(table) 66 | const target = { id: targetId } 67 | 68 | const tokens: TokenArray = [ 69 | isUpdate ? 'UPDATE' : 'INSERT INTO', 70 | mayConflict && nulls.size ? [target, 'this'] : target, 71 | ] 72 | 73 | const valuesList = ['VALUES', { list: rows }] 74 | 75 | if (isUpdate) { 76 | const assignments: TokenArray = [] 77 | 78 | /** Used to reference a row from the `valuesList` */ 79 | const valuesId = 'new' 80 | 81 | /** When true, a single row is having its primary key updated. */ 82 | const isPkUpdate = pk !== undefined && !pkUnusedColumns.length 83 | columns.forEach((column, i) => { 84 | if (!isPkUpdate && pkColumns.includes(column)) { 85 | return 86 | } 87 | if (rows.length == 1) { 88 | assignments.push([{ id: column }, '=', rows[0].tuple[i]]) 89 | } else { 90 | assignments.push([{ id: column }, '=', { id: [valuesId, column] }]) 91 | } 92 | }) 93 | 94 | tokens.push('SET', { list: assignments }) 95 | if (rows.length > 1) { 96 | tokens.push( 97 | 'FROM', 98 | { concat: ['(', valuesList, ')'] }, 99 | 'AS', 100 | valuesId, 101 | { tuple: columns.map(id => ({ id })) } 102 | ) 103 | } 104 | tokens.push('WHERE', { 105 | list: pkColumns.map(pkColumn => [ 106 | { id: [targetId, pkColumn] }, 107 | '=', 108 | rows.length == 1 109 | ? { value: pkValues[pkColumn] } 110 | : { id: [valuesId, pkColumn] }, 111 | ]), 112 | }) 113 | } else { 114 | tokens.push({ tuple: columns.map(id => ({ id })) }, valuesList) 115 | if (mayConflict) { 116 | tokens.push( 117 | 'ON CONFLICT', 118 | { tuple: pkColumns.map(id => ({ id })) }, 119 | 'DO UPDATE SET', 120 | { 121 | list: columns 122 | .filter(column => !pkColumns.includes(column)) 123 | .map(column => { 124 | let value: Token = { id: ['excluded', column] } 125 | if (nulls.has(column)) { 126 | value = { 127 | callee: 'coalesce', 128 | args: [value, { id: ['this', column] }], 129 | } 130 | } 131 | return [{ id: column }, '=', value] 132 | }), 133 | } 134 | ) 135 | } 136 | } 137 | 138 | ctx.impure = true 139 | ctx.resolvers.push(result => result.rowCount) 140 | return tokens 141 | } 142 | } 143 | 144 | export interface Put extends PromiseLike {} 145 | 146 | function tokenizeRows( 147 | data: Props['data'], 148 | table: TableRef, 149 | ctx: Query.Context 150 | ) { 151 | const rows: { tuple: TokenArray }[] = [] 152 | 153 | // This tracks which columns may have a NULL value 154 | // for this specific INSERT command. 155 | const nulls = new Set() 156 | 157 | let columns!: string[] 158 | for (const row of toArray(data)) { 159 | const values: TokenArray = [] 160 | if (columns) { 161 | const newColumns = new Set(columns.concat(Object.keys(row))) 162 | for (const column of newColumns) { 163 | if (column in row) { 164 | const type = getColumnType(table, column) 165 | values.push(tokenizeColumnValue(row[column], type, ctx)) 166 | } else { 167 | // Use NULL to indicate this column should be left alone. 168 | values.push('NULL') 169 | nulls.add(column) 170 | } 171 | } 172 | let i = columns.length 173 | if (i < newColumns.size) { 174 | columns = [...newColumns] 175 | do { 176 | // Each tuple's length must match the column count. 177 | for (const row of rows) { 178 | row.tuple.push('NULL') 179 | } 180 | nulls.add(columns[i]) 181 | } while (++i < newColumns.size) 182 | } 183 | } else { 184 | for (const column of (columns = Object.keys(row))) { 185 | const type = getColumnType(table, column) 186 | values.push(tokenizeColumnValue(row[column], type, ctx)) 187 | } 188 | } 189 | rows.push({ 190 | tuple: values, 191 | }) 192 | } 193 | 194 | return [columns, rows, nulls] as const 195 | } 196 | 197 | function tokenizeColumnValue( 198 | value: any, 199 | type: RuntimeType, 200 | ctx: Query.Context 201 | ): Token | TokenArray { 202 | // For null and undefined values, use DEFAULT instead of NULL 203 | // so that NULL can represent a preserved value. 204 | return value == null ? 'DEFAULT' : tokenizeTyped(value, type, ctx) 205 | } 206 | -------------------------------------------------------------------------------- /src/postgres/table.ts: -------------------------------------------------------------------------------- 1 | import { Omit } from '@alloc/types' 2 | import { Narrow } from '../utils/Narrow' 3 | import { kUnknownType } from './internal/type' 4 | import { JoinRef } from './join' 5 | import type { Query } from './query' 6 | import { RowRef } from './row' 7 | import { 8 | RawSelection, 9 | ResolveSelection, 10 | Selectable, 11 | Selection, 12 | } from './selection' 13 | import { makeSelector } from './selector' 14 | import { SetExpression } from './set' 15 | import { 16 | kIdentityColumns, 17 | kNullableColumns, 18 | kSelectionFrom, 19 | kTableCast, 20 | kTableColumns, 21 | kTableName, 22 | } from './symbols' 23 | import { ForeignKeyRef, TableCast } from './tableCast' 24 | import { ArrayParam, QueryParam, RuntimeType, SetType } from './type' 25 | import { isSelection, isTableCast, isTableRef } from './typeChecks' 26 | 27 | /** 28 | * For rows with a single-column primary key, this returns a query input 29 | * for that column. 30 | * 31 | * For rows with multiple-column primary keys, this returns an object 32 | * with a property (for each column) whose value is a query input. 33 | */ 34 | export type RowIdentity = [IdentityColumns, RowType] extends [ 35 | infer Keys, 36 | infer Values 37 | ] 38 | ? Keys extends [] 39 | ? never 40 | : Keys extends [keyof Values] 41 | ? QueryParam 42 | : Keys extends (keyof Values)[] 43 | ? { 44 | [Key in Keys[number]]: QueryParam 45 | } 46 | : never 47 | : never 48 | 49 | // Note: This does not support composite keys currently. 50 | export type RowIdentityArray = RowType extends infer Values 51 | ? ArrayParam[0] & keyof Values]> 52 | : never 53 | 54 | /** 55 | * Get the array of columns that represent the primary key (which may 56 | * be composite). 57 | */ 58 | export type IdentityColumns = T extends TableRef 59 | ? Columns 60 | : T extends Selection> 61 | ? Columns 62 | : [] 63 | 64 | /** Get the `SELECT *` row type. */ 65 | export type RowType = T extends Selection 66 | ? From extends SetExpression | TableRef 67 | ? Values 68 | : never 69 | : T extends SetExpression | TableRef 70 | ? Values 71 | : never 72 | 73 | export function makeTableRef< 74 | T extends object = any, 75 | TableName extends string = any, 76 | IdentityColumns extends string[] = any, 77 | NullableColumn extends string = any 78 | >( 79 | name: TableName, 80 | idColumns: IdentityColumns, 81 | columns: Record 82 | ): TableRef { 83 | const type = new (TableRef as new ( 84 | name: TableName, 85 | idColumns: IdentityColumns, 86 | columns: Record 87 | ) => TableRef)(name, idColumns, columns) 88 | 89 | const select = makeSelector(type) 90 | const ref: any = (arg: any, selector?: (from: any) => RawSelection): any => { 91 | if (typeof arg == 'function') { 92 | return select(arg) 93 | } 94 | return new TableCast(arg, selector ? select(selector) : ref) 95 | } 96 | 97 | return Object.setPrototypeOf(ref, type) 98 | } 99 | 100 | export abstract class TableRef< 101 | T extends object = any, 102 | TableName extends string = any, 103 | IdentityColumns extends string[] = string[], 104 | NullableColumn extends string = any 105 | > { 106 | /** The unique table name */ 107 | protected [kTableName]: TableName 108 | /** The primary key of this table. */ 109 | protected [kIdentityColumns]: IdentityColumns 110 | /** The column names that exist in this table. */ 111 | protected [kTableColumns]: Record 112 | /** Exists for type inference. */ 113 | protected declare [kNullableColumns]: NullableColumn[] 114 | 115 | constructor( 116 | name: TableName, 117 | idColumns: IdentityColumns, 118 | columns: Record 119 | ) { 120 | this[kTableName] = name 121 | this[kIdentityColumns] = idColumns 122 | this[kTableColumns] = columns 123 | } 124 | 125 | omit(...omitted: string[]): Selection { 126 | return new Selection( 127 | Object.keys(this[kTableColumns]).filter( 128 | name => !omitted.includes(name as any) 129 | ), 130 | this as any 131 | ) 132 | } 133 | } 134 | 135 | export interface TableRef< 136 | T extends object, 137 | TableName extends string, 138 | IdentityColumns extends string[], 139 | NullableColumn extends string 140 | > extends SetType { 141 | // Select from a table. 142 | ( 143 | selector: (row: RowRef) => Narrow 144 | ): Selection, this> 145 | 146 | // Cast a row identifier to a "SELECT *" statement. 147 | >(id: PK): TableCast 148 | 149 | // Cast a row identifier to a table selection. 150 | >( 151 | id: PK, 152 | selector: (row: RowRef) => Narrow 153 | ): TableCast, this>, PK> 154 | 155 | /** 156 | * Exclude specific columns from the result set. 157 | */ 158 | omit( 159 | ...omitted: Omitted 160 | ): Selection, this> 161 | } 162 | 163 | export function toTableRef( 164 | arg: TableRef | TableCast | Selection 165 | ): TableRef 166 | export function toTableRef(arg: Selectable | TableCast): TableRef | undefined 167 | export function toTableRef(arg: Selectable | TableCast) { 168 | return isTableRef(arg) 169 | ? arg 170 | : isSelection(arg) 171 | ? toTableRef(arg[kSelectionFrom]) 172 | : isTableCast(arg) 173 | ? toTableRef(arg[kTableCast].from) 174 | : arg instanceof JoinRef 175 | ? toTableRef(arg.from) 176 | : undefined 177 | } 178 | 179 | export function toTableName( 180 | arg: TableRef | TableCast | Selection, 181 | ctx?: Query.Context 182 | ): string 183 | export function toTableName( 184 | arg: Selectable | TableCast, 185 | ctx?: Query.Context 186 | ): string | undefined 187 | export function toTableName(arg: Selectable | TableCast, ctx?: Query.Context) { 188 | const join = ctx?.currentJoin || (arg instanceof JoinRef ? arg : null) 189 | if (join?.alias != null) { 190 | return join.alias 191 | } 192 | const tableRef = toTableRef(arg) 193 | return tableRef ? tableRef[kTableName] : undefined 194 | } 195 | 196 | export function getColumnType(table: TableRef | undefined, column: string) { 197 | return (table && table[kTableColumns][column]) || kUnknownType 198 | } 199 | 200 | /** 201 | * The callback type used for selecting columns from a table. 202 | * 203 | * Use this to wrap a `db.select` call while still allowing a 204 | * custom selector to be used. 205 | */ 206 | export type TableSelector< 207 | Selected extends RawSelection, 208 | From extends TableRef 209 | > = (row: RowRef) => Narrow 210 | -------------------------------------------------------------------------------- /src/postgres/query.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from './database' 2 | import { 3 | createQueryContext, 4 | Node, 5 | QueryInternal, 6 | renderQuery, 7 | tokenizeQuery, 8 | } from './internal/query' 9 | import { renderTokens, Token, TokenArray } from './internal/token' 10 | import { JoinRef } from './join' 11 | 12 | export type QueryPromise = Query & PromiseLike 13 | 14 | export type QueryResponse> = { 15 | rows: T[] 16 | rowCount?: number 17 | } 18 | 19 | export abstract class Query { 20 | protected db: Database 21 | protected nodes: Node[] 22 | protected position: number 23 | protected trace = Error().stack!.slice(6) 24 | 25 | constructor(parent: Query | Database) { 26 | if (parent instanceof Query) { 27 | if (parent.position < parent.nodes.length - 1) { 28 | parent.reuse() 29 | } 30 | this.db = parent.db 31 | this.nodes = parent.isReused ? [...parent.nodes] : parent.nodes 32 | } else { 33 | this.db = parent 34 | this.nodes = [] 35 | } 36 | // Assume this query's node will be added next. 37 | this.position = this.nodes.length 38 | } 39 | 40 | /** 41 | * Acquire a variable within the given `wrapper` that points 42 | * to this query, allowing easy duplication. 43 | * 44 | * One use case is creating a union of two `select` queries 45 | * without repeating the selection manually. 46 | */ 47 | wrap(wrapper: (query: this) => Result) { 48 | if (!this.isReused) { 49 | this.reuse() 50 | } 51 | return wrapper(this) 52 | } 53 | 54 | protected get props(): Props { 55 | return this.nodes[this.position].props 56 | } 57 | 58 | /** 59 | * Generate tokens for this node. The `tokenize` phase runs any hooks 60 | * in order, so later nodes will see context changes from earlier nodes. 61 | */ 62 | protected abstract tokenize( 63 | props: Props, 64 | ctx: Query.Context 65 | ): Token | TokenArray 66 | 67 | protected query(node: { 68 | type: string 69 | query: T 70 | props: T extends Query ? Props : never 71 | }): T 72 | 73 | protected query(node: any) { 74 | node.query.nodes.push(node) 75 | return node.query 76 | } 77 | 78 | protected get isReused() { 79 | return Object.isFrozen(this.nodes) 80 | } 81 | 82 | /** 83 | * To reuse a query, its node list must be sliced (so the query is last) 84 | * and then frozen. 85 | */ 86 | protected reuse(): Node[] { 87 | return (this.nodes = Object.freeze( 88 | this.nodes.slice(0, this.position + 1) 89 | ) as any) 90 | } 91 | 92 | protected clone() { 93 | const clone = Object.create(this.constructor.prototype) 94 | Object.defineProperties(clone, Object.getOwnPropertyDescriptors(this)) 95 | clone.nodes = this.nodes.slice(0, this.position + 1) 96 | clone.nodes[this.position] = { 97 | ...clone.nodes[this.position], 98 | props: this.cloneProps(), 99 | } 100 | return clone as this 101 | } 102 | 103 | /** 104 | * By default, only a shallow clone is made. 105 | */ 106 | protected cloneProps() { 107 | return { ...this.props } 108 | } 109 | } 110 | 111 | // Using defineProperty for Query#then lets subclasses easily 112 | // define their promise type without any TypeScript gymnastics. 113 | // And of course, they still inherit this default implementation. 114 | // As a bonus, any Query subclass that doesn't extend the PromiseLike 115 | // interface will not be awaitable, thus avoiding incomplete queries. 116 | Object.defineProperty(Query.prototype, 'then', { 117 | value: function then(this: Query, onfulfilled?: any, onrejected?: any) { 118 | const ctx = createQueryContext(this) 119 | 120 | const execute = (sql: string): Promise => 121 | client 122 | .query(sql, ctx.values) 123 | .then(async (response: QueryResponse) => { 124 | let result = response.rows 125 | if (ctx.mutators.length) { 126 | for (const row of response.rows) { 127 | for (const mutateRow of ctx.mutators) { 128 | mutateRow(row) 129 | } 130 | } 131 | } 132 | for (let i = 1; i <= ctx.resolvers.length; i++) { 133 | const resolver = ctx.resolvers.at(-i)! 134 | const replacement = await resolver(response) 135 | if (replacement !== undefined) { 136 | result = replacement 137 | break 138 | } 139 | } 140 | if (ctx.single && Array.isArray(result)) { 141 | return result[0] || null 142 | } 143 | return result 144 | }, onError) 145 | .then(onfulfilled, onrejected) 146 | 147 | const onError = (e: any) => { 148 | if ( 149 | process.env.NODE_ENV !== 'production' && 150 | e.message.includes('administrator command') 151 | ) { 152 | // Repeat the query if terminated by admin command in 153 | // development, since it was likely a timeout caused by a 154 | // breakpoint. 155 | return execute(sql) 156 | } 157 | throw Object.assign(e, { 158 | stack: e.stack + '\n ––––– Query origin –––––' + ctx.query.trace, 159 | context: ctx, 160 | sql, 161 | }) 162 | } 163 | 164 | try { 165 | var sql = renderQuery(ctx) 166 | var client = this.db['getClient'](ctx) 167 | return execute(sql) 168 | } catch (e: any) { 169 | onError(e) 170 | } 171 | }, 172 | }) 173 | 174 | export namespace Query { 175 | export interface Context { 176 | query: QueryInternal 177 | /** 178 | * The identifiers that represent something in this query. 179 | * This is used to prevent naming conflicts. 180 | */ 181 | idents: Set 182 | /** 183 | * When true, the query should never be sent to a read-only replica. 184 | */ 185 | impure?: boolean 186 | /** 187 | * Any values that cannot be stringified without the 188 | * help of node-postgres (aka `pg`). 189 | */ 190 | values: any[] 191 | /** 192 | * Functions called with the query result. Each resolver 193 | * may affect the query result, so they're called in order. 194 | */ 195 | resolvers: ((response: QueryResponse) => any)[] 196 | mutators: ((row: Record) => void)[] 197 | /** 198 | * Exists when there are joins. 199 | */ 200 | joins?: JoinRef[] 201 | currentJoin?: JoinRef 202 | /** 203 | * Equals true when the query promise should resolve 204 | * with a single result, even if multiple rows are returned 205 | * from the query. 206 | */ 207 | single?: boolean 208 | /** 209 | * Equals true when tokenizing a row tuple. 210 | * 211 | * If tokenizing a selection, aliases will be omitted 212 | * when this property is true. 213 | */ 214 | inTuple?: boolean 215 | } 216 | } 217 | 218 | /** Inspect the context, tokens, and SQL of a query */ 219 | export function inspectQuery(query: Query) { 220 | const ctx = createQueryContext(query) 221 | const tokens = tokenizeQuery(ctx) 222 | const rendered = renderTokens(tokens, ctx) 223 | return { 224 | sql: rendered.join(' '), 225 | tokens, 226 | context: ctx, 227 | } 228 | } 229 | --------------------------------------------------------------------------------