diff --git a/docs/database/postgres.mdx b/docs/database/postgres.mdx index 1e8262a556..9710ba5248 100644 --- a/docs/database/postgres.mdx +++ b/docs/database/postgres.mdx @@ -63,6 +63,8 @@ export default buildConfig({ | `localesSuffix` | A string appended to the end of table names for storing localized fields. Default is '_locales'. | | `relationshipsSuffix` | A string appended to the end of table names for storing relationships. Default is '_rels'. | | `versionsSuffix` | A string appended to the end of table names for storing versions. Defaults to '_v'. | +| `beforeSchemaInit` | Drizzle schema hook. Runs before the schema is built. [More Details](#beforeschemainit) | +| `afterSchemaInit` | Drizzle schema hook. Runs after the schema is built. [More Details](#afterschemainit) | ## Access to Drizzle @@ -97,3 +99,134 @@ Alternatively, you can disable `push` and rely solely on migrations to keep your In Postgres, migrations are a fundamental aspect of working with Payload and you should become familiar with how they work. For more information about migrations, [click here](/docs/beta/database/migrations#when-to-run-migrations). + +## Drizzle schema hooks + +### beforeSchemaInit + +Runs before the schema is built. You can use this hook to extend your database structure with tables that won't be managed by Payload. + +```ts +import { postgresAdapter } from '@payloadcms/db-postgres' +import { integer, pgTable, serial } from 'drizzle-orm/pg-core' + +postgresAdapter({ + beforeSchemaInit: [ + ({ schema, adapter }) => { + return { + ...schema, + tables: { + ...schema.tables, + addedTable: pgTable('added_table', { + id: serial('id').notNull(), + }), + }, + } + }, + ], +}) +``` + +One use case is preserving your existing database structure when migrating to Payload. By default, Payload drops the current database schema, which may not be desirable in this scenario. +To quickly generate the Drizzle schema from your database you can use [Drizzle Introspection](https://orm.drizzle.team/kit-docs/commands#introspect--pull) +You should get the `schema.ts` file which may look like this: + +```ts +import { pgTable, uniqueIndex, serial, varchar, text } from 'drizzle-orm/pg-core' + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + fullName: text('full_name'), + phone: varchar('phone', { length: 256 }), +}) + +export const countries = pgTable( + 'countries', + { + id: serial('id').primaryKey(), + name: varchar('name', { length: 256 }), + }, + (countries) => { + return { + nameIndex: uniqueIndex('name_idx').on(countries.name), + } + }, +) + +``` + +You can import them into your config and append to the schema with the `beforeSchemaInit` hook like this: + +```ts +import { postgresAdapter } from '@payloadcms/db-postgres' +import { users, countries } from '../drizzle/schema' + +postgresAdapter({ + beforeSchemaInit: [ + ({ schema, adapter }) => { + return { + ...schema, + tables: { + ...schema.tables, + users, + countries + }, + } + }, + ], +}) +``` + +Make sure Payload doesn't overlap table names with its collections. For example, if you already have a collection with slug "users", you should either change the slug or `dbName` to change the table name for this collection. + + +### afterSchemaInit + +Runs after the Drizzle schema is built. You can use this hook to modify the schema with features that aren't supported by Payload, or if you want to add a column that you don't want to be in the Payload config. +To extend a table, Payload exposes `extendTable` utillity to the args. You can refer to the [Drizzle documentation](https://orm.drizzle.team/docs/sql-schema-declaration). +The following example adds the `extra_integer_column` column and a composite index on `country` and `city` columns. + +```ts +import { postgresAdapter } from '@payloadcms/db-postgres' +import { index, integer } from 'drizzle-orm/pg-core' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'places', + fields: [ + { + name: 'country', + type: 'text', + }, + { + name: 'city', + type: 'text', + }, + ], + }, + ], + db: postgresAdapter({ + afterSchemaInit: [ + ({ schema, extendTable, adapter }) => { + extendTable({ + table: schema.tables.places, + columns: { + extraIntegerColumn: integer('extra_integer_column'), + }, + extraConfig: (table) => ({ + country_city_composite_index: index('country_city_composite_index').on( + table.country, + table.city, + ), + }), + }) + + return schema + }, + ], + }), +}) + +``` diff --git a/docs/database/sqlite.mdx b/docs/database/sqlite.mdx index a92ef4787c..3eecf0ca50 100644 --- a/docs/database/sqlite.mdx +++ b/docs/database/sqlite.mdx @@ -35,7 +35,7 @@ export default buildConfig({ ## Options | Option | Description | -|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client` \* | [Client connection options](https://orm.drizzle.team/docs/get-started-sqlite#turso) that will be passed to `createClient` from `@libsql/client`. | | `push` | Disable Drizzle's [`db push`](https://orm.drizzle.team/kit-docs/overview#prototyping-with-db-push) in development mode. By default, `push` is enabled for development mode only. | | `migrationDir` | Customize the directory that migrations are stored. | @@ -44,8 +44,8 @@ export default buildConfig({ | `localesSuffix` | A string appended to the end of table names for storing localized fields. Default is '_locales'. | | `relationshipsSuffix` | A string appended to the end of table names for storing relationships. Default is '_rels'. | | `versionsSuffix` | A string appended to the end of table names for storing versions. Defaults to '_v'. | - - +| `beforeSchemaInit` | Drizzle schema hook. Runs before the schema is built. [More Details](#beforeschemainit) | +| `afterSchemaInit` | Drizzle schema hook. Runs after the schema is built. [More Details](#afterschemainit) | ## Access to Drizzle @@ -79,3 +79,134 @@ Alternatively, you can disable `push` and rely solely on migrations to keep your In SQLite, migrations are a fundamental aspect of working with Payload and you should become familiar with how they work. For more information about migrations, [click here](/docs/beta/database/migrations#when-to-run-migrations). + +## Drizzle schema hooks + +### beforeSchemaInit + +Runs before the schema is built. You can use this hook to extend your database structure with tables that won't be managed by Payload. + +```ts +import { sqliteAdapter } from '@payloadcms/db-sqlite' +import { integer, sqliteTable } from 'drizzle-orm/sqlite-core' + +sqliteAdapter({ + beforeSchemaInit: [ + ({ schema, adapter }) => { + return { + ...schema, + tables: { + ...schema.tables, + addedTable: sqliteTable('added_table', { + id: integer('id').primaryKey({ autoIncrement: true }), + }), + }, + } + }, + ], +}) +``` + +One use case is preserving your existing database structure when migrating to Payload. By default, Payload drops the current database schema, which may not be desirable in this scenario. +To quickly generate the Drizzle schema from your database you can use [Drizzle Introspection](https://orm.drizzle.team/kit-docs/commands#introspect--pull) +You should get the `schema.ts` file which may look like this: + +```ts +import { sqliteTable, text, uniqueIndex, integer } from 'drizzle-orm/sqlite-core' + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + fullName: text('full_name'), + phone: text('phone', {length: 256}), +}) + +export const countries = sqliteTable( + 'countries', + { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name', { length: 256 }), + }, + (countries) => { + return { + nameIndex: uniqueIndex('name_idx').on(countries.name), + } + }, +) + +``` + +You can import them into your config and append to the schema with the `beforeSchemaInit` hook like this: + +```ts +import { sqliteAdapter } from '@payloadcms/db-sqlite' +import { users, countries } from '../drizzle/schema' + +sqliteAdapter({ + beforeSchemaInit: [ + ({ schema, adapter }) => { + return { + ...schema, + tables: { + ...schema.tables, + users, + countries + }, + } + }, + ], +}) +``` + +Make sure Payload doesn't overlap table names with its collections. For example, if you already have a collection with slug "users", you should either change the slug or `dbName` to change the table name for this collection. + + +### afterSchemaInit + +Runs after the Drizzle schema is built. You can use this hook to modify the schema with features that aren't supported by Payload, or if you want to add a column that you don't want to be in the Payload config. +To extend a table, Payload exposes `extendTable` utillity to the args. You can refer to the [Drizzle documentation](https://orm.drizzle.team/docs/sql-schema-declaration). +The following example adds the `extra_integer_column` column and a composite index on `country` and `city` columns. + +```ts +import { sqliteAdapter } from '@payloadcms/db-sqlite' +import { index, integer } from 'drizzle-orm/sqlite-core' +import { buildConfig } from 'payload' + +export default buildConfig({ + collections: [ + { + slug: 'places', + fields: [ + { + name: 'country', + type: 'text', + }, + { + name: 'city', + type: 'text', + }, + ], + }, + ], + db: sqliteAdapter({ + afterSchemaInit: [ + ({ schema, extendTable, adapter }) => { + extendTable({ + table: schema.tables.places, + columns: { + extraIntegerColumn: integer('extra_integer_column'), + }, + extraConfig: (table) => ({ + country_city_composite_index: index('country_city_composite_index').on( + table.country, + table.city, + ), + }), + }) + + return schema + }, + ], + }), +}) + +``` diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index e60dcda1f6..17cccf228d 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -76,6 +76,8 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj return createDatabaseAdapter({ name: 'postgres', + afterSchemaInit: args.afterSchemaInit ?? [], + beforeSchemaInit: args.beforeSchemaInit ?? [], defaultDrizzleSnapshot, drizzle: undefined, enums: {}, diff --git a/packages/db-postgres/src/types.ts b/packages/db-postgres/src/types.ts index 6904e116d9..3fe149d0b9 100644 --- a/packages/db-postgres/src/types.ts +++ b/packages/db-postgres/src/types.ts @@ -4,6 +4,7 @@ import type { MigrateDownArgs, MigrateUpArgs, PostgresDB, + PostgresSchemaHook, } from '@payloadcms/drizzle/postgres' import type { DrizzleAdapter } from '@payloadcms/drizzle/types' import type { DrizzleConfig } from 'drizzle-orm' @@ -11,6 +12,18 @@ import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-co import type { Pool, PoolConfig } from 'pg' export type Args = { + /** + * Transform the schema after it's built. + * You can use it to customize the schema with features that aren't supported by Payload. + * Examples may include: composite indices, generated columns, vectors + */ + afterSchemaInit?: PostgresSchemaHook[] + /** + * Transform the schema before it's built. + * You can use it to preserve an existing database schema and if there are any collissions Payload will override them. + * To generate Drizzle schema from the database, see [Drizzle Kit introspection](https://orm.drizzle.team/kit-docs/commands#introspect--pull) + */ + beforeSchemaInit?: PostgresSchemaHook[] idType?: 'serial' | 'uuid' localesSuffix?: string logger?: DrizzleConfig['logger'] @@ -41,6 +54,8 @@ declare module 'payload' { export interface DatabaseAdapter extends Omit, DrizzleAdapter { + afterSchemaInit: PostgresSchemaHook[] + beforeSchemaInit: PostgresSchemaHook[] beginTransaction: (options?: PgTransactionConfig) => Promise drizzle: PostgresDB enums: Record diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 8db109d43a..49bdefe47b 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -79,6 +79,8 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { return createDatabaseAdapter({ name: 'sqlite', + afterSchemaInit: args.afterSchemaInit ?? [], + beforeSchemaInit: args.beforeSchemaInit ?? [], client: undefined, clientConfig: args.client, defaultDrizzleSnapshot, diff --git a/packages/db-sqlite/src/init.ts b/packages/db-sqlite/src/init.ts index 69aa08f959..4a2abfcb33 100644 --- a/packages/db-sqlite/src/init.ts +++ b/packages/db-sqlite/src/init.ts @@ -1,7 +1,7 @@ import type { DrizzleAdapter } from '@payloadcms/drizzle/types' import type { Init, SanitizedCollectionConfig } from 'payload' -import { createTableName } from '@payloadcms/drizzle' +import { createTableName, executeSchemaHooks } from '@payloadcms/drizzle' import { uniqueIndex } from 'drizzle-orm/sqlite-core' import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload' import toSnakeCase from 'to-snake-case' @@ -11,8 +11,10 @@ import type { SQLiteAdapter } from './types.js' import { buildTable } from './schema/build.js' -export const init: Init = function init(this: SQLiteAdapter) { +export const init: Init = async function init(this: SQLiteAdapter) { let locales: [string, ...string[]] | undefined + await executeSchemaHooks({ type: 'beforeSchemaInit', adapter: this }) + if (this.payload.config.localization) { locales = this.payload.config.localization.locales.map(({ code }) => code) as [ string, @@ -132,4 +134,6 @@ export const init: Init = function init(this: SQLiteAdapter) { }) } }) + + await executeSchemaHooks({ type: 'afterSchemaInit', adapter: this }) } diff --git a/packages/db-sqlite/src/types.ts b/packages/db-sqlite/src/types.ts index 503202c4b1..eb11826c20 100644 --- a/packages/db-sqlite/src/types.ts +++ b/packages/db-sqlite/src/types.ts @@ -1,5 +1,5 @@ import type { Client, Config, ResultSet } from '@libsql/client' -import type { Operators } from '@payloadcms/drizzle' +import type { extendDrizzleTable, Operators } from '@payloadcms/drizzle' import type { BuildQueryJoinAliases, DrizzleAdapter } from '@payloadcms/drizzle/types' import type { DrizzleConfig, Relation, Relations, SQL } from 'drizzle-orm' import type { LibSQLDatabase } from 'drizzle-orm/libsql' @@ -12,7 +12,31 @@ import type { import type { SQLiteRaw } from 'drizzle-orm/sqlite-core/query-builders/raw' import type { Payload, PayloadRequest } from 'payload' +type SQLiteSchema = { + relations: Record + tables: Record> +} + +type SQLiteSchemaHookArgs = { + extendTable: typeof extendDrizzleTable + schema: SQLiteSchema +} + +export type SQLiteSchemaHook = (args: SQLiteSchemaHookArgs) => Promise | SQLiteSchema + export type Args = { + /** + * Transform the schema after it's built. + * You can use it to customize the schema with features that aren't supported by Payload. + * Examples may include: composite indices, generated columns, vectors + */ + afterSchemaInit?: SQLiteSchemaHook[] + /** + * Transform the schema before it's built. + * You can use it to preserve an existing database schema and if there are any collissions Payload will override them. + * To generate Drizzle schema from the database, see [Drizzle Kit introspection](https://orm.drizzle.team/kit-docs/commands#introspect--pull) + */ + beforeSchemaInit?: SQLiteSchemaHook[] client: Config idType?: 'serial' | 'uuid' localesSuffix?: string @@ -86,6 +110,8 @@ type SQLiteDrizzleAdapter = Omit< > export type SQLiteAdapter = { + afterSchemaInit: SQLiteSchemaHook[] + beforeSchemaInit: SQLiteSchemaHook[] client: Client clientConfig: Args['client'] countDistinct: CountDistinct diff --git a/packages/db-vercel-postgres/src/index.ts b/packages/db-vercel-postgres/src/index.ts index f25b007cd1..9e8d3864a7 100644 --- a/packages/db-vercel-postgres/src/index.ts +++ b/packages/db-vercel-postgres/src/index.ts @@ -76,6 +76,8 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj({ name: 'postgres', + afterSchemaInit: args.afterSchemaInit ?? [], + beforeSchemaInit: args.beforeSchemaInit ?? [], defaultDrizzleSnapshot, drizzle: undefined, enums: {}, diff --git a/packages/db-vercel-postgres/src/types.ts b/packages/db-vercel-postgres/src/types.ts index 3732d5c9e0..920d9e7804 100644 --- a/packages/db-vercel-postgres/src/types.ts +++ b/packages/db-vercel-postgres/src/types.ts @@ -4,6 +4,7 @@ import type { MigrateDownArgs, MigrateUpArgs, PostgresDB, + PostgresSchemaHook, } from '@payloadcms/drizzle/postgres' import type { DrizzleAdapter } from '@payloadcms/drizzle/types' import type { VercelPool, VercelPostgresPoolConfig } from '@vercel/postgres' @@ -11,6 +12,18 @@ import type { DrizzleConfig } from 'drizzle-orm' import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core' export type Args = { + /** + * Transform the schema after it's built. + * You can use it to customize the schema with features that aren't supported by Payload. + * Examples may include: composite indices, generated columns, vectors + */ + afterSchemaInit?: PostgresSchemaHook[] + /** + * Transform the schema before it's built. + * You can use it to preserve an existing database schema and if there are any collissions Payload will override them. + * To generate Drizzle schema from the database, see [Drizzle Kit introspection](https://orm.drizzle.team/kit-docs/commands#introspect--pull) + */ + beforeSchemaInit?: PostgresSchemaHook[] connectionString?: string idType?: 'serial' | 'uuid' localesSuffix?: string @@ -46,6 +59,8 @@ declare module 'payload' { export interface DatabaseAdapter extends Omit, DrizzleAdapter { + afterSchemaInit: PostgresSchemaHook[] + beforeSchemaInit: PostgresSchemaHook[] beginTransaction: (options?: PgTransactionConfig) => Promise drizzle: PostgresDB enums: Record diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index a94fe0b5bb..64d2dd8282 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -32,6 +32,8 @@ export { updateGlobal } from './updateGlobal.js' export { updateGlobalVersion } from './updateGlobalVersion.js' export { updateVersion } from './updateVersion.js' export { upsertRow } from './upsertRow/index.js' +export { executeSchemaHooks } from './utilities/executeSchemaHooks.js' +export { extendDrizzleTable } from './utilities/extendDrizzleTable.js' export { hasLocalesTable } from './utilities/hasLocalesTable.js' export { pushDevSchema } from './utilities/pushDevSchema.js' export { validateExistingBlockIsIdentical } from './utilities/validateExistingBlockIsIdentical.js' diff --git a/packages/drizzle/src/postgres/init.ts b/packages/drizzle/src/postgres/init.ts index 3ff5e16cf0..c76f4d9457 100644 --- a/packages/drizzle/src/postgres/init.ts +++ b/packages/drizzle/src/postgres/init.ts @@ -7,9 +7,12 @@ import toSnakeCase from 'to-snake-case' import type { BaseExtraConfig, BasePostgresAdapter } from './types.js' import { createTableName } from '../createTableName.js' +import { executeSchemaHooks } from '../utilities/executeSchemaHooks.js' import { buildTable } from './schema/build.js' -export const init: Init = function init(this: BasePostgresAdapter) { +export const init: Init = async function init(this: BasePostgresAdapter) { + await executeSchemaHooks({ type: 'beforeSchemaInit', adapter: this }) + if (this.payload.config.localization) { this.enums.enum__locales = this.pgSchema.enum( '_locales', @@ -110,4 +113,6 @@ export const init: Init = function init(this: BasePostgresAdapter) { }) } }) + + await executeSchemaHooks({ type: 'afterSchemaInit', adapter: this }) } diff --git a/packages/drizzle/src/postgres/types.ts b/packages/drizzle/src/postgres/types.ts index bfa90769dd..8a55d6d151 100644 --- a/packages/drizzle/src/postgres/types.ts +++ b/packages/drizzle/src/postgres/types.ts @@ -23,7 +23,7 @@ import type { PgTableFn } from 'drizzle-orm/pg-core/table' import type { Payload, PayloadRequest } from 'payload' import type { QueryResult } from 'pg' -import type { Operators } from '../index.js' +import type { extendDrizzleTable, Operators } from '../index.js' import type { BuildQueryJoinAliases, DrizzleAdapter, TransactionPg } from '../types.js' export type BaseExtraConfig = Record< @@ -99,7 +99,25 @@ type Schema = } | PgSchema +type PostgresSchema = { + enums: Record + relations: Record + tables: Record> +} + +type PostgresSchemaHookArgs = { + adapter: PostgresDrizzleAdapter + extendTable: typeof extendDrizzleTable + schema: PostgresSchema +} + +export type PostgresSchemaHook = ( + args: PostgresSchemaHookArgs, +) => PostgresSchema | Promise + export type BasePostgresAdapter = { + afterSchemaInit: PostgresSchemaHook[] + beforeSchemaInit: PostgresSchemaHook[] countDistinct: CountDistinct defaultDrizzleSnapshot: DrizzleSnapshotJSON deleteWhere: DeleteWhere diff --git a/packages/drizzle/src/utilities/executeSchemaHooks.ts b/packages/drizzle/src/utilities/executeSchemaHooks.ts new file mode 100644 index 0000000000..4c7f22d077 --- /dev/null +++ b/packages/drizzle/src/utilities/executeSchemaHooks.ts @@ -0,0 +1,47 @@ +import type { DrizzleAdapter } from '../types.js' + +import { extendDrizzleTable } from './extendDrizzleTable.js' + +type DatabaseSchema = { + enums?: DrizzleAdapter['enums'] + relations: Record + tables: DrizzleAdapter['tables'] +} + +type Adapter = { + afterSchemaInit: DatabaseSchemaHook[] + beforeSchemaInit: DatabaseSchemaHook[] +} & DatabaseSchema + +type DatabaseSchemaHookArgs = { + adapter: Record + extendTable: typeof extendDrizzleTable + schema: DatabaseSchema +} + +type DatabaseSchemaHook = (args: DatabaseSchemaHookArgs) => DatabaseSchema | Promise + +type Args = { + adapter: Adapter + type: 'afterSchemaInit' | 'beforeSchemaInit' +} + +export const executeSchemaHooks = async ({ type, adapter }: Args): Promise => { + for (const hook of adapter[type]) { + const result = await hook({ + adapter, + extendTable: extendDrizzleTable, + schema: { + enums: adapter.enums, + relations: adapter.relations, + tables: adapter.tables, + }, + }) + if (result.enums) { + adapter.enums = result.enums + } + + adapter.tables = result.tables + adapter.relations = result.relations + } +} diff --git a/packages/drizzle/src/utilities/extendDrizzleTable.ts b/packages/drizzle/src/utilities/extendDrizzleTable.ts new file mode 100644 index 0000000000..dc0ac59978 --- /dev/null +++ b/packages/drizzle/src/utilities/extendDrizzleTable.ts @@ -0,0 +1,63 @@ +/** + * Implemented from: + * https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/pg-core/table.ts#L73 + * Drizzle uses @internal JSDoc to remove their internal methods from types, for example + * Table.Symbol, columnBuilder.build - but they actually exist. + */ +import type { ColumnBuilderBase } from 'drizzle-orm' + +import { Table } from 'drizzle-orm' +import { APIError } from 'payload' + +const { Symbol: DrizzleSymbol } = Table as unknown as { + Symbol: { + Columns: symbol + ExtraConfigBuilder: symbol + ExtraConfigColumns: symbol + } +} + +type Args = { + columns?: Record> + extraConfig?: (self: Record) => object + table: Table +} + +/** + * Extends the passed table with additional columns / extra config + */ +export const extendDrizzleTable = ({ columns, extraConfig, table }: Args): void => { + const InlineForeignKeys = Object.getOwnPropertySymbols(table).find((symbol) => { + return symbol.description?.includes('InlineForeignKeys') + }) + + if (!InlineForeignKeys) { + throw new APIError(`Error when finding InlineForeignKeys Symbol`, 500) + } + + if (columns) { + for (const [name, columnBuilder] of Object.entries(columns) as [string, any][]) { + const column = columnBuilder.build(table) + + table[name] = column + table[InlineForeignKeys].push(...columnBuilder.buildForeignKeys(column, table)) + table[DrizzleSymbol.Columns][name] = column + + table[DrizzleSymbol.ExtraConfigColumns][name] = + 'buildExtraConfigColumn' in columnBuilder + ? columnBuilder.buildExtraConfigColumn(table) + : column + } + } + + if (extraConfig) { + const originalExtraConfigBuilder = table[DrizzleSymbol.ExtraConfigBuilder] + + table[DrizzleSymbol.ExtraConfigBuilder] = (t) => { + return { + ...originalExtraConfigBuilder(t), + ...extraConfig(t), + } + } + } +} diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 74282f914a..828764d8fa 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -273,4 +273,4 @@ export interface Auth { declare module 'payload' { // @ts-ignore export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} diff --git a/test/database/config.ts b/test/database/config.ts index f58e05ae7b..e267435c21 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -275,6 +275,19 @@ export default buildConfigWithDefaults({ drafts: true, }, }, + { + slug: 'places', + fields: [ + { + name: 'country', + type: 'text', + }, + { + name: 'city', + type: 'text', + }, + ], + }, { slug: 'fields-persistance', fields: [ diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index 1e3722f633..069c277379 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -1,8 +1,11 @@ import type { MongooseAdapter } from '@payloadcms/db-mongodb' import type { PostgresAdapter } from '@payloadcms/db-postgres/types' +import type { Table } from 'drizzle-orm' import type { NextRESTClient } from 'helpers/NextRESTClient.js' import type { Payload, PayloadRequest, TypeWithID } from 'payload' +import * as drizzlePg from 'drizzle-orm/pg-core' +import * as drizzleSqlite from 'drizzle-orm/sqlite-core' import fs from 'fs' import path from 'path' import { commitTransaction, initTransaction, QueryError } from 'payload' @@ -477,6 +480,232 @@ describe('database', () => { expect(result.select).toStrictEqual('default') }) }) + describe('drizzle: schema hooks', () => { + it('should add tables with hooks', async () => { + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name === 'mongoose') { + return + } + + let added_table_before: Table + let added_table_after: Table + + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name.includes('postgres')) { + added_table_before = drizzlePg.pgTable('added_table_before', { + id: drizzlePg.serial('id').primaryKey(), + text: drizzlePg.text('text'), + }) + + added_table_after = drizzlePg.pgTable('added_table_after', { + id: drizzlePg.serial('id').primaryKey(), + text: drizzlePg.text('text'), + }) + } + + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name.includes('sqlite')) { + added_table_before = drizzleSqlite.sqliteTable('added_table_before', { + id: drizzleSqlite.integer('id').primaryKey(), + text: drizzleSqlite.text('text'), + }) + + added_table_after = drizzleSqlite.sqliteTable('added_table_after', { + id: drizzleSqlite.integer('id').primaryKey(), + text: drizzleSqlite.text('text'), + }) + } + + payload.db.beforeSchemaInit = [ + ({ schema }) => ({ + ...schema, + tables: { + ...schema.tables, + added_table_before, + }, + }), + ] + + payload.db.afterSchemaInit = [ + ({ schema }) => { + return { + ...schema, + tables: { + ...schema.tables, + added_table_after, + }, + } + }, + ] + + delete payload.db.pool + await payload.db.init() + + expect(payload.db.tables.added_table_before).toBeDefined() + + await payload.db.connect() + + await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `INSERT into added_table_before (text) VALUES ('some-text')`, + }) + + const res_before = await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: 'SELECT * from added_table_before', + }) + expect(res_before.rows[0].text).toBe('some-text') + + await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `INSERT into added_table_after (text) VALUES ('some-text')`, + }) + + const res_after = await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `SELECT * from added_table_after`, + }) + + expect(res_after.rows[0].text).toBe('some-text') + }) + + it('should extend the existing table with extra column and modify the existing column with enforcing DB level length', async () => { + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name === 'mongoose') { + return + } + + const isSQLite = payload.db.name === 'sqlite' + + payload.db.afterSchemaInit = [ + ({ schema, extendTable }) => { + extendTable({ + table: schema.tables.places, + columns: { + // SQLite doesn't have DB length enforcement + // eslint-disable-next-line jest/no-conditional-in-test + ...(payload.db.name === 'postgres' && { + city: drizzlePg.varchar('city', { length: 10 }), + }), + // eslint-disable-next-line jest/no-conditional-in-test + extraColumn: isSQLite + ? drizzleSqlite.integer('extra_column') + : drizzlePg.integer('extra_column'), + }, + }) + + return schema + }, + ] + + delete payload.db.pool + await payload.db.init() + await payload.db.connect() + + expect(payload.db.tables.places.extraColumn).toBeDefined() + + await payload.create({ + collection: 'places', + data: { + city: 'Berlin', + country: 'Germany', + }, + }) + + // eslint-disable-next-line jest/no-conditional-in-test + const tableName = payload.db.schemaName ? `"${payload.db.schemaName}"."places"` : 'places' + + await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `UPDATE ${tableName} SET extra_column = 10`, + }) + + const res_with_extra_col = await payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `SELECT * from ${tableName}`, + }) + + expect(res_with_extra_col.rows[0].extra_column).toBe(10) + + // SQLite doesn't have DB length enforcement + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name === 'postgres') { + await expect( + payload.db.execute({ + drizzle: payload.db.drizzle, + raw: `UPDATE ${tableName} SET city = 'MoreThan10Chars'`, + }), + ).rejects.toBeTruthy() + } + }) + + it('should extend the existing table with composite unique and throw ValidationError on it', async () => { + // eslint-disable-next-line jest/no-conditional-in-test + if (payload.db.name === 'mongoose') { + return + } + + const isSQLite = payload.db.name === 'sqlite' + + payload.db.afterSchemaInit = [ + ({ schema, extendTable }) => { + extendTable({ + table: schema.tables.places, + extraConfig: (t) => ({ + // eslint-disable-next-line jest/no-conditional-in-test + uniqueOnCityAndCountry: (isSQLite ? drizzleSqlite : drizzlePg) + .unique() + .on(t.city, t.country), + }), + }) + + return schema + }, + ] + + delete payload.db.pool + await payload.db.init() + await payload.db.connect() + + await payload.create({ + collection: 'places', + data: { + city: 'A', + country: 'B', + }, + }) + + await expect( + payload.create({ + collection: 'places', + data: { + city: 'C', + country: 'B', + }, + }), + ).resolves.toBeTruthy() + + await expect( + payload.create({ + collection: 'places', + data: { + city: 'A', + country: 'D', + }, + }), + ).resolves.toBeTruthy() + + await expect( + payload.create({ + collection: 'places', + data: { + city: 'A', + country: 'B', + }, + }), + ).rejects.toBeTruthy() + }) + }) describe('virtual fields', () => { it('should not save a field with `virtual: true` to the db', async () => { diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index fe73ca27a3..76b08c09c9 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -17,8 +17,10 @@ export interface Config { 'relation-b': RelationB; 'pg-migrations': PgMigration; 'custom-schema': CustomSchema; + places: Place; 'fields-persistance': FieldsPersistance; users: User; + 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; }; @@ -199,6 +201,17 @@ export interface CustomSchema { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "places". + */ +export interface Place { + id: string; + country?: string | null; + city?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "fields-persistance". @@ -232,6 +245,61 @@ export interface User { lockUntil?: string | null; password?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'posts'; + value: string | Post; + } | null) + | ({ + relationTo: 'default-values'; + value: string | DefaultValue; + } | null) + | ({ + relationTo: 'relation-a'; + value: string | RelationA; + } | null) + | ({ + relationTo: 'relation-b'; + value: string | RelationB; + } | null) + | ({ + relationTo: 'pg-migrations'; + value: string | PgMigration; + } | null) + | ({ + relationTo: 'custom-schema'; + value: string | CustomSchema; + } | null) + | ({ + relationTo: 'places'; + value: string | Place; + } | null) + | ({ + relationTo: 'fields-persistance'; + value: string | FieldsPersistance; + } | null) + | ({ + relationTo: 'users'; + value: string | User; + } | null); + globalSlug?: string | null; + _lastEdited: { + user: { + relationTo: 'users'; + value: string | User; + }; + editedAt?: string | null; + }; + isLocked?: boolean | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-preferences".