feat(db-*): allow to thread id to create operation data without custom IDs (#11709)

Fixes https://github.com/payloadcms/payload/issues/6884

Adds a new flag `acceptIDOnCreate` that allows you to thread your own
`id` to `payload.create` `data`, for example:

```ts
// doc created with id 1
const doc = await payload.create({ collection: 'posts', data: {id: 1, title: "my title"}})
```

```ts
import { Types } from 'mongoose'
const id = new Types.ObjectId().toHexString()
const doc = await payload.create({ collection: 'posts', data: {id, title: "my title"}})
```
This commit is contained in:
Sasha
2025-03-18 05:48:35 +02:00
committed by GitHub
parent 95821c6136
commit f442d22237
17 changed files with 173 additions and 18 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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<CollationOptions, 'locale'>
collectionsSchemaOptions?: Partial<Record<CollectionSlug, SchemaOptions>>
/** 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,
}

View File

@@ -64,6 +64,7 @@ const filename = fileURLToPath(import.meta.url)
export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter> {
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<PostgresAdapter>
return createDatabaseAdapter<PostgresAdapter>({
name: 'postgres',
afterSchemaInit: args.afterSchemaInit ?? [],
allowIDOnCreate,
beforeSchemaInit: args.beforeSchemaInit ?? [],
createDatabase,
createExtensions,
@@ -204,6 +206,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
}
return {
allowIDOnCreate,
defaultIDType: payloadIDType,
init: adapter,
}

View File

@@ -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.

View File

@@ -66,6 +66,7 @@ const filename = fileURLToPath(import.meta.url)
export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
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<SQLiteAdapter> {
return createDatabaseAdapter<SQLiteAdapter>({
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<SQLiteAdapter> {
}
return {
allowIDOnCreate,
defaultIDType: payloadIDType,
init: adapter,
}

View File

@@ -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.

View File

@@ -64,6 +64,7 @@ const filename = fileURLToPath(import.meta.url)
export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<VercelPostgresAdapter> {
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<Verce
return createDatabaseAdapter<VercelPostgresAdapter>({
name: 'postgres',
afterSchemaInit: args.afterSchemaInit ?? [],
allowIDOnCreate,
beforeSchemaInit: args.beforeSchemaInit ?? [],
createDatabase,
createExtensions,
@@ -195,6 +197,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
}
return {
allowIDOnCreate,
defaultIDType: payloadIDType,
init: adapter,
}

View File

@@ -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.

View File

@@ -9,7 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export const create: Create = async function create(
this: DrizzleAdapter,
{ collection: collectionSlug, data, req, select, returning },
{ collection: collectionSlug, data, req, returning, select },
) {
const db = await getTransaction(this, req)
const collection = this.payload.collections[collectionSlug].config
@@ -21,11 +21,11 @@ export const create: Create = async function create(
data,
db,
fields: collection.flattenedFields,
ignoreResult: returning === false,
operation: 'create',
req,
select,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {

View File

@@ -66,6 +66,9 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
})
}
} else {
if (adapter.allowIDOnCreate && data.id) {
rowToInsert.row.id = data.id
}
;[insertedRow] = await adapter.insert({
db,
tableName,

View File

@@ -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,

View File

@@ -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<T extends BaseDatabaseAdapter>(
args: MarkOptional<
T,
| 'allowIDOnCreate'
| 'createMigration'
| 'migrate'
| 'migrateDown'
@@ -39,14 +40,13 @@ export function createDatabaseAdapter<T extends BaseDatabaseAdapter>(
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

View File

@@ -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<void>
/**
* Drop the current database and run all migrate up functions
*/
migrateFresh: (args: { forceAcceptWarning?: boolean }) => Promise<void>
/**
* Run all migration down functions before running up
*/
migrateRefresh: () => Promise<void>
/**
* 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<T = any> = {
}
export type DatabaseAdapterResult<T = BaseDatabaseAdapter> = {
allowIDOnCreate?: boolean
defaultIDType: 'number' | 'text'
init: (args: { payload: Payload }) => T
}

View File

@@ -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: {} })