diff --git a/packages/arktype-drizzle/README.md b/packages/arktype-drizzle/README.md new file mode 100644 index 0000000..9804e38 --- /dev/null +++ b/packages/arktype-drizzle/README.md @@ -0,0 +1 @@ +# ArkType Drizzle Validators \ No newline at end of file diff --git a/packages/arktype-drizzle/package.json b/packages/arktype-drizzle/package.json new file mode 100644 index 0000000..1f5114a --- /dev/null +++ b/packages/arktype-drizzle/package.json @@ -0,0 +1,23 @@ +{ + "name": "@jhecht/arktype-drizzle", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "echo 'Add build script here'", + "test": "vitest run", + "test:watch": "vitest --coverage", + "lint": "echo 'Add lint script here'" + }, + "peerDependencies": { + "arktype": "^2.0.4", + "drizzle-orm": "0.32.1" + }, + "devDependencies": { + "@jhecht/eslint-plugin": "workspace:*", + "@jhecht/typescript-config": "workspace:*", + "@vitest/coverage-v8": "^1.2.2", + "arktype": "^2.0.4", + "vitest": "^1.1.3" + } +} \ No newline at end of file diff --git a/packages/arktype-drizzle/src/index.test.ts b/packages/arktype-drizzle/src/index.test.ts new file mode 100644 index 0000000..eaad77a --- /dev/null +++ b/packages/arktype-drizzle/src/index.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { createInsertSchema, mapToArkType } from './main.js'; +import { + pgTable, + text, + uuid, + integer, + date, + timestamp, + smallint, + bigint, + smallserial, + bigserial, + varchar, +} from 'drizzle-orm/pg-core'; + +describe('mapToArkType', () => { + const intTable = pgTable('integers', { + base: integer('base'), + smallInt: smallint('small_int'), + bigInt: bigint('big_int', { mode: 'number' }), + reallyBigInt: bigint('really_big_int', { mode: 'bigint' }), + smallSerial: smallserial('small_serial'), + bigSerial: bigserial('big_serial', { mode: 'number' }), + reallyBigSerial: bigserial('really_big_serial', { mode: 'bigint' }), + }); + + const textTable = pgTable('texts', { + text: text('text'), + textWithEnum: text('text_with_enums', { enum: ['a', 'b'] }), + varchar: varchar('varchar_column'), + varcharWithLimit: varchar('varchar_with_limit', { length: 10 }), + }); + + it('Works with integer columns', () => { + expect(mapToArkType(intTable.base)).toBe('number'); + expect(mapToArkType(intTable.smallInt)).toBe('number'); + expect(mapToArkType(intTable.bigInt)).toBe('number'); + expect(mapToArkType(intTable.reallyBigInt)).toBe('bigint'); + expect(mapToArkType(intTable.smallSerial)).toBe('number'); + expect(mapToArkType(intTable.bigSerial)).toBe('number'); + expect(mapToArkType(intTable.reallyBigSerial)).toBe('bigint'); + }); + + it('Works with string/text columns', () => { + expect(mapToArkType(textTable.text)).toBe('string'); + expect(mapToArkType(textTable.textWithEnum)).toBe("'a' | 'b'"); + expect(mapToArkType(textTable.varchar)).toBe('string'); + expect(mapToArkType(textTable.varcharWithLimit)).toBe('string<=10'); + }); +}); + +describe('createInsertSchema', () => { + const exampleWithArrayColumn = pgTable('example', { + id: uuid('id').primaryKey(), + things: integer('things').array(), + something: integer('something').notNull(), + date: date('date', { mode: 'string' }), + }); + + const exampleSimple = pgTable('users', { + id: uuid('id').primaryKey(), + name: text('name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + varchar: varchar('varchar', { length: 100 }), + }); + + describe('createInsertSchema', () => { + it('Works with simple types', () => { + const insertSchema = createInsertSchema(exampleSimple); + const uid = crypto.randomUUID(); + const d = new Date(); + const output = insertSchema({ + id: uid, + name: 'Testing', + createdAt: d, + varchar: 'a', + }); + // No problems + expect(output.problems).toBeUndefined(); + + // data is valid + expect(output.data).toStrictEqual({ + id: uid, + name: 'Testing', + createdAt: d, + varchar: 'a', + }); + }); + }); +}); diff --git a/packages/arktype-drizzle/src/main.ts b/packages/arktype-drizzle/src/main.ts new file mode 100644 index 0000000..a382998 --- /dev/null +++ b/packages/arktype-drizzle/src/main.ts @@ -0,0 +1,197 @@ +import { type, type Type } from 'arktype'; +import type { + Assume, + Column, + DrizzleTypeError, + Equal, + Simplify, + Table, +} from 'drizzle-orm'; +import { getTableColumns, is } from 'drizzle-orm'; +import { + PgArray, + PgDate, + PgTimestamp, + PgUUID, + PgVarchar, + pgTable, + text, + uuid, +} from 'drizzle-orm/pg-core'; + +const literalSchema = type('string | number | boolean | null'); + +type Literal = typeof literalSchema.infer; + +type Json = Literal | { [key: string]: Json }; + +type MapInsertColumnsToArk< + TColumn extends Column, + TType extends Type, +> = TColumn['_']['notNull'] extends false + ? Type> + : TColumn['_']['hasDefault'] extends true + ? Type> + : TType; + +type MapSelectColumnToArk< + TColumn extends Column, + TType extends Type, +> = TColumn['_']['notNull'] extends false ? Type> : TType; + +type MapColumnToArk< + TColumn extends Column, + TType extends Type, + TMode extends 'insert' | 'select', +> = TMode extends 'insert' + ? MapInsertColumnsToArk + : MapSelectColumnToArk; + +type MaybeOptional< + TColumn extends Column, + TType extends Type, + TMode extends 'insert' | 'select', + TNoOptional extends boolean, +> = TNoOptional extends true ? TType : MapColumnToArk; + +let a = arrayOf('number | string'); + +type GetArkType = + TColumn['_']['dataType'] extends infer TDataType + ? TDataType extends 'custom' + ? PrecompiledDefaults['any'] + : TDataType extends 'json' + ? Type + : TColumn extends { enumValues: [string, ...string[]] } + ? Equal extends true + ? PrecompiledDefaults['string'] + : Type + : TDataType extends 'array' + ? GetArkType< + Assume['baseColumn'] + >[] + : TDataType extends 'bigint' + ? PrecompiledDefaults['bigint'] + : TDataType extends 'number' + ? PrecompiledDefaults['number'] + : TDataType extends 'string' + ? PrecompiledDefaults['string'] + : TDataType extends 'boolean' + ? PrecompiledDefaults['boolean'] + : TDataType extends 'date' + ? PrecompiledDefaults['Date'] + : PrecompiledDefaults['any'] + : never; + +type ValueOrUpdater = T | ((arg: TUpdaterArg) => T); + +type UnwrapValueOrUpdater = + T extends ValueOrUpdater ? U : never; + +type Refine = { + [K in keyof TTable['_']['columns']]?: ValueOrUpdater< + PrecompiledDefaults['any'], + TMode extends 'select' + ? BuildSelectSchema + : BuildInsertSchema + >; +}; + +type BuildSelectSchema< + TTable extends Table, + TRefine extends Refine, + TNoOptional extends boolean = false, +> = Simplify<{ + [K in keyof TTable['_']['columns']]: MaybeOptional< + TTable['_']['columns'][K], + K extends keyof TRefine + ? Assume, PrecompiledDefaults['any']> + : GetArkType, + 'select', + TNoOptional + >; +}>; + +export type BuildInsertSchema< + TTable extends Table, + TRefine extends Refine | {}, + TNoOptional extends boolean = false, +> = TTable['_']['columns'] extends infer TColumns extends Record< + string, + Column +> + ? { + [K in keyof TColumns & string]: MaybeOptional< + TColumns[K], + K extends keyof TRefine + ? Assume, PrecompiledDefaults['any']> + : GetArkType, + 'insert', + TNoOptional + >; + } + : never; + +const exampleTable = pgTable('something', { + id: uuid('id').primaryKey(), + name: text('name').array(), +}); + +export function createInsertSchema< + TTable extends Table, + TRefine extends Refine = Refine, +>( + table: TTable, + /** + * @param refine Refine schema fields + */ + refine?: { + [K in keyof TRefine]: K extends keyof TTable['_']['columns'] + ? TRefine[K] + : DrizzleTypeError<`Column '${K & string}' does not exist in table '${TTable['_']['name']}'`>; + }, +): Type< + BuildInsertSchema< + TTable, + Equal> extends true ? {} : TRefine + > +> { + const columns = getTableColumns(table); + const columnEntries = Object.entries(columns); + + const map = Object.fromEntries( + columnEntries.map(([key, value]) => columnToArkType(key, value)), + ); + + return type(map); +} + +function columnToArkType(key: string, column: Column) { + // console.info(column); + let mappedKey = key; + + if (!column.notNull || column.hasDefault) mappedKey = `${mappedKey}?`; + + return [mappedKey, mapToArkType(column)]; +} + +export function createUpdateSchema() {} + +export function mapToArkType( + column: Column, +): keyof PrecompiledDefaults | string { + if (is(column, PgUUID)) return 'uuid'; + + if (is(column, PgDate) || is(column, PgTimestamp)) return 'Date'; + + if (column.dataType === 'array') + return mapToArkType((column as PgArray).baseColumn); + + if (column.dataType === 'string' && column.enumValues) + return `${column.enumValues.map(v => `'${v}'`).join(' | ')}`; + + if (is(column, PgVarchar) && column.length) + return `${column.dataType}<=${column.length}`; + + return column.dataType as keyof PrecompiledDefaults; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5470a99..4e8da23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,28 @@ importers: specifier: latest version: 1.13.3 + packages/arktype-drizzle: + dependencies: + drizzle-orm: + specifier: 0.32.1 + version: 0.32.1 + devDependencies: + '@jhecht/eslint-plugin': + specifier: workspace:* + version: link:../eslint-plugin + '@jhecht/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@vitest/coverage-v8': + specifier: ^1.2.2 + version: 1.2.2(vitest@1.2.2) + arktype: + specifier: ^2.0.4 + version: 2.0.4 + vitest: + specifier: ^1.1.3 + version: 1.2.2(@vitest/browser@1.2.2)(jsdom@24.0.0) + packages/arktype-utils: devDependencies: '@jhecht/eslint-plugin': @@ -1256,6 +1278,13 @@ packages: '@ark/util': 0.39.0 dev: true + /arktype@2.0.4: + resolution: {integrity: sha512-S68rWVDnJauwH7/QCm8zCUM3aTe9Xk6oRihdcc3FSUAtxCo/q1Fwq46JhcwB5Ufv1YStwdQRz+00Y/URlvbhAQ==} + dependencies: + '@ark/schema': 0.39.0 + '@ark/util': 0.39.0 + dev: true + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -1668,6 +1697,96 @@ packages: esutils: 2.0.3 dev: true + /drizzle-orm@0.32.1: + resolution: {integrity: sha512-Wq1J+lL8PzwR5K3a1FfoWsbs8powjr3pGA4+5+2ueN1VTLDNFYEolUyUWFtqy8DVRvYbL2n7sXZkgVmK9dQkng==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=3' + '@electric-sql/pglite': '>=0.1.1' + '@libsql/client': '*' + '@neondatabase/serverless': '>=0.1' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=13.2.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true