diff --git a/docs/database/mongodb.mdx b/docs/database/mongodb.mdx index bb8e0ecf3..d47ca35c0 100644 --- a/docs/database/mongodb.mdx +++ b/docs/database/mongodb.mdx @@ -40,6 +40,7 @@ export default buildConfig({ | `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. | | `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). | | `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. | +| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | ## Access to Mongoose models diff --git a/docs/database/postgres.mdx b/docs/database/postgres.mdx index bc1b78b33..b8f3ec736 100644 --- a/docs/database/postgres.mdx +++ b/docs/database/postgres.mdx @@ -79,6 +79,7 @@ export default buildConfig({ | `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) | | `generateSchemaOutputFile` | Override generated schema from `payload generate:db-schema` file path. Defaults to `{CWD}/src/payload-generated.schema.ts` | +| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | ## Access to Drizzle diff --git a/docs/database/sqlite.mdx b/docs/database/sqlite.mdx index 34f72ab76..7d440603b 100644 --- a/docs/database/sqlite.mdx +++ b/docs/database/sqlite.mdx @@ -49,6 +49,7 @@ export default buildConfig({ | `afterSchemaInit` | Drizzle schema hook. Runs after the schema is built. [More Details](#afterschemainit) | | `generateSchemaOutputFile` | Override generated schema from `payload generate:db-schema` file path. Defaults to `{CWD}/src/payload-generated.schema.ts` | | `autoIncrement` | Pass `true` to enable SQLite [AUTOINCREMENT](https://www.sqlite.org/autoinc.html) for primary keys to ensure the same ID cannot be reused from deleted rows | +| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. | ## Access to Drizzle diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index f1638ecf3..5c7f04d82 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -1,6 +1,7 @@ -import type { CreateOptions } from 'mongoose' import type { Create } from 'payload' +import { type CreateOptions, Types } from 'mongoose' + import type { MongooseAdapter } from './index.js' import { getCollection } from './utilities/getEntity.js' @@ -29,6 +30,15 @@ export const create: Create = async function create( if (customIDType) { data._id = data.id + } else if (this.allowIDOnCreate && data.id) { + try { + data._id = new Types.ObjectId(data.id as string) + } catch (error) { + this.payload.logger.error( + `It appears you passed ID to create operation data but it cannot be sanitized to ObjectID, value - ${JSON.stringify(data.id)}`, + ) + throw error + } } try { diff --git a/packages/db-mongodb/src/index.ts b/packages/db-mongodb/src/index.ts index 6b0cfe444..b828e7dac 100644 --- a/packages/db-mongodb/src/index.ts +++ b/packages/db-mongodb/src/index.ts @@ -70,8 +70,20 @@ export interface Args { * @default false */ allowAdditionalKeys?: boolean + /** + * Enable this flag if you want to thread your own ID to create operation data, for example: + * ```ts + * import { Types } from 'mongoose' + * + * const id = new Types.ObjectId().toHexString() + * const doc = await payload.create({ collection: 'posts', data: {id, title: "my title"}}) + * assertEq(doc.id, id) + * ``` + */ + allowIDOnCreate?: boolean /** Set to false to disable auto-pluralization of collection names, Defaults to true */ autoPluralization?: boolean + /** * If enabled, collation allows for language-specific rules for string comparison. * This configuration can include the following options: @@ -98,7 +110,6 @@ export interface Args { collation?: Omit collectionsSchemaOptions?: Partial> - /** Extra configuration options */ connectOptions?: { /** @@ -183,6 +194,7 @@ declare module 'payload' { export function mongooseAdapter({ allowAdditionalKeys = false, + allowIDOnCreate = false, autoPluralization = true, collectionsSchemaOptions = {}, connectOptions, @@ -220,6 +232,7 @@ export function mongooseAdapter({ versions: {}, // DatabaseAdapter allowAdditionalKeys, + allowIDOnCreate, beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction, collectionsSchemaOptions, commitTransaction, @@ -259,6 +272,7 @@ export function mongooseAdapter({ } return { + allowIDOnCreate, defaultIDType: 'text', init: adapter, } diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index 7a24cbc91..26ec09a11 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -64,6 +64,7 @@ const filename = fileURLToPath(import.meta.url) export function postgresAdapter(args: Args): DatabaseAdapterObj { const postgresIDType = args.idType || 'serial' const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text' + const allowIDOnCreate = args.allowIDOnCreate ?? false function adapter({ payload }: { payload: Payload }) { const migrationDir = findMigrationDir(args.migrationDir) @@ -94,6 +95,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj return createDatabaseAdapter({ name: 'postgres', afterSchemaInit: args.afterSchemaInit ?? [], + allowIDOnCreate, beforeSchemaInit: args.beforeSchemaInit ?? [], createDatabase, createExtensions, @@ -204,6 +206,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj } return { + allowIDOnCreate, defaultIDType: payloadIDType, init: adapter, } diff --git a/packages/db-postgres/src/types.ts b/packages/db-postgres/src/types.ts index ec2005265..2eb8082a6 100644 --- a/packages/db-postgres/src/types.ts +++ b/packages/db-postgres/src/types.ts @@ -19,6 +19,14 @@ export type Args = { * Examples may include: composite indices, generated columns, vectors */ afterSchemaInit?: PostgresSchemaHook[] + /** + * Enable this flag if you want to thread your own ID to create operation data, for example: + * ```ts + * // doc created with id 1 + * const doc = await payload.create({ collection: 'posts', data: {id: 1, title: "my title"}}) + * ``` + */ + allowIDOnCreate?: boolean /** * 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. diff --git a/packages/db-sqlite/src/index.ts b/packages/db-sqlite/src/index.ts index 0bedf7545..7ed1185b4 100644 --- a/packages/db-sqlite/src/index.ts +++ b/packages/db-sqlite/src/index.ts @@ -66,6 +66,7 @@ const filename = fileURLToPath(import.meta.url) export function sqliteAdapter(args: Args): DatabaseAdapterObj { const sqliteIDType = args.idType || 'number' const payloadIDType = sqliteIDType === 'uuid' ? 'text' : 'number' + const allowIDOnCreate = args.allowIDOnCreate ?? false function adapter({ payload }: { payload: Payload }) { const migrationDir = findMigrationDir(args.migrationDir) @@ -88,6 +89,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { return createDatabaseAdapter({ name: 'sqlite', afterSchemaInit: args.afterSchemaInit ?? [], + allowIDOnCreate, autoIncrement: args.autoIncrement ?? false, beforeSchemaInit: args.beforeSchemaInit ?? [], client: undefined, @@ -186,6 +188,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj { } return { + allowIDOnCreate, defaultIDType: payloadIDType, init: adapter, } diff --git a/packages/db-sqlite/src/types.ts b/packages/db-sqlite/src/types.ts index 102f9547b..2322d6232 100644 --- a/packages/db-sqlite/src/types.ts +++ b/packages/db-sqlite/src/types.ts @@ -31,6 +31,14 @@ export type Args = { * Examples may include: composite indices, generated columns, vectors */ afterSchemaInit?: SQLiteSchemaHook[] + /** + * Enable this flag if you want to thread your own ID to create operation data, for example: + * ```ts + * // doc created with id 1 + * const doc = await payload.create({ collection: 'posts', data: {id: 1, title: "my title"}}) + * ``` + */ + allowIDOnCreate?: boolean /** * Enable [AUTOINCREMENT](https://www.sqlite.org/autoinc.html) for Primary Keys. * This ensures that the same ID cannot be reused from previously deleted rows. diff --git a/packages/db-vercel-postgres/src/index.ts b/packages/db-vercel-postgres/src/index.ts index 703db3ce7..c8835a972 100644 --- a/packages/db-vercel-postgres/src/index.ts +++ b/packages/db-vercel-postgres/src/index.ts @@ -64,6 +64,7 @@ const filename = fileURLToPath(import.meta.url) export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj { const postgresIDType = args.idType || 'serial' const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text' + const allowIDOnCreate = args.allowIDOnCreate ?? false function adapter({ payload }: { payload: Payload }) { const migrationDir = findMigrationDir(args.migrationDir) @@ -90,6 +91,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj({ name: 'postgres', afterSchemaInit: args.afterSchemaInit ?? [], + allowIDOnCreate, beforeSchemaInit: args.beforeSchemaInit ?? [], createDatabase, createExtensions, @@ -195,6 +197,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj | TypeWithID>( }) } } else { + if (adapter.allowIDOnCreate && data.id) { + rowToInsert.row.id = data.id + } ;[insertedRow] = await adapter.insert({ db, tableName, diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index e695ef3ec..626a2a120 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -145,10 +145,25 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ }) } + let mutationCreateInputFields = mutationInputFields + + if ( + config.db.allowIDOnCreate && + !collectionConfig.flattenedFields.some((field) => field.name === 'id') + ) { + mutationCreateInputFields = [ + ...mutationCreateInputFields, + { + name: 'id', + type: config.db.defaultIDType, + } as Field, + ] + } + const createMutationInputType = buildMutationInputType({ name: singularName, config, - fields: mutationInputFields, + fields: mutationCreateInputFields, graphqlResult, parentIsLocalized: false, parentName: singularName, diff --git a/packages/payload/src/database/createDatabaseAdapter.ts b/packages/payload/src/database/createDatabaseAdapter.ts index 03521bcb5..c2812dff3 100644 --- a/packages/payload/src/database/createDatabaseAdapter.ts +++ b/packages/payload/src/database/createDatabaseAdapter.ts @@ -15,13 +15,14 @@ import { migrateRefresh } from './migrations/migrateRefresh.js' import { migrateReset } from './migrations/migrateReset.js' import { migrateStatus } from './migrations/migrateStatus.js' -const beginTransaction: BeginTransaction = async () => null -const rollbackTransaction: RollbackTransaction = async () => null -const commitTransaction: CommitTransaction = async () => null +const beginTransaction: BeginTransaction = () => Promise.resolve(null) +const rollbackTransaction: RollbackTransaction = () => Promise.resolve(null) +const commitTransaction: CommitTransaction = () => Promise.resolve(null) export function createDatabaseAdapter( args: MarkOptional< T, + | 'allowIDOnCreate' | 'createMigration' | 'migrate' | 'migrateDown' @@ -39,14 +40,13 @@ export function createDatabaseAdapter( createMigration, migrate, migrateDown, - migrateFresh: async ({ forceAcceptWarning = null }) => null, + migrateFresh: () => Promise.resolve(null), migrateRefresh, migrateReset, migrateStatus, rollbackTransaction, ...args, - // Ensure migrationDir is set migrationDir: args.migrationDir || 'migrations', } as T diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index d9abba968..5159f74c9 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -14,11 +14,13 @@ import type { TypeWithVersion } from '../versions/types.js' export type { TypeWithVersion } export interface BaseDatabaseAdapter { + allowIDOnCreate?: boolean /** * Start a transaction, requiring commitTransaction() to be called for any changes to be made. * @returns an identifier for the transaction or null if one cannot be established */ beginTransaction: BeginTransaction + /** * Persist the changes made since the start of the transaction. */ @@ -28,16 +30,16 @@ export interface BaseDatabaseAdapter { * Open the connection to the database */ connect?: Connect - count: Count countGlobalVersions: CountGlobalVersions + countVersions: CountVersions create: Create createGlobal: CreateGlobal - createGlobalVersion: CreateGlobalVersion + /** * Output a migration file */ @@ -53,8 +55,8 @@ export interface BaseDatabaseAdapter { deleteMany: DeleteMany deleteOne: DeleteOne - deleteVersions: DeleteVersions + /** * Terminate the connection with the database */ @@ -86,16 +88,15 @@ export interface BaseDatabaseAdapter { * Run any migration down functions that have been performed */ migrateDown: () => Promise - /** * Drop the current database and run all migrate up functions */ migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise + /** * Run all migration down functions before running up */ migrateRefresh: () => Promise - /** * Run all migrate down functions */ @@ -108,6 +109,7 @@ export interface BaseDatabaseAdapter { * Path to read and write migration files from */ migrationDir: string + /** * The name of the database adapter */ @@ -119,12 +121,12 @@ export interface BaseDatabaseAdapter { * @example @payloadcms/db-postgres */ packageName: string - /** * reference to the instance of payload */ payload: Payload queryDrafts: QueryDrafts + /** * Abort any changes since the start of the transaction. */ @@ -150,7 +152,6 @@ export interface BaseDatabaseAdapter { updateOne: UpdateOne updateVersion: UpdateVersion - upsert: Upsert } @@ -607,6 +608,7 @@ export type PaginatedDocs = { } export type DatabaseAdapterResult = { + allowIDOnCreate?: boolean defaultIDType: 'number' | 'text' init: (args: { payload: Payload }) => T } diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index afddd3005..9a6626bd9 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -12,7 +12,7 @@ import { type Table } from 'drizzle-orm' import * as drizzlePg from 'drizzle-orm/pg-core' import * as drizzleSqlite from 'drizzle-orm/sqlite-core' import fs from 'fs' -import { Types } from 'mongoose' +import mongoose, { Types } from 'mongoose' import path from 'path' import { commitTransaction, @@ -306,6 +306,81 @@ describe('database', () => { }) }) + describe('allow ID on create', () => { + beforeAll(() => { + payload.db.allowIDOnCreate = true + payload.config.db.allowIDOnCreate = true + }) + + afterAll(() => { + payload.db.allowIDOnCreate = false + payload.config.db.allowIDOnCreate = false + }) + + it('Local API - accepts ID on create', async () => { + let id: any = null + if (payload.db.name === 'mongoose') { + id = new mongoose.Types.ObjectId().toHexString() + } else if (payload.db.idType === 'uuid') { + id = randomUUID() + } else { + id = 9999 + } + + const post = await payload.create({ collection: 'posts', data: { id, title: 'created' } }) + + expect(post.id).toBe(id) + }) + + it('REST API - accepts ID on create', async () => { + let id: any = null + if (payload.db.name === 'mongoose') { + id = new mongoose.Types.ObjectId().toHexString() + } else if (payload.db.idType === 'uuid') { + id = randomUUID() + } else { + id = 99999 + } + + const response = await restClient.POST(`/posts`, { + body: JSON.stringify({ + id, + title: 'created', + }), + }) + + const post = await response.json() + + expect(post.doc.id).toBe(id) + }) + + it('GraphQL - accepts ID on create', async () => { + let id: any = null + if (payload.db.name === 'mongoose') { + id = new mongoose.Types.ObjectId().toHexString() + } else if (payload.db.idType === 'uuid') { + id = randomUUID() + } else { + id = 999999 + } + + const query = `mutation { + createPost(data: {title: "created", id: ${typeof id === 'string' ? `"${id}"` : id}}) { + id + title + } + }` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + + const doc = res.data.createPost + + expect(doc).toMatchObject({ title: 'created', id }) + expect(doc.id).toBe(id) + }) + }) + describe('Compound Indexes', () => { beforeEach(async () => { await payload.delete({ collection: 'compound-indexes', where: {} })