Support _contains operation for CSV type (#22002)

Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
This commit is contained in:
Daniel Biegler
2024-04-01 17:17:24 +02:00
committed by GitHub
parent 91e17f9252
commit cf70e1a47a
4 changed files with 153 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
---
'@directus/utils': patch
---
Fixed "contains" operators (used for validation / conditions) to work with arrays and "icontains" to respect case insensitivity

View File

@@ -219,78 +219,6 @@ describe(`generateJoi`, () => {
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _ncontains contain match`, () => {
const mockFieldFilter = { field: { _ncontains: 'field' } } as FieldFilter;
const mockSchema = Joi.object({
field: (Joi.string() as StringSchema).ncontains('field'),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _ncontains with null value`, () => {
const mockFieldFilter = { field: { _ncontains: null } } as FieldFilter;
const mockSchema = Joi.object({
field: Joi.any().equal(true),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _contains contain match`, () => {
const mockFieldFilter = { field: { _contains: 'field' } } as FieldFilter;
const mockSchema = Joi.object({
field: (Joi.string() as StringSchema).contains('field'),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _contains with null value`, () => {
const mockFieldFilter = { field: { _contains: null } } as FieldFilter;
const mockSchema = Joi.object({
field: Joi.any().equal(true),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _icontains contain match`, () => {
const mockFieldFilter = { field: { _icontains: 'field' } } as FieldFilter;
const mockSchema = Joi.object({
field: (Joi.string() as StringSchema).contains('field'),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns the correct schema for an _icontains with null value`, () => {
const mockFieldFilter = { field: { _icontains: null } } as FieldFilter;
const mockSchema = Joi.object({
field: Joi.any().equal(true),
})
.unknown()
.describe();
expect(generateJoi(mockFieldFilter).describe()).toStrictEqual(mockSchema);
});
it(`returns a value if the substring is included in the value`, () => {
expect(() => {
Joi.assert('testfield', (Joi.string() as StringSchema).contains('field'));

View File

@@ -5,6 +5,7 @@ import { escapeRegExp, merge } from 'lodash-es';
export interface StringSchema extends BaseStringSchema {
contains(substring: string): this;
icontains(substring: string): this;
ncontains(substring: string): this;
}
@@ -13,6 +14,7 @@ export const Joi: typeof BaseJoi = BaseJoi.extend({
base: BaseJoi.string(),
messages: {
'string.contains': '{{#label}} must contain [{{#substring}}]',
'string.icontains': '{{#label}} must contain case insensitive [{{#substring}}]',
'string.ncontains': "{{#label}} can't contain [{{#substring}}]",
},
rules: {
@@ -36,6 +38,26 @@ export const Joi: typeof BaseJoi = BaseJoi.extend({
return value;
},
},
icontains: {
args: [
{
name: 'substring',
ref: true,
assert: (val) => typeof val === 'string',
message: 'must be a string',
},
],
method(substring) {
return this.$_addRule({ name: 'icontains', args: { substring } });
},
validate(value: string, helpers, { substring }) {
if (value.toLowerCase().includes(substring.toLowerCase()) === false) {
return helpers.error('string.icontains', { substring });
}
return value;
},
},
ncontains: {
args: [
{
@@ -135,7 +157,10 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A
if (compareValue === null || compareValue === undefined || typeof compareValue !== 'string') {
schema[key] = Joi.any().equal(true);
} else {
schema[key] = getStringSchema().contains(compareValue);
schema[key] = Joi.alternatives().try(
getStringSchema().contains(compareValue),
Joi.array().items(getStringSchema().contains(compareValue).required(), Joi.any()),
);
}
}
@@ -143,7 +168,10 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A
if (compareValue === null || compareValue === undefined || typeof compareValue !== 'string') {
schema[key] = Joi.any().equal(true);
} else {
schema[key] = getStringSchema().contains(compareValue);
schema[key] = Joi.alternatives().try(
getStringSchema().icontains(compareValue),
Joi.array().items(getStringSchema().icontains(compareValue).required(), Joi.any()),
);
}
}
@@ -151,7 +179,10 @@ export function generateJoi(filter: FieldFilter | null, options?: JoiOptions): A
if (compareValue === null || compareValue === undefined || typeof compareValue !== 'string') {
schema[key] = Joi.any().equal(true);
} else {
schema[key] = getStringSchema().ncontains(compareValue);
schema[key] = Joi.alternatives().try(
getStringSchema().ncontains(compareValue),
Joi.array().items(getStringSchema().contains(compareValue).forbidden()),
);
}
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { Filter } from '@directus/types';
import { describe, expect, it, test } from 'vitest';
import { validatePayload } from './validate-payload.js';
describe('validatePayload', () => {
@@ -87,4 +87,117 @@ describe('validatePayload', () => {
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toBe(`"field" is required`);
});
describe('validates operator: _contains', () => {
const mockFilter = {
_and: [
{
value: {
_contains: 'MATCH-EXACT',
},
},
],
};
const options = { requireAll: true };
test('string values', () => {
expect(validatePayload(mockFilter, { value: 'MATCH-EXACT' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'substring-MATCH-EXACT' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'match-exact' }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: 'mismatch' }, options)).toHaveLength(1);
});
test('array values', () => {
expect(validatePayload(mockFilter, { value: [123, 'MATCH-EXACT'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: [123, 'match-exact'] }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: [] }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: ['mismatch'] }, options)).toHaveLength(1);
});
test('other values', () => {
expect(validatePayload(mockFilter, { value: null }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: undefined }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: 123 }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: {} }, options)).toHaveLength(1);
});
});
describe('validates operator: _icontains', () => {
const mockFilter = {
_and: [
{
value: {
_icontains: 'match-insensitive',
},
},
],
};
const options = { requireAll: true };
test('string values', () => {
expect(validatePayload(mockFilter, { value: 'MATCH-insensitive' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'match-insensitive' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'substring-match-insensitive' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'mismatch' }, options)).toHaveLength(1);
});
test('array values', () => {
expect(validatePayload(mockFilter, { value: [123, 'match-insensitive'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: [123, 'MATCH-insensitive'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: [123, 'substring-MATCH-insensitive'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: [] }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: ['mismatch'] }, options)).toHaveLength(1);
});
test('other values', () => {
expect(validatePayload(mockFilter, { value: null }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: undefined }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: 123 }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: {} }, options)).toHaveLength(1);
});
});
describe('validates operator: _ncontains', () => {
const mockFilter = {
_and: [
{
value: {
_ncontains: 'match',
},
},
],
};
const options = { requireAll: true };
test('string values', () => {
expect(validatePayload(mockFilter, { value: 'foo' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'MATCH' }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: 'match' }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: 'substring-match' }, options)).toHaveLength(1);
});
test('array values', () => {
expect(validatePayload(mockFilter, { value: [] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: ['foo'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: ['MATCH'] }, options)).toHaveLength(0);
expect(validatePayload(mockFilter, { value: ['foo', 'match'] }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: ['substring-match'] }, options)).toHaveLength(1);
});
test('other values', () => {
expect(validatePayload(mockFilter, { value: null }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: undefined }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: 123 }, options)).toHaveLength(1);
expect(validatePayload(mockFilter, { value: {} }, options)).toHaveLength(1);
});
});
});