-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(db-postgres): optionally allow to store blocks as JSON and add support for "in" operator within richText #6245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…t/postgres-blocks-optional-json
This reverts commit e16cdb1.
This PR is stale due to lack of activity. To keep the PR open, please indicate that it is still relevant in a comment below. |
When can we expect this PR? This feature is really useful when there are around 50 blocks in one collection. Right now, I'm using a workaround that works great, but I'd love to have native support. |
Hi @maximseshuk, would you mind sharing what your workaround is? I'm running into issues with large numbers of blocks in a collection. |
Sure. This workaround creates a dual-field system where the original field becomes virtual (exists only in the admin interface) while a hidden JSON field handles the actual database storage. The main field displays normally in the admin but doesn't get stored directly in the database. import type {
Block,
CollapsibleField,
CollectionAfterReadHook,
CollectionBeforeChangeHook,
CollectionBeforeReadHook,
Field,
GlobalAfterReadHook,
GlobalBeforeChangeHook,
GlobalBeforeReadHook,
RowField,
TabsField,
UIField,
} from 'payload'
import toSnakeCase from 'to-snake-case'
const convertToGeoJSON = (coordinates: any[]): any => {
if (Array.isArray(coordinates) && coordinates.length === 2 &&
typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
return { type: 'Point', coordinates }
}
return coordinates
}
const findBlocksByType = (obj: any, type: string): any[] => {
if (!obj) return []
if (Array.isArray(obj)) {
return obj.filter(item =>
item && typeof item === 'object' &&
(item.blockType === type || item.type === type))
}
if (obj.blockType === type || obj.type === type) {
return [obj]
}
return []
}
const findPointFields = (fieldConfig: any, path: string[] = []): string[][] => {
let paths: string[][] = []
if (fieldConfig.type === 'point') {
paths.push([...path, fieldConfig.name])
}
if (fieldConfig.fields && Array.isArray(fieldConfig.fields)) {
fieldConfig.fields.forEach((field: any) => {
if (field.name) {
const nestedPaths = findPointFields(field, [...path, field.name])
paths = [...paths, ...nestedPaths]
}
})
}
if (fieldConfig.blocks && Array.isArray(fieldConfig.blocks)) {
fieldConfig.blocks.forEach((block: any) => {
if (block.fields && Array.isArray(block.fields)) {
block.fields.forEach((blockField: any) => {
if (blockField.name) {
const blockPaths = findPointFields(blockField, [...path, block.slug, blockField.name])
paths = [...paths, ...blockPaths]
}
})
}
})
}
return paths
}
const processFieldConfig = (
fieldConfig: Field | Block,
mainFieldName: string,
jsonFieldName: string,
): any => {
if ('type' in fieldConfig && fieldConfig.type === 'point') {
return {
...fieldConfig,
hooks: {
...fieldConfig.hooks,
afterRead: [
...(fieldConfig.hooks?.afterRead || []),
({
originalDoc,
path,
value,
}: {
originalDoc: Record<string, any>
path: (string | number)[]
value: any
}) => {
if (!originalDoc) return value
const jsonPath = [...path]
const fieldIndex = jsonPath.findIndex(segment => segment === mainFieldName)
if (fieldIndex !== -1) jsonPath[fieldIndex] = jsonFieldName
let jsonValue = originalDoc
for (const key of jsonPath) {
if (!jsonValue) return value
jsonValue = jsonValue[key]
}
return jsonValue !== undefined ? jsonValue : value
},
],
},
}
}
if ('fields' in fieldConfig) {
return {
...fieldConfig,
fields: fieldConfig.fields.map(field => processFieldConfig(field, mainFieldName, jsonFieldName)),
}
}
if ('blocks' in fieldConfig) {
return {
...fieldConfig,
blocks: fieldConfig.blocks.map(block => processFieldConfig(block, mainFieldName, jsonFieldName)),
}
}
return fieldConfig
}
const transformPointsToGeoJSON = (data: any, pointPaths: string[][]): any => {
if (!data) return data
const result = JSON.parse(JSON.stringify(data))
const simplifiedPaths = pointPaths.map(path => {
const simplified = []
for (let i = 0; i < path.length; i++) {
if (i === 0 || path[i] !== path[i - 1]) {
simplified.push(path[i])
}
}
return simplified
})
simplifiedPaths.forEach(pathTemplate => {
if (pathTemplate.length < 2) return
const blockType = pathTemplate[0]
let blocks = findBlocksByType(result, blockType)
if (blocks.length === 0 && Array.isArray(result)) {
result.forEach(item => {
blocks = blocks.concat(findBlocksByType(item, blockType))
})
}
blocks.forEach(block => {
let currentObj = block
let pathIndex = 1
while (pathIndex < pathTemplate.length - 1 && currentObj) {
const currentKey = pathTemplate[pathIndex]
if (!currentObj[currentKey]) break
if (Array.isArray(currentObj[currentKey])) {
currentObj[currentKey].forEach((item: any) => {
let subCurrent = item
let subIndex = pathIndex + 1
while (subIndex < pathTemplate.length - 1 && subCurrent) {
const subKey = pathTemplate[subIndex]
if (!subCurrent[subKey]) break
subCurrent = subCurrent[subKey]
subIndex++
}
const coordField = pathTemplate[pathTemplate.length - 1]
if (subCurrent && Array.isArray(subCurrent[coordField])) {
subCurrent[coordField] = convertToGeoJSON(subCurrent[coordField])
}
})
break
} else {
currentObj = currentObj[currentKey]
}
pathIndex++
}
const coordField = pathTemplate[pathTemplate.length - 1]
if (currentObj && Array.isArray(currentObj[coordField])) {
currentObj[coordField] = convertToGeoJSON(currentObj[coordField])
}
})
})
return result
}
export const vjsonField = <T extends 'global' | 'collection' = 'collection'>(
field: Exclude<Field, CollapsibleField | RowField | TabsField | UIField>,
jsonFieldName?: string,
): {
fields: Field[]
hooks: {
afterRead: T extends 'global' ? GlobalAfterReadHook : CollectionAfterReadHook
beforeChange: T extends 'global' ? GlobalBeforeChangeHook : CollectionBeforeChangeHook
beforeRead: T extends 'global' ? GlobalBeforeReadHook : CollectionBeforeReadHook
}
} => {
if (!('name' in field)) {
throw new Error('Field must have a name')
}
jsonFieldName = jsonFieldName || `_${toSnakeCase(field.name)}`
const pointPaths = findPointFields(field)
const fields: Field[] = [
{
...processFieldConfig(field, field.name, jsonFieldName),
required: 'required' in field ? field.required : false,
virtual: true,
},
{
name: jsonFieldName,
type: 'json',
access: field.access,
admin: { hidden: true, readOnly: true },
hidden: true,
localized: field.localized,
},
]
return {
fields,
hooks: {
afterRead: async ({
doc,
req,
}: Parameters<CollectionAfterReadHook>[0] | Parameters<GlobalAfterReadHook>[0]) => {
if (req.context[field.name]) doc[field.name] = req.context[field.name]
delete doc[jsonFieldName]
return doc
},
beforeChange: async ({
data,
req,
}: Parameters<CollectionBeforeChangeHook>[0] | Parameters<GlobalBeforeChangeHook>[0]) => {
if (
data[field.name] && (
Array.isArray(data[field.name]) && data[field.name].length > 0 ||
(typeof req.data === 'object' && field.name in req.data)
)
) {
const jsonData = data[field.name]
const transformedJsonData = transformPointsToGeoJSON(jsonData, pointPaths)
data[jsonFieldName] = transformedJsonData
req.context[field.name] = jsonData
}
return data
},
beforeRead: async ({
doc,
}: Parameters<CollectionBeforeReadHook>[0] | Parameters<GlobalBeforeReadHook>[0]) => {
if (doc[jsonFieldName]) {
doc[field.name] = doc[jsonFieldName]
}
return doc
},
},
}
} Usage example: export const layoutField = vjsonField({
name: 'layout',
type: 'blocks',
access: access.collection.field('collections', 'layout'),
blocks: [
...Object.values(blocks),
],
label: false,
localized: true,
required: true,
}) |
@maximseshuk this is impressive! Thanks so much for sharing. I'll try this out on my end and see how it goes. Edit: unfortunately after trying a number of different things I could only get it working in certain scenarios. Hooks that called Hopefully this PR can get revisited soon, it solves a very real problem. |
I'm still hesitant to introduce a separate mode for managing schema in our DB adapters because of the complexity of maintaining multiple. Imagine we have to do a breaking change in the future and need to write a migration script for existing projects for example. This could be problematic if we have not only all the different DB adapters to worry about but a multitude of schema shapes to consider. |
@maximseshuk I opened a new PR #12750 and we release a canary version with this change |
Closing this old one. |
Continuation of #6245. This PR allows you to pass `blocksAsJSON: true` to SQL adapters and the adapter instead of aligning with the SQL preferred relation approach for blocks will just use a simple JSON column, which can improve performance with a large amount of blocks. To try these changes you can install `3.43.0-internal.c5bbc84`.
Description
This PR adds option to store blocks as JSON column in Postgres with
storeBlocksAsJSON
property for the adapter.While i like that you guys choose a hard, traditional way in implementing blocks with SQL, i think this may be not for everyone.
I like the strict approach to a schema when we talk about things like Users, Products, Orders etc.
But when it comes to a sites content it's different, not only that we can have too much things here (resulting into long table / constraints names, many joins on
SELECT
), it could also be changed frequently and you need to deal with a big migrations.With Lexical blocks feature - they are stored in the same way, so i guess it's fine to allow that here too?
This PR also adds support for querying with
in
operator onrichText
and as wellblocks
when it's JSON.This was done in order to pass as many tests as i can with
storeBlocksAsJSON
. And here's the result for "fields -> blocks"Yes, not great here with querying on richtext, which is nested to already json blocks.
If this is something that will block this PR to merge, i have an idea on how we can improve overall our JSON queries, it could work for any nested structure. And JSON blocks, blocks in Lexical will work too.
This in theory could be achieved with this query:
Why? we don't know in runtime, is current key object or array, because we don't know the structure when dealing with JSON field type. But with this Postgres JSON query way we can determine that.
In any way whatever separated PR or here i want to try implement this, because for now JSON / RichText querying isn't great in Payload.
p.s wrong base branch caused first commit, i reverted it.
Type of change
Checklist:
storeBlocksAsJSON: true
3 tests are failing.