feat(db-*): add defaultValues to database schemas (#7368)

## Description

Prior to this change, the `defaultValue` for fields have only been used
in the application layer of Payload. With this change, you get the added
benefit of having the database columns get the default also. This is
especially helpful when adding new columns to postgres with existing
data to avoid needing to write complex migrations. In MongoDB this
change applies the default to the Mongoose model which is useful when
calling payload.db.create() directly.

This only works for statically defined values.

🙏 A big thanks to @r1tsuu for the feature and implementation idea as I
lifted some code from PR https://github.com/payloadcms/payload/pull/6983

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [x] I have made corresponding changes to the documentation
This commit is contained in:
Dan Ribbens
2024-07-30 13:41:18 -04:00
committed by GitHub
parent b5b2bb1907
commit 695ef32d1e
24 changed files with 175 additions and 49 deletions

View File

@@ -205,7 +205,9 @@ export const MyField: Field = {
}
```
Default values can be defined as a static string or a function that returns a string. Functions are called with the following arguments:
Default values can be defined as a static value or a function that returns a value. When a `defaultValue` is defined statically, Payload's DB adapters will apply it to the database schema or models.
Functions can be written to make use of the following argument properties:
- `user` - the authenticated user object
- `locale` - the currently selected locale string

View File

@@ -52,9 +52,19 @@ type FieldSchemaGenerator = (
buildSchemaOptions: BuildSchemaOptions,
) => void
/**
* get a field's defaultValue only if defined and not dynamic so that it can be set on the field schema
* @param field
*/
const formatDefaultValue = (field: FieldAffectingData) =>
typeof field.defaultValue !== 'undefined' && typeof field.defaultValue !== 'function'
? field.defaultValue
: undefined
const formatBaseSchema = (field: FieldAffectingData, buildSchemaOptions: BuildSchemaOptions) => {
const { disableUnique, draftsEnabled, indexSortableFields } = buildSchemaOptions
const schema: SchemaTypeOptions<unknown> = {
default: formatDefaultValue(field),
index: field.index || (!disableUnique && field.unique) || indexSortableFields || false,
required: false,
unique: (!disableUnique && field.unique) || false,
@@ -159,7 +169,6 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
},
}),
],
default: undefined,
}
schema.add({
@@ -174,7 +183,6 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
): void => {
const fieldSchema = {
type: [new mongoose.Schema({}, { _id: false, discriminatorKey: 'blockType' })],
default: undefined,
}
schema.add({
@@ -339,7 +347,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
},
coordinates: {
type: [Number],
default: field.defaultValue || undefined,
default: formatDefaultValue(field),
required: false,
},
}
@@ -420,7 +428,9 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
return {
...locales,
[locale]: field.hasMany ? { type: [localeSchema], default: undefined } : localeSchema,
[locale]: field.hasMany
? { type: [localeSchema], default: formatDefaultValue(field) }
: localeSchema,
}
}, {}),
localized: true,
@@ -440,7 +450,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: undefined,
default: formatDefaultValue(field),
}
}
} else {
@@ -453,7 +463,7 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: undefined,
default: formatDefaultValue(field),
}
}
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Relation } from 'drizzle-orm'
import type {
ForeignKeyBuilder,

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Relation } from 'drizzle-orm'
import type { IndexBuilder, PgColumnBuilder } from 'drizzle-orm/pg-core'
import type { Field, TabAsField } from 'payload'
@@ -35,6 +34,7 @@ import { buildTable } from './build.js'
import { createIndex } from './createIndex.js'
import { idToUUID } from './idToUUID.js'
import { parentIDColumnMap } from './parentIDColumnMap.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: PostgresAdapter
@@ -170,14 +170,14 @@ export const traverseFields = ({
)
}
} else {
targetTable[fieldName] = varchar(columnName)
targetTable[fieldName] = withDefault(varchar(columnName), field)
}
break
}
case 'email':
case 'code':
case 'textarea': {
targetTable[fieldName] = varchar(columnName)
targetTable[fieldName] = withDefault(varchar(columnName), field)
break
}
@@ -199,23 +199,26 @@ export const traverseFields = ({
)
}
} else {
targetTable[fieldName] = numeric(columnName)
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'richText':
case 'json': {
targetTable[fieldName] = jsonb(columnName)
targetTable[fieldName] = withDefault(jsonb(columnName), field)
break
}
case 'date': {
targetTable[fieldName] = timestamp(columnName, {
targetTable[fieldName] = withDefault(
timestamp(columnName, {
mode: 'string',
precision: 3,
withTimezone: true,
})
}),
field,
)
break
}
@@ -311,13 +314,13 @@ export const traverseFields = ({
}),
)
} else {
targetTable[fieldName] = adapter.enums[enumName](fieldName)
targetTable[fieldName] = withDefault(adapter.enums[enumName](fieldName), field)
}
break
}
case 'checkbox': {
targetTable[fieldName] = boolean(columnName)
targetTable[fieldName] = withDefault(boolean(columnName), field)
break
}

View File

@@ -0,0 +1,17 @@
import type { PgColumnBuilder } from 'drizzle-orm/pg-core'
import type { FieldAffectingData } from 'payload'
export const withDefault = (
column: PgColumnBuilder,
field: FieldAffectingData,
): PgColumnBuilder => {
if (typeof field.defaultValue === 'undefined' || typeof field.defaultValue === 'function')
return column
if (typeof field.defaultValue === 'string' && field.defaultValue.includes("'")) {
const escapedString = field.defaultValue.replaceAll("'", "''")
return column.default(escapedString)
}
return column.default(field.defaultValue)
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Relation } from 'drizzle-orm'
import type { IndexBuilder, SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
import type { Field, TabAsField } from 'payload'
@@ -30,6 +29,7 @@ import { buildTable } from './build.js'
import { createIndex } from './createIndex.js'
import { getIDColumn } from './getIDColumn.js'
import { idToUUID } from './idToUUID.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: SQLiteAdapter
@@ -166,14 +166,14 @@ export const traverseFields = ({
)
}
} else {
targetTable[fieldName] = text(columnName)
targetTable[fieldName] = withDefault(text(columnName), field)
}
break
}
case 'email':
case 'code':
case 'textarea': {
targetTable[fieldName] = text(columnName)
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
@@ -195,19 +195,19 @@ export const traverseFields = ({
)
}
} else {
targetTable[fieldName] = numeric(columnName)
targetTable[fieldName] = withDefault(numeric(columnName), field)
}
break
}
case 'richText':
case 'json': {
targetTable[fieldName] = text(columnName, { mode: 'json' })
targetTable[fieldName] = withDefault(text(columnName, { mode: 'json' }), field)
break
}
case 'date': {
targetTable[fieldName] = text(columnName)
targetTable[fieldName] = withDefault(text(columnName), field)
break
}
@@ -295,13 +295,13 @@ export const traverseFields = ({
}),
)
} else {
targetTable[fieldName] = text(fieldName, { enum: options })
targetTable[fieldName] = withDefault(text(fieldName, { enum: options }), field)
}
break
}
case 'checkbox': {
targetTable[fieldName] = integer(columnName, { mode: 'boolean' })
targetTable[fieldName] = withDefault(integer(columnName, { mode: 'boolean' }), field)
break
}

View File

@@ -0,0 +1,17 @@
import type { SQLiteColumnBuilder } from 'drizzle-orm/sqlite-core'
import type { FieldAffectingData } from 'payload'
export const withDefault = (
column: SQLiteColumnBuilder,
field: FieldAffectingData,
): SQLiteColumnBuilder => {
if (typeof field.defaultValue === 'undefined' || typeof field.defaultValue === 'function')
return column
if (typeof field.defaultValue === 'string' && field.defaultValue.includes("'")) {
const escapedString = field.defaultValue.replaceAll("'", "''")
return column.default(escapedString)
}
return column.default(field.defaultValue)
}

View File

@@ -1,5 +1,4 @@
import type { Count } from 'payload'
import type { SanitizedCollectionConfig } from 'payload'
import type { Count , SanitizedCollectionConfig } from 'payload'
import toSnakeCase from 'to-snake-case'
@@ -15,7 +14,7 @@ export const count: Count = async function count(
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const { joins, where } = await buildQuery({
adapter: this,

View File

@@ -10,7 +10,7 @@ export const create: Create = async function create(
this: DrizzleAdapter,
{ collection: collectionSlug, data, req },
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))

View File

@@ -10,7 +10,7 @@ export async function createGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req = {} as PayloadRequest }: CreateGlobalArgs,
): Promise<T> {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug))

View File

@@ -12,7 +12,7 @@ export async function createGlobalVersion<T extends TypeWithID>(
this: DrizzleAdapter,
{ autosave, globalSlug, req = {} as PayloadRequest, versionData }: CreateGlobalVersionArgs,
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const global = this.payload.globals.config.find(({ slug }) => slug === globalSlug)
const tableName = this.tableNameMap.get(`_${toSnakeCase(global.slug)}${this.versionsSuffix}`)

View File

@@ -18,7 +18,7 @@ export async function createVersion<T extends TypeWithID>(
versionData,
}: CreateVersionArgs<T>,
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
const defaultTableName = toSnakeCase(collection.slug)

View File

@@ -11,7 +11,7 @@ export const deleteMany: DeleteMany = async function deleteMany(
this: DrizzleAdapter,
{ collection, req = {} as PayloadRequest, where },
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collectionConfig = this.payload.collections[collection].config
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))

View File

@@ -14,7 +14,7 @@ export const deleteOne: DeleteOne = async function deleteOne(
this: DrizzleAdapter,
{ collection: collectionSlug, req = {} as PayloadRequest, where: whereArg },
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))

View File

@@ -12,7 +12,7 @@ export const deleteVersions: DeleteVersions = async function deleteVersion(
this: DrizzleAdapter,
{ collection, locale, req = {} as PayloadRequest, where: where },
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const tableName = this.tableNameMap.get(

View File

@@ -12,7 +12,7 @@ export const updateOne: UpdateOne = async function updateOne(
this: DrizzleAdapter,
{ id, collection: collectionSlug, data, draft, locale, req, where: whereArg },
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collection = this.payload.collections[collectionSlug].config
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
const whereToUse = whereArg || { id: { equals: id } }

View File

@@ -10,7 +10,7 @@ export async function updateGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req = {} as PayloadRequest }: UpdateGlobalArgs,
): Promise<T> {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug))

View File

@@ -25,7 +25,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
where: whereArg,
}: UpdateGlobalVersionArgs<T>,
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
({ slug }) => slug === global,
)

View File

@@ -25,7 +25,7 @@ export async function updateVersion<T extends TypeWithID>(
where: whereArg,
}: UpdateVersionArgs<T>,
) {
const db = this.sessions[await req.transactionID]?.db || this.drizzle
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const whereToUse = whereArg || { id: { equals: id } }
const tableName = this.tableNameMap.get(

View File

@@ -2,9 +2,17 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { TextField } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
const defaultValueField: TextField = {
name: 'defaultValue',
type: 'text',
defaultValue: 'default value from database',
}
export default buildConfigWithDefaults({
collections: [
{
@@ -46,6 +54,40 @@ export default buildConfigWithDefaults({
],
},
},
{
slug: 'default-values',
fields: [
{
name: 'title',
type: 'text',
},
defaultValueField,
{
name: 'array',
type: 'array',
// default array with one object to test subfield defaultValue properties for Mongoose
defaultValue: [{}],
fields: [defaultValueField],
},
{
name: 'group',
type: 'group',
// we need to have to use as default in order to have subfield defaultValue properties directly for Mongoose
defaultValue: {},
fields: [defaultValueField],
},
{
name: 'select',
type: 'select',
defaultValue: 'default',
options: [
{ value: 'option0', label: 'Option 0' },
{ value: 'option1', label: 'Option 1' },
{ value: 'default', label: 'Default' },
],
},
],
},
{
slug: 'relation-a',
fields: [

View File

@@ -89,11 +89,10 @@ describe('database', () => {
})
it('should allow createdAt to be set in create', async () => {
const createdAt = new Date('2021-01-01T00:00:00.000Z')
const createdAt = new Date('2021-01-01T00:00:00.000Z').toISOString()
const result = await payload.create({
collection: 'posts',
data: {
// TODO: createdAt should be optional on RequiredDataFromCollectionSlug
createdAt,
title: 'hello',
},
@@ -104,8 +103,8 @@ describe('database', () => {
collection: 'posts',
})
expect(result.createdAt).toStrictEqual(createdAt.toISOString())
expect(doc.createdAt).toStrictEqual(createdAt.toISOString())
expect(result.createdAt).toStrictEqual(createdAt)
expect(doc.createdAt).toStrictEqual(createdAt)
})
it('updatedAt cannot be set in create', async () => {
@@ -461,4 +460,24 @@ describe('database', () => {
})
})
})
describe('defaultValue', () => {
it('should set default value from db.create', async () => {
// call the db adapter create directly to bypass Payload's default value assignment
const result = await payload.db.create({
collection: 'default-values',
data: {
// for drizzle DBs, we need to pass an array of objects to test subfields
array: [{ id: 1 }],
title: 'hello',
},
req: undefined,
})
expect(result.defaultValue).toStrictEqual('default value from database')
expect(result.array[0].defaultValue).toStrictEqual('default value from database')
expect(result.group.defaultValue).toStrictEqual('default value from database')
expect(result.select).toStrictEqual('default')
})
})
})

View File

@@ -76,17 +76,26 @@ describe('fields', () => {
// TODO - This test is flaky. Rarely, but sometimes it randomly fails.
test('should display unique constraint error in ui', async () => {
const uniqueText = 'uniqueText'
await payload.create({
const doc = await payload.create({
collection: 'indexed-fields',
data: {
group: {
unique: uniqueText,
},
localizedUniqueRequiredText: 'text',
text: 'text',
uniqueRequiredText: 'text',
uniqueText,
},
})
await payload.update({
id: doc.id,
collection: 'indexed-fields',
data: {
localizedUniqueRequiredText: 'es text',
},
locale: 'es',
})
await page.goto(url.create)
await page.waitForURL(url.create)

View File

@@ -700,10 +700,19 @@ describe('Fields', () => {
uniqueRequiredText: 'a',
// uniqueText omitted on purpose
}
await payload.create({
const doc = await payload.create({
collection: 'indexed-fields',
data,
})
// Update spanish so we do not run into the unique constraint for other locales
await payload.update({
id: doc.id,
collection: 'indexed-fields',
data: {
localizedUniqueRequiredText: 'es1',
},
locale: 'es',
})
data.uniqueRequiredText = 'b'
const result = await payload.create({
collection: 'indexed-fields',

View File

@@ -137,7 +137,7 @@ const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } =
linkResult.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
defaultValue: appearanceOptionsToUse[0].value,
options: appearanceOptionsToUse,
admin: {
description: 'Choose how the link should be rendered.',