diff --git a/src/packages/dumbo/src/core/index.ts b/src/packages/dumbo/src/core/index.ts index cb6df199..dfba1a8e 100644 --- a/src/packages/dumbo/src/core/index.ts +++ b/src/packages/dumbo/src/core/index.ts @@ -6,6 +6,8 @@ import type { ExtractDumboDatabaseDriverOptions, InferDriverDatabaseType, } from './drivers'; +import { dumboSchema } from './schema'; +import { SQL, SQLColumnTypeTokensFactory } from './sql'; export * from './connections'; export * from './drivers'; @@ -15,6 +17,7 @@ export * from './query'; export * from './schema'; export * from './serializer'; export * from './sql'; +export * from './testing'; export * from './tracing'; export type Dumbo< @@ -39,3 +42,16 @@ export type DumboConnectionOptions< } & Omit : never : never; + +declare module './sql' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace SQL { + export const columnN: typeof dumboSchema.column & { + type: typeof SQLColumnTypeTokensFactory; + }; + } +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access +(SQL as any).columnN = Object.assign(dumboSchema.column, { + type: SQLColumnTypeTokensFactory, +}); diff --git a/src/packages/dumbo/src/core/schema/components/columnSchemaComponent.ts b/src/packages/dumbo/src/core/schema/components/columnSchemaComponent.ts new file mode 100644 index 00000000..af9d3ba2 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/columnSchemaComponent.ts @@ -0,0 +1,81 @@ +import type { AnyColumnTypeToken, SQLColumnToken } from '../../sql'; +import { + schemaComponent, + type SchemaComponent, + type SchemaComponentOptions, +} from '../schemaComponent'; + +export type ColumnURNType = 'sc:dumbo:column'; +export type ColumnURN = + `${ColumnURNType}:${ColumnName}`; + +export const ColumnURNType: ColumnURNType = 'sc:dumbo:column'; +export const ColumnURN = ({ + name, +}: { + name: ColumnName; +}): ColumnURN => `${ColumnURNType}:${name}`; + +export type ColumnSchemaComponent< + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, + ColumnName extends string = string, +> = SchemaComponent< + ColumnURN, + Readonly<{ + columnName: ColumnName; + }> +> & + SQLColumnToken; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyColumnSchemaComponent = ColumnSchemaComponent; + +export type ColumnSchemaComponentOptions< + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, +> = Omit, 'name' | 'sqlTokenType'> & + SchemaComponentOptions; + +export const columnSchemaComponent = < + const ColumnType extends AnyColumnTypeToken | string = + | AnyColumnTypeToken + | string, + const TOptions extends + ColumnSchemaComponentOptions = ColumnSchemaComponentOptions, + const ColumnName extends string = string, +>( + params: { + columnName: ColumnName; + } & TOptions, +): ColumnSchemaComponent & + (TOptions extends { notNull: true } | { primaryKey: true } + ? { notNull: true } + : { notNull?: false }) => { + const { + columnName, + type, + notNull, + unique, + primaryKey, + default: defaultValue, + ...schemaOptions + } = params; + + const sc = schemaComponent(ColumnURN({ name: columnName }), schemaOptions); + + const result: Record = { + ...sc, + columnName, + notNull, + unique, + primaryKey, + defaultValue, + sqlTokenType: 'SQL_COLUMN', + name: columnName, + type, + }; + + return result as ColumnSchemaComponent & + (TOptions extends { notNull: true } | { primaryKey: true } + ? { notNull: true } + : { notNull?: false }); +}; diff --git a/src/packages/dumbo/src/core/schema/components/databaseSchemaComponent.ts b/src/packages/dumbo/src/core/schema/components/databaseSchemaComponent.ts new file mode 100644 index 00000000..f5eb61d1 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/databaseSchemaComponent.ts @@ -0,0 +1,83 @@ +import { + mapSchemaComponentsOfType, + schemaComponent, + type SchemaComponent, + type SchemaComponentOptions, +} from '../schemaComponent'; +import { + DatabaseSchemaURNType, + databaseSchemaSchemaComponent, + type AnyDatabaseSchemaSchemaComponent, + type DatabaseSchemaSchemaComponent, +} from './databaseSchemaSchemaComponent'; + +export type DatabaseURNType = 'sc:dumbo:database'; +export type DatabaseURN = `${DatabaseURNType}:${string}`; + +export const DatabaseURNType: DatabaseURNType = 'sc:dumbo:database'; +export const DatabaseURN = ({ name }: { name: string }): DatabaseURN => + `${DatabaseURNType}:${name}`; + +export type DatabaseSchemas< + Schemas extends + AnyDatabaseSchemaSchemaComponent = AnyDatabaseSchemaSchemaComponent, +> = Record; + +export type DatabaseSchemaComponent< + Schemas extends DatabaseSchemas = DatabaseSchemas, +> = SchemaComponent< + DatabaseURN, + Readonly<{ + databaseName: string; + schemas: ReadonlyMap & Schemas; + addSchema: ( + schema: string | DatabaseSchemaSchemaComponent, + ) => DatabaseSchemaSchemaComponent; + }> +>; + +export type AnyDatabaseSchemaComponent = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + DatabaseSchemaComponent; + +export const databaseSchemaComponent = < + Schemas extends DatabaseSchemas = DatabaseSchemas, +>({ + databaseName, + schemas, + ...migrationsOrComponents +}: { + databaseName: string; + schemas?: Schemas; +} & SchemaComponentOptions): DatabaseSchemaComponent => { + schemas ??= {} as Schemas; + + const base = schemaComponent(DatabaseURN({ name: databaseName }), { + migrations: migrationsOrComponents.migrations ?? [], + components: [ + ...(migrationsOrComponents.components ?? []), + ...Object.values(schemas), + ], + }); + + return { + ...base, + databaseName, + get schemas() { + const schemasMap = + mapSchemaComponentsOfType( + base.components, + DatabaseSchemaURNType, + (c) => c.schemaName, + ); + + return Object.assign(schemasMap, schemas); + }, + addSchema: (schema: string | DatabaseSchemaSchemaComponent) => + base.addComponent( + typeof schema === 'string' + ? databaseSchemaSchemaComponent({ schemaName: schema }) + : schema, + ), + }; +}; diff --git a/src/packages/dumbo/src/core/schema/components/databaseSchemaSchemaComponent.ts b/src/packages/dumbo/src/core/schema/components/databaseSchemaSchemaComponent.ts new file mode 100644 index 00000000..99074006 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/databaseSchemaSchemaComponent.ts @@ -0,0 +1,87 @@ +import { + mapSchemaComponentsOfType, + schemaComponent, + type SchemaComponent, + type SchemaComponentOptions, +} from '../schemaComponent'; +import { + TableURNType, + tableSchemaComponent, + type AnyTableSchemaComponent, + type TableSchemaComponent, +} from './tableSchemaComponent'; + +export type DatabaseSchemaURNType = 'sc:dumbo:database_schema'; +export type DatabaseSchemaURN = + `${DatabaseSchemaURNType}:${SchemaName}`; + +export const DatabaseSchemaURNType: DatabaseSchemaURNType = + 'sc:dumbo:database_schema'; +export const DatabaseSchemaURN = ({ + name, +}: { + name: SchemaName; +}): DatabaseSchemaURN => `${DatabaseSchemaURNType}:${name}`; + +export type DatabaseSchemaTables< + Tables extends AnyTableSchemaComponent = AnyTableSchemaComponent, +> = Record; + +export type DatabaseSchemaSchemaComponent< + Tables extends DatabaseSchemaTables = DatabaseSchemaTables, + SchemaName extends string = string, +> = SchemaComponent< + DatabaseSchemaURN, + Readonly<{ + schemaName: SchemaName; + tables: ReadonlyMap & Tables; + addTable: (table: string | TableSchemaComponent) => TableSchemaComponent; + }> +>; + +export type AnyDatabaseSchemaSchemaComponent = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + DatabaseSchemaSchemaComponent; + +export const databaseSchemaSchemaComponent = < + const Tables extends DatabaseSchemaTables = DatabaseSchemaTables, + const SchemaName extends string = string, +>({ + schemaName, + tables, + ...migrationsOrComponents +}: { + schemaName: SchemaName; + tables?: Tables; +} & SchemaComponentOptions): DatabaseSchemaSchemaComponent< + Tables, + SchemaName +> => { + const base = schemaComponent(DatabaseSchemaURN({ name: schemaName }), { + migrations: migrationsOrComponents.migrations ?? [], + components: [ + ...(migrationsOrComponents.components ?? []), + ...Object.values(tables ?? {}), + ], + }); + + return { + ...base, + schemaName, + get tables() { + const tablesMap = mapSchemaComponentsOfType( + base.components, + TableURNType, + (c) => c.tableName, + ); + + return Object.assign(tablesMap, tables); + }, + addTable: (table: string | TableSchemaComponent) => + base.addComponent( + typeof table === 'string' + ? tableSchemaComponent({ tableName: table }) + : table, + ), + }; +}; diff --git a/src/packages/dumbo/src/core/schema/components/index.ts b/src/packages/dumbo/src/core/schema/components/index.ts new file mode 100644 index 00000000..f1bc6b9d --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/index.ts @@ -0,0 +1,25 @@ +import { ColumnURN } from './columnSchemaComponent'; +import { DatabaseURN } from './databaseSchemaComponent'; +import { DatabaseSchemaURN } from './databaseSchemaSchemaComponent'; +import { IndexURN } from './indexSchemaComponent'; +import { TableURN } from './tableSchemaComponent'; + +export * from './columnSchemaComponent'; +export * from './databaseSchemaComponent'; +export * from './databaseSchemaSchemaComponent'; +export * from './indexSchemaComponent'; +export * from './relationships'; +export * from './tableSchemaComponent'; +export * from './tableTypesInference'; + +export const schemaComponentURN = { + database: DatabaseURN, + schema: DatabaseSchemaURN, + table: TableURN, + column: ColumnURN, + index: IndexURN, + extractName: (urn: string): string => { + const parts = urn.split(':'); + return parts[parts.length - 1] || ''; + }, +} as const; diff --git a/src/packages/dumbo/src/core/schema/components/indexSchemaComponent.ts b/src/packages/dumbo/src/core/schema/components/indexSchemaComponent.ts new file mode 100644 index 00000000..6dc80afd --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/indexSchemaComponent.ts @@ -0,0 +1,50 @@ +import { + schemaComponent, + type SchemaComponent, + type SchemaComponentOptions, +} from '../schemaComponent'; +import { type ColumnSchemaComponent } from './columnSchemaComponent'; + +export type IndexURNType = 'sc:dumbo:index'; +export type IndexURN = `${IndexURNType}:${string}`; + +export type IndexSchemaComponent = SchemaComponent< + IndexURN, + Readonly<{ + indexName: string; + columnNames: ReadonlyArray; + isUnique: boolean; + addColumn: (column: string | ColumnSchemaComponent) => void; + }> +>; + +export const IndexURNType: IndexURNType = 'sc:dumbo:index'; +export const IndexURN = ({ name }: { name: string }): IndexURN => + `${IndexURNType}:${name}`; + +export const indexSchemaComponent = ({ + indexName, + columnNames, + isUnique, + ...migrationsOrComponents +}: { + indexName: string; + columnNames: string[]; + isUnique: boolean; +} & SchemaComponentOptions): IndexSchemaComponent => { + const sc = schemaComponent(IndexURN({ name: indexName }), { + migrations: migrationsOrComponents.migrations ?? [], + components: [...(migrationsOrComponents.components ?? [])], + }); + + return { + ...sc, + indexName, + get columnNames() { + return columnNames; + }, + addColumn: (column: string | ColumnSchemaComponent) => + columnNames.push(typeof column === 'string' ? column : column.columnName), + isUnique, + }; +}; diff --git a/src/packages/dumbo/src/core/schema/components/relationships/collectReferencesErrors.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/collectReferencesErrors.type.spec.ts new file mode 100644 index 00000000..f470ed68 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/collectReferencesErrors.type.spec.ts @@ -0,0 +1,272 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect } from '../../../testing'; +import { dumboSchema } from '../../dumboSchema'; +import type { SchemaColumnName } from './relationshipTypes'; +import type { + CollectReferencesErrors, + ColumnReferenceExistanceError, + ColumnReferenceTypeMismatchError, +} from './relationshipValidation'; + +const { column, table, schema } = dumboSchema; +const { BigInteger, Varchar, Integer } = SQL.column.type; + +void describe('CollectReferencesErrors', () => { + const usersTable = table('users', { + columns: { + id: column('id', BigInteger), + name: column('name', Varchar('max')), + age: column('age', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + title: column('title', Varchar('max')), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + }); + + type TestSchemas = { + public: typeof _publicSchema; + }; + + void it('returns empty array when all references are valid', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [SchemaColumnName<'public', 'users', 'id'>]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type _Then = Expect>; + }); + + void it('returns empty array for multiple valid references', () => { + type Columns = readonly [ + SchemaColumnName<'public', 'posts', 'user_id'>, + SchemaColumnName<'public', 'posts', 'title'>, + ]; + type References = readonly [ + SchemaColumnName<'public', 'users', 'id'>, + SchemaColumnName<'public', 'users', 'name'>, + ]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type _Then = Expect>; + }); + + void it('collects error for missing schema', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [SchemaColumnName<'nonexistent', 'users', 'id'>]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceExistanceError<'missing_schema', 'nonexistent.users.id'>, + ]; + + type _Then = Expect>; + }); + + void it('collects error for missing table', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [ + SchemaColumnName<'public', 'nonexistent', 'id'>, + ]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceExistanceError<'missing_table', 'public.nonexistent.id'>, + ]; + + type _Then = Expect>; + }); + + void it('collects error for missing column', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [ + SchemaColumnName<'public', 'users', 'nonexistent'>, + ]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceExistanceError< + 'missing_column', + 'public.users.nonexistent' + >, + ]; + + type _Then = Expect>; + }); + + void it('collects error for type mismatch', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [SchemaColumnName<'public', 'users', 'name'>]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceTypeMismatchError< + 'public.users.name', + 'VARCHAR', + 'BIGINT' + >, + ]; + + type _Then = Expect>; + }); + + void it('collects multiple errors for different invalid references', () => { + type Columns = readonly [ + SchemaColumnName<'public', 'posts', 'user_id'>, + SchemaColumnName<'public', 'posts', 'title'>, + ]; + type References = readonly [ + SchemaColumnName<'public', 'users', 'nonexistent'>, + SchemaColumnName<'public', 'users', 'age'>, + ]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceExistanceError< + 'missing_column', + 'public.users.nonexistent' + >, + ColumnReferenceTypeMismatchError< + 'public.users.age', + 'INTEGER', + 'VARCHAR' + >, + ]; + + type _Then = Expect>; + }); + + void it('collects only errors, skipping valid references', () => { + type Columns = readonly [ + SchemaColumnName<'public', 'posts', 'user_id'>, + SchemaColumnName<'public', 'posts', 'title'>, + SchemaColumnName<'public', 'posts', 'id'>, + ]; + type References = readonly [ + SchemaColumnName<'public', 'users', 'id'>, + SchemaColumnName<'public', 'users', 'age'>, + SchemaColumnName<'public', 'users', 'id'>, + ]; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type Expected = [ + ColumnReferenceTypeMismatchError< + 'public.users.age', + 'INTEGER', + 'VARCHAR' + >, + ]; + + type _Then = Expect>; + }); + + void it('returns empty array for empty input tuples', () => { + type Columns = readonly []; + type References = readonly []; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas + >; + + type _Then = Expect>; + }); + + void it('accumulates errors with pre-existing errors', () => { + type Columns = readonly [SchemaColumnName<'public', 'posts', 'user_id'>]; + type References = readonly [ + SchemaColumnName<'public', 'users', 'nonexistent'>, + ]; + type ExistingError = ColumnReferenceExistanceError< + 'missing_column', + 'public.foo.bar' + >; + + type Result = CollectReferencesErrors< + Columns, + References, + 'public', + 'posts', + TestSchemas, + [ExistingError] + >; + + type Expected = [ + ExistingError, + ColumnReferenceExistanceError< + 'missing_column', + 'public.users.nonexistent' + >, + ]; + + type _Then = Expect>; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/collectRelationshipErrors.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/collectRelationshipErrors.type.spec.ts new file mode 100644 index 00000000..7b687582 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/collectRelationshipErrors.type.spec.ts @@ -0,0 +1,346 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import type { InferTableSchemaComponentColumns } from '../tableSchemaComponent'; +import { relationship } from './relationshipTypes'; +import type { CollectRelationshipErrors } from './relationshipValidation'; + +const { schema, table, column } = dumboSchema; +const { Integer, BigInteger, Varchar } = SQL.column.type; + +void describe('CollectRelationshipErrors', () => { + const usersTable = table('users', { + columns: { + id: column('id', BigInteger), + tenant_id: column('tenant_id', Integer), + name: column('name', Varchar('max')), + }, + }); + + const tenantsTable = table('tenants', { + columns: { + id: column('id', Integer), + name: column('name', Varchar('max')), + }, + }); + + const postsTable = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + tenant_id: column('tenant_id', Integer), + title: column('title', Varchar('max')), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'many-to-one'), + tenant: relationship(['tenant_id'], ['public.tenants.id'], 'many-to-one'), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + tenants: tenantsTable, + }); + + type TestSchemas = { + public: typeof _publicSchema; + }; + + type PostsTable = typeof postsTable; + type PostsColumns = InferTableSchemaComponentColumns; + + void it('returns empty array when all relationships are valid', () => { + type Result = CollectRelationshipErrors< + PostsColumns, + PostsTable['relationships'], + PostsTable, + typeof _publicSchema, + TestSchemas + >; + + type _Then = Expect>; + }); + + void it('collects errors for invalid references', () => { + const _postsTableWithBadRef = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + }, + relationships: { + user: relationship( + ['user_id'], + ['public.users.nonexistent'], + 'many-to-one', + ), + }, + }); + + type BadPostsTable = typeof _postsTableWithBadRef; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + BadPostsTable['relationships'], + BadPostsTable, + typeof _publicSchema, + TestSchemas + >; + + type Expected = [ + TypeValidationResult< + false, + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_column'; + reference: 'public.users.nonexistent'; + }, + ]; + } + >, + ]; + + type _Then = Expect>; + }); + + void it('collects errors from multiple invalid relationships', () => { + const _postsTableMultipleErrors = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + tenant_id: column('tenant_id', Integer), + }, + relationships: { + user: relationship( + ['user_id'], + ['nonexistent.users.id'], + 'many-to-one', + ), + tenant: relationship( + ['tenant_id'], + ['public.missing.id'], + 'many-to-one', + ), + }, + }); + + type MultiErrorTable = typeof _postsTableMultipleErrors; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + MultiErrorTable['relationships'], + MultiErrorTable, + typeof _publicSchema, + TestSchemas + >; + + type Expected = [ + TypeValidationResult< + false, + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + } + >, + TypeValidationResult< + false, + { + relationship: 'tenant'; + errors: [ + { + errorCode: 'missing_table'; + reference: 'public.missing.id'; + }, + ]; + } + >, + ]; + + type _Then = Expect>; + }); + + void it('collects type mismatch errors', () => { + const _postsTableTypeMismatch = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + }, + relationships: { + user: relationship(['user_id'], ['public.users.name'], 'many-to-one'), + }, + }); + + type MismatchTable = typeof _postsTableTypeMismatch; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + MismatchTable['relationships'], + MismatchTable, + typeof _publicSchema, + TestSchemas + >; + + type Expected = [ + TypeValidationResult< + false, + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.name'; + referenceType: 'VARCHAR'; + columnTypeName: 'BIGINT'; + }, + ]; + } + >, + ]; + + type _Then = Expect>; + }); + + void it('collects length mismatch errors', () => { + const _postsTableLengthMismatch = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + tenant_id: column('tenant_id', Integer), + }, + relationships: { + composite: relationship( + ['user_id', 'tenant_id'], + ['public.users.id'], + 'many-to-one', + ), + }, + }); + + type LengthMismatchTable = typeof _postsTableLengthMismatch; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + LengthMismatchTable['relationships'], + LengthMismatchTable, + typeof _publicSchema, + TestSchemas + >; + + type Expected = [ + TypeValidationResult< + false, + { + relationship: 'composite'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: readonly ['user_id', 'tenant_id']; + references: readonly ['public.users.id']; + }, + ]; + } + >, + ]; + + type _Then = Expect>; + }); + + void it('skips valid relationships and only collects errors', () => { + const _postsTableMixed = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + tenant_id: column('tenant_id', Integer), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'many-to-one'), + tenant: relationship( + ['tenant_id'], + ['public.tenants.bad'], + 'many-to-one', + ), + }, + }); + + type MixedTable = typeof _postsTableMixed; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + MixedTable['relationships'], + MixedTable, + typeof _publicSchema, + TestSchemas + >; + + type Expected = [ + TypeValidationResult< + false, + { + relationship: 'tenant'; + errors: [ + { + errorCode: 'missing_column'; + reference: 'public.tenants.bad'; + }, + ]; + } + >, + ]; + + type _Then = Expect>; + }); + + void it('handles composite foreign keys', () => { + const compositeUsersTable = table('users', { + columns: { + id: column('id', BigInteger), + tenant_id: column('tenant_id', Integer), + }, + }); + + const compositePostsTable = table('posts', { + columns: { + id: column('id', BigInteger), + user_id: column('user_id', BigInteger), + tenant_id: column('tenant_id', Integer), + }, + relationships: { + userTenant: relationship( + ['user_id', 'tenant_id'], + ['public.users.id', 'public.users.tenant_id'], + 'many-to-one', + ), + }, + }); + + const _compositeSchema = schema('public', { + users: compositeUsersTable, + posts: compositePostsTable, + }); + + type CompositeSchemas = { + public: typeof _compositeSchema; + }; + + type CompositePostsTable = typeof compositePostsTable; + + type Result = CollectRelationshipErrors< + InferTableSchemaComponentColumns, + CompositePostsTable['relationships'], + CompositePostsTable, + typeof _compositeSchema, + CompositeSchemas + >; + + type _Then = Expect>; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.ts b/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.ts new file mode 100644 index 00000000..6a28eac5 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.ts @@ -0,0 +1,155 @@ +export type Join< + T extends readonly string[], + Sep extends string, +> = T extends readonly [ + infer First extends string, + ...infer Rest extends readonly string[], +] + ? Rest extends readonly [] + ? First + : `${First}${Sep}${Join}` + : ''; + +export type IndentErrors = + Messages extends readonly [ + infer First extends string, + ...infer Rest extends readonly string[], + ] + ? [` - ${First}`, ...IndentErrors] + : []; + +type ExtractSchemaFromReference = + T extends `${infer Schema}.${string}.${string}` ? Schema : never; + +type ExtractTableFromReference = + T extends `${string}.${infer Table}.${string}` ? Table : never; + +type ExtractColumnFromReference = + T extends `${string}.${string}.${infer Column}` ? Column : never; + +type TupleLength = T extends { length: infer L } + ? L + : never; + +export type FormatSingleError = E extends { + errorCode: 'reference_columns_mismatch'; + invalidColumns: infer InvalidCols extends readonly string[]; + availableColumns: infer AvailableCols extends readonly string[]; +} + ? `Invalid columns: ${Join}. Available columns: ${Join}` + : E extends { + errorCode: 'reference_length_mismatch'; + columns: infer Cols extends readonly string[]; + references: infer Refs extends readonly string[]; + } + ? `Column count mismatch: ${TupleLength} columns ([${Join}]) but ${TupleLength} references ([${Join}])` + : E extends { + errorCode: 'missing_schema'; + reference: infer Ref extends string; + } + ? `Schema "${ExtractSchemaFromReference}" does not exist (${Ref})` + : E extends { + errorCode: 'missing_table'; + reference: infer Ref extends string; + } + ? `Table "${ExtractTableFromReference}" does not exist in schema "${ExtractSchemaFromReference}" (${Ref})` + : E extends { + errorCode: 'missing_column'; + reference: infer Ref extends string; + } + ? `Column "${ExtractColumnFromReference}" does not exist in table "${ExtractSchemaFromReference}.${ExtractTableFromReference}" (${Ref})` + : E extends { + errorCode: 'type_mismatch'; + reference: infer Ref extends string; + referenceType: infer RefType extends string; + columnTypeName: infer ColType extends string; + } + ? `Type mismatch: column type "${ColType}" does not match referenced column type "${RefType}" (${Ref})` + : never; + +type FormatErrorMessages = + Errors extends readonly [ + infer First, + ...infer Rest extends readonly unknown[], + ] + ? [FormatSingleError, ...FormatErrorMessages] + : []; + +export type FormatRelationshipBlock = E extends { + relationship: infer RelName extends string; + errors: infer Errors extends readonly unknown[]; +} + ? Join< + [ + `Relationship "${RelName}":`, + ...IndentErrors>, + ], + '\n' + > + : never; + +type IndentLine = ` ${Line}`; + +type IndentRelationshipBlock = + Block extends `${infer FirstLine}\n${infer Rest}` + ? `${IndentLine}\n${IndentRelationshipBlock}` + : IndentLine; + +type FormatRelationshipBlocks = + RelErrors extends readonly [ + infer First, + ...infer Rest extends readonly unknown[], + ] + ? Rest extends readonly [] + ? IndentRelationshipBlock> + : `${IndentRelationshipBlock>}\n${FormatRelationshipBlocks}` + : ''; + +export type FormatTableLevel = E extends { + table: infer TableName extends string; + errors: infer RelErrors extends readonly unknown[]; +} + ? `Table "${TableName}":\n${FormatRelationshipBlocks}` + : never; + +type IndentTableBlock = + Block extends `${infer FirstLine}\n${infer Rest}` + ? ` ${FirstLine}\n${IndentTableBlock}` + : ` ${Block}`; + +type FormatTableBlocks = + TableErrors extends readonly [ + infer First, + ...infer Rest extends readonly unknown[], + ] + ? Rest extends readonly [] + ? IndentTableBlock> + : `${IndentTableBlock>}\n${FormatTableBlocks}` + : ''; + +export type FormatSchemaLevel = E extends { + schema: infer SchemaName extends string; + errors: infer TableErrors extends readonly unknown[]; +} + ? `Schema "${SchemaName}":\n${FormatTableBlocks}` + : never; + +type FormatSchemaBlocks = + SchemaErrors extends readonly [ + infer First, + ...infer Rest extends readonly unknown[], + ] + ? Rest extends readonly [] + ? FormatSchemaLevel + : `${FormatSchemaLevel}\n${FormatSchemaBlocks}` + : ''; + +export type FormatDatabaseValidationErrors = + FormatSchemaBlocks; + +export type FormatValidationErrors = Result extends { + valid: false; + error: infer Errors extends readonly unknown[]; +} + ? `Relationship validation errors:\n\n${FormatDatabaseValidationErrors}` + : never; diff --git a/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.type.spec.ts new file mode 100644 index 00000000..32c8332b --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/formatRelationshipErrors.type.spec.ts @@ -0,0 +1,404 @@ +import { describe, it } from 'node:test'; +import type { Equals, Expect } from '../../../testing'; +import type { + Join, + IndentErrors, + FormatSingleError, + FormatRelationshipBlock, + FormatTableLevel, + FormatSchemaLevel, + FormatDatabaseValidationErrors, + FormatValidationErrors, +} from './formatRelationshipErrors'; + +void describe('Join', () => { + void it('concatenates empty array to empty string', () => { + type Result = Join<[], ', '>; + type _Then = Expect>; + }); + + void it('handles single element', () => { + type Result = Join<['foo'], ', '>; + type _Then = Expect>; + }); + + void it('concatenates multiple elements with separator', () => { + type Result = Join<['foo', 'bar', 'baz'], ', '>; + type _Then = Expect>; + }); + + void it('handles different separators', () => { + type Result = Join<['a', 'b', 'c'], ' | '>; + type _Then = Expect>; + }); +}); + +void describe('IndentErrors', () => { + void it('formats empty array', () => { + type Result = IndentErrors<[]>; + type _Then = Expect>; + }); + + void it('adds bullet and indent to single message', () => { + type Result = IndentErrors<['Missing column']>; + type _Then = Expect>; + }); + + void it('adds bullet and indent to multiple messages', () => { + type Result = IndentErrors<['First error', 'Second error', 'Third error']>; + type Expected = [' - First error', ' - Second error', ' - Third error']; + type _Then = Expect>; + }); +}); + +void describe('FormatSingleError', () => { + void it('formats reference_columns_mismatch error', () => { + type Error = { + errorCode: 'reference_columns_mismatch'; + invalidColumns: ['col1', 'col2']; + availableColumns: ['id', 'name']; + }; + type Result = FormatSingleError; + type Expected = 'Invalid columns: col1, col2. Available columns: id, name'; + type _Then = Expect>; + }); + + void it('formats reference_length_mismatch error', () => { + type Error = { + errorCode: 'reference_length_mismatch'; + columns: ['col1', 'col2']; + references: ['public.users.id']; + }; + type Result = FormatSingleError; + type Expected = + 'Column count mismatch: 2 columns ([col1, col2]) but 1 references ([public.users.id])'; + type _Then = Expect>; + }); + + void it('formats missing_schema error', () => { + type Error = { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }; + type Result = FormatSingleError; + type Expected = + 'Schema "nonexistent" does not exist (nonexistent.users.id)'; + type _Then = Expect>; + }); + + void it('formats missing_table error', () => { + type Error = { + errorCode: 'missing_table'; + reference: 'public.nonexistent.id'; + }; + type Result = FormatSingleError; + type Expected = + 'Table "nonexistent" does not exist in schema "public" (public.nonexistent.id)'; + type _Then = Expect>; + }); + + void it('formats missing_column error', () => { + type Error = { + errorCode: 'missing_column'; + reference: 'public.users.nonexistent'; + }; + type Result = FormatSingleError; + type Expected = + 'Column "nonexistent" does not exist in table "public.users" (public.users.nonexistent)'; + type _Then = Expect>; + }); + + void it('formats type_mismatch error', () => { + type Error = { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }; + type Result = FormatSingleError; + type Expected = + 'Type mismatch: column type "BIGINT" does not match referenced column type "INTEGER" (public.users.id)'; + type _Then = Expect>; + }); +}); + +void describe('FormatRelationshipBlock', () => { + void it('formats single error in relationship', () => { + type Input = { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + }; + type Result = FormatRelationshipBlock; + type Expected = `Relationship "user": + - Schema "nonexistent" does not exist (nonexistent.users.id)`; + type _Then = Expect>; + }); + + void it('formats multiple errors in relationship', () => { + type Input = { + relationship: 'posts'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: ['col1', 'col2']; + references: ['public.users.id']; + }, + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }; + type Result = FormatRelationshipBlock; + type Expected = `Relationship "posts": + - Column count mismatch: 2 columns ([col1, col2]) but 1 references ([public.users.id]) + - Type mismatch: column type "BIGINT" does not match referenced column type "INTEGER" (public.users.id)`; + type _Then = Expect>; + }); +}); + +void describe('FormatTableLevel', () => { + void it('formats single relationship error in table', () => { + type Input = { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + }, + ]; + }; + type Result = FormatTableLevel; + type Expected = `Table "posts": + Relationship "user": + - Schema "nonexistent" does not exist (nonexistent.users.id)`; + type _Then = Expect>; + }); + + void it('formats multiple relationship errors in table', () => { + type Input = { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + { + relationship: 'category'; + errors: [ + { + errorCode: 'missing_table'; + reference: 'public.categories.id'; + }, + ]; + }, + ]; + }; + type Result = FormatTableLevel; + type Expected = `Table "posts": + Relationship "user": + - Type mismatch: column type "BIGINT" does not match referenced column type "INTEGER" (public.users.id) + Relationship "category": + - Table "categories" does not exist in schema "public" (public.categories.id)`; + type _Then = Expect>; + }); +}); + +void describe('FormatSchemaLevel', () => { + void it('formats single table error in schema', () => { + type Input = { + schema: 'public'; + errors: [ + { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + }, + ]; + }, + ]; + }; + type Result = FormatSchemaLevel; + type Expected = `Schema "public": + Table "posts": + Relationship "user": + - Schema "nonexistent" does not exist (nonexistent.users.id)`; + type _Then = Expect>; + }); + + void it('formats multiple table errors in schema', () => { + type Input = { + schema: 'public'; + errors: [ + { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + ]; + }, + { + table: 'comments'; + errors: [ + { + relationship: 'post'; + errors: [ + { + errorCode: 'missing_column'; + reference: 'public.posts.post_id'; + }, + ]; + }, + ]; + }, + ]; + }; + type Result = FormatSchemaLevel; + type Expected = `Schema "public": + Table "posts": + Relationship "user": + - Type mismatch: column type "BIGINT" does not match referenced column type "INTEGER" (public.users.id) + Table "comments": + Relationship "post": + - Column "post_id" does not exist in table "public.posts" (public.posts.post_id)`; + type _Then = Expect>; + }); +}); + +void describe('FormatDatabaseValidationErrors', () => { + void it('formats database validation errors with multiple schemas', () => { + type Input = [ + { + schema: 'public'; + errors: [ + { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + ]; + }, + ]; + }, + { + schema: 'auth'; + errors: [ + { + table: 'sessions'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_table'; + reference: 'auth.users.id'; + }, + ]; + }, + ]; + }, + ]; + }, + ]; + type Result = FormatDatabaseValidationErrors; + type Expected = `Schema "public": + Table "posts": + Relationship "user": + - Type mismatch: column type "BIGINT" does not match referenced column type "INTEGER" (public.users.id) +Schema "auth": + Table "sessions": + Relationship "user": + - Table "users" does not exist in schema "auth" (auth.users.id)`; + type _Then = Expect>; + }); +}); + +void describe('FormatValidationErrors', () => { + void it('formats validation result with errors', () => { + type Input = { + valid: false; + error: [ + { + schema: 'public'; + errors: [ + { + table: 'posts'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + }, + ]; + }, + ]; + }, + ]; + }; + type Result = FormatValidationErrors; + type Expected = `Relationship validation errors: + +Schema "public": + Table "posts": + Relationship "user": + - Schema "nonexistent" does not exist (nonexistent.users.id)`; + type _Then = Expect>; + }); + + void it('returns never for valid result', () => { + type Input = { + valid: true; + error: undefined; + }; + type Result = FormatValidationErrors; + type _Then = Expect>; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/index.ts b/src/packages/dumbo/src/core/schema/components/relationships/index.ts new file mode 100644 index 00000000..1451ea17 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/index.ts @@ -0,0 +1,5 @@ +export * from './relationshipTypes'; + +export * from './relationshipValidation'; + +export * from './formatRelationshipErrors'; diff --git a/src/packages/dumbo/src/core/schema/components/relationships/relationshipTypes.ts b/src/packages/dumbo/src/core/schema/components/relationships/relationshipTypes.ts new file mode 100644 index 00000000..5d38be3a --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/relationshipTypes.ts @@ -0,0 +1,265 @@ +import type { + AnyDatabaseSchemaSchemaComponent, + AnyTableSchemaComponent, + DatabaseSchemaComponent, + DatabaseSchemas, + DatabaseSchemaSchemaComponent, + DatabaseSchemaTables, + TableColumnNames, + TableColumns, + TableSchemaComponent, + Writable, +} from '..'; +import type { ColumnTypeToken } from '../../../sql/tokens/columnTokens'; +import type { NotEmptyTuple } from '../../../typing'; + +export type ExtractSchemaNames = + DB extends DatabaseSchemaComponent + ? keyof Schemas + : never; + +export type ExtractTableNames = + Schema extends DatabaseSchemaSchemaComponent< + infer Tables extends DatabaseSchemaTables + > + ? keyof Tables + : never; + +export type ExtractColumnNames = + Table extends TableSchemaComponent + ? TableColumnNames> + : never; + +export type ExtractColumnTypeName = + T extends ColumnTypeToken< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + infer TypeName, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > + ? Uppercase + : never; + +export type AllColumnTypes = { + [SchemaName in keyof Schemas]: Schemas[SchemaName] extends DatabaseSchemaSchemaComponent< + infer Tables + > + ? Writable<{ + [TableName in keyof Tables]: Tables[TableName] extends TableSchemaComponent< + infer Columns + > + ? Writable<{ + [ColumnName in keyof Columns]: { + columnTypeName: ExtractColumnTypeName< + Columns[ColumnName]['type'] + >; + }; + }> + : never; + }> + : never; +}; + +export type AllColumnReferences = { + [SchemaName in keyof Schemas]: Schemas[SchemaName] extends DatabaseSchemaSchemaComponent< + infer Tables + > + ? { + [TableName in keyof Tables]: Tables[TableName] extends TableSchemaComponent< + infer Columns + > + ? { + [ColumnName in keyof Columns]: `${SchemaName & + string}.${TableName & string}.${ColumnName & string}`; + }[keyof Columns] + : never; + }[keyof Tables] + : never; +}[keyof Schemas]; + +export type AllColumnTypesInSchema< + Schema extends AnyDatabaseSchemaSchemaComponent, +> = + Schema extends DatabaseSchemaSchemaComponent + ? { + [TableName in keyof Tables]: Tables[TableName] extends TableSchemaComponent< + infer Columns + > + ? { + [ColumnName in keyof Columns]: { + columnTypeName: ExtractColumnTypeName< + Columns[ColumnName]['type'] + >; + }; + } + : never; + } + : never; + +export type AllColumnReferencesInSchema< + Schema extends AnyDatabaseSchemaSchemaComponent, + SchemaName extends string, +> = + Schema extends DatabaseSchemaSchemaComponent + ? { + [TableName in keyof Tables]: Tables[TableName] extends TableSchemaComponent< + infer Columns + > + ? { + [ColumnName in keyof Columns]: `${SchemaName & string}.${TableName & + string}.${ColumnName & string}`; + }[keyof Columns] + : never; + }[keyof Tables] + : never; + +export type NormalizeReference< + Path extends string, + CurrentSchema extends string, + CurrentTable extends string, +> = Path extends `${infer Schema}.${infer Table}.${infer Column}` + ? `${Schema}.${Table}.${Column}` + : Path extends `${infer Table}.${infer Column}` + ? `${CurrentSchema}.${Table}.${Column}` + : Path extends string + ? `${CurrentSchema}.${CurrentTable}.${Path}` + : never; + +export type NormalizeColumnPath< + References extends readonly string[], + SchemaName extends string, + TableName extends string, +> = References extends readonly [infer First, ...infer Rest] + ? First extends string + ? Rest extends readonly string[] + ? readonly [ + NormalizeReference, + ...NormalizeColumnPath, + ] + : readonly [] + : readonly [] + : readonly []; + +export type ColumnName = `${ColName}`; + +export type TableColumnName< + TableName extends string = string, + ColName extends string = string, +> = `${TableName}.${ColName}`; + +export type SchemaColumnName< + SchemaName extends string = string, + TableName extends string = string, + ColumnName extends string = string, +> = `${SchemaName}.${TableName}.${ColumnName}`; + +export type ColumnPath< + SchemaName extends string = string, + TableName extends string = string, + ColName extends string = string, +> = + | SchemaColumnName + | TableColumnName + | ColumnName; + +export type ColumnReference< + SchemaName extends string = string, + TableName extends string = string, + ColumnName extends string = string, +> = { schemaName: SchemaName; tableName: TableName; columnName: ColumnName }; + +export type ColumnPathToReference< + Reference extends ColumnPath = ColumnPath, + CurrentSchema extends string = string, + CurrentTable extends string = string, +> = + NormalizeReference< + Reference, + CurrentSchema, + CurrentTable + > extends `${infer S}.${infer T}.${infer C}` + ? { schemaName: S; tableName: T; columnName: C } + : never; + +export type ParseReferencePath = + Path extends `${infer Schema}.${infer Table}.${infer Column}` + ? { schema: Schema; table: Table; column: Column } + : never; + +export type LookupColumnType = + ParseReferencePath extends { + schema: infer S; + table: infer T; + column: infer C; + } + ? S extends keyof AllTypes + ? T extends keyof AllTypes[S] + ? C extends keyof AllTypes[S][T] + ? AllTypes[S][T][C] extends { columnTypeName: infer TypeName } + ? TypeName + : never + : never + : never + : never + : never; + +export type RelationshipType = + | 'one-to-one' + | 'one-to-many' + | 'many-to-one' + | 'many-to-many'; + +export type RelationshipDefinition< + Columns extends string = string, + Reference extends string = string, + RelType extends RelationshipType = RelationshipType, +> = { + readonly columns: NotEmptyTuple; + readonly references: NotEmptyTuple; + readonly type: RelType; +}; + +export type AnyTableRelationshipDefinition = RelationshipDefinition< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any +>; + +export type AnyTableRelationshipDefinitionWithColumns< + Columns extends string = string, +> = RelationshipDefinition< + Columns, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any +>; + +export type TableRelationships = Record< + string, + AnyTableRelationshipDefinitionWithColumns +>; + +export const relationship = < + const Columns extends readonly string[], + const References extends readonly string[], + const RelType extends RelationshipType = RelationshipType, +>( + columns: Columns, + references: References, + type: RelType, +) => { + return { + columns, + references, + type, + } as const; +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyRelationshipDefinition = RelationshipDefinition; diff --git a/src/packages/dumbo/src/core/schema/components/relationships/relationshipValidation.ts b/src/packages/dumbo/src/core/schema/components/relationships/relationshipValidation.ts new file mode 100644 index 00000000..0eb6c31d --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/relationshipValidation.ts @@ -0,0 +1,531 @@ +import type { + AnyColumnSchemaComponent, + AnyDatabaseSchemaSchemaComponent, + ColumnSchemaComponent, + DatabaseSchemas, + DatabaseSchemaSchemaComponent, +} from '..'; +import type { FormatValidationErrors } from './formatRelationshipErrors'; +import type { AnyColumnTypeToken, ColumnTypeToken } from '../../../sql'; +import type { + ALL, + AND, + AnyTypeValidationError, + AnyTypeValidationFailed, + FailOnFirstTypeValidationError, + FilterNotExistingInUnion, + HaveTuplesTheSameLength, + IF, + IsEmptyTuple, + IsNotEmptyTuple, + KeysOfString, + MapRecordCollectErrors, + NotEmptyTuple, + TypeValidationError, + TypeValidationResult, + TypeValidationSuccess, + UnwrapTypeValidationErrors, + ZipTuplesCollectErrors, +} from '../../../typing'; +import type { + AnyTableSchemaComponent, + TableColumns, + TableSchemaComponent, +} from '../tableSchemaComponent'; +import type { + AnyTableRelationshipDefinition, + AnyTableRelationshipDefinitionWithColumns, + NormalizeColumnPath, + SchemaColumnName, + TableRelationships, +} from './relationshipTypes'; + +export type RelationshipColumnsMismatchError< + ColumnPath extends SchemaColumnName = SchemaColumnName, +> = { + valid: false; + error: { + errorCode: 'reference_columns_mismatch'; + invalidColumns: ColumnPath[]; + availableColumns: ColumnPath[]; + }; +}; + +export type RelationshipReferencesLengthMismatchError< + ColumnPath extends SchemaColumnName = SchemaColumnName, +> = { + valid: false; + error: { + errorCode: 'reference_length_mismatch'; + columns: ColumnPath[]; + references: ColumnPath[]; + }; +}; + +export type ColumnReferenceExistanceError< + ErrorCode extends 'missing_schema' | 'missing_table' | 'missing_column' = + | 'missing_schema' + | 'missing_table' + | 'missing_column', + ColumnPath extends SchemaColumnName = SchemaColumnName, +> = { + valid: false; + error: { + errorCode: ErrorCode; + reference: ColumnPath; + }; +}; + +export type ColumnReferenceTypeMismatchError< + Reference extends SchemaColumnName = SchemaColumnName, + ReferenceTypeName extends string = string, + ColumnTypeName extends string = string, +> = { + valid: false; + error: { + errorCode: 'type_mismatch'; + reference: Reference; + referenceType: ReferenceTypeName; + columnTypeName: ColumnTypeName; + }; +}; + +export type NoError = TypeValidationSuccess; + +export type ColumnReferenceError = + | ColumnReferenceExistanceError + | ColumnReferenceTypeMismatchError; + +export type RelationshipValidationError = + | RelationshipColumnsMismatchError + | RelationshipReferencesLengthMismatchError + | ColumnReferenceError; + +export type ValidateRelationshipLength< + Rel extends AnyTableRelationshipDefinition, +> = IF< + ALL< + [ + HaveTuplesTheSameLength, + IsNotEmptyTuple, + IsNotEmptyTuple, + ] + >, + TypeValidationSuccess, + TypeValidationResult< + false, + { + errorCode: 'reference_length_mismatch'; + columns: Rel['columns']; + references: Rel['references']; + } + > +>; + +export type ValidateRelationshipColumns< + Relationship extends AnyTableRelationshipDefinition, + ValidColumns extends TableColumns, +> = + FilterNotExistingInUnion< + Relationship['columns'], + KeysOfString + > extends infer InvalidColumns extends NotEmptyTuple + ? IF< + AND< + IsEmptyTuple, + IsNotEmptyTuple + >, + TypeValidationSuccess, + TypeValidationResult< + false, + { + errorCode: 'reference_columns_mismatch'; + invalidColumns: InvalidColumns; + availableColumns: KeysOfString; + } + > + > + : TypeValidationSuccess; + +export type ValidateColumnReference< + ColReference extends SchemaColumnName, + Schemas extends DatabaseSchemas, +> = + ColReference extends SchemaColumnName< + infer SchemaName, + infer TableName, + infer ColumnName + > + ? SchemaName extends keyof Schemas + ? TableName extends keyof Schemas[SchemaName]['tables'] + ? Schemas[SchemaName]['tables'][TableName] extends TableSchemaComponent< + infer Columns, + infer _TableName, + infer _Relationships + > + ? ColumnName extends keyof Columns + ? Columns[ColumnName] + : ColumnReferenceExistanceError< + 'missing_column', + `${SchemaName}.${TableName}.${ColumnName}` + > + : never + : ColumnReferenceExistanceError< + 'missing_table', + `${SchemaName}.${TableName}.${ColumnName}` + > + : ColumnReferenceExistanceError< + 'missing_schema', + `${SchemaName}.${TableName}.${ColumnName}` + > + : never; + +export type ValidateColumnTypeMatch< + RefColumnType extends AnyColumnTypeToken | string = + | AnyColumnTypeToken + | string, + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, + Reference extends SchemaColumnName = SchemaColumnName, +> = + ColumnType extends ColumnTypeToken< + infer _JsType, + infer ColumnTypeName, + infer _TProps + > + ? RefColumnType extends ColumnTypeToken< + infer _JsType, + infer RefColumnTypeName, + infer _TProps + > + ? RefColumnTypeName extends ColumnTypeName + ? TypeValidationSuccess + : ColumnReferenceTypeMismatchError< + Reference, + RefColumnTypeName, + ColumnTypeName + > + : RefColumnType extends ColumnTypeName + ? TypeValidationSuccess + : ColumnReferenceTypeMismatchError< + Reference, + Extract, + ColumnTypeName + > + : RefColumnType extends ColumnTypeToken< + infer _JsType, + infer RefColumnTypeName, + infer _TProps + > + ? RefColumnTypeName extends ColumnType + ? TypeValidationSuccess + : ColumnReferenceTypeMismatchError< + Reference, + RefColumnTypeName, + Extract + > + : RefColumnType extends ColumnType + ? TypeValidationSuccess + : ColumnReferenceTypeMismatchError< + Reference, + Extract, + Extract + >; + +export type ValidateColumnsMatch< + ReferenceColumn extends AnyColumnSchemaComponent, + Column extends AnyColumnSchemaComponent, + references extends SchemaColumnName = SchemaColumnName, +> = + Column extends ColumnSchemaComponent + ? ReferenceColumn extends ColumnSchemaComponent + ? ValidateColumnTypeMatch + : never + : never; + +export type ValidateReference< + RefPath extends SchemaColumnName = SchemaColumnName, + ColPath extends SchemaColumnName = SchemaColumnName, + Schemas extends DatabaseSchemas = DatabaseSchemas, +> = + ColPath extends SchemaColumnName< + infer SchemaName, + infer TableName, + infer Column + > + ? ValidateColumnReference extends infer RefColumn + ? RefColumn extends AnyColumnSchemaComponent + ? ValidateColumnsMatch< + RefColumn, + Schemas[SchemaName]['tables'][TableName]['columns'][Column], + RefPath + > + : RefColumn extends { + valid: false; + error: infer E; + } + ? TypeValidationError + : never + : never + : never; + +export type ValidateReferences< + RefPath extends SchemaColumnName = SchemaColumnName, + ColPath extends SchemaColumnName = SchemaColumnName, + Schemas extends DatabaseSchemas = DatabaseSchemas, +> = + ColPath extends SchemaColumnName< + infer SchemaName, + infer TableName, + infer Column + > + ? ValidateColumnReference extends infer RefColumn + ? RefColumn extends AnyColumnSchemaComponent + ? ValidateColumnsMatch< + RefColumn, + Schemas[SchemaName]['tables'][TableName]['columns'][Column], + RefPath + > + : RefColumn extends { + valid: false; + error: infer E; + } + ? TypeValidationError + : never + : never + : never; + +export type CollectReferencesErrors< + Columns extends readonly SchemaColumnName[], + References extends readonly SchemaColumnName[], + _CurrentSchema extends string, + _CurrentTable extends string, + Schemas extends DatabaseSchemas = DatabaseSchemas, + Errors extends AnyTypeValidationError[] = [], +> = ZipTuplesCollectErrors< + References, + Columns, + { + [R in References[number]]: { + [C in Columns[number]]: ValidateReference; + }; + }, + Errors +>; + +export type SchemaTablesWithSingle
= + Table extends TableSchemaComponent< + infer _Columns, + infer TableName, + infer _FKs + > + ? DatabaseSchemaSchemaComponent<{ + [K in TableName]: Table; + }> + : never; + +export type DatabaseSchemasWithSingle< + Schema extends AnyDatabaseSchemaSchemaComponent, +> = + Schema extends DatabaseSchemaSchemaComponent + ? { + [K in _SchemaName]: Schema; + } + : never; + +export type ValidateRelationship< + Columns extends TableColumns, + Relationship extends AnyTableRelationshipDefinitionWithColumns< + Extract + >, + RelationshipName extends string, + CurrentTableName extends string, + Table extends AnyTableSchemaComponent = AnyTableSchemaComponent, + Schema extends + AnyDatabaseSchemaSchemaComponent = SchemaTablesWithSingle
, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, +> = + FailOnFirstTypeValidationError< + [ + ValidateRelationshipLength, + ValidateRelationshipColumns, + CollectReferencesErrors< + NormalizeColumnPath< + Relationship['columns'], + Schema['schemaName'], + CurrentTableName + >, + NormalizeColumnPath< + Relationship['references'], + Schema['schemaName'], + CurrentTableName + >, + Schema['schemaName'], + CurrentTableName, + Schemas + > extends infer Results extends readonly AnyTypeValidationError[] + ? IF< + AnyTypeValidationFailed, + TypeValidationError>, + TypeValidationSuccess + > + : TypeValidationSuccess, + ] + > extends infer Error extends AnyTypeValidationError + ? TypeValidationError<{ + relationship: RelationshipName; + errors: Error extends TypeValidationError + ? E extends readonly unknown[] + ? E + : [E] + : never; + }> + : TypeValidationSuccess; + +export type CollectRelationshipErrors< + Columns extends TableColumns = TableColumns, + Relationships extends TableRelationships< + keyof Columns & string + > = {} & TableRelationships, + Table extends AnyTableSchemaComponent = AnyTableSchemaComponent, + Schema extends + AnyDatabaseSchemaSchemaComponent = SchemaTablesWithSingle
, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, + Errors extends AnyTypeValidationError[] = [], +> = MapRecordCollectErrors< + Relationships, + { + [R in keyof Relationships]: ValidateRelationship< + Columns, + Relationships[R] extends AnyTableRelationshipDefinitionWithColumns< + Extract + > + ? Relationships[R] + : never, + Extract, + Table['tableName'], + Table, + Schema, + Schemas + >; + }, + Errors +>; + +export type ValidateTableRelationships< + Table extends AnyTableSchemaComponent, + Schema extends + AnyDatabaseSchemaSchemaComponent = SchemaTablesWithSingle
, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, +> = + Table extends TableSchemaComponent< + infer Columns, + infer TableName, + infer Relationships + > + ? keyof Relationships extends Extract + ? CollectRelationshipErrors< + Columns, + Relationships, + Table, + Schema, + Schemas + > extends infer Results + ? AnyTypeValidationFailed extends true + ? TypeValidationError<{ + table: TableName; + errors: UnwrapTypeValidationErrors< + Results extends readonly AnyTypeValidationError[] + ? Results + : never + >; + }> + : Results + : TypeValidationSuccess + : TypeValidationSuccess + : TypeValidationSuccess; + +export type ValidateTable< + Table extends AnyTableSchemaComponent, + Schema extends + AnyDatabaseSchemaSchemaComponent = SchemaTablesWithSingle
, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, +> = ValidateTableRelationships; + +export type ValidateSchemaTables< + Tables extends Record, + SchemaName extends string, + Schema extends AnyDatabaseSchemaSchemaComponent, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, +> = + MapRecordCollectErrors< + Tables, + { + [TableName in keyof Tables]: ValidateTable< + Tables[TableName], + Schema, + Schemas + >; + } + > extends infer Results + ? AnyTypeValidationFailed extends true + ? TypeValidationError<{ + schema: SchemaName; + errors: UnwrapTypeValidationErrors< + Results extends readonly AnyTypeValidationError[] ? Results : never + >; + }> + : TypeValidationSuccess + : TypeValidationSuccess; + +export type ValidateDatabaseSchema< + Schema extends AnyDatabaseSchemaSchemaComponent, + Schemas extends DatabaseSchemas = DatabaseSchemasWithSingle, +> = + Schema extends DatabaseSchemaSchemaComponent + ? ValidateSchemaTables + : TypeValidationSuccess; + +export type ValidateDatabaseSchemas = + MapRecordCollectErrors< + Schemas, + { + [SchemaName in keyof Schemas]: ValidateDatabaseSchema< + Schemas[SchemaName], + Schemas + >; + } + > extends infer Results + ? AnyTypeValidationFailed extends true + ? TypeValidationError< + UnwrapTypeValidationErrors< + Results extends readonly AnyTypeValidationError[] ? Results : never + > + > + : TypeValidationSuccess + : TypeValidationSuccess; + +export type ValidateDatabaseSchemasWithMessages< + Schemas extends DatabaseSchemas, +> = FormatValidationErrors>; + +// TODO: Use in DatabaseSchema schema component validation +// export type ValidatedSchemaComponent< +// Tables extends DatabaseSchemaTables, +// SchemaName extends string, +// > = +// ValidateDatabaseSchema< +// DatabaseSchemaSchemaComponent, +// { schemaName: DatabaseSchemaSchemaComponent } +// > extends { +// valid: true; +// } +// ? DatabaseSchemaSchemaComponent +// : ValidateDatabaseSchema< +// DatabaseSchemaSchemaComponent, +// { schemaName: DatabaseSchemaSchemaComponent } +// > extends { +// valid: false; +// error: infer E; +// } +// ? { valid: false; error: FormatError } +// : DatabaseSchemaSchemaComponent; diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchema.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchema.type.spec.ts new file mode 100644 index 00000000..7ac8f440 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchema.type.spec.ts @@ -0,0 +1,187 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect, IsError } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import { relationship } from './relationshipTypes'; +import type { ValidateDatabaseSchema } from './relationshipValidation'; + +const { schema, table, column } = dumboSchema; +const { Integer, BigInteger } = SQL.column.type; + +void describe('ValidateDatabaseSchema', () => { + const usersTable = table('users', { + columns: { + id: column('id', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + post_id: column('post_id', Integer), + user_id: column('user_id', Integer), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'one-to-one'), + }, + }); + + const _validSchema = schema('public', { + users: usersTable, + posts: postsTable, + }); + + type ValidSchemas = { + public: typeof _validSchema; + }; + + void it('returns success when all tables are valid', () => { + type Result = ValidateDatabaseSchema; + + type _Then = Expect>>; + }); + + void it('collects errors from a single invalid table', () => { + const invalidTable = table('invalid', { + columns: { + col1: column('col1', Integer), + col2: column('col2', Integer), + }, + relationships: { + bad_rel: relationship( + ['col1', 'col2'], + ['public.users.id'], + 'one-to-one', + ), + }, + }); + + const _schemaWithInvalidTable = schema('public', { + users: usersTable, + invalid: invalidTable, + }); + + type TestSchemas = { + public: typeof _schemaWithInvalidTable; + }; + + type Result = ValidateDatabaseSchema< + typeof _schemaWithInvalidTable, + TestSchemas + >; + + type Expected = TypeValidationResult< + false, + { + schema: 'public'; + errors: [ + { + table: 'invalid'; + errors: [ + { + relationship: 'bad_rel'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: readonly ['col1', 'col2']; + references: readonly ['public.users.id']; + }, + ]; + }, + ]; + }, + ]; + } + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('collects errors from multiple invalid tables', () => { + const invalidTable1 = table('invalid1', { + columns: { + col1: column('col1', Integer), + }, + relationships: { + bad_rel: relationship(['col1'], ['nonexistent.table.id'], 'one-to-one'), + }, + }); + + const invalidTable2 = table('invalid2', { + columns: { + col2: column('col2', BigInteger), + }, + relationships: { + user: relationship(['col2'], ['public.users.id'], 'one-to-one'), + }, + }); + + const _schemaWithMultipleInvalid = schema('public', { + users: usersTable, + invalid1: invalidTable1, + invalid2: invalidTable2, + }); + + type TestSchemas = { + public: typeof _schemaWithMultipleInvalid; + }; + + type Result = ValidateDatabaseSchema< + typeof _schemaWithMultipleInvalid, + TestSchemas + >; + + type Expected = TypeValidationResult< + false, + { + schema: 'public'; + errors: [ + { + table: 'invalid1'; + errors: [ + { + relationship: 'bad_rel'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.table.id'; + }, + ]; + }, + ]; + }, + { + table: 'invalid2'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + ]; + }, + ]; + } + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('returns success when schema has no tables', () => { + const _emptySchema = schema('empty', {}); + + type EmptySchemas = { + empty: typeof _emptySchema; + }; + + type Result = ValidateDatabaseSchema; + + type _Then = Expect>>; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchemas.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchemas.type.spec.ts new file mode 100644 index 00000000..05d9efcd --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateDatabaseSchemas.type.spec.ts @@ -0,0 +1,190 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect, IsError } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import { relationship } from './relationshipTypes'; +import type { ValidateDatabaseSchemas } from './relationshipValidation'; + +const { schema, table, column } = dumboSchema; +const { Integer, BigInteger } = SQL.column.type; + +void describe('ValidateDatabaseSchemas', () => { + const usersTable = table('users', { + columns: { + id: column('id', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + post_id: column('post_id', Integer), + user_id: column('user_id', Integer), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'one-to-one'), + }, + }); + + void it('returns success when all schemas are valid', () => { + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + }); + + type ValidSchemas = { + public: typeof _publicSchema; + }; + + type Result = ValidateDatabaseSchemas; + + type _Then = Expect>>; + }); + + void it('collects errors from a single invalid schema', () => { + const invalidTable = table('invalid', { + columns: { + col1: column('col1', Integer), + col2: column('col2', Integer), + }, + relationships: { + bad_rel: relationship( + ['col1', 'col2'], + ['public.users.id'], + 'one-to-one', + ), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + invalid: invalidTable, + }); + + type TestSchemas = { + public: typeof _publicSchema; + }; + + type Result = ValidateDatabaseSchemas; + + type Expected = TypeValidationResult< + false, + [ + { + schema: 'public'; + errors: [ + { + table: 'invalid'; + errors: [ + { + relationship: 'bad_rel'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: readonly ['col1', 'col2']; + references: readonly ['public.users.id']; + }, + ]; + }, + ]; + }, + ]; + }, + ] + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('collects errors from multiple invalid schemas', () => { + const invalidTable1 = table('invalid1', { + columns: { + col1: column('col1', Integer), + }, + relationships: { + bad_rel: relationship(['col1'], ['nonexistent.table.id'], 'one-to-one'), + }, + }); + + const _schema1 = schema('schema1', { + invalid1: invalidTable1, + }); + + const invalidTable2 = table('invalid2', { + columns: { + col2: column('col2', BigInteger), + }, + relationships: { + user: relationship(['col2'], ['schema1.invalid1.col1'], 'one-to-one'), + }, + }); + + const _schema2 = schema('schema2', { + invalid2: invalidTable2, + }); + + type TestSchemas = { + schema1: typeof _schema1; + schema2: typeof _schema2; + }; + + type Result = ValidateDatabaseSchemas; + + type Expected = TypeValidationResult< + false, + [ + { + schema: 'schema1'; + errors: [ + { + table: 'invalid1'; + errors: [ + { + relationship: 'bad_rel'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.table.id'; + }, + ]; + }, + ]; + }, + ]; + }, + { + schema: 'schema2'; + errors: [ + { + table: 'invalid2'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'schema1.invalid1.col1'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + ]; + }, + ]; + }, + ] + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('returns success when database has no schemas', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + type EmptySchemas = {}; + + type Result = ValidateDatabaseSchemas; + + type _Then = Expect>>; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateReference.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateReference.type.spec.ts new file mode 100644 index 00000000..f42b1dff --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateReference.type.spec.ts @@ -0,0 +1,365 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect, IsError } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import type { SchemaColumnName } from './relationshipTypes'; +import type { ValidateReference } from './relationshipValidation'; + +const { column, table, schema } = dumboSchema; +const { BigInteger, Varchar, Integer } = SQL.column.type; + +void describe('ValidateReference', () => { + const usersTable = table('users', { + columns: { + id: column('id', BigInteger), + name: column('name', Varchar('max')), + age: column('age', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + post_id: column('post_id', BigInteger), + user_id: column('user_id', BigInteger), + title: column('title', Varchar('max')), + view_count: column('view_count', Integer), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + }); + + type TestSchemas = { + public: typeof _publicSchema; + }; + + void describe('reference existence validation', () => { + void it('fails when referenced schema does not exist', () => { + type RefPath = SchemaColumnName<'nonexistent', 'users', 'id'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + } + > + > + >, + ]; + }); + + void it('fails when referenced table does not exist', () => { + type RefPath = SchemaColumnName<'public', 'nonexistent', 'id'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'missing_table'; + reference: 'public.nonexistent.id'; + } + > + > + >, + ]; + }); + + void it('fails when referenced column does not exist', () => { + type RefPath = SchemaColumnName<'public', 'users', 'nonexistent'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'missing_column'; + reference: 'public.users.nonexistent'; + } + > + > + >, + ]; + }); + }); + + void describe('type matching: both ColumnTypeToken', () => { + void it('validates when types match', () => { + type RefPath = SchemaColumnName<'public', 'users', 'id'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + + void it('fails when types do not match', () => { + type RefPath = SchemaColumnName<'public', 'users', 'name'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'type_mismatch'; + reference: 'public.users.name'; + referenceType: 'VARCHAR'; + columnTypeName: 'BIGINT'; + } + > + > + >, + ]; + }); + + void it('fails when integer does not match bigint', () => { + type RefPath = SchemaColumnName<'public', 'users', 'age'>; + type ColPath = SchemaColumnName<'public', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'type_mismatch'; + reference: 'public.users.age'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + } + > + > + >, + ]; + }); + }); + + void describe('type matching: ColumnType is ColumnTypeToken, RefColumnType is string', () => { + const stringRefTable = table('string_ref', { + columns: { + id: column('id', 'BIGINT'), + label: column('label', 'VARCHAR'), + }, + }); + + const _mixedSchema1 = schema('mixed1', { + string_ref: stringRefTable, + posts: postsTable, + }); + + type MixedSchemas1 = { + mixed1: typeof _mixedSchema1; + }; + + void it('validates when string reference type matches ColumnTypeToken', () => { + type RefPath = SchemaColumnName<'mixed1', 'string_ref', 'id'>; + type ColPath = SchemaColumnName<'mixed1', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + + void it('fails when string reference type does not match ColumnTypeToken', () => { + type RefPath = SchemaColumnName<'mixed1', 'string_ref', 'label'>; + type ColPath = SchemaColumnName<'mixed1', 'posts', 'user_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'type_mismatch'; + reference: 'mixed1.string_ref.label'; + referenceType: 'VARCHAR'; + columnTypeName: 'BIGINT'; + } + > + > + >, + ]; + }); + }); + + void describe('type matching: RefColumnType is ColumnTypeToken, ColumnType is string', () => { + const stringColTable = table('string_col', { + columns: { + id: column('id', 'BIGINT'), + count: column('count', 'INTEGER'), + }, + }); + + const _mixedSchema2 = schema('mixed2', { + string_col: stringColTable, + users: usersTable, + }); + + type MixedSchemas2 = { + mixed2: typeof _mixedSchema2; + }; + + void it('validates when ColumnTypeToken reference matches string type', () => { + type RefPath = SchemaColumnName<'mixed2', 'users', 'id'>; + type ColPath = SchemaColumnName<'mixed2', 'string_col', 'id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + + void it('fails when ColumnTypeToken reference does not match string type', () => { + type RefPath = SchemaColumnName<'mixed2', 'users', 'age'>; + type ColPath = SchemaColumnName<'mixed2', 'string_col', 'id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'type_mismatch'; + reference: 'mixed2.users.age'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + } + > + > + >, + ]; + }); + }); + + void describe('type matching: both are strings', () => { + const stringOnlyTable1 = table('string_only1', { + columns: { + id: column('id', 'BIGINT'), + label: column('label', 'VARCHAR'), + }, + }); + + const stringOnlyTable2 = table('string_only2', { + columns: { + ref_id: column('ref_id', 'BIGINT'), + description: column('description', 'VARCHAR'), + }, + }); + + const _mixedSchema3 = schema('mixed3', { + string_only1: stringOnlyTable1, + string_only2: stringOnlyTable2, + }); + + type MixedSchemas3 = { + mixed3: typeof _mixedSchema3; + }; + + void it('validates when both string types match', () => { + type RefPath = SchemaColumnName<'mixed3', 'string_only1', 'id'>; + type ColPath = SchemaColumnName<'mixed3', 'string_only2', 'ref_id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + + void it('fails when both string types do not match', () => { + type RefPath = SchemaColumnName<'mixed3', 'string_only1', 'label'>; + type ColPath = SchemaColumnName<'mixed3', 'string_only2', 'ref_id'>; + + type Result = ValidateReference; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'type_mismatch'; + reference: 'mixed3.string_only1.label'; + referenceType: 'VARCHAR'; + columnTypeName: 'BIGINT'; + } + > + > + >, + ]; + }); + }); + + void describe('edge cases', () => { + void it('validates self-referencing column', () => { + type RefPath = SchemaColumnName<'public', 'users', 'id'>; + type ColPath = SchemaColumnName<'public', 'users', 'id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + + void it('validates reference across different tables with same type', () => { + type RefPath = SchemaColumnName<'public', 'posts', 'post_id'>; + type ColPath = SchemaColumnName<'public', 'users', 'id'>; + + type Result = ValidateReference; + + type _Then = Expect< + Equals> + >; + }); + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateRelationship.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationship.type.spec.ts new file mode 100644 index 00000000..d8d663b6 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationship.type.spec.ts @@ -0,0 +1,421 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect, IsError } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import type { InferTableSchemaComponentColumns } from '../tableSchemaComponent'; +import type { ValidateRelationship } from './relationshipValidation'; + +const { schema, table, column } = dumboSchema; +const { Integer, BigInteger } = SQL.column.type; + +void describe('ValidateRelationship', () => { + const usersTable = table('users', { + columns: { + id: column('id', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + post_id: column('post_id', Integer), + user_id: column('user_id', Integer), + tenant_id: column('tenant_id', Integer), + }, + }); + + const typeMismatchTable = table('type_mismatch', { + columns: { + user_id: column('user_id', BigInteger), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + type_mismatch: typeMismatchTable, + }); + + type _TestSchemas = { + public: typeof _publicSchema; + }; + + type PostsTable = typeof postsTable; + type ExistingColumns = InferTableSchemaComponentColumns; + + void it('fails when columns and references have different lengths', () => { + type MismatchedLengthRel = { + columns: ['user_id', 'tenant_id']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + MismatchedLengthRel, + 'user_author', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'user_author'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: ['user_id', 'tenant_id']; + references: ['public.users.id']; + }, + ]; + } + > + > + >, + ]; + }); + + void it('fails when columns and references are both empty', () => { + type EmptyRel = { + columns: []; + references: []; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + EmptyRel, + 'empty_rel', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'empty_rel'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: []; + references: []; + }, + ]; + } + > + > + >, + ]; + }); + + void it('fails when references are longer than columns', () => { + type ReferencesLongerRel = { + columns: ['user_id']; + references: ['public.users.id', 'public.users.tenant_id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + ReferencesLongerRel, + 'multi_ref', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'multi_ref'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: ['user_id']; + references: ['public.users.id', 'public.users.tenant_id']; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects missing schema errors', () => { + type MissingSchemaRel = { + columns: ['user_id']; + references: ['nonexistent.users.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + MissingSchemaRel, + 'bad_schema_ref', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'bad_schema_ref'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects missing table errors', () => { + type MissingTableRel = { + columns: ['user_id']; + references: ['public.nonexistent.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + MissingTableRel, + 'bad_table_ref', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'bad_table_ref'; + errors: [ + { + errorCode: 'missing_table'; + reference: 'public.nonexistent.id'; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects missing column errors', () => { + type MissingColumnRel = { + columns: ['user_id']; + references: ['public.users.nonexistent']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + MissingColumnRel, + 'bad_column_ref', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'bad_column_ref'; + errors: [ + { + errorCode: 'missing_column'; + reference: 'public.users.nonexistent'; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects multiple errors from different references', () => { + type MultipleErrorsRel = { + columns: ['user_id', 'tenant_id']; + references: ['nonexistent.users.id', 'public.missing_table.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + MultipleErrorsRel, + 'multi_error_rel', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'multi_error_rel'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + { + errorCode: 'missing_table'; + reference: 'public.missing_table.id'; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects all errors when all references are invalid', () => { + type AllInvalidRel = { + columns: ['user_id', 'tenant_id', 'post_id']; + references: [ + 'schema1.table1.col1', + 'schema2.table2.col2', + 'schema3.table3.col3', + ]; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + ExistingColumns, + AllInvalidRel, + 'all_invalid', + 'posts', + PostsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'all_invalid'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'schema1.table1.col1'; + }, + { + errorCode: 'missing_schema'; + reference: 'schema2.table2.col2'; + }, + { + errorCode: 'missing_schema'; + reference: 'schema3.table3.col3'; + }, + ]; + } + > + > + >, + ]; + }); + + void it('collects type mismatch errors', () => { + type TypeMismatchTable = typeof typeMismatchTable; + type TypeMismatchColumns = + InferTableSchemaComponentColumns; + + type TypeMismatchRel = { + columns: ['user_id']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationship< + TypeMismatchColumns, + TypeMismatchRel, + 'user', + 'type_mismatch', + TypeMismatchTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + } + > + > + >, + ]; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipColumns.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipColumns.type.spec.ts new file mode 100644 index 00000000..2bd54488 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipColumns.type.spec.ts @@ -0,0 +1,167 @@ +import { describe, it } from 'node:test'; +import type { Equals, Expect, IsError, IsOK } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import type { AnyColumnSchemaComponent } from '../columnSchemaComponent'; +import type { ValidateRelationshipColumns } from './relationshipValidation'; + +void describe('ValidateRelationshipColumns', () => { + type ExistingColumns = { + post_id: AnyColumnSchemaComponent; + user_id: AnyColumnSchemaComponent; + tenant_id: AnyColumnSchemaComponent; + }; + + void it('succeeds for single reference and column', () => { + type SingleColumnAndReferences = { + columns: ['user_id']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + SingleColumnAndReferences, + ExistingColumns + >; + + type _Then = Expect>; + }); + + void it('succeeds for multiple reference and column of the same length', () => { + type MultipleColumnsAndReferences = { + columns: ['user_id', 'tenant_id']; + references: ['public.users.id', 'public.users.tenant_id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + MultipleColumnsAndReferences, + ExistingColumns + >; + + type _Then = Expect>; + }); + + void it('fails when references and columns are empty', () => { + type EmptyRelationship = { + columns: []; + references: []; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + EmptyRelationship, + ExistingColumns + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_columns_mismatch'; + invalidColumns: []; + availableColumns: keyof ExistingColumns; + } + > + > + >, + ]; + }); + + void it('fails for single invalid columns', () => { + type SingleColumnAndReferences = { + columns: ['invalid']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + SingleColumnAndReferences, + ExistingColumns + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_columns_mismatch'; + invalidColumns: ['invalid']; + availableColumns: keyof ExistingColumns; + } + > + > + >, + ]; + }); + + void it('fails for multiple invalid columns', () => { + type MultipleColumnsAndReferences = { + columns: ['invalid', 'not_exist']; + references: ['public.users.id', 'public.users.tenant_id']; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + MultipleColumnsAndReferences, + ExistingColumns + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_columns_mismatch'; + invalidColumns: ['invalid', 'not_exist']; + availableColumns: keyof ExistingColumns; + } + > + > + >, + ]; + }); + + void it('fails for multiple invalid columns with a valid one', () => { + type MultipleColumnsAndReferences = { + columns: ['invalid', 'not_exist', 'user_id']; + references: [ + 'public.users.id', + 'public.users.tenant_id', + 'public.users.id', + ]; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipColumns< + MultipleColumnsAndReferences, + ExistingColumns + >; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_columns_mismatch'; + invalidColumns: ['invalid', 'not_exist']; + availableColumns: keyof ExistingColumns; + } + > + > + >, + ]; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipLength.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipLength.type.spec.ts new file mode 100644 index 00000000..eaad98f7 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateRelationshipLength.type.spec.ts @@ -0,0 +1,124 @@ +import { describe, it } from 'node:test'; +import type { Equals, Expect, IsError, IsOK } from '../../../testing'; +import type { + TypeValidationResult, + TypeValidationSuccess, +} from '../../../typing'; +import type { ValidateRelationshipLength } from './relationshipValidation'; + +void describe('ValidateRelationshipLength', () => { + void it('succeeds for single reference and column', () => { + type SingleColumnAndReferences = { + columns: ['user_id']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type _Result_LengthMismatch = + ValidateRelationshipLength; + + type _Then = [ + Expect>, + Expect>, + ]; + }); + + void it('succeeds for multiple reference and column of the same length', () => { + type MultipleColumnsAndReferences = { + columns: ['user_id', 'tenant_id']; + references: ['public.users.id', 'public.users.tenant_id']; + type: 'one-to-one'; + }; + + type _Result_LengthMismatch = + ValidateRelationshipLength; + + type _Assert = [ + Expect>, + Expect>, + ]; + }); + + void it('fails when references and columns are empty', () => { + type EmptyRelationship = { + columns: []; + references: []; + type: 'one-to-one'; + }; + + type Result = ValidateRelationshipLength; + + type _Assert = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_length_mismatch'; + columns: []; + references: []; + } + > + > + >, + ]; + }); + + void it('fails when columns are longer than references', () => { + type RelWithColumnsLongerThanReferences = { + columns: ['user_id', 'tenant_id']; + references: ['public.users.id']; + type: 'one-to-one'; + }; + + type Result = + ValidateRelationshipLength; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_length_mismatch'; + columns: ['user_id', 'tenant_id']; + references: ['public.users.id']; + } + > + > + >, + ]; + }); + + void it('fails when references are longer than columns', () => { + type RelWithReferencesLongerThanColumns = { + columns: ['user_id']; + references: ['public.users.id', 'public.users.tenant_id']; + type: 'one-to-one'; + }; + + type Result = + ValidateRelationshipLength; + + type _Then = [ + Expect>, + Expect< + Equals< + Result, + TypeValidationResult< + false, + { + errorCode: 'reference_length_mismatch'; + columns: ['user_id']; + references: ['public.users.id', 'public.users.tenant_id']; + } + > + > + >, + ]; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/relationships/validateTableRelationships.type.spec.ts b/src/packages/dumbo/src/core/schema/components/relationships/validateTableRelationships.type.spec.ts new file mode 100644 index 00000000..3e2047a3 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/relationships/validateTableRelationships.type.spec.ts @@ -0,0 +1,236 @@ +import { describe, it } from 'node:test'; +import { SQL } from '../../../sql'; +import type { Equals, Expect, IsError } from '../../../testing'; +import type { TypeValidationResult } from '../../../typing'; +import { dumboSchema } from '../../dumboSchema'; +import { relationship } from './relationshipTypes'; +import type { ValidateTableRelationships } from './relationshipValidation'; + +const { schema, table, column } = dumboSchema; +const { Integer, BigInteger } = SQL.column.type; + +void describe('ValidateTableRelationships', () => { + const usersTable = table('users', { + columns: { + id: column('id', Integer), + }, + }); + + const postsTable = table('posts', { + columns: { + post_id: column('post_id', Integer), + user_id: column('user_id', Integer), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'one-to-one'), + }, + }); + + const typeMismatchTable = table('type_mismatch', { + columns: { + user_id: column('user_id', BigInteger), + }, + relationships: { + user: relationship(['user_id'], ['public.users.id'], 'one-to-one'), + }, + }); + + const _publicSchema = schema('public', { + users: usersTable, + posts: postsTable, + type_mismatch: typeMismatchTable, + }); + + type _TestSchemas = { + public: typeof _publicSchema; + }; + + void it('returns empty array when all relationships are valid', () => { + type Result = ValidateTableRelationships< + typeof postsTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = Expect>; + }); + + void it('collects errors when relationship has length mismatch', () => { + const _invalidTable = table('invalid', { + columns: { + col1: column('col1', Integer), + col2: column('col2', Integer), + }, + relationships: { + bad_rel: relationship( + ['col1', 'col2'], + ['public.users.id'], + 'one-to-one', + ), + }, + }); + + type Result = ValidateTableRelationships< + typeof _invalidTable, + typeof _publicSchema, + _TestSchemas + >; + + type Expected = TypeValidationResult< + false, + { + table: 'invalid'; + errors: [ + { + relationship: 'bad_rel'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: readonly ['col1', 'col2']; + references: readonly ['public.users.id']; + }, + ]; + }, + ]; + } + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('collects errors when relationship references missing schema', () => { + const _missingSchemaTable = table('missing_schema', { + columns: { + user_id: column('user_id', Integer), + }, + relationships: { + bad_schema_rel: relationship( + ['user_id'], + ['nonexistent.users.id'], + 'one-to-one', + ), + }, + }); + + type Result = ValidateTableRelationships< + typeof _missingSchemaTable, + typeof _publicSchema, + _TestSchemas + >; + + type Expected = TypeValidationResult< + false, + { + table: 'missing_schema'; + errors: [ + { + relationship: 'bad_schema_rel'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.users.id'; + }, + ]; + }, + ]; + } + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('collects errors from multiple invalid relationships', () => { + const _multiErrorTable = table('multi_error', { + columns: { + col1: column('col1', Integer), + col2: column('col2', Integer), + }, + relationships: { + rel1: relationship(['col1', 'col2'], ['public.users.id'], 'one-to-one'), + rel2: relationship(['col1'], ['nonexistent.table.id'], 'one-to-one'), + }, + }); + + type Result = ValidateTableRelationships< + typeof _multiErrorTable, + typeof _publicSchema, + _TestSchemas + >; + + type Expected = TypeValidationResult< + false, + { + table: 'multi_error'; + errors: [ + { + relationship: 'rel1'; + errors: [ + { + errorCode: 'reference_length_mismatch'; + columns: readonly ['col1', 'col2']; + references: readonly ['public.users.id']; + }, + ]; + }, + { + relationship: 'rel2'; + errors: [ + { + errorCode: 'missing_schema'; + reference: 'nonexistent.table.id'; + }, + ]; + }, + ]; + } + >; + + type _Then = [Expect>, Expect>]; + }); + + void it('returns empty array when table has no relationships', () => { + const _noRelTable = table('no_rels', { + columns: { + id: column('id', Integer), + }, + }); + + type Result = ValidateTableRelationships< + typeof _noRelTable, + typeof _publicSchema, + _TestSchemas + >; + + type _Then = Expect>; + }); + + void it('collects errors for type mismatch', () => { + type Result = ValidateTableRelationships< + typeof typeMismatchTable, + typeof _publicSchema, + _TestSchemas + >; + + type Expected = { + valid: false; + error: { + table: 'type_mismatch'; + errors: [ + { + relationship: 'user'; + errors: [ + { + errorCode: 'type_mismatch'; + reference: 'public.users.id'; + referenceType: 'INTEGER'; + columnTypeName: 'BIGINT'; + }, + ]; + }, + ]; + }; + }; + + type _Then = [Expect>, Expect>]; + }); +}); diff --git a/src/packages/dumbo/src/core/schema/components/tableSchemaComponent.ts b/src/packages/dumbo/src/core/schema/components/tableSchemaComponent.ts new file mode 100644 index 00000000..81b980c7 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/tableSchemaComponent.ts @@ -0,0 +1,126 @@ +import { + mapSchemaComponentsOfType, + schemaComponent, + type SchemaComponent, + type SchemaComponentOptions, +} from '../schemaComponent'; +import { + ColumnURNType, + type AnyColumnSchemaComponent, +} from './columnSchemaComponent'; +import { + IndexURNType, + type IndexSchemaComponent, +} from './indexSchemaComponent'; +import type { TableRelationships } from './relationships/relationshipTypes'; +import type { TableColumnNames } from './tableTypesInference'; + +export type TableURNType = 'sc:dumbo:table'; +export type TableURN = `${TableURNType}:${string}`; + +export const TableURNType: TableURNType = 'sc:dumbo:table'; +export const TableURN = ({ name }: { name: string }): TableURN => + `${TableURNType}:${name}`; + +export type TableColumns = Record; + +export type TableSchemaComponent< + Columns extends TableColumns = TableColumns, + TableName extends string = string, + Relationships extends TableRelationships< + keyof Columns & string + > = {} & TableRelationships, +> = SchemaComponent< + TableURN, + Readonly<{ + tableName: TableName; + columns: ReadonlyMap & Columns; + primaryKey: TableColumnNames< + TableSchemaComponent + >[]; + relationships: Relationships; + indexes: ReadonlyMap; + addColumn: (column: AnyColumnSchemaComponent) => AnyColumnSchemaComponent; + addIndex: (index: IndexSchemaComponent) => IndexSchemaComponent; + }> +>; + +export type InferTableSchemaComponentTypes = + T extends TableSchemaComponent< + infer Columns, + infer TableName, + infer Relationships + > + ? [Columns, TableName, Relationships] + : never; + +export type InferTableSchemaComponentColumns< + T extends AnyTableSchemaComponent, +> = InferTableSchemaComponentTypes[0]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyTableSchemaComponent = TableSchemaComponent; + +export const tableSchemaComponent = < + const Columns extends TableColumns = TableColumns, + const TableName extends string = string, + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + const Relationships extends TableRelationships = {}, +>({ + tableName, + columns, + primaryKey, + relationships, + ...migrationsOrComponents +}: { + tableName: TableName; + columns?: Columns; + primaryKey?: TableColumnNames< + TableSchemaComponent + >[]; + relationships?: Relationships; +} & SchemaComponentOptions): TableSchemaComponent< + Columns, + TableName, + Relationships +> & { + relationships: Relationships; +} => { + columns ??= {} as Columns; + relationships ??= {} as Relationships; + + const base = schemaComponent(TableURN({ name: tableName }), { + migrations: migrationsOrComponents.migrations ?? [], + components: [ + ...(migrationsOrComponents.components ?? []), + ...Object.values(columns), + ], + }); + + return { + ...base, + tableName, + primaryKey: primaryKey ?? [], + relationships, + get columns() { + const columnsMap = mapSchemaComponentsOfType( + base.components, + ColumnURNType, + (c) => c.columnName, + ); + + return Object.assign(columnsMap, columns); + }, + get indexes() { + return mapSchemaComponentsOfType( + base.components, + IndexURNType, + (c) => c.indexName, + ); + }, + addColumn: (column: AnyColumnSchemaComponent) => base.addComponent(column), + addIndex: (index: IndexSchemaComponent) => base.addComponent(index), + } as TableSchemaComponent & { + relationships: Relationships; + }; +}; diff --git a/src/packages/dumbo/src/core/schema/components/tableTypesInference.ts b/src/packages/dumbo/src/core/schema/components/tableTypesInference.ts new file mode 100644 index 00000000..6d3c76a0 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/tableTypesInference.ts @@ -0,0 +1,59 @@ +import type { ColumnTypeToken } from '../../sql/tokens/columnTokens'; +import type { + AnyColumnSchemaComponent, + ColumnSchemaComponent, +} from './columnSchemaComponent'; +import type { + AnyDatabaseSchemaComponent, + DatabaseSchemaComponent, +} from './databaseSchemaComponent'; +import type { + AnyDatabaseSchemaSchemaComponent, + DatabaseSchemaSchemaComponent, +} from './databaseSchemaSchemaComponent'; +import type { + AnyTableSchemaComponent, + TableColumns, + TableSchemaComponent, +} from './tableSchemaComponent'; + +export type Writable = { + -readonly [P in keyof T]: T[P]; +}; + +export type InferColumnType = + ColumnType extends ColumnTypeToken< + infer _JSType, + infer _ColumnTypeName, + infer _TProps, + infer ValueType + > + ? ValueType + : ColumnType; + +export type TableColumnType = + T extends ColumnSchemaComponent + ? T extends { notNull: true } | { primaryKey: true } + ? InferColumnType + : InferColumnType | null + : unknown; + +export type TableColumnNames = Exclude< + keyof T['columns'], + keyof ReadonlyMap +>; + +export type InferTableRow = Writable<{ + [K in keyof Columns]: TableColumnType; +}>; + +export type TableRowType = + T extends TableSchemaComponent + ? InferTableRow + : never; + +export type InferSchemaTables = + T extends DatabaseSchemaSchemaComponent ? Tables : never; + +export type InferDatabaseSchemas = + T extends DatabaseSchemaComponent ? Schemas : never; diff --git a/src/packages/dumbo/src/core/schema/components/tableTypesInference.type.spec.ts b/src/packages/dumbo/src/core/schema/components/tableTypesInference.type.spec.ts new file mode 100644 index 00000000..4692c87d --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/tableTypesInference.type.spec.ts @@ -0,0 +1,175 @@ +import { SQL } from '../../sql'; +import type { + BigIntegerToken, + BigSerialToken, + IntegerToken, + JSONBToken, + SerialToken, + TimestampToken, + TimestamptzToken, + VarcharToken, +} from '../../sql/tokens/columnTokens'; +import type { Equals, Expect } from '../../testing'; +import { dumboSchema } from '../dumboSchema'; +import type { + InferColumnType, + InferTableRow, + TableColumnType, + TableRowType, +} from './tableTypesInference'; + +const { table, column } = dumboSchema; +const { Serial, BigSerial, Integer, BigInteger, Varchar, Timestamp, JSONB } = + SQL.column.type; + +// InferColumnValueType - basic types +type _Test1 = Expect, number>>; +type _Test2 = Expect, bigint>>; +type _Test3 = Expect, number>>; +type _Test4 = Expect, bigint>>; +type _Test5 = Expect, string>>; +type _Test6 = Expect, Date>>; +type _Test7 = Expect, Date>>; + +// InferColumnValueType - JSONB with custom type +type CustomType = { foo: string; bar: number }; +type _Test8 = Expect< + Equals>, CustomType> +>; + +// InferColumnType - primary key is non-nullable +const _idColumn = column('id', Serial, { primaryKey: true }); +type _Test9 = Expect, number>>; + +// InferColumnType - notNull is non-nullable +const _emailColumn = column('email', Varchar(255), { notNull: true }); +type _Test10 = Expect, string>>; + +// InferColumnType - default column is nullable +const _nicknameColumn = column('nickname', Varchar(100)); +type _Test11 = Expect< + Equals, string | null> +>; + +// InferColumnType - column with default is still nullable +const _createdAtColumn = column('createdAt', Timestamp, { + default: SQL.plain(`NOW()`), +}); +type _Test12 = Expect< + Equals, Date | null> +>; + +// InferColumnType - unique column is nullable +const _usernameColumn = column('username', Varchar(50), { unique: true }); +type _Test13 = Expect< + Equals, string | null> +>; + +// InferColumnType - serial without primary key is nullable +const _sortOrderColumn = column('sortOrder', Serial); +type _Test14 = Expect< + Equals, number | null> +>; + +// InferColumnType - bigint types +const _bigIdColumn = column('bigId', BigSerial, { primaryKey: true }); +const _nullableBigIntColumn = column('bigValue', BigInteger); +type _Test15 = Expect, bigint>>; +type _Test16 = Expect< + Equals, bigint | null> +>; + +// InferTableRow - complex table with mixed nullability +const _usersColumns = { + id: column('id', Serial, { primaryKey: true }), + email: column('email', Varchar(255), { notNull: true }), + nickname: column('nickname', Varchar(100)), + age: column('age', Integer), + createdAt: column('createdAt', Timestamp, { default: SQL.plain(`NOW()`) }), + username: column('username', Varchar(50), { unique: true }), +}; +const _usersTable = table('users', { + columns: _usersColumns, +}); +type UserRow = InferTableRow; +type _Test17 = Expect< + Equals< + UserRow, + { + id: number; + email: string; + nickname: string | null; + age: number | null; + createdAt: Date | null; + username: string | null; + } + > +>; + +// InferTableType - infer from TableSchemaComponent +const _productsTable = table('products', { + columns: { + id: column('id', BigSerial, { primaryKey: true }), + name: column('name', Varchar(255), { notNull: true }), + description: column('description', Varchar('max')), + price: column('price', Integer, { notNull: true }), + metadata: column('metadata', JSONB<{ tags: string[] }>()), + }, +}); +type ProductRow = TableRowType; +type _Test18 = Expect< + Equals< + ProductRow, + { + id: bigint; + name: string; + description: string | null; + price: number; + metadata: { tags: string[] } | null; + } + > +>; + +// InferTableType - table with all non-nullable columns +const _strictTable = table('strict', { + columns: { + id: column('id', Serial, { primaryKey: true }), + field1: column('field1', Varchar(100), { notNull: true }), + field2: column('field2', Integer, { notNull: true }), + field3: column('field3', Timestamp, { notNull: true }), + }, +}); +type StrictRow = TableRowType; +type _Test19 = Expect< + Equals< + StrictRow, + { + id: number; + field1: string; + field2: number; + field3: Date; + } + > +>; + +// InferTableType - table with all nullable columns (except PK) +const _nullableTable = table('nullable', { + columns: { + id: column('id', Serial, { primaryKey: true }), + field1: column('field1', Varchar(100)), + field2: column('field2', Integer), + field3: column('field3', Timestamp), + }, +}); +type NullableRow = TableRowType; +type _Test20 = Expect< + Equals< + NullableRow, + { + id: number; + field1: string | null; + field2: number | null; + field3: Date | null; + } + > +>; diff --git a/src/packages/dumbo/src/core/schema/components/tableTypesInference.unit.spec.ts b/src/packages/dumbo/src/core/schema/components/tableTypesInference.unit.spec.ts new file mode 100644 index 00000000..9099cf97 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/components/tableTypesInference.unit.spec.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { SQL } from '../../sql'; +import { dumboSchema } from '../dumboSchema'; +import type { TableRowType } from './tableTypesInference'; + +const { table, column } = dumboSchema; +const { Serial, Varchar, Integer, Timestamp, JSONB } = SQL.column.type; + +void describe('Type Inference Runtime Tests', () => { + void it('should compile successfully with basic table', () => { + const _users = table('users', { + columns: { + id: column('id', Serial, { primaryKey: true }), + email: column('email', Varchar(255), { notNull: true }), + nickname: column('nickname', Varchar(100)), + }, + }); + + type UserRow = TableRowType; + + const sampleUser: UserRow = { + id: 1, + email: 'test@example.com', + nickname: 'tester', + }; + + assert.strictEqual(sampleUser.id, 1); + assert.strictEqual(sampleUser.email, 'test@example.com'); + assert.strictEqual(sampleUser.nickname, 'tester'); + }); + + void it('should allow null for nullable columns', () => { + const _users2 = table('users', { + columns: { + id: column('id', Serial, { primaryKey: true }), + nickname: column('nickname', Varchar(100)), + }, + }); + + type UserRow = TableRowType; + + const user1: UserRow = { + id: 1, + nickname: null, + }; + + const user2: UserRow = { + id: 2, + nickname: 'test', + }; + + assert.strictEqual(user1.nickname, null); + assert.strictEqual(user2.nickname, 'test'); + }); + + void it('should work with JSONB custom types', () => { + const _products = table('products', { + columns: { + id: column('id', Serial, { primaryKey: true }), + metadata: column('metadata', JSONB<{ tags: string[] }>()), + }, + }); + + type ProductRow = TableRowType; + + const product: ProductRow = { + id: 1, + metadata: { tags: ['electronics', 'sale'] }, + }; + + assert.strictEqual(product.id, 1); + assert.deepStrictEqual(product.metadata, { tags: ['electronics', 'sale'] }); + }); + + void it('should work with mixed nullable and non-nullable columns', () => { + const _posts = table('posts', { + columns: { + id: column('id', Serial, { primaryKey: true }), + title: column('title', Varchar(255), { notNull: true }), + content: column('content', Varchar('max'), { notNull: true }), + publishedAt: column('publishedAt', Timestamp), + viewCount: column('viewCount', Integer), + }, + }); + + type PostRow = TableRowType; + + const draftPost: PostRow = { + id: 1, + title: 'My First Post', + content: 'This is the content', + publishedAt: null, + viewCount: null, + }; + + const publishedPost: PostRow = { + id: 2, + title: 'Published Post', + content: 'Published content', + publishedAt: new Date(), + viewCount: 42, + }; + + assert.strictEqual(draftPost.publishedAt, null); + assert.strictEqual(draftPost.viewCount, null); + assert.ok(publishedPost.publishedAt instanceof Date); + assert.strictEqual(publishedPost.viewCount, 42); + }); + + void it('should work with default values (still nullable)', () => { + const _events = table('events', { + columns: { + id: column('id', Serial, { primaryKey: true }), + createdAt: column('createdAt', Timestamp, { + default: SQL.plain(`NOW()`), + }), + }, + }); + + type EventRow = TableRowType; + + const event1: EventRow = { + id: 1, + createdAt: new Date(), + }; + + const event2: EventRow = { + id: 2, + createdAt: null, + }; + + assert.ok(event1.createdAt instanceof Date); + assert.strictEqual(event2.createdAt, null); + }); + + void it('example: type-safe query result processing', () => { + const _users3 = table('users', { + columns: { + id: column('id', Serial, { primaryKey: true }), + email: column('email', Varchar(255), { notNull: true }), + nickname: column('nickname', Varchar(100)), + age: column('age', Integer), + }, + }); + + type UserRow = TableRowType; + + const mockQueryResults: UserRow[] = [ + { id: 1, email: 'alice@example.com', nickname: 'alice', age: 30 }, + { id: 2, email: 'bob@example.com', nickname: null, age: null }, + { id: 3, email: 'charlie@example.com', nickname: 'charlie', age: 25 }, + ]; + + const processedResults = mockQueryResults.map((user) => ({ + id: user.id, + email: user.email.toUpperCase(), + displayName: user.nickname ?? 'Anonymous', + isAdult: user.age !== null && user.age >= 18, + })); + + assert.strictEqual(processedResults[0]?.email, 'ALICE@EXAMPLE.COM'); + assert.strictEqual(processedResults[1]?.displayName, 'Anonymous'); + assert.strictEqual(processedResults[2]?.isAdult, true); + }); +}); diff --git a/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.ts b/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.ts new file mode 100644 index 00000000..9aec74f4 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.ts @@ -0,0 +1,249 @@ +import type { AnyColumnTypeToken, SQLColumnToken } from '../../sql'; +import type { ValidateDatabaseSchemas } from '../components'; +import { + type AnyDatabaseSchemaSchemaComponent, + columnSchemaComponent, + type ColumnSchemaComponentOptions, + databaseSchemaComponent, + type DatabaseSchemaComponent, + type DatabaseSchemas, + databaseSchemaSchemaComponent, + type DatabaseSchemaSchemaComponent, + type DatabaseSchemaTables, + indexSchemaComponent, + type IndexSchemaComponent, + type TableColumnNames, + type TableColumns, + type TableRelationships, + tableSchemaComponent, + type TableSchemaComponent, +} from '../components'; +import { + type AnySchemaComponent, + isSchemaComponentOfType, + type SchemaComponentOptions, +} from '../schemaComponent'; + +const DEFAULT_DATABASE_NAME = '__default_database__'; +const DEFAULT_DATABASE_SCHEMA_NAME = '__default_database_schema__'; + +const dumboColumn = < + const ColumnType extends AnyColumnTypeToken | string = + | AnyColumnTypeToken + | string, + const TOptions extends SchemaComponentOptions & + Omit, 'name' | 'type' | 'sqlTokenType'> = Omit< + ColumnSchemaComponentOptions, + 'type' + >, + const ColumnName extends string = string, +>( + name: ColumnName, + type: ColumnType, + options?: TOptions, +) => + columnSchemaComponent< + ColumnType, + TOptions & { type: ColumnType }, + ColumnName + >({ + columnName: name, + type, + ...options, + } as { columnName: ColumnName } & TOptions & { type: ColumnType }); + +const dumboIndex = ( + name: string, + columnNames: string[], + options?: { unique?: boolean } & SchemaComponentOptions, +): IndexSchemaComponent => + indexSchemaComponent({ + indexName: name, + columnNames, + isUnique: options?.unique ?? false, + ...options, + }); + +const dumboTable = < + const Columns extends TableColumns = TableColumns, + const TableName extends string = string, + const Relationships extends TableRelationships< + keyof Columns & string + > = TableRelationships, +>( + name: TableName, + definition: { + columns?: Columns; + primaryKey?: TableColumnNames< + TableSchemaComponent + >[]; + relationships?: Relationships; + indexes?: Record; + } & SchemaComponentOptions, +): TableSchemaComponent => { + const { columns, indexes, primaryKey, relationships, ...options } = + definition; + + const components = [...(indexes ? Object.values(indexes) : [])]; + + return tableSchemaComponent({ + tableName: name, + columns: columns ?? ({} as Columns), + primaryKey: primaryKey ?? [], + ...(relationships !== undefined ? { relationships } : {}), + components, + ...options, + }); +}; + +function dumboDatabaseSchema< + const Tables extends DatabaseSchemaTables = DatabaseSchemaTables, +>( + tables: Tables, +): DatabaseSchemaSchemaComponent; +function dumboDatabaseSchema< + const Tables extends DatabaseSchemaTables = DatabaseSchemaTables, + const SchemaName extends string = string, +>( + schemaName: SchemaName, + tables: Tables, + options?: SchemaComponentOptions, +): DatabaseSchemaSchemaComponent; +function dumboDatabaseSchema< + const Tables extends DatabaseSchemaTables = DatabaseSchemaTables, + const SchemaName extends string = string, +>( + nameOrTables: SchemaName | Tables, + tables?: Tables, + options?: SchemaComponentOptions, +): DatabaseSchemaSchemaComponent { + const schemaName = + typeof nameOrTables === 'string' + ? nameOrTables + : (DEFAULT_DATABASE_SCHEMA_NAME as SchemaName); + const tablesMap = + (typeof nameOrTables === 'string' ? tables : nameOrTables) ?? + ({} as Tables); + return databaseSchemaSchemaComponent({ + schemaName, + tables: tablesMap, + ...options, + }); +} + +dumboDatabaseSchema.from = ( + schemaName: string | undefined, + tableNames: string[], +): DatabaseSchemaSchemaComponent => { + const tables = tableNames.reduce( + (acc, tableName) => { + acc[tableName] = dumboTable(tableName, {}); + return acc; + }, + {} as Record, + ); + + return schemaName + ? dumboDatabaseSchema(schemaName, tables) + : dumboDatabaseSchema(tables); +}; + +type ValidatedDatabaseSchemaComponent< + Schemas extends DatabaseSchemas = DatabaseSchemas, +> = + ValidateDatabaseSchemas extends { + valid: true; + } + ? DatabaseSchemaComponent + : ValidateDatabaseSchemas extends { + valid: false; + error: infer E; + } + ? { valid: false; error: E } + : DatabaseSchemaComponent; + +function dumboDatabase( + schemas: Schemas, +): ValidatedDatabaseSchemaComponent; +function dumboDatabase( + schema: DatabaseSchemaSchemaComponent, +): ValidatedDatabaseSchemaComponent; +function dumboDatabase( + databaseName: string, + schemas: Schemas, + options?: SchemaComponentOptions, +): ValidatedDatabaseSchemaComponent; +function dumboDatabase( + databaseName: string, + schema: AnyDatabaseSchemaSchemaComponent, + options?: SchemaComponentOptions, +): ValidatedDatabaseSchemaComponent; +function dumboDatabase( + nameOrSchemas: string | DatabaseSchemaSchemaComponent | Schemas, + schemasOrOptions?: + | DatabaseSchemaSchemaComponent + | Schemas + | SchemaComponentOptions, + options?: SchemaComponentOptions, +): ValidatedDatabaseSchemaComponent { + const databaseName = + typeof nameOrSchemas === 'string' ? nameOrSchemas : DEFAULT_DATABASE_NAME; + + const schemasOrSchema = + typeof nameOrSchemas === 'string' + ? (schemasOrOptions ?? {}) + : nameOrSchemas; + const schemaMap: Record = + 'schemaComponentKey' in schemasOrSchema && + isSchemaComponentOfType( + schemasOrSchema as AnySchemaComponent, + 'sc:dumbo:database_schema', + ) + ? { + [DEFAULT_DATABASE_SCHEMA_NAME]: + schemasOrSchema as DatabaseSchemaSchemaComponent, + } + : (schemasOrSchema as Record); + + const dbOptions: typeof options = + typeof nameOrSchemas === 'string' + ? options + : (schemasOrOptions as typeof options); + + return databaseSchemaComponent({ + databaseName, + schemas: schemaMap as Schemas, + ...dbOptions, + }) as ValidatedDatabaseSchemaComponent; +} + +dumboDatabase.from = ( + databaseName: string | undefined, + schemaNames: string[], +): ValidatedDatabaseSchemaComponent => { + const schemas = schemaNames.reduce( + (acc, schemaName) => { + acc[schemaName] = dumboDatabaseSchema( + schemaName, + {} as DatabaseSchemaTables, + ); + return acc; + }, + {} as Record, + ) as Schemas; + + return databaseName + ? dumboDatabase(databaseName, schemas) + : dumboDatabase(schemas); +}; + +dumboDatabase.defaultName = DEFAULT_DATABASE_NAME; +dumboDatabaseSchema.defaultName = DEFAULT_DATABASE_SCHEMA_NAME; + +export const dumboSchema = { + database: dumboDatabase, + schema: dumboDatabaseSchema, + table: dumboTable, + column: dumboColumn, + index: dumboIndex, +}; diff --git a/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.unit.spec.ts b/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.unit.spec.ts new file mode 100644 index 00000000..633aa469 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/dumboSchema/dumboSchema.unit.spec.ts @@ -0,0 +1,387 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { SQL } from '../../sql'; +import type { Equals, Expect } from '../../testing'; +import type { TableColumnNames, TableRowType } from '../components'; +import { relationship } from '../components'; +import { dumboSchema } from './index'; + +const { database, schema, table, column, index } = dumboSchema; +const { Varchar, JSONB } = SQL.column.type; + +void describe('dumboSchema', () => { + void it('should create a column', () => { + const col = column('id', Varchar('max')); + assert.strictEqual(col.columnName, 'id'); + }); + + void it('should create an index', () => { + const idx = index('idx_email', ['email']); + assert.strictEqual(idx.indexName, 'idx_email'); + assert.strictEqual(idx.isUnique, false); + }); + + void it('should create a unique index', () => { + const idx = index('idx_email', ['email'], { unique: true }); + assert.strictEqual(idx.indexName, 'idx_email'); + assert.strictEqual(idx.isUnique, true); + }); + + void it('should create a table with columns and indexes', () => { + const tbl = table('users', { + columns: { + id: column('id', Varchar('max')), + email: column('email', Varchar('max')), + }, + indexes: { + idx_email: index('idx_email', ['email']), + }, + }); + + assert.strictEqual(tbl.tableName, 'users'); + assert.strictEqual(tbl.columns.size, 2); + assert.strictEqual(tbl.indexes.size, 1); + assert.ok(tbl.columns.has('id')); + assert.ok(tbl.columns.has('email')); + assert.ok(tbl.indexes.has('idx_email')); + assert.ok(tbl.columns.id !== undefined); + assert.ok(tbl.columns.email !== undefined); + }); + + void it('should create a named schema', () => { + const sch = schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }); + + assert.strictEqual(sch.schemaName, 'public'); + assert.strictEqual(sch.tables.size, 1); + assert.ok(sch.tables.has('users')); + assert.ok(sch.tables.users.columns.id !== undefined); + }); + + void it('should create a default schema without name', () => { + const sch = schema({ + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }); + + assert.strictEqual(sch.schemaName, dumboSchema.schema.defaultName); + assert.strictEqual(sch.tables.size, 1); + }); + + void it('should create a default database', () => { + const db = database({ + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }), + }); + + assert.strictEqual(db.databaseName, dumboSchema.database.defaultName); + assert.strictEqual(db.schemas.size, 1); + assert.ok(db.schemas.has('public')); + }); + + void it('should create a named database', () => { + const db = database('myapp', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }), + }); + + assert.strictEqual(db.databaseName, 'myapp'); + assert.strictEqual(db.schemas.size, 1); + assert.ok(db.schemas.has('public')); + assert.ok(db.schemas.public !== undefined); + assert.ok(db.schemas.public.tables.users !== undefined); + assert.ok(db.schemas.public.tables.users.columns.id !== undefined); + }); + + void it('should handle DEFAULT_SCHEMA', () => { + const db = database( + 'myapp', + schema({ + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }), + ); + + assert.strictEqual(db.databaseName, 'myapp'); + assert.strictEqual(db.schemas.size, 1); + assert.ok(db.schemas.has(dumboSchema.schema.defaultName)); + }); + + void it('should create schema from table names', () => { + const sch = schema.from('public', ['users', 'posts']); + assert.strictEqual(sch.schemaName, 'public'); + assert.strictEqual(sch.tables.size, 2); + }); + + void it('should create database from schema names', () => { + const db = database.from('myapp', ['public', 'analytics']); + assert.strictEqual(db.databaseName, 'myapp'); + assert.strictEqual(db.schemas.size, 2); + }); +}); + +// Samples + +// Simple database with tables in default schema + +const users = table('users', { + columns: { + id: column('id', Varchar('max'), { primaryKey: true, notNull: true }), + email: column('email', Varchar('max'), { notNull: true }), + name: column('name', Varchar('max')), + }, + relationships: { + profile: relationship(['id'], ['public.profiles.user_id'], 'one-to-one'), + }, +}); + +const _users2 = table('users', { + columns: { + id: column('id', Varchar('max'), { primaryKey: true, notNull: true }), + email: column('email', Varchar('max'), { notNull: true }), + name: column('name', Varchar('max')), + }, + relationships: { + profile: { + columns: ['id'], + references: ['public.profiles.user_id'], + type: 'one-to-one', + }, + }, +}); + +export const simpleDb = database( + 'myapp', + schema({ + users, + }), +); + +// Database with multiple schemas +const multiSchemaDb = database('myapp', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max'), { notNull: true }), + email: column('email', Varchar('max'), { notNull: true }), + name: column('name', Varchar('max')), + metadata: column('metadata', JSONB<{ preferences: string[] }>()), + }, + primaryKey: ['id'], + }), + }), + analytics: schema('analytics', { + events: table('events', { + columns: { + id: column('id', Varchar('max'), { notNull: true, primaryKey: true }), + userId: column('user_id', Varchar('max')), + timestamp: column('timestamp', Varchar('max')), + }, + relationships: { + user: { + columns: ['userId'], + references: ['public.users.id'], + type: 'many-to-one', + }, + }, + }), + }), +}); + +// Access using name-based maps +const publicSchema = multiSchemaDb.schemas.public; +const _usersTable = publicSchema.tables.users; + +type Users = TableRowType; + +type _IdColumnIsNonNullableString = Expect>; +type _EmailColumnIsNonNullableString = Expect>; +type _NameColumnIsNullableString = Expect>; +type _MetadataColumnIsNullableObject = Expect< + Equals +>; + +type UserColumns = TableColumnNames; + +const _userColumns: UserColumns[] = ['id', 'email', 'name', 'metadata']; + +void describe('Foreign Key Validation', () => { + void it('should accept valid single foreign key', () => { + const db = database('test', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + email: column('email', Varchar('max')), + }, + }), + posts: table('posts', { + columns: { + id: column('id', Varchar('max')), + user_id: column('user_id', Varchar('max')), + }, + relationships: { + user: { + columns: ['user_id'], + references: ['public.users.id'], + type: 'many-to-one', + }, + }, + }), + }), + }); + + assert.ok(db.schemas.public.tables.posts.relationships); + assert.deepStrictEqual( + db.schemas.public.tables.posts.relationships.user.columns, + ['user_id'], + ); + }); + + void it('should accept valid composite foreign key', () => { + const db = database('test', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + tenant_id: column('tenant_id', Varchar('max')), + }, + }), + posts: table('posts', { + columns: { + id: column('id', Varchar('max')), + user_id: column('user_id', Varchar('max')), + tenant_id: column('tenant_id', Varchar('max')), + }, + relationships: { + user: { + columns: ['user_id', 'tenant_id'], + references: ['public.users.id', 'public.users.tenant_id'], + type: 'many-to-one', + }, + }, + }), + }), + }); + + assert.deepStrictEqual( + db.schemas.public.tables.posts.relationships.user.columns, + ['user_id', 'tenant_id'], + ); + }); + + void it('should accept self-referential foreign key', () => { + const db = database('test', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + manager_id: column('manager_id', Varchar('max')), + }, + relationships: { + manager: { + columns: ['manager_id'], + references: ['public.users.id'], + type: 'many-to-one', + }, + } as const, + }), + }), + }); + + assert.ok(db.schemas.public.tables.users.relationships); + assert.deepStrictEqual( + db.schemas.public.tables.users.relationships.manager.references, + ['public.users.id'], + ); + }); + + void it('should accept multiple foreign keys in one table', () => { + const db = database('test', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + posts: table('posts', { + columns: { + id: column('id', Varchar('max')), + user_id: column('user_id', Varchar('max')), + author_id: column('author_id', Varchar('max')), + }, + relationships: { + user: { + columns: ['user_id'], + references: ['public.users.id'], + type: 'many-to-one', + }, + author: { + columns: ['author_id'], + references: ['public.users.id'], + type: 'many-to-one', + }, + } as const, + }), + }), + }); + + assert.strictEqual( + Object.keys(db.schemas.public.tables.posts.relationships).length, + 2, + ); + }); + + void it('should accept cross-schema foreign key', () => { + const db = database('test', { + public: schema('public', { + users: table('users', { + columns: { + id: column('id', Varchar('max')), + }, + }), + }), + analytics: schema('analytics', { + events: table('events', { + columns: { + id: column('id', Varchar('max')), + user_id: column('user_id', Varchar('max')), + }, + relationships: { + user: { + columns: ['user_id'], + references: ['public.users.id'], + type: 'many-to-one', + }, + }, + }), + }), + }); + + assert.deepStrictEqual( + db.schemas.analytics.tables.events.relationships.user.references, + ['public.users.id'], + ); + }); +}); diff --git a/src/packages/dumbo/src/core/schema/dumboSchema/index.ts b/src/packages/dumbo/src/core/schema/dumboSchema/index.ts new file mode 100644 index 00000000..5ad75ea3 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/dumboSchema/index.ts @@ -0,0 +1 @@ +export * from './dumboSchema'; diff --git a/src/packages/dumbo/src/core/schema/index.ts b/src/packages/dumbo/src/core/schema/index.ts index 582a76eb..31539d0b 100644 --- a/src/packages/dumbo/src/core/schema/index.ts +++ b/src/packages/dumbo/src/core/schema/index.ts @@ -1,2 +1,5 @@ -export * from './migrations'; +export * from './components'; +export * from './dumboSchema'; +export * from './migrators'; export * from './schemaComponent'; +export * from './sqlMigration'; diff --git a/src/packages/dumbo/src/core/schema/migrators/index.ts b/src/packages/dumbo/src/core/schema/migrators/index.ts new file mode 100644 index 00000000..58982d62 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/migrators/index.ts @@ -0,0 +1,2 @@ +export * from './migrator'; +export * from './schemaComponentMigrator'; diff --git a/src/packages/dumbo/src/core/schema/migrations.ts b/src/packages/dumbo/src/core/schema/migrators/migrator.ts similarity index 79% rename from src/packages/dumbo/src/core/schema/migrations.ts rename to src/packages/dumbo/src/core/schema/migrators/migrator.ts index 2286e00a..69c8f451 100644 --- a/src/packages/dumbo/src/core/schema/migrations.ts +++ b/src/packages/dumbo/src/core/schema/migrators/migrator.ts @@ -1,56 +1,20 @@ -import type { Dumbo } from '..'; -import { type DatabaseType, fromDatabaseDriverType } from '../drivers'; -import type { SQLExecutor } from '../execute'; +import { type Dumbo } from '../..'; +import { type DatabaseType, fromDatabaseDriverType } from '../../drivers'; +import type { SQLExecutor } from '../../execute'; import { type DatabaseLock, type DatabaseLockOptions, NoDatabaseLock, -} from '../locks'; -import { mapToCamelCase, singleOrNull } from '../query'; -import { getFormatter, SQL, type SQLFormatter } from '../sql'; -import { tracer } from '../tracing'; -import { schemaComponent, type SchemaComponent } from './schemaComponent'; +} from '../../locks'; +import { mapToCamelCase, singleOrNull } from '../../query'; +import { SQL, SQLFormatter, getFormatter } from '../../sql'; +import { tracer } from '../../tracing'; +import { type SchemaComponent } from '../schemaComponent'; +import type { MigrationRecord, SQLMigration } from '../sqlMigration'; +import { migrationTableSchemaComponent } from './schemaComponentMigrator'; -export type MigrationStyle = 'None' | 'CreateOrUpdate'; - -export type SQLMigration = { - name: string; - sqls: SQL[]; -}; - -export const sqlMigration = (name: string, sqls: SQL[]): SQLMigration => ({ - name, - sqls, -}); - -export type MigrationRecord = { - id: number; - name: string; - application: string; - sqlHash: string; - timestamp: Date; -}; export const MIGRATIONS_LOCK_ID = 999956789; -const { AutoIncrement, Varchar, Timestamp } = SQL.column.type; - -const migrationTableSQL = SQL` - CREATE TABLE IF NOT EXISTS migrations ( - id ${AutoIncrement({ primaryKey: true })}, - name ${Varchar(255)} NOT NULL UNIQUE, - application ${Varchar(255)} NOT NULL DEFAULT 'default', - sql_hash ${Varchar(64)} NOT NULL, - timestamp ${Timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP - ); -`; - -export const migrationTableSchemaComponent = schemaComponent( - 'dumbo:schema-component:migrations-table', - { - migrations: [sqlMigration('dumbo:migrationTable:001', [migrationTableSQL])], - }, -); - declare global { var defaultMigratorOptions: Record; } diff --git a/src/packages/dumbo/src/core/schema/migrators/schemaComponentMigrator.ts b/src/packages/dumbo/src/core/schema/migrators/schemaComponentMigrator.ts new file mode 100644 index 00000000..1ae2db4a --- /dev/null +++ b/src/packages/dumbo/src/core/schema/migrators/schemaComponentMigrator.ts @@ -0,0 +1,59 @@ +import type { Dumbo } from '../..'; +import type { DatabaseDriverType } from '../../drivers'; +import { SQL } from '../../sql'; +import { schemaComponent, type SchemaComponent } from '../schemaComponent'; +import { sqlMigration } from '../sqlMigration'; +import { type MigratorOptions, runSQLMigrations } from './migrator'; + +const { AutoIncrement, Varchar, Timestamp } = SQL.column.type; + +const migrationTableSQL = SQL` + CREATE TABLE IF NOT EXISTS migrations ( + id ${AutoIncrement({ primaryKey: true })}, + name ${Varchar(255)} NOT NULL UNIQUE, + application ${Varchar(255)} NOT NULL DEFAULT 'default', + sql_hash ${Varchar(64)} NOT NULL, + timestamp ${Timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +`; + +export const migrationTableSchemaComponent = schemaComponent( + 'dumbo:schema-component:migrations-table', + { + migrations: [sqlMigration('dumbo:migrationTable:001', [migrationTableSQL])], + }, +); + +export type SchemaComponentMigrator = { + component: SchemaComponent; + run: (options?: Partial) => Promise; +}; + +export const SchemaComponentMigrator = ( + component: SchemaComponent, + dumbo: Dumbo, +): SchemaComponentMigrator => { + const completedMigrations: string[] = []; + + return { + component, + run: async (options) => { + const pendingMigrations = component.migrations.filter( + (m) => + !completedMigrations.includes( + `${component.schemaComponentKey}:${m.name}`, + ), + ); + + if (pendingMigrations.length === 0) return; + + await runSQLMigrations(dumbo, pendingMigrations, options); + + completedMigrations.push( + ...pendingMigrations.map( + (m) => `${component.schemaComponentKey}:${m.name}`, + ), + ); + }, + }; +}; diff --git a/src/packages/dumbo/src/core/schema/schemaComponent.ts b/src/packages/dumbo/src/core/schema/schemaComponent.ts index bebf5768..3d199291 100644 --- a/src/packages/dumbo/src/core/schema/schemaComponent.ts +++ b/src/packages/dumbo/src/core/schema/schemaComponent.ts @@ -1,37 +1,145 @@ -import { type SQLMigration } from './migrations'; +import { type SQLMigration } from './sqlMigration'; -export type SchemaComponent = { - schemaComponentType: ComponentType; - components: ReadonlyArray; +export type SchemaComponent< + ComponentKey extends string = string, + AdditionalData extends + | Exclude< + Record, + | 'schemaComponentKey' + | 'components' + | 'migrations' + | 'addComponent' + | 'addMigration' + > + | undefined = undefined, +> = { + schemaComponentKey: ComponentKey; + components: ReadonlyMap; migrations: ReadonlyArray; -}; -export type SchemaComponentOptions = - | { - migrations: ReadonlyArray; - components?: never; - } - | { - migrations: ReadonlyArray; - components: ReadonlyArray; - } - | { - migrations?: never; - components: ReadonlyArray; - }; - -export const schemaComponent = ( - type: ComponentType, - migrationsOrComponents: SchemaComponentOptions, -): SchemaComponent => { - const components = migrationsOrComponents.components ?? []; - const migrations = migrationsOrComponents.migrations ?? []; + addComponent: < + SchemaComponentType extends SchemaComponent< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Record + > = SchemaComponent< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Record + >, + >( + component: SchemaComponentType, + ) => SchemaComponentType; + addMigration: (migration: SQLMigration) => void; +} & Exclude< + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + AdditionalData extends undefined ? {} : AdditionalData, + | 'schemaComponentKey' + | 'components' + | 'migrations' + | 'addComponent' + | 'addMigration' +>; + +export type ExtractAdditionalData = + T extends SchemaComponent ? Data : never; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnySchemaComponent = SchemaComponent>; + +export type AnySchemaComponentOfType = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SchemaComponent; + +export type SchemaComponentOptions< + AdditionalOptions extends Record = Record, +> = { + migrations?: ReadonlyArray; + components?: ReadonlyArray; +} & Omit; + +export type SchemaComponentType = `sc:${Kind}`; + +export type DumboSchemaComponentType = + SchemaComponentType<`dumbo:${Kind}`>; + +export const schemaComponent = ( + key: ComponentKey, + options: SchemaComponentOptions, +): SchemaComponent => { + const componentsMap = new Map( + options.components?.map((comp) => [comp.schemaComponentKey, comp]), + ); + + const migrations: SQLMigration[] = [...(options.migrations ?? [])]; return { - schemaComponentType: type, - components, + schemaComponentKey: key, + components: componentsMap, get migrations(): SQLMigration[] { - return [...migrations, ...components.flatMap((c) => c.migrations)]; + return [ + ...migrations, + ...Array.from(componentsMap.values()).flatMap((c) => c.migrations), + ]; + }, + addComponent: < + SchemaComponentType extends AnySchemaComponent = AnySchemaComponent, + >( + component: SchemaComponentType, + ): SchemaComponentType => { + componentsMap.set(component.schemaComponentKey, component); + migrations.push(...component.migrations); + return component; }, + addMigration: (migration: SQLMigration) => { + migrations.push(migration); + }, + }; +}; + +export const isSchemaComponentOfType = < + SchemaComponentOfType extends AnySchemaComponent = AnySchemaComponent, +>( + component: AnySchemaComponent, + prefix: string, +): component is SchemaComponentOfType => + component.schemaComponentKey.startsWith(prefix); + +export const filterSchemaComponentsOfType = ( + components: ReadonlyMap, + prefix: string, +): ReadonlyMap => mapSchemaComponentsOfType(components, prefix); + +export const mapSchemaComponentsOfType = ( + components: ReadonlyMap, + prefix: string, + keyMapper?: (component: T) => string, +): ReadonlyMap => + new Map( + Array.from(components.entries()) + .filter(([urn]) => urn.startsWith(prefix)) + .map(([urn, component]) => [ + keyMapper ? keyMapper(component as T) : urn, + component as T, + ]), + ); + +export const findSchemaComponentsOfType = ( + root: AnySchemaComponent, + prefix: string, +): T[] => { + const results: T[] = []; + + const traverse = (component: AnySchemaComponent) => { + if (component.schemaComponentKey.startsWith(prefix)) { + results.push(component as T); + } + for (const child of component.components.values()) { + traverse(child); + } }; + + traverse(root); + + return results; }; diff --git a/src/packages/dumbo/src/core/schema/sqlMigration.ts b/src/packages/dumbo/src/core/schema/sqlMigration.ts new file mode 100644 index 00000000..590472c4 --- /dev/null +++ b/src/packages/dumbo/src/core/schema/sqlMigration.ts @@ -0,0 +1,21 @@ +import { SQL } from '../sql'; + +export type MigrationStyle = 'None' | 'CreateOrUpdate'; + +export type SQLMigration = { + name: string; + sqls: SQL[]; +}; + +export const sqlMigration = (name: string, sqls: SQL[]): SQLMigration => ({ + name, + sqls, +}); + +export type MigrationRecord = { + id: number; + name: string; + application: string; + sqlHash: string; + timestamp: Date; +}; diff --git a/src/packages/dumbo/src/core/sql/processors/columnProcessors.ts b/src/packages/dumbo/src/core/sql/processors/columnProcessors.ts index 78766385..20f6af40 100644 --- a/src/packages/dumbo/src/core/sql/processors/columnProcessors.ts +++ b/src/packages/dumbo/src/core/sql/processors/columnProcessors.ts @@ -1,23 +1,9 @@ -import type { - BigIntegerToken, - DefaultSQLColumnToken, - SQLToken, -} from '../tokens'; -import { SQLColumnTokens } from '../tokens'; +import type { BigIntegerToken, DefaultSQLColumnToken } from '../tokens'; +import { AutoIncrementSQLColumnToken, SQLColumnTypeTokens } from '../tokens'; import { SQLProcessor, type SQLProcessorContext } from './sqlProcessor'; -type ExtractTokenType = T extends (...args: never[]) => infer R - ? R extends SQLToken - ? R - : never - : T extends SQLToken - ? T - : never; - export type DefaultSQLColumnProcessors = { - [key in keyof SQLColumnTokens]: SQLProcessor< - ExtractTokenType<(typeof SQLColumnTokens)[key]> - >; + [key in keyof SQLColumnTypeTokens]: SQLProcessor; }; export const mapDefaultSQLColumnProcessors = ( @@ -26,9 +12,9 @@ export const mapDefaultSQLColumnProcessors = ( context: SQLProcessorContext, ) => void, ): DefaultSQLColumnProcessors => ({ - AutoIncrement: SQLProcessor({ + AutoIncrement: SQLProcessor({ canHandle: 'SQL_COLUMN_AUTO_INCREMENT', - handle: (token, context) => { + handle: (token: AutoIncrementSQLColumnToken, context) => { mapColumnType(token, context); }, }), diff --git a/src/packages/dumbo/src/core/sql/processors/defaultProcessors.ts b/src/packages/dumbo/src/core/sql/processors/defaultProcessors.ts index e95f5868..215dad9f 100644 --- a/src/packages/dumbo/src/core/sql/processors/defaultProcessors.ts +++ b/src/packages/dumbo/src/core/sql/processors/defaultProcessors.ts @@ -17,7 +17,7 @@ export const ExpandSQLInProcessor: SQLProcessor = SQLProcessor({ canHandle: 'SQL_IN', handle: (token: SQLIn, context: SQLProcessorContext) => { const { builder, mapper, processorsRegistry } = context; - const { values: inValues, column } = token.value; + const { values: inValues, column } = token; if (inValues.value.length === 0) { builder.addParam(mapper.mapValue(false)); diff --git a/src/packages/dumbo/src/core/sql/processors/sqlProcessor.ts b/src/packages/dumbo/src/core/sql/processors/sqlProcessor.ts index d3ea4936..268282d3 100644 --- a/src/packages/dumbo/src/core/sql/processors/sqlProcessor.ts +++ b/src/packages/dumbo/src/core/sql/processors/sqlProcessor.ts @@ -1,5 +1,5 @@ import type { ParametrizedSQLBuilder } from '../parametrizedSQL'; -import type { SQLToken } from '../tokens'; +import type { AnySQLToken } from '../tokens'; import type { SQLValueMapper } from '../valueMappers'; import type { SQLProcessorsReadonlyRegistry } from './sqlProcessorRegistry'; @@ -9,7 +9,7 @@ export type SQLProcessorContext = { processorsRegistry: SQLProcessorsReadonlyRegistry; }; -export type SQLProcessor = { +export type SQLProcessor = { canHandle: Token['sqlTokenType']; handle: (value: Token, context: SQLProcessorContext) => void; }; @@ -17,11 +17,11 @@ export type SQLProcessor = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnySQLProcessor = SQLProcessor; -export type SQLProcessorOptions = { +export type SQLProcessorOptions = { canHandle: Token['sqlTokenType']; handle: (value: Token, context: SQLProcessorContext) => void; }; -export const SQLProcessor = ( +export const SQLProcessor = ( options: SQLProcessorOptions, ): SQLProcessor => options; diff --git a/src/packages/dumbo/src/core/sql/processors/sqlProcessorRegistry.ts b/src/packages/dumbo/src/core/sql/processors/sqlProcessorRegistry.ts index fb1faf66..7a560e69 100644 --- a/src/packages/dumbo/src/core/sql/processors/sqlProcessorRegistry.ts +++ b/src/packages/dumbo/src/core/sql/processors/sqlProcessorRegistry.ts @@ -1,8 +1,8 @@ -import type { SQLToken } from '../tokens'; +import type { AnySQLToken } from '../tokens'; import type { AnySQLProcessor, SQLProcessor } from './sqlProcessor'; export interface SQLProcessorsReadonlyRegistry { - get( + get( tokenType: Token['sqlTokenType'], ): SQLProcessor | null; all(): ReadonlyMap; @@ -46,7 +46,7 @@ export const SQLProcessorsRegistry = (options?: { const registry = { register, - get: ( + get: ( tokenType: string, ): SQLProcessor | null => { return processors.get(tokenType) ?? null; diff --git a/src/packages/dumbo/src/core/sql/sql.ts b/src/packages/dumbo/src/core/sql/sql.ts index e4f317e7..170bff24 100644 --- a/src/packages/dumbo/src/core/sql/sql.ts +++ b/src/packages/dumbo/src/core/sql/sql.ts @@ -8,7 +8,7 @@ import type { ParametrizedSQL } from './parametrizedSQL'; import { isTokenizedSQL, TokenizedSQL } from './tokenizedSQL'; import { SQLColumnToken, - SQLColumnTokens, + SQLColumnTypeTokensFactory, SQLIdentifier, SQLIn, SQLPlain, @@ -99,10 +99,12 @@ SQL.check = { isSQLIn: SQLIn.check, }; -const columnFactory: SQLColumnToken & { type: typeof SQLColumnTokens } = - SQLColumnToken as unknown as SQLColumnToken & { - type: typeof SQLColumnTokens; - }; -columnFactory.type = SQLColumnTokens; +const columnFactory: typeof SQLColumnToken.from & { + type: typeof SQLColumnTypeTokensFactory; +} = SQLColumnToken.from as unknown as typeof SQLColumnToken.from & { + type: typeof SQLColumnTypeTokensFactory; +}; +columnFactory.type = + SQLColumnTypeTokensFactory as unknown as typeof SQLColumnTypeTokensFactory; SQL.column = columnFactory; diff --git a/src/packages/dumbo/src/core/sql/tokenizedSQL/tokenizedSQL.ts b/src/packages/dumbo/src/core/sql/tokenizedSQL/tokenizedSQL.ts index c8dcd802..ac663c59 100644 --- a/src/packages/dumbo/src/core/sql/tokenizedSQL/tokenizedSQL.ts +++ b/src/packages/dumbo/src/core/sql/tokenizedSQL/tokenizedSQL.ts @@ -1,14 +1,20 @@ -import { SQLArray, SQLLiteral, SQLPlain, SQLToken } from '../tokens'; +import { + SQLArray, + SQLLiteral, + SQLPlain, + SQLToken, + type AnySQLToken, +} from '../tokens'; export type TokenizedSQL = Readonly<{ __brand: 'tokenized-sql'; sqlChunks: ReadonlyArray; - sqlTokens: ReadonlyArray; + sqlTokens: ReadonlyArray; }>; const TokenizedSQLBuilder = () => { const sqlChunks: string[] = []; - const sqlTokens: SQLToken[] = []; + const sqlTokens: AnySQLToken[] = []; return { addSQL(str: string): void { @@ -17,10 +23,10 @@ const TokenizedSQLBuilder = () => { addSQLs(str: ReadonlyArray): void { sqlChunks.push(...str); }, - addToken(value: SQLToken): void { + addToken(value: AnySQLToken): void { sqlTokens.push(value); }, - addTokens(vals: ReadonlyArray): void { + addTokens(vals: ReadonlyArray): void { sqlTokens.push(...vals); }, build(): TokenizedSQL { diff --git a/src/packages/dumbo/src/core/sql/tokens/columnTokens.ts b/src/packages/dumbo/src/core/sql/tokens/columnTokens.ts index 34b06247..bb55b61f 100644 --- a/src/packages/dumbo/src/core/sql/tokens/columnTokens.ts +++ b/src/packages/dumbo/src/core/sql/tokens/columnTokens.ts @@ -1,92 +1,309 @@ import { SQLToken } from './sqlToken'; -export type SerialToken = SQLToken<'SQL_COLUMN_SERIAL', never>; -export const SerialToken = SQLToken( +export type JSONValueType = + | Record + | Array + | string + | number + | boolean + | null; + +export type JSONValueTypeName = + | 'value_type:json:object' + | 'value_type:json:array' + | 'value_type:json:string' + | 'value_type:json:number' + | 'value_type:json:boolean' + | 'value_type:json:null'; + +export type JavaScriptValueType = + | Record + | Array + | string + | number + | boolean + | null + | undefined + | Date + | bigint; + +export type JavaScriptValueTypeName = + | 'value_type:js:object' + | 'value_type:js:array' + | 'value_type:js:string' + | 'value_type:js:number' + | 'value_type:js:boolean' + | 'value_type:js:null' + | 'value_type:js:undefined' + | 'value_type:js:date' + | 'value_type:js:bigint'; + +export type JavaScriptValueTypeToNameMap = { + [K in JavaScriptValueType as K extends Record + ? 'value_type:js:object' + : K extends Array + ? 'value_type:js:array' + : K extends string + ? 'value_type:js:string' + : K extends number + ? 'value_type:js:number' + : K extends boolean + ? 'value_type:js:boolean' + : K extends null + ? 'value_type:js:null' + : K extends undefined + ? 'value_type:js:undefined' + : K extends Date + ? 'value_type:js:date' + : K extends bigint + ? 'value_type:js:bigint' + : never]: K; +}; + +// TODO: Use URNs for sqltoken +export type ColumnTypeToken< + JSValueTypeName extends JavaScriptValueTypeName = JavaScriptValueTypeName, + ColumnTypeName extends string = string, + TProps extends Omit, 'sqlTokenType'> | undefined = + | Omit, 'sqlTokenType'> + | undefined, + ValueType = undefined, +> = SQLToken<`SQL_COLUMN_${ColumnTypeName}`, TProps> & { + __brand: ValueType extends undefined + ? JavaScriptValueTypeToNameMap[JSValueTypeName] + : ValueType; + jsTypeName: JSValueTypeName; +}; + +export const ColumnTypeToken = < + SQLTokenType extends AnyColumnTypeToken, + TInput = keyof Omit< + SQLTokenType, + 'sqlTokenType' | '__brand' | 'jsTypeName' + > extends never + ? void + : Omit, +>( + sqlTokenType: SQLTokenType['sqlTokenType'], + jsTypeName: SQLTokenType['jsTypeName'], + map?: ( + input: TInput, + ) => Omit, +) => { + const factory = (input: TInput): SQLTokenType => { + let props: Omit; + + if (map !== undefined) { + props = map(input) as SQLTokenType; + } else if (input === undefined || input === null) { + props = {} as Omit< + SQLTokenType, + 'sqlTokenType' | '__brand' | 'jsTypeName' + >; + } else if (typeof input === 'object' && !Array.isArray(input)) { + // If input is already an object (but not array), spread it + props = input as Omit< + SQLTokenType, + 'sqlTokenType' | '__brand' | 'jsTypeName' + >; + } else { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot create SQLToken of type ${sqlTokenType} with input: ${input}`, + ); + } + + return { + sqlTokenType: sqlTokenType, + [sqlTokenType]: true, + jsTypeName, + ...props, + } as unknown as SQLTokenType; + }; + + const check = (token: unknown): token is SQLTokenType => + SQLToken.check(token) && token.sqlTokenType === sqlTokenType; + + return { from: factory, check: check, type: sqlTokenType }; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyColumnTypeToken = ColumnTypeToken; + +export type SerialToken = ColumnTypeToken<'value_type:js:number', 'SERIAL'>; +export const SerialToken = ColumnTypeToken( 'SQL_COLUMN_SERIAL', - () => undefined!, + 'value_type:js:number', ); -export type BigSerialToken = SQLToken<'SQL_COLUMN_BIGSERIAL', never>; -export const BigSerialToken = SQLToken( +export type BigSerialToken = ColumnTypeToken< + 'value_type:js:bigint', + 'BIGSERIAL' +>; +export const BigSerialToken = ColumnTypeToken( 'SQL_COLUMN_BIGSERIAL', - () => undefined!, + 'value_type:js:bigint', ); -export type IntegerToken = SQLToken<'SQL_COLUMN_INTEGER', never>; -export const IntegerToken = SQLToken( +export type IntegerToken = ColumnTypeToken<'value_type:js:number', 'INTEGER'>; +export const IntegerToken = ColumnTypeToken( 'SQL_COLUMN_INTEGER', - () => undefined!, + 'value_type:js:number', ); -export type BigIntegerToken = SQLToken<'SQL_COLUMN_BIGINT', never>; -export const BigIntegerToken = SQLToken( +export type BigIntegerToken = ColumnTypeToken<'value_type:js:bigint', 'BIGINT'>; +export const BigIntegerToken = ColumnTypeToken( 'SQL_COLUMN_BIGINT', - () => undefined!, + 'value_type:js:bigint', ); -export type JSONBToken = SQLToken<'SQL_COLUMN_JSONB', never>; -export const JSONBToken = SQLToken( - 'SQL_COLUMN_JSONB', - () => undefined!, -); +export type JSONBToken< + ValueType extends Record = Record, +> = ColumnTypeToken<'value_type:js:object', 'JSONB', undefined, ValueType>; -export type TimestampToken = SQLToken<'SQL_COLUMN_TIMESTAMP', never>; -export const TimestampToken = SQLToken( +export const JSONBToken = { + type: 'SQL_COLUMN_JSONB', + from: < + ValueType extends Record = Record, + >(): JSONBToken => { + return { + sqlTokenType: 'SQL_COLUMN_JSONB', + ['SQL_COLUMN_JSONB']: true, + } as unknown as JSONBToken; + }, + check: = Record>( + token: unknown, + ): token is JSONBToken => + SQLToken.check(token) && token.sqlTokenType === 'SQL_COLUMN_JSONB', +}; + +export type TimestampToken = ColumnTypeToken<'value_type:js:date', 'TIMESTAMP'>; +export const TimestampToken = ColumnTypeToken( 'SQL_COLUMN_TIMESTAMP', - () => undefined!, + 'value_type:js:date', ); -export type TimestamptzToken = SQLToken<'SQL_COLUMN_TIMESTAMPTZ', never>; -export const TimestamptzToken = SQLToken( +export type TimestamptzToken = ColumnTypeToken< + 'value_type:js:date', + 'TIMESTAMPTZ' +>; +export const TimestamptzToken = ColumnTypeToken( 'SQL_COLUMN_TIMESTAMPTZ', - () => undefined!, + 'value_type:js:date', ); -export type VarcharToken = SQLToken<'SQL_COLUMN_VARCHAR', number | 'max'>; -export const VarcharToken = SQLToken('SQL_COLUMN_VARCHAR'); +export type VarcharToken = ColumnTypeToken< + 'value_type:js:string', + 'VARCHAR', + { length: number | 'max' } +>; +export const VarcharToken = ColumnTypeToken( + 'SQL_COLUMN_VARCHAR', + 'value_type:js:string', + (length?: number | 'max') => + ({ + length: length ?? 'max', + jsTypeName: 'value_type:js:string', + }) as Omit, +); -export type SQLColumnToken = SQLToken< +export type NotNullableSQLColumnTokenProps< + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, +> = + | { + name: string; + type: ColumnType; + notNull: true; + unique?: boolean; + primaryKey?: boolean; + default?: ColumnType | SQLToken; + } + | { + name: string; + type: ColumnType; + notNull?: false; + unique?: boolean; + primaryKey: never; + default?: ColumnType | SQLToken; + }; + +export type NullableSQLColumnTokenProps< + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, +> = { + name: string; + type: ColumnType; + notNull?: false; + unique?: boolean; + primaryKey?: false; + default?: ColumnType | SQLToken; +}; + +export type SQLColumnToken< + ColumnType extends AnyColumnTypeToken | string = AnyColumnTypeToken | string, +> = SQLToken< 'SQL_COLUMN', - { - name: string; - type: ColumnType | SQLToken; - notNull?: boolean; - unique?: boolean; - primaryKey?: boolean; - default?: ColumnType | SQLToken; - } + | NotNullableSQLColumnTokenProps + | NullableSQLColumnTokenProps >; -export type AutoIncrementSQLColumnToken = SQLToken< - 'SQL_COLUMN_AUTO_INCREMENT', +export type AutoIncrementSQLColumnToken = ColumnTypeToken< + 'value_type:js:bigint', + 'AUTO_INCREMENT', { primaryKey: boolean; bigint?: boolean; - } + }, + bigint >; export const AutoIncrementSQLColumnToken = - SQLToken('SQL_COLUMN_AUTO_INCREMENT'); + ColumnTypeToken( + 'SQL_COLUMN_AUTO_INCREMENT', + 'value_type:js:bigint', + ); -export const SQLColumnTokens = { +export const SQLColumnTypeTokens = { + AutoIncrement: AutoIncrementSQLColumnToken, + BigInteger: BigIntegerToken, + BigSerial: BigSerialToken, + Integer: IntegerToken, + JSONB: JSONBToken, + Serial: SerialToken, + Timestamp: TimestampToken, + Timestamptz: TimestamptzToken, + Varchar: VarcharToken, +}; + +export type SQLColumnTypeTokens = { + AutoIncrement: AutoIncrementSQLColumnToken; + BigInteger: BigIntegerToken; + BigSerial: BigSerialToken; + Integer: IntegerToken; + JSONB: JSONBToken; + Serial: SerialToken; + Timestamp: TimestampToken; + Timestamptz: TimestamptzToken; + Varchar: VarcharToken; +}; + +export const SQLColumnTypeTokensFactory = { AutoIncrement: AutoIncrementSQLColumnToken.from, - BigInteger: BigIntegerToken.from(undefined!), - BigSerial: BigSerialToken.from(undefined!), - Integer: IntegerToken.from(undefined!), - JSONB: JSONBToken.from(undefined!), - Serial: SerialToken.from(undefined!), - Timestamp: TimestampToken.from(undefined!), - Timestamptz: TimestamptzToken.from(undefined!), + BigInteger: BigIntegerToken.from(), + BigSerial: BigSerialToken.from(), + Integer: IntegerToken.from(), + JSONB: JSONBToken.from, + Serial: SerialToken.from(), + Timestamp: TimestampToken.from(), + Timestamptz: TimestamptzToken.from(), Varchar: VarcharToken.from, }; -export type SQLColumnTokens = typeof SQLColumnTokens; export type DefaultSQLColumnToken = | AutoIncrementSQLColumnToken - | BigIntegerToken + | SerialToken | BigSerialToken | IntegerToken | JSONBToken - | SerialToken + | BigIntegerToken | TimestampToken | TimestamptzToken | VarcharToken; diff --git a/src/packages/dumbo/src/core/sql/tokens/sqlToken.ts b/src/packages/dumbo/src/core/sql/tokens/sqlToken.ts index 0248a642..e511ff50 100644 --- a/src/packages/dumbo/src/core/sql/tokens/sqlToken.ts +++ b/src/packages/dumbo/src/core/sql/tokens/sqlToken.ts @@ -1,28 +1,53 @@ export type SQLToken< TSymbol extends string = string, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint, @typescript-eslint/no-explicit-any - TProps extends any = any, + TProps extends Omit, 'sqlTokenType'> | undefined = + | Omit, 'sqlTokenType'> + | undefined, > = { sqlTokenType: TSymbol; - value: TProps; -}; +} & (TProps extends undefined ? void : Omit); + +export type ExtractSQLTokenType = T extends (...args: never[]) => infer R + ? R extends SQLToken + ? R + : never + : T extends SQLToken + ? T + : never; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnySQLToken = SQLToken; export const SQLToken = < - SQLTokenType extends SQLToken, - TValue = SQLTokenType['value'], + SQLTokenType extends AnySQLToken, + TInput = keyof Omit extends never + ? void + : Omit, >( sqlTokenType: SQLTokenType['sqlTokenType'], - map?: (value: TValue) => SQLTokenType['value'], + map?: (input: TInput) => Omit, ) => { - const factory = (props: TValue): SQLTokenType => { - props = - map === undefined - ? (props as unknown as SQLTokenType['value']) - : map(props); + const factory = (input: TInput): SQLTokenType => { + let props: Omit; + + if (map !== undefined) { + props = map(input); + } else if (input === undefined || input === null) { + props = {} as Omit; + } else if (typeof input === 'object' && !Array.isArray(input)) { + // If input is already an object (but not array), spread it + props = input as Omit; + } else { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot create SQLToken of type ${sqlTokenType} with input: ${input}`, + ); + } + return { sqlTokenType: sqlTokenType, [sqlTokenType]: true, - value: props, + ...props, } as unknown as SQLTokenType; }; @@ -32,20 +57,36 @@ export const SQLToken = < return { from: factory, check: check, type: sqlTokenType }; }; -SQLToken.check = (token: unknown): token is SQLToken => +SQLToken.check = ( + token: unknown, +): token is SQLTokenType => token !== null && typeof token === 'object' && 'sqlTokenType' in token; -export type SQLIdentifier = SQLToken<'SQL_IDENTIFIER', string>; -export const SQLIdentifier = SQLToken('SQL_IDENTIFIER'); +export type SQLIdentifier = SQLToken<'SQL_IDENTIFIER', { value: string }>; +export const SQLIdentifier = SQLToken( + 'SQL_IDENTIFIER', + (value) => ({ + value, + }), +); -export type SQLPlain = SQLToken<'SQL_RAW', string>; -export const SQLPlain = SQLToken('SQL_RAW'); +export type SQLPlain = SQLToken<'SQL_RAW', { value: string }>; +export const SQLPlain = SQLToken('SQL_RAW', (value) => ({ + value, +})); -export type SQLLiteral = SQLToken<'SQL_LITERAL', unknown>; -export const SQLLiteral = SQLToken('SQL_LITERAL'); +export type SQLLiteral = SQLToken<'SQL_LITERAL', { value: unknown }>; +export const SQLLiteral = SQLToken( + 'SQL_LITERAL', + (value) => ({ + value, + }), +); -export type SQLArray = SQLToken<'SQL_ARRAY', unknown[]>; -export const SQLArray = SQLToken('SQL_ARRAY'); +export type SQLArray = SQLToken<'SQL_ARRAY', { value: unknown[] }>; +export const SQLArray = SQLToken('SQL_ARRAY', (value) => ({ + value, +})); export type SQLIn = SQLToken< 'SQL_IN', diff --git a/src/packages/dumbo/src/core/testing/index.ts b/src/packages/dumbo/src/core/testing/index.ts new file mode 100644 index 00000000..d2afd3d7 --- /dev/null +++ b/src/packages/dumbo/src/core/testing/index.ts @@ -0,0 +1 @@ +export * from './typesTesting'; diff --git a/src/packages/dumbo/src/core/testing/typesTesting.ts b/src/packages/dumbo/src/core/testing/typesTesting.ts new file mode 100644 index 00000000..3f7d7b5b --- /dev/null +++ b/src/packages/dumbo/src/core/testing/typesTesting.ts @@ -0,0 +1,9 @@ +import type { AnyTypeValidationError, TypeValidationSuccess } from '../typing'; + +export type Expect = T; +export type Equals = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; +export type IsError = T extends AnyTypeValidationError ? true : false; +export type IsOK = T extends TypeValidationSuccess ? true : false; diff --git a/src/packages/dumbo/src/core/typing/conditionals.ts b/src/packages/dumbo/src/core/typing/conditionals.ts new file mode 100644 index 00000000..8222e06d --- /dev/null +++ b/src/packages/dumbo/src/core/typing/conditionals.ts @@ -0,0 +1,43 @@ +export type IF = Condition extends true + ? Then + : Else; + +export type OR = A extends true + ? true + : B extends true + ? true + : false; + +export type AND = A extends true + ? B extends true + ? true + : false + : false; + +export type NOT = A extends true ? false : true; + +export type ANY = A extends [infer First, ...infer Rest] + ? First extends true + ? true + : Rest extends boolean[] + ? ANY + : false + : false; + +export type ALL = A extends [infer First, ...infer Rest] + ? First extends true + ? Rest extends boolean[] + ? ALL + : true + : false + : true; + +export type NONE = A extends [infer First, ...infer Rest] + ? First extends true + ? false + : Rest extends boolean[] + ? NONE + : true + : true; + +export type EXTENDS = A extends B ? true : false; diff --git a/src/packages/dumbo/src/core/typing/index.ts b/src/packages/dumbo/src/core/typing/index.ts new file mode 100644 index 00000000..473c79ee --- /dev/null +++ b/src/packages/dumbo/src/core/typing/index.ts @@ -0,0 +1,4 @@ +export * from './conditionals'; +export * from './records'; +export * from './tuples'; +export * from './validation'; diff --git a/src/packages/dumbo/src/core/typing/records.ts b/src/packages/dumbo/src/core/typing/records.ts new file mode 100644 index 00000000..59b3e823 --- /dev/null +++ b/src/packages/dumbo/src/core/typing/records.ts @@ -0,0 +1,4 @@ +export type KeysOfString> = Extract< + keyof T, + string +>; diff --git a/src/packages/dumbo/src/core/typing/tuples.ts b/src/packages/dumbo/src/core/typing/tuples.ts new file mode 100644 index 00000000..077cd725 --- /dev/null +++ b/src/packages/dumbo/src/core/typing/tuples.ts @@ -0,0 +1,148 @@ +import type { AnyTypeValidationError } from './validation'; + +export type GetTupleLength = T['length']; + +export type NotEmptyTuple = + GetTupleLength extends 0 ? never : T; + +export type HaveTuplesTheSameLength< + T extends readonly unknown[], + U extends readonly unknown[], +> = GetTupleLength extends GetTupleLength ? true : false; + +export type IsEmptyTuple = T extends [] + ? true + : false; + +export type IsNotEmptyTuple = + IsEmptyTuple extends true ? false : true; + +export type AllInTuple< + Tuple extends readonly string[], + Union extends string, +> = Tuple extends readonly [infer First, ...infer Rest] + ? First extends Union + ? Rest extends readonly string[] + ? AllInTuple + : true + : false + : true; + +export type FilterExistingInUnion< + Tuple extends readonly string[], + Union extends string, +> = Tuple extends readonly [infer First, ...infer Rest] + ? First extends Union + ? [First, ...FilterNotExistingInUnion] + : [...FilterNotExistingInUnion] + : []; + +export type FilterNotExistingInUnion< + Tuple extends readonly string[], + Union extends string, +> = Tuple extends readonly [infer First, ...infer Rest] + ? First extends Union + ? [ + ...FilterNotExistingInUnion< + Rest extends readonly string[] ? Rest : [], + Union + >, + ] + : [ + First, + ...FilterNotExistingInUnion< + Rest extends readonly string[] ? Rest : [], + Union + >, + ] + : []; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EnsureTuple = T extends any[] ? T : [T]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +type LastOfUnion = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + UnionToIntersection T : never> extends () => infer R + ? R + : never; + +type UnionToTuple> = [T] extends [never] + ? [] + : [...UnionToTuple>, L]; + +type TaggedUnion = { [K in keyof T]: [K, T[K]] }[keyof T]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ToKeyValue = { + [I in keyof T]: { key: T[I][0]; value: T[I][1] }; +}; + +export type EntriesToTuple = ToKeyValue< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + UnionToTuple> extends [any, any][] + ? UnionToTuple> + : never +>; + +export type ZipTuplesCollectErrors< + TupleA extends readonly unknown[], + TupleB extends readonly unknown[], + ValidateMap, + Accumulated extends AnyTypeValidationError[] = [], +> = [TupleA, TupleB] extends [ + readonly [infer FirstA, ...infer RestA], + readonly [infer FirstB, ...infer RestB], +] + ? FirstA extends keyof ValidateMap + ? FirstB extends keyof ValidateMap[FirstA] + ? ValidateMap[FirstA][FirstB] extends infer Result extends + AnyTypeValidationError + ? ZipTuplesCollectErrors< + RestA, + RestB, + ValidateMap, + [...Accumulated, Result] + > + : ZipTuplesCollectErrors + : ZipTuplesCollectErrors + : ZipTuplesCollectErrors + : Accumulated; + +export type MapEntriesCollectErrors< + Entries extends readonly unknown[], + ValidateMap, + Accumulated extends AnyTypeValidationError[] = [], +> = Entries extends readonly [infer First, ...infer Rest] + ? First extends { key: infer K; value: infer _V } + ? K extends keyof ValidateMap + ? ValidateMap[K] extends infer Result extends AnyTypeValidationError + ? MapEntriesCollectErrors + : MapEntriesCollectErrors + : MapEntriesCollectErrors + : MapEntriesCollectErrors + : Accumulated; + +export type MapRecordCollectErrors< + Record extends object, + ValidateMap, + Accumulated extends AnyTypeValidationError[] = [], + Keys extends readonly unknown[] = UnionToTuple, +> = Keys extends readonly [infer K, ...infer Rest] + ? K extends keyof ValidateMap + ? ValidateMap[K] extends infer Result extends AnyTypeValidationError + ? MapRecordCollectErrors< + Record, + ValidateMap, + [...Accumulated, Result], + Rest + > + : MapRecordCollectErrors + : MapRecordCollectErrors + : Accumulated; diff --git a/src/packages/dumbo/src/core/typing/validation.ts b/src/packages/dumbo/src/core/typing/validation.ts new file mode 100644 index 00000000..ea5fda44 --- /dev/null +++ b/src/packages/dumbo/src/core/typing/validation.ts @@ -0,0 +1,66 @@ +export type TypeValidationResult< + Valid extends boolean = boolean, + Error = never, +> = Valid extends true ? { valid: true } : { valid: false; error: Error }; + +export type TypeValidationError = TypeValidationResult; + +export type TypeValidationSuccess = TypeValidationResult; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyTypeValidationError = TypeValidationError; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyTypeValidationResult = TypeValidationResult; + +export type AnyTypeValidationFailed = + Results extends readonly [infer First, ...infer Rest] + ? First extends { valid: false } + ? true + : Rest extends AnyTypeValidationResult[] + ? AnyTypeValidationFailed + : false + : false; + +export type ExtractTypeValidationErrors = T extends { + valid: false; + error: infer E; +} + ? E + : never; + +export type UnwrapTypeValidationError = + T extends TypeValidationResult ? E : T; + +export type UnwrapTypeValidationErrors< + Results extends readonly AnyTypeValidationResult[], +> = Results extends readonly [infer First, ...infer Rest] + ? First extends TypeValidationResult + ? Rest extends readonly AnyTypeValidationResult[] + ? [E, ...UnwrapTypeValidationErrors] + : [E] + : Rest extends readonly AnyTypeValidationResult[] + ? UnwrapTypeValidationErrors + : [] + : []; + +export type CompareTypes = + Uppercase extends Uppercase ? true : false; + +export type FailOnFirstTypeValidationError< + Validations extends readonly AnyTypeValidationResult[], +> = Validations extends readonly [infer First, ...infer Rest] + ? First extends AnyTypeValidationError + ? First + : Rest extends readonly AnyTypeValidationResult[] + ? FailOnFirstTypeValidationError + : First + : null; + +export type MergeTypeValidationResultIfError< + Errors extends readonly AnyTypeValidationError[], + Result extends AnyTypeValidationResult, +> = Result extends AnyTypeValidationError + ? Errors extends [] + ? [Result] + : [...Errors, Result] + : Errors; diff --git a/src/packages/dumbo/src/storage/postgresql/core/sql/processors/columProcessors.ts b/src/packages/dumbo/src/storage/postgresql/core/sql/processors/columProcessors.ts index 11466237..a43c69f7 100644 --- a/src/packages/dumbo/src/storage/postgresql/core/sql/processors/columProcessors.ts +++ b/src/packages/dumbo/src/storage/postgresql/core/sql/processors/columProcessors.ts @@ -10,10 +10,10 @@ const mapColumnType = ( { builder }: SQLProcessorContext, ): void => { let columnSQL: string; - const { sqlTokenType, value } = token; + const { sqlTokenType } = token; switch (sqlTokenType) { case 'SQL_COLUMN_AUTO_INCREMENT': - columnSQL = `${value.bigint ? 'BIGSERIAL' : 'SERIAL'} ${value.primaryKey ? 'PRIMARY KEY' : ''}`; + columnSQL = `${token.bigint ? 'BIGSERIAL' : 'SERIAL'} ${token.primaryKey ? 'PRIMARY KEY' : ''}`; break; case 'SQL_COLUMN_BIGINT': columnSQL = 'BIGINT'; @@ -37,7 +37,7 @@ const mapColumnType = ( columnSQL = 'TIMESTAMPTZ'; break; case 'SQL_COLUMN_VARCHAR': - columnSQL = `VARCHAR ${Number.isNaN(value) ? '' : `(${value})`}`; + columnSQL = `VARCHAR ${Number.isNaN(token.length) ? '' : `(${token.length})`}`; break; default: { const exhaustiveCheck: never = sqlTokenType; diff --git a/src/packages/dumbo/src/storage/sqlite/core/sql/processors/columProcessors.ts b/src/packages/dumbo/src/storage/sqlite/core/sql/processors/columProcessors.ts index fbe6ccec..5a201a05 100644 --- a/src/packages/dumbo/src/storage/sqlite/core/sql/processors/columProcessors.ts +++ b/src/packages/dumbo/src/storage/sqlite/core/sql/processors/columProcessors.ts @@ -10,10 +10,10 @@ const mapColumnType = ( { builder }: SQLProcessorContext, ): void => { let columnSQL: string; - const { sqlTokenType, value } = token; + const { sqlTokenType } = token; switch (sqlTokenType) { case 'SQL_COLUMN_AUTO_INCREMENT': - columnSQL = `INTEGER ${value.primaryKey ? 'PRIMARY KEY' : ''} AUTOINCREMENT`; + columnSQL = `INTEGER ${token.primaryKey ? 'PRIMARY KEY' : ''} AUTOINCREMENT`; break; case 'SQL_COLUMN_BIGINT': columnSQL = 'INTEGER'; @@ -37,7 +37,7 @@ const mapColumnType = ( columnSQL = 'DATETIME'; break; case 'SQL_COLUMN_VARCHAR': - columnSQL = `VARCHAR ${Number.isNaN(value) ? '' : `(${value})`}`; + columnSQL = `VARCHAR ${Number.isNaN(token.length) ? '' : `(${token.length})`}`; break; default: { const exhaustiveCheck: never = sqlTokenType; diff --git a/src/packages/pongo/src/core/collection/pongoCollectionSchemaComponent.ts b/src/packages/pongo/src/core/collection/pongoCollectionSchemaComponent.ts index 86b3af25..d41af64b 100644 --- a/src/packages/pongo/src/core/collection/pongoCollectionSchemaComponent.ts +++ b/src/packages/pongo/src/core/collection/pongoCollectionSchemaComponent.ts @@ -6,8 +6,11 @@ import { } from '@event-driven-io/dumbo'; import type { PongoCollectionSchema, PongoCollectionSQLBuilder } from '..'; +export type PongoCollectionURNType = 'sc:pongo:collection'; +export type PongoCollectionURN = `${PongoCollectionURNType}:${string}`; + export type PongoCollectionSchemaComponent = - SchemaComponent<'pongo:schema-component:collection'> & { + SchemaComponent & { collectionName: string; definition: PongoCollectionSchema; sqlBuilder: PongoCollectionSQLBuilder; @@ -31,7 +34,7 @@ export const PongoCollectionSchemaComponent = < }: PongoCollectionSchemaComponentOptions): PongoCollectionSchemaComponent => ({ ...schemaComponent( - 'pongo:schema-component:collection', + `sc:pongo:collection:${definition.name}`, migrationsOrSchemaComponents, ), sqlBuilder, diff --git a/src/packages/pongo/src/core/database/pongoDatabaseSchemaComponent.ts b/src/packages/pongo/src/core/database/pongoDatabaseSchemaComponent.ts index 42aaab37..26cb0d9a 100644 --- a/src/packages/pongo/src/core/database/pongoDatabaseSchemaComponent.ts +++ b/src/packages/pongo/src/core/database/pongoDatabaseSchemaComponent.ts @@ -14,6 +14,9 @@ import { } from '../schema'; import type { PongoDocument } from '../typing'; +export type PongoDatabaseURNType = 'sc:dumbo:database'; +export type PongoDatabaseURN = `${PongoDatabaseURNType}:${string}`; + export type PongoDatabaseSchemaComponent< // eslint-disable-next-line @typescript-eslint/no-unused-vars DriverType extends DatabaseDriverType = DatabaseDriverType, @@ -21,7 +24,7 @@ export type PongoDatabaseSchemaComponent< string, PongoCollectionSchema >, -> = SchemaComponent<'pongo:schema-component:database'> & { +> = SchemaComponent & { definition: PongoDbSchema; collections: ReadonlyArray; @@ -30,13 +33,6 @@ export type PongoDatabaseSchemaComponent< ) => PongoCollectionSchemaComponent; }; -export type PongoDatabaseSchemaComponentFactory = < - DriverType extends DatabaseDriverType = DatabaseDriverType, ->( - driverType: DriverType, - existingCollections: PongoCollectionSchemaComponent[], -) => PongoDatabaseSchemaComponent; - export type PongoDatabaseSchemaComponentOptions< DriverType extends DatabaseDriverType = DatabaseDriverType, T extends Record = Record< @@ -61,7 +57,7 @@ export const PongoDatabaseSchemaComponent = < Object.values(definition.collections).map(collectionFactory) ?? []; return { - ...schemaComponent('pongo:schema-component:database', { + ...schemaComponent(`sc:dumbo:database:${definition.name}`, { components: collections, }), definition,