Skip to content

Commit

Permalink
Add configuration for custom search fields (#4892)
Browse files Browse the repository at this point in the history
  • Loading branch information
timleslie authored Feb 19, 2021
1 parent d53eb87 commit 5d565ea
Show file tree
Hide file tree
Showing 12 changed files with 118 additions and 15 deletions.
7 changes: 7 additions & 0 deletions .changeset/shiny-ways-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@keystonejs/adapter-knex': minor
'@keystonejs/adapter-mongoose': minor
'@keystonejs/adapter-prisma': minor
---

Added support for configuring the field to use for `search` filtering via the `searchField` list adapter config option.
8 changes: 8 additions & 0 deletions .changeset/yellow-hornets-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@keystone-next/website': minor
'@keystone-next/keystone': minor
'@keystone-next/types': minor
'@keystonejs/api-tests': minor
---

Added support for configuring the field to use for `search` filtering via the `db: { searchField }` list config option.
26 changes: 26 additions & 0 deletions docs-next/pages/apis/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default config({
ui: { /* ... */ },
hooks: { /* ... */ },
graphql: { /* ... */ },
db: { /* ... */ },
description: '...',
}),
/* ... */
Expand Down Expand Up @@ -213,6 +214,31 @@ export default config({
});
```

## db

The `db` config option allows you to configures certain aspects of the database connection specific to this list.

Options:

- `searchField` (default: `"name"`): The name of the field to use when performing `search` filters in the GraphQL API.

```typescript
import { config, createSchema, list } from '@keystone-next/keystone/schema';

export default config({
lists: createSchema({
ListName: list({
db: {
searchField: 'email',
},
/* ... */
}),
/* ... */
}),
/* ... */
});
```

## description

The `description` option defines a string which will be used as a description in the Admin UI and GraphQL API docs.
Expand Down
4 changes: 2 additions & 2 deletions packages-next/keystone/src/lib/createKeystone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function createKeystone(config: KeystoneConfig, dotKeystonePath: string,
// appVersion
});

Object.entries(lists).forEach(([key, { fields, graphql, access, hooks, description }]) => {
Object.entries(lists).forEach(([key, { fields, graphql, access, hooks, description, db }]) => {
keystone.createList(key, {
fields: Object.fromEntries(
Object.entries(fields).map(([key, { type, config }]: any) => [key, { type, ...config }])
Expand All @@ -61,6 +61,7 @@ export function createKeystone(config: KeystoneConfig, dotKeystonePath: string,
listQueryName: graphql?.listQueryName,
itemQueryName: graphql?.itemQueryName,
hooks,
adapterConfig: db,
// FIXME: Unsupported options: Need to work which of these we want to support with backwards
// compatibility options.
// adminDoc
Expand All @@ -71,7 +72,6 @@ export function createKeystone(config: KeystoneConfig, dotKeystonePath: string,
// singular
// plural
// path
// adapterConfig
// cacheHint
// plugins
});
Expand Down
1 change: 1 addition & 0 deletions packages-next/types/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type BaseKeystone = {
listQueryName?: string;
itemQueryName?: string;
hooks?: Record<string, any>;
adapterConfig?: { searchField?: string };
}
) => BaseKeystoneList;
connect: (args?: any) => Promise<void>;
Expand Down
10 changes: 10 additions & 0 deletions packages-next/types/src/config/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type ListConfig<

graphql?: ListGraphQLConfig;

db?: ListDBConfig;

// Not currently supported
// plugins?: any[]; // array of plugins that can modify the list config

Expand Down Expand Up @@ -224,4 +226,12 @@ export type ListGraphQLConfig = {
};
};

export type ListDBConfig = {
/**
* The name of the field to use for `search` filter operations.
* @default "name""
*/
searchField?: string;
};

// export type CacheHint = { scope: 'PRIVATE' | 'PUBLIC'; maxAge: number };
5 changes: 3 additions & 2 deletions packages/adapter-knex/lib/adapter-knex.js
Original file line number Diff line number Diff line change
Expand Up @@ -620,11 +620,12 @@ class QueryBuilder {
this._addWheres(w => this._query.andWhere(w), listAdapter, where, baseTableAlias);

// TODO: Implement configurable search fields for lists
const searchField = listAdapter.fieldAdaptersByPath['name'];
const searchFieldName = listAdapter.config.searchField || 'name';
const searchField = listAdapter.fieldAdaptersByPath[searchFieldName];
if (search !== undefined && searchField) {
if (searchField.fieldName === 'Text') {
const f = escapeRegExp;
this._query.andWhere(`${baseTableAlias}.name`, '~*', f(search));
this._query.andWhere(`${baseTableAlias}.${searchFieldName}`, '~*', f(search));
} else {
this._query.whereRaw('false'); // Return no results
}
Expand Down
3 changes: 2 additions & 1 deletion packages/adapter-mongoose/lib/tokenizers.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,14 @@ const simpleTokenizer = (listAdapter, query, queryKey, path) => {

const modifierTokenizer = (listAdapter, query, queryKey, path) => {
const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path);
const searchFieldName = listAdapter.config.searchField || 'name';
return {
// TODO: Implement configurable search fields for lists
$search: value => {
if (!value || (getType(value) === 'String' && !value.trim())) {
return undefined;
}
return { $match: { name: new RegExp(`${escapeRegExp(value)}`, 'i') } };
return { $match: { [searchFieldName]: new RegExp(`${escapeRegExp(value)}`, 'i') } };
},
$orderBy: (value, _, listAdapter) => {
const [orderField, orderDirection] = value.split('_');
Expand Down
8 changes: 4 additions & 4 deletions packages/adapter-mongoose/tests/simple.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ describe('Simple tokenizer', () => {
test('Uses correct conditions', () => {
const simpleConditions = { name: () => ({ foo: 'bar' }) };
const getQueryConditions = jest.fn(() => simpleConditions);
const listAdapter = { fieldAdapters: [{ getQueryConditions }] };
const listAdapter = { fieldAdapters: [{ getQueryConditions }], config: {} };

expect(simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name'])).toMatchObject({
foo: 'bar',
Expand All @@ -15,7 +15,7 @@ describe('Simple tokenizer', () => {
test('Falls back to modifier conditions when no simple condition found', () => {
const simpleConditions = { notinuse: () => ({ foo: 'bar' }) };
const getQueryConditions = jest.fn(() => simpleConditions);
const listAdapter = { fieldAdapters: [{ getQueryConditions }] };
const listAdapter = { fieldAdapters: [{ getQueryConditions }], config: {} };

expect(modifierTokenizer(listAdapter, { $count: 'hi' }, '$count', ['$count'])).toMatchObject({
$count: 'hi',
Expand All @@ -26,7 +26,7 @@ describe('Simple tokenizer', () => {
test('returns empty array when no matches found', () => {
const simpleConditions = { notinuse: () => ({ foo: 'bar' }) };
const getQueryConditions = jest.fn(() => simpleConditions);
const listAdapter = { fieldAdapters: [{ getQueryConditions }] };
const listAdapter = { fieldAdapters: [{ getQueryConditions }], config: {} };

const result = simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name']);
expect(result).toBe(undefined);
Expand All @@ -37,7 +37,7 @@ describe('Simple tokenizer', () => {
const nameConditions = jest.fn(() => ({ foo: 'bar' }));
const simpleConditions = { name: nameConditions };
const getQueryConditions = jest.fn(() => simpleConditions);
const listAdapter = { fieldAdapters: [{ getQueryConditions }] };
const listAdapter = { fieldAdapters: [{ getQueryConditions }], config: {} };

expect(simpleTokenizer(listAdapter, { name: 'hi' }, 'name', ['name'])).toMatchObject({
foo: 'bar',
Expand Down
3 changes: 3 additions & 0 deletions packages/adapter-mongoose/tests/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const tagsAdapter = {
},
],
graphQlQueryPathToMongoField: orderField => orderField,
config: {},
};

const postsAdapter = {
Expand Down Expand Up @@ -42,6 +43,7 @@ const postsAdapter = {
},
],
graphQlQueryPathToMongoField: orderField => orderField,
config: {},
};

const listAdapter = {
Expand Down Expand Up @@ -87,6 +89,7 @@ const listAdapter = {
}),
},
],
config: {},
};

listAdapter.fieldAdapters.push({
Expand Down
23 changes: 17 additions & 6 deletions packages/adapter-prisma/lib/adapter-prisma.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,18 +411,29 @@ class PrismaListAdapter extends BaseListAdapter {
}

// TODO: Implement configurable search fields for lists
const searchField = this.fieldAdaptersByPath['name'];
const searchFieldName = this.config.searchField || 'name';
const searchField = this.fieldAdaptersByPath[searchFieldName];
if (search !== undefined && search !== '' && searchField) {
if (searchField.fieldName === 'Text') {
// FIXME: Think about regex
if (!ret.where) ret.where = { name: { contains: search, mode: 'insensitive' } };
else ret.where = { AND: [ret.where, { name: { contains: search, mode: 'insensitive' } }] };
if (!ret.where) {
ret.where = { [searchFieldName]: { contains: search, mode: 'insensitive' } };
} else {
ret.where = {
AND: [ret.where, { [searchFieldName]: { contains: search, mode: 'insensitive' } }],
};
}
// const f = escapeRegExp;
// this._query.andWhere(`${baseTableAlias}.name`, '~*', f(search));
// this._query.andWhere(`${baseTableAlias}.${searchFieldName}`, '~*', f(search));
} else {
// Return no results
if (!ret.where) ret.where = { AND: [{ name: null }, { NOT: { name: null } }] };
else ret.where = { AND: [ret.where, { name: null }, { NOT: { name: null } }] };
if (!ret.where) {
ret.where = { AND: [{ [searchFieldName]: null }, { NOT: { [searchFieldName]: null } }] };
} else {
ret.where = {
AND: [ret.where, { [searchFieldName]: null }, { NOT: { [searchFieldName]: null } }],
};
}
}
}

Expand Down
35 changes: 35 additions & 0 deletions tests/api-tests/queries/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ function setupKeystone(adapterName) {
name: integer(),
},
}),
Custom: list({
fields: {
other: text(),
},
db: { searchField: 'other' },
}),
},
}),
});
Expand Down Expand Up @@ -226,5 +232,34 @@ multiAdapterRunners().map(({ runner, adapterName }) =>
]); // All results
})
);
test(
'custom',
runner(setupKeystone, async ({ context }) => {
const create = async (listKey, item) => createItem({ context, listKey, item });
await Promise.all([
create('Test', { name: 'one' }),
create('Test', { name: '%islikelike%' }),
create('Test', { name: 'three' }),
create('Number', { name: 12345 }),
create('Custom', { other: 'one' }),
create('Custom', { other: 'two' }),
]);

const { data, errors } = await context.executeGraphQL({
query: `
query {
allCustoms(
search: "one",
) {
other
}
}
`,
});
expect(errors).toBe(undefined);
expect(data).toHaveProperty('allCustoms');
expect(data.allCustoms).toEqual([{ other: 'one' }]);
})
);
})
);

1 comment on commit 5d565ea

@vercel
Copy link

@vercel vercel bot commented on 5d565ea Feb 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.