Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0a96649
feat: add Json field type and implementation
pahaz Jun 29, 2026
6ebc1a1
test(@open-keystone/fields): Json clean logic
pahaz Jun 29, 2026
0174c07
refactor(@open-keystone/fields): copy base Json logic with tests
pahaz Jun 30, 2026
0667f7e
test(@open-keystone/fields): add tricky Json values cases
pahaz Jun 30, 2026
2bee610
feat(@open-keystone/fields): add filters with tests
pahaz Jun 30, 2026
b03a40b
chore(@open-keystone/fields): add some fixes
pahaz Jun 30, 2026
41cfa60
chore(@open-keystone/fields): knex working
pahaz Jun 30, 2026
e10255d
chore(@open-keystone/fields): knex working
pahaz Jun 30, 2026
70a904c
chore(@open-keystone/fields): knex working
pahaz Jun 30, 2026
38defa1
fix(@open-keystone/fields): all test cases
pahaz Jun 30, 2026
de0b3a4
chore: yarn format
pahaz Jun 30, 2026
d312be5
chore: yarn format
pahaz Jul 1, 2026
8f1a25a
chore: yarn format
pahaz Jul 1, 2026
3e76cef
chore: use existing types
pahaz Jul 1, 2026
eaba8df
docs: init json.md spec
pahaz Jul 1, 2026
0320fb5
fix: mock iframe.ly for tests
pahaz Jul 1, 2026
6c53242
docs: init json-cases.md spec
pahaz Jul 1, 2026
2a6b594
docs: clarify spec
pahaz Jul 1, 2026
e65ff31
fix: base json tests should follow the spec
pahaz Jul 1, 2026
1a7ffb4
fix: add prisma extra null types
pahaz Jul 1, 2026
32e6662
feat: new base implementation for Json
pahaz Jul 1, 2026
385fa2d
refactor: improve readability of JSON field
pahaz Jul 1, 2026
8790aeb
refactor: improve readability of JSON field
pahaz Jul 1, 2026
cd6d4c6
refactor: mongo workarounds
pahaz Jul 1, 2026
19e16e1
refactor: knex working
pahaz Jul 1, 2026
c2882c5
refactor: prisma all simple cases done
pahaz Jul 1, 2026
3f0d07f
test: add negative tests for Json
pahaz Jul 1, 2026
4c1d8e6
test: add negative tests for Json
pahaz Jul 1, 2026
948f1ff
chore: yarn format
pahaz Jul 1, 2026
5dba9a6
fix: knex case for root string operators
pahaz Jul 1, 2026
8d0aaea
chore: readability
pahaz Jul 1, 2026
639ddd7
fix: mongo query parser and some sanitization improvements
pahaz Jul 1, 2026
4c963f4
test: add optional root filter tests for Json field
pahaz Jul 1, 2026
83049ff
docs: remove JSON test cases doc
pahaz Jul 1, 2026
b86ba58
fix: json tests
pahaz Jul 1, 2026
93f1030
fix: backward compatibility with legacy JSON field
pahaz Jul 2, 2026
a53cc30
fix: check backward compatibility with legacy field
pahaz Jul 2, 2026
1fdd188
fix: like injections
pahaz Jul 2, 2026
fe19fb2
fix: better JSON filter validation
pahaz Jul 2, 2026
ff1cf98
Update packages/adapter-knex/lib/adapter-knex.js
pahaz Jul 2, 2026
b497f7e
Merge remote-tracking branch 'origin/json-field' into json-field
pahaz Jul 2, 2026
f355a2b
chore: yarn lint:eslint
pahaz Jul 2, 2026
cd794a2
chore: extract escapeRegExp tests
pahaz Jul 3, 2026
e47f252
fix: use mongo strict mode
pahaz Jul 3, 2026
92c9c8b
refactor: deep refactoring
pahaz Jul 4, 2026
eb77d3c
chore: reuse consts
pahaz Jul 4, 2026
61b0e8e
refactor: remove check-infrastructure.js
pahaz Jul 4, 2026
a346029
refactor: Json field should follow KS in not_in spec
pahaz Jul 4, 2026
dd1c85f
fix: Mongo query relationship tokenizer fallback
pahaz Jul 4, 2026
950014a
chore: yarn format
pahaz Jul 4, 2026
d81b684
chore: clean up
pahaz Jul 4, 2026
68a05ba
feat: add Json field enableMatchFilter allowedMatchFilterPaths strict…
pahaz Jul 4, 2026
ab8769c
chore: rm useless import
pahaz Jul 4, 2026
dacd751
refactor: simplify access to Json field configuration options
pahaz Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,766 changes: 1,766 additions & 0 deletions docs/specs/json.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions packages/adapter-knex/lib/adapter-knex.js
Original file line number Diff line number Diff line change
Expand Up @@ -821,12 +821,16 @@ class QueryBuilder {
whereJoiner(q => {
// AND/OR need to traverse both side of the query
let subJoiner;
if (path == 'AND') {
if (path === 'AND') {
q.whereRaw('true');
subJoiner = w => q.andWhere(w);
} else {
} else if (path === 'OR') {
q.whereRaw('false');
subJoiner = w => q.orWhere(w);
} else {
throw new Error(
'Unknown add wheres condition. If you are trying to implement NOT, you are in the right place'
);
}
where[path].forEach(subWhere =>
this._addWheres(subJoiner, listAdapter, subWhere, tableAlias)
Expand Down
74 changes: 37 additions & 37 deletions packages/adapter-mongoose/lib/query-parser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
const { getType, flatten } = require('@open-keystone/utils');

const { simpleTokenizer, relationshipTokenizer, modifierTokenizer } = require('./tokenizers');
const {
simpleTokenizer,
relationshipTokenizer,
modifierTokenizer,
getRelatedListAdapterFromQueryPath,
} = require('./tokenizers');

// If it's 0 or 1 items, we can use it as-is. Any more needs an $and/$or
const joinTerms = (matchTerms, joinOp) =>
Expand Down Expand Up @@ -43,44 +48,39 @@ function queryParser({ listAdapter, getUID }, query, pathSoFar = [], include) {
}
} else if (getType(value) === 'Object') {
// A relationship query component
const { matchTerm, relationshipInfo } = relationshipTokenizer(listAdapter, key, path, getUID);
let currentListAdapter;
try {
currentListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
} catch (e) {
// If we can't find the list adapter, it's definitely not a relationship
return { matchTerm: simpleTokenizer(listAdapter, query, key, path, null) };
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const fieldAdapter = currentListAdapter.fieldAdapters.find(
({ path, isRelationship }) =>
isRelationship && [path, `${path}_every`, `${path}_some`, `${path}_none`].includes(key)
);

// FIXME: This code introduced regressions. We need to add test coverage
// to unique exercise this code and verify its behaviour.
// // Greatly improve query using indexes
// if (
// Object.keys(value).length === 1 &&
// (value.id || value.id_not || value.id_in || value.id_not_in)
// ) {
// const { ObjectId } = listAdapter.mongoose.Types;
// const fieldParser = key.split(/(\_some|\_every|\_none)+$/gm);
// const _path = fieldParser[0];
// const filterType = fieldParser.length > 1 ? fieldParser[1] : '_only';
// const queryCondition = {};
// if (value.id) {
// if (['_only', '_some', '_every'].includes(filterType)) {
// queryCondition[_path] = { $eq: ObjectId(value.id) };
// } else if (filterType === '_none') {
// queryCondition[_path] = { $ne: ObjectId(value.id) };
// }
// } else if (value.id_not && ['_only', '_some', '_every', '_none'].includes(filterType)) {
// queryCondition[_path] = { $ne: ObjectId(value.id_not) };
// } else if (value.id_in && ['_only', '_some'].includes(filterType)) {
// queryCondition[_path] = { $in: value.id_in.map(el => ObjectId(el)) };
// } else if (value.id_not_in && ['_only', '_every'].includes(filterType)) {
// queryCondition[_path] = { $not: { $in: value.id_not_in.map(el => ObjectId(el)) } };
// }
// if (Object.keys(queryCondition).length > 0) {
// return { matchTerm: queryCondition };
// }
// }
if (fieldAdapter) {
const { matchTerm, relationshipInfo } = relationshipTokenizer(
listAdapter,
key,
path,
getUID,
currentListAdapter
);

return {
// matchTerm is our filtering expression. This determines if the
// parent item is included in the final list
matchTerm,
relationships: [{ relationshipInfo, ...queryParser({ listAdapter, getUID }, value, path) }],
};
return {
// matchTerm is our filtering expression. This determines if the
// parent item is included in the final list
matchTerm,
relationships: [
{ relationshipInfo, ...queryParser({ listAdapter, getUID }, value, path) },
],
};
} else {
return { matchTerm: simpleTokenizer(listAdapter, query, key, path, currentListAdapter) };
}
} else {
// A simple field query component
return { matchTerm: simpleTokenizer(listAdapter, query, key, path) };
Expand Down
21 changes: 15 additions & 6 deletions packages/adapter-mongoose/lib/tokenizers.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ const getRelatedListAdapterFromQueryPath = (listAdapter, queryPath) => {
return foundListAdapter;
};

const relationshipTokenizer = (listAdapter, queryKey, path, getUID = cuid) => {
const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
const relationshipTokenizer = (listAdapter, queryKey, path, getUID = cuid, refListAdapter) => {
if (refListAdapter === undefined) {
refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
}
if (!refListAdapter) return {};
const fieldAdapter = refListAdapter.fieldAdapters
.filter(adapter => adapter.isRelationship)
.find(({ path }) => [path, `${path}_every`, `${path}_some`, `${path}_none`].includes(queryKey));
Expand Down Expand Up @@ -100,8 +103,11 @@ const relationshipTokenizer = (listAdapter, queryKey, path, getUID = cuid) => {
};
};

const simpleTokenizer = (listAdapter, query, queryKey, path) => {
const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
const simpleTokenizer = (listAdapter, query, queryKey, path, refListAdapter) => {
if (refListAdapter === undefined) {
refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
}
if (!refListAdapter) return;
const simpleQueryConditions = objMerge(
refListAdapter.fieldAdapters.map(a => a.getQueryConditions(a.dbPath))
);
Expand All @@ -110,8 +116,11 @@ const simpleTokenizer = (listAdapter, query, queryKey, path) => {
}
};

const modifierTokenizer = (listAdapter, query, queryKey, path) => {
const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
const modifierTokenizer = (listAdapter, query, queryKey, path, refListAdapter) => {
if (refListAdapter === undefined) {
refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
}
if (!refListAdapter) return;
const searchFieldName = listAdapter.config.searchField || 'name';
return {
// TODO: Implement configurable search fields for lists
Expand Down
16 changes: 15 additions & 1 deletion packages/adapter-mongoose/tests/query-parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ describe('query parser', () => {
);

expect(queryTree).toMatchObject({
relationships: {},
relationships: [],
matchTerm: {
$and: [
{ $or: [{ name: { $eq: 'foobar' } }, { age: { $eq: 23 } }] },
Expand All @@ -548,5 +548,19 @@ describe('query parser', () => {
},
});
});

test('falls back to simpleTokenizer when path resolution fails in relationship query', () => {
const query = {
a: { b: 1 },
};

// We expect this NOT to throw, but to fall back to simpleTokenizer
// which returns undefined since 'a' is not a field in User list
const queryTree = queryParser({ listAdapter, getUID: k => k }, query, ['broken']);
expect(queryTree).toMatchObject({
relationships: [],
matchTerm: undefined,
});
});
});
});
65 changes: 37 additions & 28 deletions packages/adapter-prisma/lib/adapter-prisma.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ class PrismaAdapter extends BaseKeystoneAdapter {
datasources: { [this.provider]: { url: this._url() } },
});
this.prisma.DbNull = Prisma.DbNull;
this.prisma.JsonNull = Prisma.JsonNull;
this.prisma.AnyNull = Prisma.AnyNull;
await this.prisma.$connect();
}

Expand Down Expand Up @@ -506,36 +508,43 @@ class PrismaListAdapter extends BaseListAdapter {
this.getListAdapterByKey(this.fieldAdaptersByPath[fieldPath].refListKey).processWheres(
clause
);
const wheres = Object.entries(where).map(([condition, value]) => {
if (condition === 'AND' || condition === 'OR') {
return { [condition]: value.map(w => this.processWheres(w)) };
} else if (
this.fieldAdaptersByPath[condition] &&
this.fieldAdaptersByPath[condition].isRelationship
) {
// Non-many relationship. Traverse the sub-query, using the referenced list as a root.
return { [condition]: processRelClause(condition, value) };
} else {
// See if any of our fields know what to do with this condition
let dbPath = condition;
let fieldAdapter = this.fieldAdaptersByPath[dbPath];
while (!fieldAdapter && dbPath.includes('_')) {
dbPath = dbPath.split('_').slice(0, -1).join('_');
fieldAdapter = this.fieldAdaptersByPath[dbPath];
}

// FIXME: ask the field adapter if it supports the condition type
const supported =
fieldAdapter && fieldAdapter.getQueryConditions(fieldAdapter.dbPath)[condition];
if (supported) {
return supported(value);
const wheres = Object.entries(where)
.map(([condition, value]) => {
if (condition === 'AND' || condition === 'OR') {
const processed = value.map(w => this.processWheres(w)).filter(w => w !== undefined);
return processed.length > 0 ? { [condition]: processed } : undefined;
} else if (
this.fieldAdaptersByPath[condition] &&
this.fieldAdaptersByPath[condition].isRelationship
) {
// Non-many relationship. Traverse the sub-query, using the referenced list as a root.
const processed = processRelClause(condition, value);
return processed !== undefined ? { [condition]: processed } : undefined;
} else {
// Many relationship
const [fieldPath, constraintType] = condition.split('_');
return { [fieldPath]: { [constraintType]: processRelClause(fieldPath, value) } };
// See if any of our fields know what to do with this condition
let dbPath = condition;
let fieldAdapter = this.fieldAdaptersByPath[dbPath];
while (!fieldAdapter && dbPath.includes('_')) {
dbPath = dbPath.split('_').slice(0, -1).join('_');
fieldAdapter = this.fieldAdaptersByPath[dbPath];
}

// FIXME: ask the field adapter if it supports the condition type
const supported =
fieldAdapter && fieldAdapter.getQueryConditions(fieldAdapter.dbPath)[condition];
if (supported) {
return supported(value);
} else {
// Many relationship
const [fieldPath, constraintType] = condition.split('_');
const processed = processRelClause(fieldPath, value);
return processed !== undefined
? { [fieldPath]: { [constraintType]: processed } }
: undefined;
}
}
}
});
})
.filter(w => w !== undefined);

return wheres.length === 0 ? undefined : wheres.length === 1 ? wheres[0] : { AND: wheres };
}
Expand Down
38 changes: 36 additions & 2 deletions packages/fields-oembed/src/test-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const { Text } = require('@open-keystone/fields');
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });

import { OEmbed, IframelyOEmbedAdapter } from './';
import { OEmbed } from './';
Comment thread
pahaz marked this conversation as resolved.

export const name = 'OEmbed';
export const type = OEmbed;
Expand All @@ -12,7 +12,41 @@ export const supportsUnique = false;
export const fieldName = 'portfolio';
export const subfieldName = 'originalUrl';

const iframelyAdapter = new IframelyOEmbedAdapter({
class MockOEmbedAdapter {
constructor({ apiKey, parameters } = {}) {
this.apiKey = apiKey;
this.parameters = parameters;
}
fetch(parameters = {}) {
return Promise.resolve({
type: 'link',
version: '1.0',
title: 'Mock Title',
author_name: 'Mock Author',
author_url: 'https://mock.com/author',
provider_name: 'Mock Provider',
provider_url: 'https://mock.com/provider',
cache_age: 3600,
thumbnail_url: 'https://mock.com/thumbnail.png',
thumbnail_width: 100,
thumbnail_height: 100,
url: parameters.url,
html: '<div>Mock HTML</div>',
width: 800,
height: 600,
});
}
getAdminViews() {
return [];
}
getViewOptions() {
return {
clientApiKey: this.apiKey,
};
}
}

const iframelyAdapter = new MockOEmbedAdapter({
apiKey: process.env.IFRAMELY_API_KEY || 'iframely_api_key',
});

Expand Down
4 changes: 4 additions & 0 deletions packages/fields/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
"inflection": "^1.13.1",
"intersection-observer": "0.12.2",
"lodash.groupby": "^4.6.0",
"lodash.isarray": "^4.0.0",
"lodash.isequal": "^4.5.0",
"lodash.isfunction": "^3.0.9",
"lodash.isobject": "^3.0.2",
"lodash.omit": "^4.18.0",
Comment thread
pahaz marked this conversation as resolved.
"luxon": "^1.27.0",
"mongoose": "^8.16.0",
"p-settle": "^4.1.1",
Expand Down
1 change: 1 addition & 0 deletions packages/fields/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { default as Decimal } from './types/Decimal';
export { default as File } from './types/File';
export { default as Float } from './types/Float';
export { default as Integer } from './types/Integer';
export { default as Json } from './types/Json';
export { default as Password } from './types/Password';
export { default as Relationship } from './types/Relationship';
export { default as Select } from './types/Select';
Expand Down
Loading
Loading