commit d0a0de766bad96582066463af6814d2211e7603f Author: glm-5.1 Date: Sat Apr 25 09:45:14 2026 +0000 feat: fork drizzle-typebox as @alkdev/drizzlebox - Rebrand package from drizzle-typebox to @alkdev/drizzlebox - Replace @sinclair/typebox with @alkdev/typebox in all source and test files - Replace @sinclair/typebox with @alkdev/typebox in rollup externals - Convert tsconfig.json from monorepo extends to standalone config - Fix build script monorepo remnant (dist.new -> dist) - Add missing devDependencies (recast, tsx, typescript, resolve-tspaths) - Replace monorepo link dependency for drizzle-orm with ^0.38.4 - Add .gitignore, LICENSE (Apache-2.0 with attribution), and README - Initialize git repo with remote at git.alk.dev:alkdev/drizzlebox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d0aa70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tgz +.cache/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9ae65a --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024 Drizzle Team +Copyright 2025 alkdev + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + +NOTICE + +This project is a fork of drizzle-typebox, originally part of the +drizzle-orm monorepo by the Drizzle Team (https://github.com/drizzle-team/drizzle-orm). + +The original work is Copyright 2024 Drizzle Team and is licensed under the +Apache License, Version 2.0. + +Modifications (rebranding to @alkdev/drizzlebox, dependency migration to +@alkdev/typebox, and standalone packaging) are Copyright 2025 alkdev. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fea0490 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# @alkdev/drizzlebox + +Generate [TypeBox](https://github.com/alkdev/typebox) schemas from [Drizzle ORM](https://orm.drizzle.team) schemas. + +This is a fork of [drizzle-typebox](https://github.com/drizzle-team/drizzle-orm/tree/main/drizzle-typebox) by the Drizzle Team, adapted for use with `@alkdev/typebox` (a maintained fork of `@sinclair/typebox`). + +## Install + +```bash +npm install @alkdev/drizzlebox +npm install @alkdev/typebox +npm install drizzle-orm +``` + +## Features + +- Create select schemas for tables, views, and enums +- Create insert and update schemas for tables +- Supports all dialects: PostgreSQL, MySQL, and SQLite +- Custom TypeBox instance support via `createSchemaFactory` + +## Usage + +```ts +import { pgEnum, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from '@alkdev/drizzlebox'; +import { Type } from '@alkdev/typebox'; +import { Value } from '@alkdev/typebox/value'; + +const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull(), + role: text('role', { enum: ['admin', 'user'] }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +// Schema for inserting a user +const insertUserSchema = createInsertSchema(users); + +// Schema for updating a user +const updateUserSchema = createUpdateSchema(users); + +// Schema for selecting a user +const selectUserSchema = createSelectSchema(users); + +// Overriding fields +const insertUserSchema = createInsertSchema(users, { + role: Type.String(), +}); + +// Refining fields +const insertUserSchema = createInsertSchema(users, { + id: (schema) => Type.Number({ ...schema, minimum: 0 }), + role: Type.String(), +}); + +// Validation +const isUserValid: boolean = Value.Check(insertUserSchema, { + name: 'John Doe', + email: 'johndoe@test.com', + role: 'admin', +}); +``` + +## Differences from drizzle-typebox + +- Uses `@alkdev/typebox` instead of `@sinclair/typebox` +- Standalone package (no monorepo dependency) +- Published as `@alkdev/drizzlebox` on npm + +## Attribution + +Based on [drizzle-typebox](https://github.com/drizzle-team/drizzle-orm/tree/main/drizzle-typebox) by the Drizzle Team, licensed under Apache-2.0. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f667443 --- /dev/null +++ b/package.json @@ -0,0 +1,75 @@ +{ + "name": "@alkdev/drizzlebox", + "version": "0.1.0", + "description": "Generate Typebox schemas from Drizzle ORM schemas — fork of drizzle-typebox with @alkdev/typebox support", + "type": "module", + "scripts": { + "build": "tsx scripts/build.ts", + "b": "pnpm build", + "test:types": "cd tests && tsc", + "pack": "(cd dist && npm pack --pack-destination ..) && rm -f package.tgz && mv *.tgz package.tgz", + "test": "vitest run" + }, + "exports": { + ".": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.cjs", + "default": "./index.cjs" + }, + "types": "./index.d.ts", + "default": "./index.mjs" + } + }, + "main": "./index.cjs", + "module": "./index.mjs", + "types": "./index.d.ts", + "repository": { + "type": "git", + "url": "git+https://git.alk.dev/alkdev/drizzlebox.git" + }, + "keywords": [ + "typebox", + "drizzlebox", + "validate", + "validation", + "schema", + "drizzle", + "orm", + "pg", + "mysql", + "postgresql", + "postgres", + "sqlite", + "database", + "sql", + "typescript", + "ts" + ], + "author": "Based on drizzle-typebox by Drizzle Team; fork maintained by alkdev", + "license": "Apache-2.0", + "peerDependencies": { + "@alkdev/typebox": ">=0.34.49", + "drizzle-orm": ">=0.36.0" + }, + "devDependencies": { + "@alkdev/typebox": "^0.34.49", + "@rollup/plugin-typescript": "^11.1.0", + "@types/node": "^18.15.10", + "@types/recast": "^0.23.0", + "cpy": "^10.1.0", + "drizzle-orm": "^0.38.4", + "recast": "^0.23.0", + "resolve-tspaths": "^0.8.23", + "rimraf": "^5.0.0", + "rollup": "^3.20.7", + "tsx": "^4.0.0", + "typescript": "^5.4.0", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^1.6.0", + "zx": "^7.2.2" + } +} \ No newline at end of file diff --git a/rollup.config.ts b/rollup.config.ts new file mode 100644 index 0000000..c1e5f96 --- /dev/null +++ b/rollup.config.ts @@ -0,0 +1,33 @@ +import typescript from '@rollup/plugin-typescript'; +import { defineConfig } from 'rollup'; + +export default defineConfig([ + { + input: 'src/index.ts', + output: [ + { + format: 'esm', + dir: 'dist', + entryFileNames: '[name].mjs', + chunkFileNames: '[name]-[hash].mjs', + sourcemap: true, + }, + { + format: 'cjs', + dir: 'dist', + entryFileNames: '[name].cjs', + chunkFileNames: '[name]-[hash].cjs', + sourcemap: true, + }, + ], + external: [ + /^drizzle-orm\/?/, + '@alkdev/typebox', + ], + plugins: [ + typescript({ + tsconfig: 'tsconfig.build.json', + }), + ], + }, +]); diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..07330ff --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env -S pnpm tsx +import 'zx/globals'; +import cpy from 'cpy'; + +await fs.remove('dist'); +await $`rollup --config rollup.config.ts --configPlugin typescript`; +await $`resolve-tspaths`; +await fs.copy('README.md', 'dist/README.md'); +await cpy('dist/**/*.d.ts', 'dist', { + rename: (basename) => basename.replace(/\.d\.ts$/, '.d.mts'), +}); +await cpy('dist/**/*.d.ts', 'dist', { + rename: (basename) => basename.replace(/\.d\.ts$/, '.d.cts'), +}); +await fs.copy('package.json', 'dist/package.json'); +await $`scripts/fix-imports.ts`; diff --git a/scripts/fix-imports.ts b/scripts/fix-imports.ts new file mode 100755 index 0000000..7bce7c6 --- /dev/null +++ b/scripts/fix-imports.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env -S pnpm tsx +import 'zx/globals'; + +import path from 'node:path'; +import { parse, print, visit } from 'recast'; +import parser from 'recast/parsers/typescript'; + +function resolvePathAlias(importPath: string, file: string) { + if (importPath.startsWith('~/')) { + const relativePath = path.relative(path.dirname(file), path.resolve('dist', importPath.slice(2))); + importPath = relativePath.startsWith('.') ? relativePath : './' + relativePath; + } + + return importPath; +} + +function fixImportPath(importPath: string, file: string, ext: string) { + importPath = resolvePathAlias(importPath, file); + + if (!/\..*\.(js|ts)$/.test(importPath)) { + return importPath; + } + + return importPath.replace(/\.(js|ts)$/, ext); +} + +const cjsFiles = await glob('dist/**/*.{cjs,d.cts}'); + +await Promise.all(cjsFiles.map(async (file) => { + const code = parse(await fs.readFile(file, 'utf8'), { parser }); + + visit(code, { + visitImportDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.cjs'); + this.traverse(path); + }, + visitExportAllDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.cjs'); + this.traverse(path); + }, + visitExportNamedDeclaration(path) { + if (path.value.source) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.cjs'); + } + this.traverse(path); + }, + visitCallExpression(path) { + if (path.value.callee.type === 'Identifier' && path.value.callee.name === 'require') { + path.value.arguments[0].value = fixImportPath(path.value.arguments[0].value, file, '.cjs'); + } + this.traverse(path); + }, + visitTSImportType(path) { + path.value.argument.value = resolvePathAlias(path.value.argument.value, file); + this.traverse(path); + }, + visitAwaitExpression(path) { + if (print(path.value).code.startsWith(`await import("./`)) { + path.value.argument.arguments[0].value = fixImportPath(path.value.argument.arguments[0].value, file, '.cjs'); + } + this.traverse(path); + }, + }); + + await fs.writeFile(file, print(code).code); +})); + +let esmFiles = await glob('dist/**/*.{js,d.ts}'); + +await Promise.all(esmFiles.map(async (file) => { + const code = parse(await fs.readFile(file, 'utf8'), { parser }); + + visit(code, { + visitImportDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.js'); + this.traverse(path); + }, + visitExportAllDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.js'); + this.traverse(path); + }, + visitExportNamedDeclaration(path) { + if (path.value.source) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.js'); + } + this.traverse(path); + }, + visitTSImportType(path) { + path.value.argument.value = fixImportPath(path.value.argument.value, file, '.js'); + this.traverse(path); + }, + visitAwaitExpression(path) { + if (print(path.value).code.startsWith(`await import("./`)) { + path.value.argument.arguments[0].value = fixImportPath(path.value.argument.arguments[0].value, file, '.js'); + } + this.traverse(path); + }, + }); + + await fs.writeFile(file, print(code).code); +})); + +esmFiles = await glob('dist/**/*.{mjs,d.mts}'); + +await Promise.all(esmFiles.map(async (file) => { + const code = parse(await fs.readFile(file, 'utf8'), { parser }); + + visit(code, { + visitImportDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.mjs'); + this.traverse(path); + }, + visitExportAllDeclaration(path) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.mjs'); + this.traverse(path); + }, + visitExportNamedDeclaration(path) { + if (path.value.source) { + path.value.source.value = fixImportPath(path.value.source.value, file, '.mjs'); + } + this.traverse(path); + }, + visitTSImportType(path) { + path.value.argument.value = fixImportPath(path.value.argument.value, file, '.mjs'); + this.traverse(path); + }, + visitAwaitExpression(path) { + if (print(path.value).code.startsWith(`await import("./`)) { + path.value.argument.arguments[0].value = fixImportPath(path.value.argument.arguments[0].value, file, '.mjs'); + } + this.traverse(path); + }, + }); + + await fs.writeFile(file, print(code).code); +})); diff --git a/src/column.ts b/src/column.ts new file mode 100644 index 0000000..f946d53 --- /dev/null +++ b/src/column.ts @@ -0,0 +1,316 @@ +import { Kind, Type as t, TypeRegistry } from '@alkdev/typebox'; +import type { StringOptions, TSchema, Type as typebox } from '@alkdev/typebox'; +import type { Column, ColumnBaseConfig } from 'drizzle-orm'; +import type { + MySqlBigInt53, + MySqlChar, + MySqlDouble, + MySqlFloat, + MySqlInt, + MySqlMediumInt, + MySqlReal, + MySqlSerial, + MySqlSmallInt, + MySqlText, + MySqlTinyInt, + MySqlVarChar, + MySqlYear, +} from 'drizzle-orm/mysql-core'; +import type { + PgArray, + PgBigInt53, + PgBigSerial53, + PgBinaryVector, + PgChar, + PgDoublePrecision, + PgGeometry, + PgGeometryObject, + PgHalfVector, + PgInteger, + PgLineABC, + PgLineTuple, + PgPointObject, + PgPointTuple, + PgReal, + PgSerial, + PgSmallInt, + PgSmallSerial, + PgUUID, + PgVarchar, + PgVector, +} from 'drizzle-orm/pg-core'; +import { + type SingleStoreBigInt53, + SingleStoreChar, + type SingleStoreDouble, + type SingleStoreFloat, + type SingleStoreInt, + type SingleStoreMediumInt, + type SingleStoreReal, + type SingleStoreSerial, + type SingleStoreSmallInt, + SingleStoreText, + type SingleStoreTinyInt, + SingleStoreVarChar, + SingleStoreYear, +} from 'drizzle-orm/singlestore-core'; +import type { SQLiteInteger, SQLiteReal, SQLiteText } from 'drizzle-orm/sqlite-core'; +import { CONSTANTS } from './constants.ts'; +import { isColumnType, isWithEnum } from './utils.ts'; +import type { BufferSchema, JsonSchema } from './utils.ts'; + +export const literalSchema = t.Union([t.String(), t.Number(), t.Boolean(), t.Null()]); +export const jsonSchema: JsonSchema = t.Recursive((self) => + t.Union([literalSchema, t.Array(self), t.Record(t.String(), self)]) +) as any; +TypeRegistry.Set('Buffer', (_, value) => value instanceof Buffer); // eslint-disable-line no-instanceof/no-instanceof +export const bufferSchema: BufferSchema = { [Kind]: 'Buffer', type: 'buffer' } as any; + +export function mapEnumValues(values: string[]) { + return Object.fromEntries(values.map((value) => [value, value])); +} + +export function columnToSchema(column: Column, t: typeof typebox): TSchema { + let schema!: TSchema; + + if (isWithEnum(column)) { + schema = column.enumValues.length ? t.Enum(mapEnumValues(column.enumValues)) : t.String(); + } + + if (!schema) { + // Handle specific types + if (isColumnType | PgPointTuple>(column, ['PgGeometry', 'PgPointTuple'])) { + schema = t.Tuple([t.Number(), t.Number()]); + } else if ( + isColumnType | PgGeometryObject>(column, ['PgGeometryObject', 'PgPointObject']) + ) { + schema = t.Object({ x: t.Number(), y: t.Number() }); + } else if (isColumnType | PgVector>(column, ['PgHalfVector', 'PgVector'])) { + schema = t.Array( + t.Number(), + column.dimensions + ? { + minItems: column.dimensions, + maxItems: column.dimensions, + } + : undefined, + ); + } else if (isColumnType>(column, ['PgLine'])) { + schema = t.Tuple([t.Number(), t.Number(), t.Number()]); + } else if (isColumnType>(column, ['PgLineABC'])) { + schema = t.Object({ + a: t.Number(), + b: t.Number(), + c: t.Number(), + }); + } // Handle other types + else if (isColumnType>(column, ['PgArray'])) { + schema = t.Array( + columnToSchema(column.baseColumn, t), + column.size + ? { + minItems: column.size, + maxItems: column.size, + } + : undefined, + ); + } else if (column.dataType === 'array') { + schema = t.Array(t.Any()); + } else if (column.dataType === 'number') { + schema = numberColumnToSchema(column, t); + } else if (column.dataType === 'bigint') { + schema = bigintColumnToSchema(column, t); + } else if (column.dataType === 'boolean') { + schema = t.Boolean(); + } else if (column.dataType === 'date') { + schema = t.Date(); + } else if (column.dataType === 'string') { + schema = stringColumnToSchema(column, t); + } else if (column.dataType === 'json') { + schema = jsonSchema; + } else if (column.dataType === 'custom') { + schema = t.Any(); + } else if (column.dataType === 'buffer') { + schema = bufferSchema; + } + } + + if (!schema) { + schema = t.Any(); + } + + return schema; +} + +function numberColumnToSchema(column: Column, t: typeof typebox): TSchema { + let unsigned = column.getSQLType().includes('unsigned'); + let min!: number; + let max!: number; + let integer = false; + + if (isColumnType | SingleStoreTinyInt>(column, ['MySqlTinyInt', 'SingleStoreTinyInt'])) { + min = unsigned ? 0 : CONSTANTS.INT8_MIN; + max = unsigned ? CONSTANTS.INT8_UNSIGNED_MAX : CONSTANTS.INT8_MAX; + integer = true; + } else if ( + isColumnType | PgSmallSerial | MySqlSmallInt | SingleStoreSmallInt>(column, [ + 'PgSmallInt', + 'PgSmallSerial', + 'MySqlSmallInt', + 'SingleStoreSmallInt', + ]) + ) { + min = unsigned ? 0 : CONSTANTS.INT16_MIN; + max = unsigned ? CONSTANTS.INT16_UNSIGNED_MAX : CONSTANTS.INT16_MAX; + integer = true; + } else if ( + isColumnType< + PgReal | MySqlFloat | MySqlMediumInt | SingleStoreFloat | SingleStoreMediumInt + >(column, [ + 'PgReal', + 'MySqlFloat', + 'MySqlMediumInt', + 'SingleStoreFloat', + 'SingleStoreMediumInt', + ]) + ) { + min = unsigned ? 0 : CONSTANTS.INT24_MIN; + max = unsigned ? CONSTANTS.INT24_UNSIGNED_MAX : CONSTANTS.INT24_MAX; + integer = isColumnType(column, ['MySqlMediumInt', 'SingleStoreMediumInt']); + } else if ( + isColumnType | PgSerial | MySqlInt | SingleStoreInt>(column, [ + 'PgInteger', + 'PgSerial', + 'MySqlInt', + 'SingleStoreInt', + ]) + ) { + min = unsigned ? 0 : CONSTANTS.INT32_MIN; + max = unsigned ? CONSTANTS.INT32_UNSIGNED_MAX : CONSTANTS.INT32_MAX; + integer = true; + } else if ( + isColumnType< + | PgDoublePrecision + | MySqlReal + | MySqlDouble + | SingleStoreReal + | SingleStoreDouble + | SQLiteReal + >(column, [ + 'PgDoublePrecision', + 'MySqlReal', + 'MySqlDouble', + 'SingleStoreReal', + 'SingleStoreDouble', + 'SQLiteReal', + ]) + ) { + min = unsigned ? 0 : CONSTANTS.INT48_MIN; + max = unsigned ? CONSTANTS.INT48_UNSIGNED_MAX : CONSTANTS.INT48_MAX; + } else if ( + isColumnType< + | PgBigInt53 + | PgBigSerial53 + | MySqlBigInt53 + | MySqlSerial + | SingleStoreBigInt53 + | SingleStoreSerial + | SQLiteInteger + >( + column, + [ + 'PgBigInt53', + 'PgBigSerial53', + 'MySqlBigInt53', + 'MySqlSerial', + 'SingleStoreBigInt53', + 'SingleStoreSerial', + 'SQLiteInteger', + ], + ) + ) { + unsigned = unsigned || isColumnType(column, ['MySqlSerial', 'SingleStoreSerial']); + min = unsigned ? 0 : Number.MIN_SAFE_INTEGER; + max = Number.MAX_SAFE_INTEGER; + integer = true; + } else if (isColumnType | SingleStoreYear>(column, ['MySqlYear', 'SingleStoreYear'])) { + min = 1901; + max = 2155; + integer = true; + } else { + min = Number.MIN_SAFE_INTEGER; + max = Number.MAX_SAFE_INTEGER; + } + + const key = integer ? 'Integer' : 'Number'; + return t[key]({ + minimum: min, + maximum: max, + }); +} + +function bigintColumnToSchema(column: Column, t: typeof typebox): TSchema { + const unsigned = column.getSQLType().includes('unsigned'); + const min = unsigned ? 0n : CONSTANTS.INT64_MIN; + const max = unsigned ? CONSTANTS.INT64_UNSIGNED_MAX : CONSTANTS.INT64_MAX; + + return t.BigInt({ + minimum: min, + maximum: max, + }); +} + +function stringColumnToSchema(column: Column, t: typeof typebox): TSchema { + if (isColumnType>>(column, ['PgUUID'])) { + return t.String({ format: 'uuid' }); + } else if ( + isColumnType & { dimensions: number }>>(column, [ + 'PgBinaryVector', + ]) + ) { + return t.RegExp(/^[01]+$/, column.dimensions ? { maxLength: column.dimensions } : undefined); + } + + let max: number | undefined; + let fixed = false; + + if (isColumnType | SQLiteText>(column, ['PgVarchar', 'SQLiteText'])) { + max = column.length; + } else if ( + isColumnType | SingleStoreVarChar>(column, ['MySqlVarChar', 'SingleStoreVarChar']) + ) { + max = column.length ?? CONSTANTS.INT16_UNSIGNED_MAX; + } else if (isColumnType | SingleStoreText>(column, ['MySqlText', 'SingleStoreText'])) { + if (column.textType === 'longtext') { + max = CONSTANTS.INT32_UNSIGNED_MAX; + } else if (column.textType === 'mediumtext') { + max = CONSTANTS.INT24_UNSIGNED_MAX; + } else if (column.textType === 'text') { + max = CONSTANTS.INT16_UNSIGNED_MAX; + } else { + max = CONSTANTS.INT8_UNSIGNED_MAX; + } + } + + if ( + isColumnType | MySqlChar | SingleStoreChar>(column, [ + 'PgChar', + 'MySqlChar', + 'SingleStoreChar', + ]) + ) { + max = column.length; + fixed = true; + } + + const options: Partial = {}; + + if (max !== undefined && fixed) { + options.minLength = max; + options.maxLength = max; + } else if (max !== undefined) { + options.maxLength = max; + } + + return t.String(Object.keys(options).length > 0 ? options : undefined); +} diff --git a/src/column.types.ts b/src/column.types.ts new file mode 100644 index 0000000..ad839ee --- /dev/null +++ b/src/column.types.ts @@ -0,0 +1,107 @@ +import type * as t from '@alkdev/typebox'; +import type { Assume, Column } from 'drizzle-orm'; +import type { ArrayHasAtLeastOneValue, BufferSchema, ColumnIsGeneratedAlwaysAs, IsNever, JsonSchema } from './utils.ts'; + +export type GetEnumValuesFromColumn = TColumn['_'] extends { enumValues: [string, ...string[]] } + ? TColumn['_']['enumValues'] + : undefined; + +export type GetBaseColumn = TColumn['_'] extends { baseColumn: Column | never | undefined } + ? IsNever extends false ? TColumn['_']['baseColumn'] + : undefined + : undefined; + +export type EnumValuesToEnum = { [K in TEnumValues[number]]: K }; + +export type GetTypeboxType< + TData, + TDataType extends string, + TColumnType extends string, + TEnumValues extends [string, ...string[]] | undefined, + TBaseColumn extends Column | undefined, +> = TColumnType extends + | 'MySqlTinyInt' + | 'SingleStoreTinyInt' + | 'PgSmallInt' + | 'PgSmallSerial' + | 'MySqlSmallInt' + | 'MySqlMediumInt' + | 'SingleStoreSmallInt' + | 'SingleStoreMediumInt' + | 'PgInteger' + | 'PgSerial' + | 'MySqlInt' + | 'SingleStoreInt' + | 'PgBigInt53' + | 'PgBigSerial53' + | 'MySqlBigInt53' + | 'MySqlSerial' + | 'SingleStoreBigInt53' + | 'SingleStoreSerial' + | 'SQLiteInteger' + | 'MySqlYear' + | 'SingleStoreYear' ? t.TInteger + : TColumnType extends 'PgBinaryVector' ? t.TRegExp + : TBaseColumn extends Column ? t.TArray< + GetTypeboxType< + TBaseColumn['_']['data'], + TBaseColumn['_']['dataType'], + TBaseColumn['_']['columnType'], + GetEnumValuesFromColumn, + GetBaseColumn + > + > + : ArrayHasAtLeastOneValue extends true + ? t.TEnum>> + : TData extends infer TTuple extends [any, ...any[]] ? t.TTuple< + Assume<{ [K in keyof TTuple]: GetTypeboxType }, [any, ...any[]]> + > + : TData extends Date ? t.TDate + : TData extends Buffer ? BufferSchema + : TDataType extends 'array' + ? t.TArray[number], string, string, undefined, undefined>> + : TData extends infer TDict extends Record + ? t.TObject<{ [K in keyof TDict]: GetTypeboxType }> + : TDataType extends 'json' ? JsonSchema + : TData extends number ? t.TNumber + : TData extends bigint ? t.TBigInt + : TData extends boolean ? t.TBoolean + : TData extends string ? t.TString + : t.TAny; + +type HandleSelectColumn< + TSchema extends t.TSchema, + TColumn extends Column, +> = TColumn['_']['notNull'] extends true ? TSchema + : t.Union<[TSchema, t.TNull]>; + +type HandleInsertColumn< + TSchema extends t.TSchema, + TColumn extends Column, +> = ColumnIsGeneratedAlwaysAs extends true ? never + : TColumn['_']['notNull'] extends true ? TColumn['_']['hasDefault'] extends true ? t.TOptional + : TSchema + : t.TOptional>; + +type HandleUpdateColumn< + TSchema extends t.TSchema, + TColumn extends Column, +> = ColumnIsGeneratedAlwaysAs extends true ? never + : TColumn['_']['notNull'] extends true ? t.TOptional + : t.TOptional>; + +export type HandleColumn< + TType extends 'select' | 'insert' | 'update', + TColumn extends Column, +> = GetTypeboxType< + TColumn['_']['data'], + TColumn['_']['dataType'], + TColumn['_']['columnType'], + GetEnumValuesFromColumn, + GetBaseColumn +> extends infer TSchema extends t.TSchema ? TSchema extends t.TAny ? t.TAny + : TType extends 'select' ? HandleSelectColumn + : TType extends 'insert' ? HandleInsertColumn + : TType extends 'update' ? HandleUpdateColumn + : TSchema + : t.TAny; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..99f5d7a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,20 @@ +export const CONSTANTS = { + INT8_MIN: -128, + INT8_MAX: 127, + INT8_UNSIGNED_MAX: 255, + INT16_MIN: -32768, + INT16_MAX: 32767, + INT16_UNSIGNED_MAX: 65535, + INT24_MIN: -8388608, + INT24_MAX: 8388607, + INT24_UNSIGNED_MAX: 16777215, + INT32_MIN: -2147483648, + INT32_MAX: 2147483647, + INT32_UNSIGNED_MAX: 4294967295, + INT48_MIN: -140737488355328, + INT48_MAX: 140737488355327, + INT48_UNSIGNED_MAX: 281474976710655, + INT64_MIN: -9223372036854775808n, + INT64_MAX: 9223372036854775807n, + INT64_UNSIGNED_MAX: 18446744073709551615n, +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0a6499e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './schema.ts'; +export * from './schema.types.ts'; diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..4d13ac3 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,144 @@ +import { Type as t } from '@alkdev/typebox'; +import type { TSchema } from '@alkdev/typebox'; +import { Column, getTableColumns, getViewSelectedFields, is, isTable, isView, SQL } from 'drizzle-orm'; +import type { Table, View } from 'drizzle-orm'; +import type { PgEnum } from 'drizzle-orm/pg-core'; +import { columnToSchema, mapEnumValues } from './column.ts'; +import type { Conditions } from './schema.types.internal.ts'; +import type { + CreateInsertSchema, + CreateSchemaFactoryOptions, + CreateSelectSchema, + CreateUpdateSchema, +} from './schema.types.ts'; +import { isPgEnum } from './utils.ts'; + +function getColumns(tableLike: Table | View) { + return isTable(tableLike) ? getTableColumns(tableLike) : getViewSelectedFields(tableLike); +} + +function handleColumns( + columns: Record, + refinements: Record, + conditions: Conditions, + factory?: CreateSchemaFactoryOptions, +): TSchema { + const columnSchemas: Record = {}; + + for (const [key, selected] of Object.entries(columns)) { + if (!is(selected, Column) && !is(selected, SQL) && !is(selected, SQL.Aliased) && typeof selected === 'object') { + const columns = isTable(selected) || isView(selected) ? getColumns(selected) : selected; + columnSchemas[key] = handleColumns(columns, refinements[key] ?? {}, conditions, factory); + continue; + } + + const refinement = refinements[key]; + if (refinement !== undefined && typeof refinement !== 'function') { + columnSchemas[key] = refinement; + continue; + } + + const column = is(selected, Column) ? selected : undefined; + const schema = column ? columnToSchema(column, factory?.typeboxInstance ?? t) : t.Any(); + const refined = typeof refinement === 'function' ? refinement(schema) : schema; + + if (conditions.never(column)) { + continue; + } else { + columnSchemas[key] = refined; + } + + if (column) { + if (conditions.nullable(column)) { + columnSchemas[key] = t.Union([columnSchemas[key]!, t.Null()]); + } + + if (conditions.optional(column)) { + columnSchemas[key] = t.Optional(columnSchemas[key]!); + } + } + } + + return t.Object(columnSchemas) as any; +} + +function handleEnum(enum_: PgEnum, factory?: CreateSchemaFactoryOptions) { + const typebox: typeof t = factory?.typeboxInstance ?? t; + return typebox.Enum(mapEnumValues(enum_.enumValues)); +} + +const selectConditions: Conditions = { + never: () => false, + optional: () => false, + nullable: (column) => !column.notNull, +}; + +const insertConditions: Conditions = { + never: (column) => column?.generated?.type === 'always' || column?.generatedIdentity?.type === 'always', + optional: (column) => !column.notNull || (column.notNull && column.hasDefault), + nullable: (column) => !column.notNull, +}; + +const updateConditions: Conditions = { + never: (column) => column?.generated?.type === 'always' || column?.generatedIdentity?.type === 'always', + optional: () => true, + nullable: (column) => !column.notNull, +}; + +export const createSelectSchema: CreateSelectSchema = ( + entity: Table | View | PgEnum<[string, ...string[]]>, + refine?: Record, +) => { + if (isPgEnum(entity)) { + return handleEnum(entity); + } + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, selectConditions) as any; +}; + +export const createInsertSchema: CreateInsertSchema = ( + entity: Table, + refine?: Record, +) => { + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, insertConditions) as any; +}; + +export const createUpdateSchema: CreateUpdateSchema = ( + entity: Table, + refine?: Record, +) => { + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, updateConditions) as any; +}; + +export function createSchemaFactory(options?: CreateSchemaFactoryOptions) { + const createSelectSchema: CreateSelectSchema = ( + entity: Table | View | PgEnum<[string, ...string[]]>, + refine?: Record, + ) => { + if (isPgEnum(entity)) { + return handleEnum(entity, options); + } + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, selectConditions, options) as any; + }; + + const createInsertSchema: CreateInsertSchema = ( + entity: Table, + refine?: Record, + ) => { + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, insertConditions, options) as any; + }; + + const createUpdateSchema: CreateUpdateSchema = ( + entity: Table, + refine?: Record, + ) => { + const columns = getColumns(entity); + return handleColumns(columns, refine ?? {}, updateConditions, options) as any; + }; + + return { createSelectSchema, createInsertSchema, createUpdateSchema }; +} diff --git a/src/schema.types.internal.ts b/src/schema.types.internal.ts new file mode 100644 index 0000000..20b2ca0 --- /dev/null +++ b/src/schema.types.internal.ts @@ -0,0 +1,94 @@ +import type * as t from '@alkdev/typebox'; +import type { Assume, Column, DrizzleTypeError, SelectedFieldsFlat, Simplify, Table, View } from 'drizzle-orm'; +import type { GetBaseColumn, GetEnumValuesFromColumn, GetTypeboxType, HandleColumn } from './column.types.ts'; +import type { GetSelection, RemoveNever } from './utils.ts'; + +export interface Conditions { + never: (column?: Column) => boolean; + optional: (column: Column) => boolean; + nullable: (column: Column) => boolean; +} + +export type BuildRefineColumns< + TColumns extends Record, +> = Simplify< + RemoveNever< + { + [K in keyof TColumns]: TColumns[K] extends infer TColumn extends Column ? GetTypeboxType< + TColumn['_']['data'], + TColumn['_']['dataType'], + TColumn['_']['columnType'], + GetEnumValuesFromColumn, + GetBaseColumn + > extends infer TSchema extends t.TSchema ? TSchema + : t.TAny + : TColumns[K] extends infer TObject extends SelectedFieldsFlat | Table | View + ? BuildRefineColumns> + : TColumns[K]; + } + > +>; + +export type BuildRefine< + TColumns extends Record, +> = BuildRefineColumns extends infer TBuildColumns ? { + [K in keyof TBuildColumns]?: TBuildColumns[K] extends t.TSchema + ? ((schema: TBuildColumns[K]) => t.TSchema) | t.TSchema + : TBuildColumns[K] extends Record ? Simplify> + : never; + } + : never; + +type HandleRefinement< + TType extends 'select' | 'insert' | 'update', + TRefinement extends t.TSchema | ((schema: t.TSchema) => t.TSchema), + TColumn extends Column, +> = TRefinement extends (schema: any) => t.TSchema ? (TColumn['_']['notNull'] extends true ? ReturnType + : t.TUnion<[ReturnType, t.TNull]>) extends infer TSchema + ? TType extends 'update' ? t.TOptional> : TSchema + : t.TSchema + : TRefinement; + +type IsRefinementDefined = TKey extends keyof TRefinements + ? TRefinements[TKey] extends t.TSchema | ((schema: any) => any) ? true + : false + : false; + +export type BuildSchema< + TType extends 'select' | 'insert' | 'update', + TColumns extends Record, + TRefinements extends Record | undefined, +> = t.TObject< + Simplify< + RemoveNever< + { + [K in keyof TColumns]: TColumns[K] extends infer TColumn extends Column + ? TRefinements extends object + ? IsRefinementDefined> extends true + ? HandleRefinement], TColumn> + : HandleColumn + : HandleColumn + : TColumns[K] extends infer TObject extends SelectedFieldsFlat | Table | View ? BuildSchema< + TType, + GetSelection, + TRefinements extends object + ? TRefinements[Assume] extends infer TNestedRefinements extends object + ? TNestedRefinements + : undefined + : undefined + > + : t.TAny; + } + > + > +>; + +export type NoUnknownKeys< + TRefinement extends Record, + TCompare extends Record, +> = { + [K in keyof TRefinement]: K extends keyof TCompare ? TRefinement[K] extends t.TSchema ? TRefinement[K] + : TRefinement[K] extends Record ? NoUnknownKeys + : TRefinement[K] + : DrizzleTypeError<`Found unknown key in refinement: "${K & string}"`>; +}; diff --git a/src/schema.types.ts b/src/schema.types.ts new file mode 100644 index 0000000..113a15a --- /dev/null +++ b/src/schema.types.ts @@ -0,0 +1,53 @@ +import type * as t from '@alkdev/typebox'; +import type { Table, View } from 'drizzle-orm'; +import type { PgEnum } from 'drizzle-orm/pg-core'; +import type { EnumValuesToEnum } from './column.types.ts'; +import type { BuildRefine, BuildSchema, NoUnknownKeys } from './schema.types.internal.ts'; + +export interface CreateSelectSchema { + (table: TTable): BuildSchema<'select', TTable['_']['columns'], undefined>; + < + TTable extends Table, + TRefine extends BuildRefine, + >( + table: TTable, + refine?: NoUnknownKeys, + ): BuildSchema<'select', TTable['_']['columns'], TRefine>; + + (view: TView): BuildSchema<'select', TView['_']['selectedFields'], undefined>; + < + TView extends View, + TRefine extends BuildRefine, + >( + view: TView, + refine: NoUnknownKeys, + ): BuildSchema<'select', TView['_']['selectedFields'], TRefine>; + + >(enum_: TEnum): t.TEnum>; +} + +export interface CreateInsertSchema { + (table: TTable): BuildSchema<'insert', TTable['_']['columns'], undefined>; + < + TTable extends Table, + TRefine extends BuildRefine>, + >( + table: TTable, + refine?: NoUnknownKeys, + ): BuildSchema<'insert', TTable['_']['columns'], TRefine>; +} + +export interface CreateUpdateSchema { + (table: TTable): BuildSchema<'update', TTable['_']['columns'], undefined>; + < + TTable extends Table, + TRefine extends BuildRefine>, + >( + table: TTable, + refine?: TRefine, + ): BuildSchema<'update', TTable['_']['columns'], TRefine>; +} + +export interface CreateSchemaFactoryOptions { + typeboxInstance?: any; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..7d8a77b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,50 @@ +import type { Kind, Static, TSchema } from '@alkdev/typebox'; +import type { Column, SelectedFieldsFlat, Table, View } from 'drizzle-orm'; +import type { PgEnum } from 'drizzle-orm/pg-core'; +import type { literalSchema } from './column.ts'; + +export function isColumnType(column: Column, columnTypes: string[]): column is T { + return columnTypes.includes(column.columnType); +} + +export function isWithEnum(column: Column): column is typeof column & { enumValues: [string, ...string[]] } { + return 'enumValues' in column && Array.isArray(column.enumValues) && column.enumValues.length > 0; +} + +export const isPgEnum: (entity: any) => entity is PgEnum<[string, ...string[]]> = isWithEnum as any; + +type Literal = Static; +export type Json = Literal | { [key: string]: Json } | Json[]; +export interface JsonSchema extends TSchema { + [Kind]: 'Union'; + static: Json; + anyOf: Json; +} +export interface BufferSchema extends TSchema { + [Kind]: 'Buffer'; + static: Buffer; + type: 'buffer'; +} + +export type IsNever = [T] extends [never] ? true : false; + +export type ArrayHasAtLeastOneValue = TEnum extends [infer TString, ...any[]] + ? TString extends `${infer TLiteral}` ? TLiteral extends any ? true + : false + : false + : false; + +export type ColumnIsGeneratedAlwaysAs = TColumn['_']['identity'] extends 'always' ? true + : TColumn['_']['generated'] extends undefined ? false + : TColumn['_']['generated'] extends infer TGenerated extends { type: string } + ? TGenerated['type'] extends 'byDefault' ? false + : true + : true; + +export type RemoveNever = { + [K in keyof T as T[K] extends never ? never : K]: T[K]; +}; + +export type GetSelection | Table | View> = T extends Table ? T['_']['columns'] + : T extends View ? T['_']['selectedFields'] + : T; diff --git a/tests/mysql.test.ts b/tests/mysql.test.ts new file mode 100644 index 0000000..9ef03d5 --- /dev/null +++ b/tests/mysql.test.ts @@ -0,0 +1,495 @@ +import { Type as t } from '@alkdev/typebox'; +import { type Equal, sql } from 'drizzle-orm'; +import { customType, int, mysqlSchema, mysqlTable, mysqlView, serial, text } from 'drizzle-orm/mysql-core'; +import { test } from 'vitest'; +import { jsonSchema } from '~/column.ts'; +import { CONSTANTS } from '~/constants.ts'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from '../src'; +import { Expect, expectSchemaShape } from './utils.ts'; + +const intSchema = t.Integer({ + minimum: CONSTANTS.INT32_MIN, + maximum: CONSTANTS.INT32_MAX, +}); +const serialNumberModeSchema = t.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, +}); +const textSchema = t.String({ maxLength: CONSTANTS.INT16_UNSIGNED_MAX }); + +test('table - select', (tc) => { + const table = mysqlTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table in schema - select', (tc) => { + const schema = mysqlSchema('test'); + const table = schema.table('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - insert', (tc) => { + const table = mysqlTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + age: int(), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + id: t.Optional(serialNumberModeSchema), + name: textSchema, + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - update', (tc) => { + const table = mysqlTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + age: int(), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + id: t.Optional(serialNumberModeSchema), + name: t.Optional(textSchema), + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view qb - select', (tc) => { + const table = mysqlTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + const view = mysqlView('test').as((qb) => qb.select({ id: table.id, age: sql``.as('age') }).from(table)); + + const result = createSelectSchema(view); + const expected = t.Object({ id: serialNumberModeSchema, age: t.Any() }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view columns - select', (tc) => { + const view = mysqlView('test', { + id: serial().primaryKey(), + name: text().notNull(), + }).as(sql``); + + const result = createSelectSchema(view); + const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view with nested fields - select', (tc) => { + const table = mysqlTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + const view = mysqlView('test').as((qb) => + qb.select({ + id: table.id, + nested: { + name: table.name, + age: sql``.as('age'), + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view); + const expected = t.Object({ + id: serialNumberModeSchema, + nested: t.Object({ name: textSchema, age: t.Any() }), + table: t.Object({ id: serialNumberModeSchema, name: textSchema }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - select', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: intSchema, + c3: t.Union([intSchema, t.Null()]), + c4: intSchema, + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - insert', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: intSchema, + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - update', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(intSchema), + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + }); + + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select with custom data type', (tc) => { + const customText = customType({ dataType: () => 'text' }); + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: customText(), + }); + + const customTextSchema = t.String({ minLength: 1, maxLength: 100 }); + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - insert', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - update', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 })), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine view - select', (tc) => { + const table = mysqlTable('test', { + c1: int(), + c2: int(), + c3: int(), + c4: int(), + c5: int(), + c6: int(), + }); + const view = mysqlView('test').as((qb) => + qb.select({ + c1: table.c1, + c2: table.c2, + c3: table.c3, + nested: { + c4: table.c4, + c5: table.c5, + c6: table.c6, + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: { + c5: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }, + table: { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }, + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: t.Object({ + c4: t.Union([intSchema, t.Null()]), + c5: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }), + table: t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: t.Union([intSchema, t.Null()]), + c5: t.Union([intSchema, t.Null()]), + c6: t.Union([intSchema, t.Null()]), + }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('all data types', (tc) => { + const table = mysqlTable('test', ({ + bigint, + binary, + boolean, + char, + date, + datetime, + decimal, + double, + float, + int, + json, + mediumint, + mysqlEnum, + real, + serial, + smallint, + text, + time, + timestamp, + tinyint, + varchar, + varbinary, + year, + longtext, + mediumtext, + tinytext, + }) => ({ + bigint1: bigint({ mode: 'number' }).notNull(), + bigint2: bigint({ mode: 'bigint' }).notNull(), + bigint3: bigint({ unsigned: true, mode: 'number' }).notNull(), + bigint4: bigint({ unsigned: true, mode: 'bigint' }).notNull(), + binary: binary({ length: 10 }).notNull(), + boolean: boolean().notNull(), + char1: char({ length: 10 }).notNull(), + char2: char({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + date1: date({ mode: 'date' }).notNull(), + date2: date({ mode: 'string' }).notNull(), + datetime1: datetime({ mode: 'date' }).notNull(), + datetime2: datetime({ mode: 'string' }).notNull(), + decimal1: decimal().notNull(), + decimal2: decimal({ unsigned: true }).notNull(), + double1: double().notNull(), + double2: double({ unsigned: true }).notNull(), + float1: float().notNull(), + float2: float({ unsigned: true }).notNull(), + int1: int().notNull(), + int2: int({ unsigned: true }).notNull(), + json: json().notNull(), + mediumint1: mediumint().notNull(), + mediumint2: mediumint({ unsigned: true }).notNull(), + enum: mysqlEnum('enum', ['a', 'b', 'c']).notNull(), + real: real().notNull(), + serial: serial().notNull(), + smallint1: smallint().notNull(), + smallint2: smallint({ unsigned: true }).notNull(), + text1: text().notNull(), + text2: text({ enum: ['a', 'b', 'c'] }).notNull(), + time: time().notNull(), + timestamp1: timestamp({ mode: 'date' }).notNull(), + timestamp2: timestamp({ mode: 'string' }).notNull(), + tinyint1: tinyint().notNull(), + tinyint2: tinyint({ unsigned: true }).notNull(), + varchar1: varchar({ length: 10 }).notNull(), + varchar2: varchar({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + varbinary: varbinary({ length: 10 }).notNull(), + year: year().notNull(), + longtext1: longtext().notNull(), + longtext2: longtext({ enum: ['a', 'b', 'c'] }).notNull(), + mediumtext1: mediumtext().notNull(), + mediumtext2: mediumtext({ enum: ['a', 'b', 'c'] }).notNull(), + tinytext1: tinytext().notNull(), + tinytext2: tinytext({ enum: ['a', 'b', 'c'] }).notNull(), + })); + + const result = createSelectSchema(table); + const expected = t.Object({ + bigint1: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }), + bigint2: t.BigInt({ minimum: CONSTANTS.INT64_MIN, maximum: CONSTANTS.INT64_MAX }), + bigint3: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + bigint4: t.BigInt({ minimum: 0n, maximum: CONSTANTS.INT64_UNSIGNED_MAX }), + binary: t.String(), + boolean: t.Boolean(), + char1: t.String({ minLength: 10, maxLength: 10 }), + char2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + date1: t.Date(), + date2: t.String(), + datetime1: t.Date(), + datetime2: t.String(), + decimal1: t.String(), + decimal2: t.String(), + double1: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + double2: t.Number({ minimum: 0, maximum: CONSTANTS.INT48_UNSIGNED_MAX }), + float1: t.Number({ minimum: CONSTANTS.INT24_MIN, maximum: CONSTANTS.INT24_MAX }), + float2: t.Number({ minimum: 0, maximum: CONSTANTS.INT24_UNSIGNED_MAX }), + int1: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: CONSTANTS.INT32_MAX }), + int2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT32_UNSIGNED_MAX }), + json: jsonSchema, + mediumint1: t.Integer({ minimum: CONSTANTS.INT24_MIN, maximum: CONSTANTS.INT24_MAX }), + mediumint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT24_UNSIGNED_MAX }), + enum: t.Enum({ a: 'a', b: 'b', c: 'c' }), + real: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + serial: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + smallint1: t.Integer({ minimum: CONSTANTS.INT16_MIN, maximum: CONSTANTS.INT16_MAX }), + smallint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT16_UNSIGNED_MAX }), + text1: t.String({ maxLength: CONSTANTS.INT16_UNSIGNED_MAX }), + text2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + time: t.String(), + timestamp1: t.Date(), + timestamp2: t.String(), + tinyint1: t.Integer({ minimum: CONSTANTS.INT8_MIN, maximum: CONSTANTS.INT8_MAX }), + tinyint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT8_UNSIGNED_MAX }), + varchar1: t.String({ maxLength: 10 }), + varchar2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + varbinary: t.String(), + year: t.Integer({ minimum: 1901, maximum: 2155 }), + longtext1: t.String({ maxLength: CONSTANTS.INT32_UNSIGNED_MAX }), + longtext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + mediumtext1: t.String({ maxLength: CONSTANTS.INT24_UNSIGNED_MAX }), + mediumtext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + tinytext1: t.String({ maxLength: CONSTANTS.INT8_UNSIGNED_MAX }), + tinytext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +/* Disallow unknown keys in table refinement - select */ { + const table = mysqlTable('test', { id: int() }); + // @ts-expect-error + createSelectSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - insert */ { + const table = mysqlTable('test', { id: int() }); + // @ts-expect-error + createInsertSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - update */ { + const table = mysqlTable('test', { id: int() }); + // @ts-expect-error + createUpdateSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in view qb - select */ { + const table = mysqlTable('test', { id: int() }); + const view = mysqlView('test').as((qb) => qb.select().from(table)); + const nestedSelect = mysqlView('test').as((qb) => qb.select({ table }).from(table)); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); + // @ts-expect-error + createSelectSchema(nestedSelect, { table: { unknown: t.String() } }); +} + +/* Disallow unknown keys in view columns - select */ { + const view = mysqlView('test', { id: int() }).as(sql``); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); +} diff --git a/tests/pg.test.ts b/tests/pg.test.ts new file mode 100644 index 0000000..3c17f25 --- /dev/null +++ b/tests/pg.test.ts @@ -0,0 +1,540 @@ +import { Type as t } from '@alkdev/typebox'; +import { type Equal, sql } from 'drizzle-orm'; +import { + customType, + integer, + pgEnum, + pgMaterializedView, + pgSchema, + pgTable, + pgView, + serial, + text, +} from 'drizzle-orm/pg-core'; +import { test } from 'vitest'; +import { jsonSchema } from '~/column.ts'; +import { CONSTANTS } from '~/constants.ts'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from '../src'; +import { Expect, expectEnumValues, expectSchemaShape } from './utils.ts'; + +const integerSchema = t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: CONSTANTS.INT32_MAX }); +const textSchema = t.String(); + +test('table - select', (tc) => { + const table = pgTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: integerSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table in schema - select', (tc) => { + const schema = pgSchema('test'); + const table = schema.table('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: integerSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - insert', (tc) => { + const table = pgTable('test', { + id: integer().generatedAlwaysAsIdentity().primaryKey(), + name: text().notNull(), + age: integer(), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ name: textSchema, age: t.Optional(t.Union([integerSchema, t.Null()])) }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - update', (tc) => { + const table = pgTable('test', { + id: integer().generatedAlwaysAsIdentity().primaryKey(), + name: text().notNull(), + age: integer(), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + name: t.Optional(textSchema), + age: t.Optional(t.Union([integerSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view qb - select', (tc) => { + const table = pgTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + const view = pgView('test').as((qb) => qb.select({ id: table.id, age: sql``.as('age') }).from(table)); + + const result = createSelectSchema(view); + const expected = t.Object({ id: integerSchema, age: t.Any() }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view columns - select', (tc) => { + const view = pgView('test', { + id: serial().primaryKey(), + name: text().notNull(), + }).as(sql``); + + const result = createSelectSchema(view); + const expected = t.Object({ id: integerSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('materialized view qb - select', (tc) => { + const table = pgTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + const view = pgMaterializedView('test').as((qb) => qb.select({ id: table.id, age: sql``.as('age') }).from(table)); + + const result = createSelectSchema(view); + const expected = t.Object({ id: integerSchema, age: t.Any() }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('materialized view columns - select', (tc) => { + const view = pgView('test', { + id: serial().primaryKey(), + name: text().notNull(), + }).as(sql``); + + const result = createSelectSchema(view); + const expected = t.Object({ id: integerSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view with nested fields - select', (tc) => { + const table = pgTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + const view = pgMaterializedView('test').as((qb) => + qb.select({ + id: table.id, + nested: { + name: table.name, + age: sql``.as('age'), + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view); + const expected = t.Object({ + id: integerSchema, + nested: t.Object({ name: textSchema, age: t.Any() }), + table: t.Object({ id: integerSchema, name: textSchema }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('enum - select', (tc) => { + const enum_ = pgEnum('test', ['a', 'b', 'c']); + + const result = createSelectSchema(enum_); + const expected = t.Enum({ a: 'a', b: 'b', c: 'c' }); + expectEnumValues(tc, expected).from(result); + Expect>(); +}); + +test('nullability - select', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().default(1), + c4: integer().notNull().default(1), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ + c1: t.Union([integerSchema, t.Null()]), + c2: integerSchema, + c3: t.Union([integerSchema, t.Null()]), + c4: integerSchema, + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - insert', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().default(1), + c4: integer().notNull().default(1), + c5: integer().generatedAlwaysAs(1), + c6: integer().generatedAlwaysAsIdentity(), + c7: integer().generatedByDefaultAsIdentity(), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([integerSchema, t.Null()])), + c2: integerSchema, + c3: t.Optional(t.Union([integerSchema, t.Null()])), + c4: t.Optional(integerSchema), + c7: t.Optional(integerSchema), + }); + expectSchemaShape(tc, expected).from(result); +}); + +test('nullability - update', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().default(1), + c4: integer().notNull().default(1), + c5: integer().generatedAlwaysAs(1), + c6: integer().generatedAlwaysAsIdentity(), + c7: integer().generatedByDefaultAsIdentity(), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([integerSchema, t.Null()])), + c2: t.Optional(integerSchema), + c3: t.Optional(t.Union([integerSchema, t.Null()])), + c4: t.Optional(integerSchema), + c7: t.Optional(integerSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().notNull(), + }); + + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Union([integerSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select with custom data type', (tc) => { + const customText = customType({ dataType: () => 'text' }); + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().notNull(), + c4: customText(), + }); + + const customTextSchema = t.String({ minLength: 1, maxLength: 100 }); + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + const expected = t.Object({ + c1: t.Union([integerSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - insert', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().notNull(), + c4: integer().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([integerSchema, t.Null()])), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - update', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer().notNull(), + c3: integer().notNull(), + c4: integer().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([integerSchema, t.Null()])), + c2: t.Optional(t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 })), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine view - select', (tc) => { + const table = pgTable('test', { + c1: integer(), + c2: integer(), + c3: integer(), + c4: integer(), + c5: integer(), + c6: integer(), + }); + const view = pgView('test').as((qb) => + qb.select({ + c1: table.c1, + c2: table.c2, + c3: table.c3, + nested: { + c4: table.c4, + c5: table.c5, + c6: table.c6, + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: { + c5: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }, + table: { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }, + }); + const expected = t.Object({ + c1: t.Union([integerSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: t.Object({ + c4: t.Union([integerSchema, t.Null()]), + c5: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }), + table: t.Object({ + c1: t.Union([integerSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: t.Union([integerSchema, t.Null()]), + c5: t.Union([integerSchema, t.Null()]), + c6: t.Union([integerSchema, t.Null()]), + }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('all data types', (tc) => { + const table = pgTable('test', ({ + bigint, + bigserial, + bit, + boolean, + date, + char, + cidr, + doublePrecision, + geometry, + halfvec, + inet, + integer, + interval, + json, + jsonb, + line, + macaddr, + macaddr8, + numeric, + point, + real, + serial, + smallint, + smallserial, + text, + sparsevec, + time, + timestamp, + uuid, + varchar, + vector, + }) => ({ + bigint1: bigint({ mode: 'number' }).notNull(), + bigint2: bigint({ mode: 'bigint' }).notNull(), + bigserial1: bigserial({ mode: 'number' }).notNull(), + bigserial2: bigserial({ mode: 'bigint' }).notNull(), + bit: bit({ dimensions: 5 }).notNull(), + boolean: boolean().notNull(), + date1: date({ mode: 'date' }).notNull(), + date2: date({ mode: 'string' }).notNull(), + char1: char({ length: 10 }).notNull(), + char2: char({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + cidr: cidr().notNull(), + doublePrecision: doublePrecision().notNull(), + geometry1: geometry({ type: 'point', mode: 'tuple' }).notNull(), + geometry2: geometry({ type: 'point', mode: 'xy' }).notNull(), + halfvec: halfvec({ dimensions: 3 }).notNull(), + inet: inet().notNull(), + integer: integer().notNull(), + interval: interval().notNull(), + json: json().notNull(), + jsonb: jsonb().notNull(), + line1: line({ mode: 'abc' }).notNull(), + line2: line({ mode: 'tuple' }).notNull(), + macaddr: macaddr().notNull(), + macaddr8: macaddr8().notNull(), + numeric: numeric().notNull(), + point1: point({ mode: 'xy' }).notNull(), + point2: point({ mode: 'tuple' }).notNull(), + real: real().notNull(), + serial: serial().notNull(), + smallint: smallint().notNull(), + smallserial: smallserial().notNull(), + text1: text().notNull(), + text2: text({ enum: ['a', 'b', 'c'] }).notNull(), + sparsevec: sparsevec({ dimensions: 3 }).notNull(), + time: time().notNull(), + timestamp1: timestamp({ mode: 'date' }).notNull(), + timestamp2: timestamp({ mode: 'string' }).notNull(), + uuid: uuid().notNull(), + varchar1: varchar({ length: 10 }).notNull(), + varchar2: varchar({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + vector: vector({ dimensions: 3 }).notNull(), + array1: integer().array().notNull(), + array2: integer().array().array(2).notNull(), + array3: varchar({ length: 10 }).array().array(2).notNull(), + })); + + const result = createSelectSchema(table); + const expected = t.Object({ + bigint1: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }), + bigint2: t.BigInt({ minimum: CONSTANTS.INT64_MIN, maximum: CONSTANTS.INT64_MAX }), + bigserial1: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }), + bigserial2: t.BigInt({ minimum: CONSTANTS.INT64_MIN, maximum: CONSTANTS.INT64_MAX }), + bit: t.RegExp(/^[01]+$/, { maxLength: 5 }), + boolean: t.Boolean(), + date1: t.Date(), + date2: t.String(), + char1: t.String({ minLength: 10, maxLength: 10 }), + char2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + cidr: t.String(), + doublePrecision: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + geometry1: t.Tuple([t.Number(), t.Number()]), + geometry2: t.Object({ x: t.Number(), y: t.Number() }), + halfvec: t.Array(t.Number(), { minItems: 3, maxItems: 3 }), + inet: t.String(), + integer: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: CONSTANTS.INT32_MAX }), + interval: t.String(), + json: jsonSchema, + jsonb: jsonSchema, + line1: t.Object({ a: t.Number(), b: t.Number(), c: t.Number() }), + line2: t.Tuple([t.Number(), t.Number(), t.Number()]), + macaddr: t.String(), + macaddr8: t.String(), + numeric: t.String(), + point1: t.Object({ x: t.Number(), y: t.Number() }), + point2: t.Tuple([t.Number(), t.Number()]), + real: t.Number({ minimum: CONSTANTS.INT24_MIN, maximum: CONSTANTS.INT24_MAX }), + serial: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: CONSTANTS.INT32_MAX }), + smallint: t.Integer({ minimum: CONSTANTS.INT16_MIN, maximum: CONSTANTS.INT16_MAX }), + smallserial: t.Integer({ minimum: CONSTANTS.INT16_MIN, maximum: CONSTANTS.INT16_MAX }), + text1: t.String(), + text2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + sparsevec: t.String(), + time: t.String(), + timestamp1: t.Date(), + timestamp2: t.String(), + uuid: t.String({ format: 'uuid' }), + varchar1: t.String({ maxLength: 10 }), + varchar2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + vector: t.Array(t.Number(), { minItems: 3, maxItems: 3 }), + array1: t.Array(integerSchema), + array2: t.Array(t.Array(integerSchema), { minItems: 2, maxItems: 2 }), + array3: t.Array(t.Array(t.String({ maxLength: 10 })), { minItems: 2, maxItems: 2 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +/* Disallow unknown keys in table refinement - select */ { + const table = pgTable('test', { id: integer() }); + // @ts-expect-error + createSelectSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - insert */ { + const table = pgTable('test', { id: integer() }); + // @ts-expect-error + createInsertSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - update */ { + const table = pgTable('test', { id: integer() }); + // @ts-expect-error + createUpdateSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in view qb - select */ { + const table = pgTable('test', { id: integer() }); + const view = pgView('test').as((qb) => qb.select().from(table)); + const mView = pgMaterializedView('test').as((qb) => qb.select().from(table)); + const nestedSelect = pgView('test').as((qb) => qb.select({ table }).from(table)); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); + // @ts-expect-error + createSelectSchema(mView, { unknown: t.String() }); + // @ts-expect-error + createSelectSchema(nestedSelect, { table: { unknown: t.String() } }); +} + +/* Disallow unknown keys in view columns - select */ { + const view = pgView('test', { id: integer() }).as(sql``); + const mView = pgView('test', { id: integer() }).as(sql``); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); + // @ts-expect-error + createSelectSchema(mView, { unknown: t.String() }); +} diff --git a/tests/singlestore.test.ts b/tests/singlestore.test.ts new file mode 100644 index 0000000..e594cfe --- /dev/null +++ b/tests/singlestore.test.ts @@ -0,0 +1,497 @@ +import { Type as t } from '@alkdev/typebox'; +import { type Equal, sql } from 'drizzle-orm'; +import { customType, int, serial, singlestoreSchema, singlestoreTable, text } from 'drizzle-orm/singlestore-core'; +import { test } from 'vitest'; +import { jsonSchema } from '~/column.ts'; +import { CONSTANTS } from '~/constants.ts'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from '../src'; +import { Expect, expectSchemaShape } from './utils.ts'; + +const intSchema = t.Integer({ + minimum: CONSTANTS.INT32_MIN, + maximum: CONSTANTS.INT32_MAX, +}); +const serialNumberModeSchema = t.Integer({ + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, +}); +const textSchema = t.String({ maxLength: CONSTANTS.INT16_UNSIGNED_MAX }); + +test('table - select', (tc) => { + const table = singlestoreTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table in schema - select', (tc) => { + const schema = singlestoreSchema('test'); + const table = schema.table('test', { + id: serial().primaryKey(), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - insert', (tc) => { + const table = singlestoreTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + age: int(), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + id: t.Optional(serialNumberModeSchema), + name: textSchema, + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - update', (tc) => { + const table = singlestoreTable('test', { + id: serial().primaryKey(), + name: text().notNull(), + age: int(), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + id: t.Optional(serialNumberModeSchema), + name: t.Optional(textSchema), + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +// TODO: SingleStore doesn't support views yet. Add these tests when they're added + +// test('view qb - select', (tc) => { +// const table = singlestoreTable('test', { +// id: serial().primaryKey(), +// name: text().notNull(), +// }); +// const view = mysqlView('test').as((qb) => qb.select({ id: table.id, age: sql``.as('age') }).from(table)); + +// const result = createSelectSchema(view); +// const expected = t.Object({ id: serialNumberModeSchema, age: t.Any() }); +// expectSchemaShape(tc, expected).from(result); +// Expect>(); +// }); + +// test('view columns - select', (tc) => { +// const view = mysqlView('test', { +// id: serial().primaryKey(), +// name: text().notNull(), +// }).as(sql``); + +// const result = createSelectSchema(view); +// const expected = t.Object({ id: serialNumberModeSchema, name: textSchema }); +// expectSchemaShape(tc, expected).from(result); +// Expect>(); +// }); + +// test('view with nested fields - select', (tc) => { +// const table = singlestoreTable('test', { +// id: serial().primaryKey(), +// name: text().notNull(), +// }); +// const view = mysqlView('test').as((qb) => +// qb.select({ +// id: table.id, +// nested: { +// name: table.name, +// age: sql``.as('age'), +// }, +// table, +// }).from(table) +// ); + +// const result = createSelectSchema(view); +// const expected = t.Object({ +// id: serialNumberModeSchema, +// nested: t.Object({ name: textSchema, age: t.Any() }), +// table: t.Object({ id: serialNumberModeSchema, name: textSchema }), +// }); +// expectSchemaShape(tc, expected).from(result); +// Expect>(); +// }); + +test('nullability - select', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: intSchema, + c3: t.Union([intSchema, t.Null()]), + c4: intSchema, + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - insert', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: intSchema, + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - update', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(intSchema), + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + }); + + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select with custom data type', (tc) => { + const customText = customType({ dataType: () => 'text' }); + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: customText(), + }); + + const customTextSchema = t.String({ minLength: 1, maxLength: 100 }); + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - insert', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - update', (tc) => { + const table = singlestoreTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 })), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +// test('refine view - select', (tc) => { +// const table = singlestoreTable('test', { +// c1: int(), +// c2: int(), +// c3: int(), +// c4: int(), +// c5: int(), +// c6: int(), +// }); +// const view = mysqlView('test').as((qb) => +// qb.select({ +// c1: table.c1, +// c2: table.c2, +// c3: table.c3, +// nested: { +// c4: table.c4, +// c5: table.c5, +// c6: table.c6, +// }, +// table, +// }).from(table) +// ); + +// const result = createSelectSchema(view, { +// c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), +// c3: t.Integer({ minimum: 1, maximum: 10 }), +// nested: { +// c5: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), +// c6: t.Integer({ minimum: 1, maximum: 10 }), +// }, +// table: { +// c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), +// c3: t.Integer({ minimum: 1, maximum: 10 }), +// }, +// }); +// const expected = t.Object({ +// c1: t.Union([intSchema, t.Null()]), +// c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), +// c3: t.Integer({ minimum: 1, maximum: 10 }), +// nested: t.Object({ +// c4: t.Union([intSchema, t.Null()]), +// c5: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), +// c6: t.Integer({ minimum: 1, maximum: 10 }), +// }), +// table: t.Object({ +// c1: t.Union([intSchema, t.Null()]), +// c2: t.Union([t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: 1000 }), t.Null()]), +// c3: t.Integer({ minimum: 1, maximum: 10 }), +// c4: t.Union([intSchema, t.Null()]), +// c5: t.Union([intSchema, t.Null()]), +// c6: t.Union([intSchema, t.Null()]), +// }), +// }); +// expectSchemaShape(tc, expected).from(result); +// Expect>(); +// }); + +test('all data types', (tc) => { + const table = singlestoreTable('test', ({ + bigint, + binary, + boolean, + char, + date, + datetime, + decimal, + double, + float, + int, + json, + mediumint, + singlestoreEnum, + real, + serial, + smallint, + text, + time, + timestamp, + tinyint, + varchar, + varbinary, + year, + longtext, + mediumtext, + tinytext, + }) => ({ + bigint1: bigint({ mode: 'number' }).notNull(), + bigint2: bigint({ mode: 'bigint' }).notNull(), + bigint3: bigint({ unsigned: true, mode: 'number' }).notNull(), + bigint4: bigint({ unsigned: true, mode: 'bigint' }).notNull(), + binary: binary({ length: 10 }).notNull(), + boolean: boolean().notNull(), + char1: char({ length: 10 }).notNull(), + char2: char({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + date1: date({ mode: 'date' }).notNull(), + date2: date({ mode: 'string' }).notNull(), + datetime1: datetime({ mode: 'date' }).notNull(), + datetime2: datetime({ mode: 'string' }).notNull(), + decimal1: decimal().notNull(), + decimal2: decimal({ unsigned: true }).notNull(), + double1: double().notNull(), + double2: double({ unsigned: true }).notNull(), + float1: float().notNull(), + float2: float({ unsigned: true }).notNull(), + int1: int().notNull(), + int2: int({ unsigned: true }).notNull(), + json: json().notNull(), + mediumint1: mediumint().notNull(), + mediumint2: mediumint({ unsigned: true }).notNull(), + enum: singlestoreEnum('enum', ['a', 'b', 'c']).notNull(), + real: real().notNull(), + serial: serial().notNull(), + smallint1: smallint().notNull(), + smallint2: smallint({ unsigned: true }).notNull(), + text1: text().notNull(), + text2: text({ enum: ['a', 'b', 'c'] }).notNull(), + time: time().notNull(), + timestamp1: timestamp({ mode: 'date' }).notNull(), + timestamp2: timestamp({ mode: 'string' }).notNull(), + tinyint1: tinyint().notNull(), + tinyint2: tinyint({ unsigned: true }).notNull(), + varchar1: varchar({ length: 10 }).notNull(), + varchar2: varchar({ length: 1, enum: ['a', 'b', 'c'] }).notNull(), + varbinary: varbinary({ length: 10 }).notNull(), + year: year().notNull(), + longtext1: longtext().notNull(), + longtext2: longtext({ enum: ['a', 'b', 'c'] }).notNull(), + mediumtext1: mediumtext().notNull(), + mediumtext2: mediumtext({ enum: ['a', 'b', 'c'] }).notNull(), + tinytext1: tinytext().notNull(), + tinytext2: tinytext({ enum: ['a', 'b', 'c'] }).notNull(), + })); + + const result = createSelectSchema(table); + const expected = t.Object({ + bigint1: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }), + bigint2: t.BigInt({ minimum: CONSTANTS.INT64_MIN, maximum: CONSTANTS.INT64_MAX }), + bigint3: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + bigint4: t.BigInt({ minimum: 0n, maximum: CONSTANTS.INT64_UNSIGNED_MAX }), + binary: t.String(), + boolean: t.Boolean(), + char1: t.String({ minLength: 10, maxLength: 10 }), + char2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + date1: t.Date(), + date2: t.String(), + datetime1: t.Date(), + datetime2: t.String(), + decimal1: t.String(), + decimal2: t.String(), + double1: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + double2: t.Number({ minimum: 0, maximum: CONSTANTS.INT48_UNSIGNED_MAX }), + float1: t.Number({ minimum: CONSTANTS.INT24_MIN, maximum: CONSTANTS.INT24_MAX }), + float2: t.Number({ minimum: 0, maximum: CONSTANTS.INT24_UNSIGNED_MAX }), + int1: t.Integer({ minimum: CONSTANTS.INT32_MIN, maximum: CONSTANTS.INT32_MAX }), + int2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT32_UNSIGNED_MAX }), + json: jsonSchema, + mediumint1: t.Integer({ minimum: CONSTANTS.INT24_MIN, maximum: CONSTANTS.INT24_MAX }), + mediumint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT24_UNSIGNED_MAX }), + enum: t.Enum({ a: 'a', b: 'b', c: 'c' }), + real: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + serial: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + smallint1: t.Integer({ minimum: CONSTANTS.INT16_MIN, maximum: CONSTANTS.INT16_MAX }), + smallint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT16_UNSIGNED_MAX }), + text1: t.String({ maxLength: CONSTANTS.INT16_UNSIGNED_MAX }), + text2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + time: t.String(), + timestamp1: t.Date(), + timestamp2: t.String(), + tinyint1: t.Integer({ minimum: CONSTANTS.INT8_MIN, maximum: CONSTANTS.INT8_MAX }), + tinyint2: t.Integer({ minimum: 0, maximum: CONSTANTS.INT8_UNSIGNED_MAX }), + varchar1: t.String({ maxLength: 10 }), + varchar2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + varbinary: t.String(), + year: t.Integer({ minimum: 1901, maximum: 2155 }), + longtext1: t.String({ maxLength: CONSTANTS.INT32_UNSIGNED_MAX }), + longtext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + mediumtext1: t.String({ maxLength: CONSTANTS.INT24_UNSIGNED_MAX }), + mediumtext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + tinytext1: t.String({ maxLength: CONSTANTS.INT8_UNSIGNED_MAX }), + tinytext2: t.Enum({ a: 'a', b: 'b', c: 'c' }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +/* Disallow unknown keys in table refinement - select */ { + const table = singlestoreTable('test', { id: int() }); + // @ts-expect-error + createSelectSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - insert */ { + const table = singlestoreTable('test', { id: int() }); + // @ts-expect-error + createInsertSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - update */ { + const table = singlestoreTable('test', { id: int() }); + // @ts-expect-error + createUpdateSchema(table, { unknown: t.String() }); +} + +// /* Disallow unknown keys in view qb - select */ { +// const table = singlestoreTable('test', { id: int() }); +// const view = mysqlView('test').as((qb) => qb.select().from(table)); +// const nestedSelect = mysqlView('test').as((qb) => qb.select({ table }).from(table)); +// // @ts-expect-error +// createSelectSchema(view, { unknown: t.String() }); +// // @ts-expect-error +// createSelectSchema(nestedSelect, { table: { unknown: t.String() } }); +// } + +// /* Disallow unknown keys in view columns - select */ { +// const view = mysqlView('test', { id: int() }).as(sql``); +// // @ts-expect-error +// createSelectSchema(view, { unknown: t.String() }); +// } diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts new file mode 100644 index 0000000..468b225 --- /dev/null +++ b/tests/sqlite.test.ts @@ -0,0 +1,389 @@ +import { Type as t } from '@alkdev/typebox'; +import { type Equal, sql } from 'drizzle-orm'; +import { customType, int, sqliteTable, sqliteView, text } from 'drizzle-orm/sqlite-core'; +import { test } from 'vitest'; +import { bufferSchema, jsonSchema } from '~/column.ts'; +import { CONSTANTS } from '~/constants.ts'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from '../src'; +import { Expect, expectSchemaShape } from './utils.ts'; + +const intSchema = t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }); +const textSchema = t.String(); + +test('table - select', (tc) => { + const table = sqliteTable('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ id: intSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - insert', (tc) => { + const table = sqliteTable('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + age: int(), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + id: t.Optional(intSchema), + name: textSchema, + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('table - update', (tc) => { + const table = sqliteTable('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + age: int(), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + id: t.Optional(intSchema), + name: t.Optional(textSchema), + age: t.Optional(t.Union([intSchema, t.Null()])), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view qb - select', (tc) => { + const table = sqliteTable('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + }); + const view = sqliteView('test').as((qb) => qb.select({ id: table.id, age: sql``.as('age') }).from(table)); + + const result = createSelectSchema(view); + const expected = t.Object({ id: intSchema, age: t.Any() }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view columns - select', (tc) => { + const view = sqliteView('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + }).as(sql``); + + const result = createSelectSchema(view); + const expected = t.Object({ id: intSchema, name: textSchema }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('view with nested fields - select', (tc) => { + const table = sqliteTable('test', { + id: int().primaryKey({ autoIncrement: true }), + name: text().notNull(), + }); + const view = sqliteView('test').as((qb) => + qb.select({ + id: table.id, + nested: { + name: table.name, + age: sql``.as('age'), + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view); + const expected = t.Object({ + id: intSchema, + nested: t.Object({ name: textSchema, age: t.Any() }), + table: t.Object({ id: intSchema, name: textSchema }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - select', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + }); + + const result = createSelectSchema(table); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: intSchema, + c3: t.Union([intSchema, t.Null()]), + c4: intSchema, + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - insert', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: intSchema, + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('nullability - update', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().default(1), + c4: int().notNull().default(1), + c5: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(intSchema), + c3: t.Optional(t.Union([intSchema, t.Null()])), + c4: t.Optional(intSchema), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + }); + + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - select with custom data type', (tc) => { + const customText = customType({ dataType: () => 'text' }); + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: customText(), + }); + + const customTextSchema = t.String({ minLength: 1, maxLength: 100 }); + const result = createSelectSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: customTextSchema, + }); + + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - insert', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createInsertSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine table - update', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int().notNull(), + c3: int().notNull(), + c4: int().generatedAlwaysAs(1), + }); + + const result = createUpdateSchema(table, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + const expected = t.Object({ + c1: t.Optional(t.Union([intSchema, t.Null()])), + c2: t.Optional(t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 })), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('refine view - select', (tc) => { + const table = sqliteTable('test', { + c1: int(), + c2: int(), + c3: int(), + c4: int(), + c5: int(), + c6: int(), + }); + const view = sqliteView('test').as((qb) => + qb.select({ + c1: table.c1, + c2: table.c2, + c3: table.c3, + nested: { + c4: table.c4, + c5: table.c5, + c6: table.c6, + }, + table, + }).from(table) + ); + + const result = createSelectSchema(view, { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: { + c5: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }, + table: { + c2: (schema) => t.Integer({ minimum: schema.minimum, maximum: 1000 }), + c3: t.Integer({ minimum: 1, maximum: 10 }), + }, + }); + const expected = t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + nested: t.Object({ + c4: t.Union([intSchema, t.Null()]), + c5: t.Union([t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), t.Null()]), + c6: t.Integer({ minimum: 1, maximum: 10 }), + }), + table: t.Object({ + c1: t.Union([intSchema, t.Null()]), + c2: t.Union([t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: 1000 }), t.Null()]), + c3: t.Integer({ minimum: 1, maximum: 10 }), + c4: t.Union([intSchema, t.Null()]), + c5: t.Union([intSchema, t.Null()]), + c6: t.Union([intSchema, t.Null()]), + }), + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +test('all data types', (tc) => { + const table = sqliteTable('test', ({ + blob, + integer, + numeric, + real, + text, + }) => ({ + blob1: blob({ mode: 'buffer' }).notNull(), + blob2: blob({ mode: 'bigint' }).notNull(), + blob3: blob({ mode: 'json' }).notNull(), + integer1: integer({ mode: 'number' }).notNull(), + integer2: integer({ mode: 'boolean' }).notNull(), + integer3: integer({ mode: 'timestamp' }).notNull(), + integer4: integer({ mode: 'timestamp_ms' }).notNull(), + numeric: numeric().notNull(), + real: real().notNull(), + text1: text({ mode: 'text' }).notNull(), + text2: text({ mode: 'text', length: 10 }).notNull(), + text3: text({ mode: 'text', enum: ['a', 'b', 'c'] }).notNull(), + text4: text({ mode: 'json' }).notNull(), + })); + + const result = createSelectSchema(table); + const expected = t.Object({ + blob1: bufferSchema, + blob2: t.BigInt({ minimum: CONSTANTS.INT64_MIN, maximum: CONSTANTS.INT64_MAX }), + blob3: jsonSchema, + integer1: t.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }), + integer2: t.Boolean(), + integer3: t.Date(), + integer4: t.Date(), + numeric: t.String(), + real: t.Number({ minimum: CONSTANTS.INT48_MIN, maximum: CONSTANTS.INT48_MAX }), + text1: t.String(), + text2: t.String({ maxLength: 10 }), + text3: t.Enum({ a: 'a', b: 'b', c: 'c' }), + text4: jsonSchema, + }); + expectSchemaShape(tc, expected).from(result); + Expect>(); +}); + +/* Disallow unknown keys in table refinement - select */ { + const table = sqliteTable('test', { id: int() }); + // @ts-expect-error + createSelectSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - insert */ { + const table = sqliteTable('test', { id: int() }); + // @ts-expect-error + createInsertSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in table refinement - update */ { + const table = sqliteTable('test', { id: int() }); + // @ts-expect-error + createUpdateSchema(table, { unknown: t.String() }); +} + +/* Disallow unknown keys in view qb - select */ { + const table = sqliteTable('test', { id: int() }); + const view = sqliteView('test').as((qb) => qb.select().from(table)); + const nestedSelect = sqliteView('test').as((qb) => qb.select({ table }).from(table)); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); + // @ts-expect-error + createSelectSchema(nestedSelect, { table: { unknown: t.String() } }); +} + +/* Disallow unknown keys in view columns - select */ { + const view = sqliteView('test', { id: int() }).as(sql``); + // @ts-expect-error + createSelectSchema(view, { unknown: t.String() }); +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..18f5dee --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "noEmit": true, + "rootDir": "..", + "outDir": "./.cache" + }, + "include": [".", "../src"] +} diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..7f99e97 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,34 @@ +import type * as t from '@alkdev/typebox'; +import { expect, type TaskContext } from 'vitest'; + +function removeKeysFromObject(obj: Record, keys: string[]) { + for (const key of keys) { + delete obj[key]; + } + return obj; +} + +export function expectSchemaShape(t: TaskContext, expected: T) { + return { + from(actual: T) { + expect(Object.keys(actual.properties)).toStrictEqual(Object.keys(expected.properties)); + const keys = ['$id', '$schema', 'title', 'description', 'default', 'examples', 'readOnly', 'writeOnly']; + + for (const key of Object.keys(actual.properties)) { + expect(removeKeysFromObject(actual.properties[key]!, keys)).toStrictEqual( + removeKeysFromObject(expected.properties[key]!, keys), + ); + } + }, + }; +} + +export function expectEnumValues>(t: TaskContext, expected: T) { + return { + from(actual: T) { + expect(actual.anyOf).toStrictEqual(expected.anyOf); + }, + }; +} + +export function Expect<_ extends true>() {} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3377281 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ab320f9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["es2020", "es2018", "es2017", "es7", "es6", "es5"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "skipLibCheck": true, + "noErrorTruncation": true, + "noEmit": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "~/*": ["src/*"] + } + }, + "include": ["src", "*.ts"] +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1f0eb7a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: [ + 'tests/**/*.test.ts', + ], + exclude: [ + 'tests/bun/**/*', + ], + typecheck: { + tsconfig: 'tsconfig.json', + }, + testTimeout: 100000, + hookTimeout: 100000, + isolate: false, + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, + plugins: [tsconfigPaths()], +});