diff --git a/packages/nocodb/tests/unit/factory/row.ts b/packages/nocodb/tests/unit/factory/row.ts index 78540c943d..4e4a656614 100644 --- a/packages/nocodb/tests/unit/factory/row.ts +++ b/packages/nocodb/tests/unit/factory/row.ts @@ -24,13 +24,174 @@ const rowValue = (column: ColumnType, index: number) => { } }; -const getRow = async (context, {project, table, id}) => { +const rowMixedValue = (column: ColumnType, index: number) => { + // Array of country names + const countries = [ + 'Afghanistan', + 'Albania', + '', + 'Andorra', + 'Angola', + 'Antigua and Barbuda', + 'Argentina', + null, + 'Armenia', + 'Australia', + 'Austria', + '', + null, + ]; + + // Array of sample random paragraphs (comma separated list of cities and countries). Not more than 200 characters + const longText = [ + 'Aberdeen, United Kingdom', + 'Abidjan, Côte d’Ivoire', + 'Abuja, Nigeria', + '', + 'Addis Ababa, Ethiopia', + 'Adelaide, Australia', + 'Ahmedabad, India', + 'Albuquerque, United States', + null, + 'Alexandria, Egypt', + 'Algiers, Algeria', + 'Allahabad, India', + '', + null, + ]; + + // Array of random integers, not more than 10000 + const numbers = [33, null, 456, 333, 267, 34, 8754, 3234, 44, 33, null]; + const decimals = [ + 33.3, + 456.34, + 333.3, + null, + 267.5674, + 34.0, + 8754.0, + 3234.547, + 44.2647, + 33.98, + null, + ]; + const duration = [10, 20, 30, 40, 50, 60, null, 70, 80, 90, null]; + const rating = [0, 1, 2, 3, null, 0, 4, 5, 0, 1, null]; + + // Array of random sample email strings (not more than 100 characters) + const emails = [ + 'jbutt@gmail.com', + 'josephine_darakjy@darakjy.org', + 'art@venere.org', + '', + null, + 'donette.foller@cox.net', + 'simona@morasca.com', + 'mitsue_tollner@yahoo.com', + 'leota@hotmail.com', + 'sage_wieser@cox.net', + '', + null, + ]; + + // Array of random sample phone numbers + const phoneNumbers = [ + '1-541-754-3010', + '504-621-8927', + '810-292-9388', + '856-636-8749', + '907-385-4412', + '513-570-1893', + '419-503-2484', + '773-573-6914', + '', + null, + ]; + + // Array of random sample URLs + const urls = [ + 'https://www.google.com', + 'https://www.facebook.com', + 'https://www.youtube.com', + 'https://www.amazon.com', + 'https://www.wikipedia.org', + 'https://www.twitter.com', + 'https://www.instagram.com', + 'https://www.linkedin.com', + 'https://www.reddit.com', + 'https://www.tiktok.com', + 'https://www.pinterest.com', + 'https://www.netflix.com', + 'https://www.microsoft.com', + 'https://www.apple.com', + '', + null, + ]; + + const singleSelect = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', + null, + ]; + + const multiSelect = [ + 'jan,feb,mar', + 'apr,may,jun', + 'jul,aug,sep', + 'oct,nov,dec', + 'jan,feb,mar', + null, + ]; + + switch (column.uidt) { + case UITypes.Number: + case UITypes.Percent: + return numbers[index % numbers.length]; + case UITypes.Decimal: + case UITypes.Currency: + return decimals[index % decimals.length]; + case UITypes.Duration: + return duration[index % duration.length]; + case UITypes.Rating: + return rating[index % rating.length]; + case UITypes.SingleLineText: + return countries[index % countries.length]; + case UITypes.Email: + return emails[index % emails.length]; + case UITypes.PhoneNumber: + return phoneNumbers[index % phoneNumbers.length]; + case UITypes.LongText: + return longText[index % longText.length]; + case UITypes.Date: + return '2020-01-01'; + case UITypes.URL: + return urls[index % urls.length]; + case UITypes.SingleSelect: + return singleSelect[index % singleSelect.length]; + case UITypes.MultiSelect: + return multiSelect[index % multiSelect.length]; + default: + return `test-${index}`; + } +}; + +const getRow = async (context, { project, table, id }) => { const response = await request(context.app) .get(`/api/v1/db/data/noco/${project.id}/${table.id}/${id}`) .set('xc-auth', context.token); - if(response.status !== 200) { - return undefined + if (response.status !== 200) { + return undefined; } return response.body; @@ -119,18 +280,19 @@ const createBulkRows = async ( { project, table, - values + values, }: { project: Project; table: Model; values: any[]; - }) => { - await request(context.app) - .post(`/api/v1/db/data/bulk/noco/${project.id}/${table.id}`) - .set('xc-auth', context.token) - .send(values) - .expect(200); } +) => { + await request(context.app) + .post(`/api/v1/db/data/bulk/noco/${project.id}/${table.id}`) + .set('xc-auth', context.token) + .send(values) + .expect(200); +}; // Links 2 table rows together. Will create rows if ids are not provided const createChildRow = async ( @@ -174,6 +336,26 @@ const createChildRow = async ( return row; }; +// Mixed row attributes +const generateMixedRowAttributes = ({ + columns, + index = 0, +}: { + columns: ColumnType[]; + index?: number; +}) => + columns.reduce((acc, column) => { + if ( + column.uidt === UITypes.LinkToAnotherRecord || + column.uidt === UITypes.ForeignKey || + column.uidt === UITypes.ID + ) { + return acc; + } + acc[column.title!] = rowMixedValue(column, index); + return acc; + }, {}); + export { createRow, getRow, @@ -181,5 +363,7 @@ export { getOneRow, listRow, generateDefaultRowAttributes, - createBulkRows + generateMixedRowAttributes, + createBulkRows, + rowMixedValue, }; diff --git a/packages/nocodb/tests/unit/rest/index.test.ts b/packages/nocodb/tests/unit/rest/index.test.ts index 8227a1b5e4..e7954e4a35 100644 --- a/packages/nocodb/tests/unit/rest/index.test.ts +++ b/packages/nocodb/tests/unit/rest/index.test.ts @@ -7,6 +7,7 @@ import tableTests from './tests/table.test'; import tableRowTests from './tests/tableRow.test'; import viewRowTests from './tests/viewRow.test'; import attachmentTests from './tests/attachment.test'; +import filterTest from './tests/filter.test'; function restTests() { authTests(); @@ -17,6 +18,7 @@ function restTests() { viewRowTests(); columnTypeSpecificTests(); attachmentTests(); + filterTest(); } export default function () { diff --git a/packages/nocodb/tests/unit/rest/tests/filter.test.ts b/packages/nocodb/tests/unit/rest/tests/filter.test.ts new file mode 100644 index 0000000000..3c83a22d04 --- /dev/null +++ b/packages/nocodb/tests/unit/rest/tests/filter.test.ts @@ -0,0 +1,579 @@ +import 'mocha'; +import init from '../../init'; +import { createProject } from '../../factory/project'; +import Project from '../../../../src/lib/models/Project'; +import { createTable } from '../../factory/table'; +import { UITypes } from 'nocodb-sdk'; +import { createBulkRows, rowMixedValue, listRow } from '../../factory/row'; +import Model from '../../../../src/lib/models/Model'; +import { expect } from 'chai'; +import request from 'supertest'; + +const debugMode = true; + +// Test case list + +async function retrieveRecordsAndValidate( + filter: { + comparison_op: string; + value: string; + fk_column_id: any; + status: string; + logical_op: string; + }, + title: string +) { + let expectedRecords = []; + let toFloat = false; + if ( + ['Number', 'Decimal', 'Currency', 'Percent', 'Duration', 'Rating'].includes( + title + ) + ) { + toFloat = true; + } + + // case for all comparison operators + switch (filter.comparison_op) { + case 'eq': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) === + (toFloat ? parseFloat(filter.value) : filter.value) + ); + break; + case 'neq': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) !== + (toFloat ? parseFloat(filter.value) : filter.value) + ); + break; + case 'null': + expectedRecords = unfilteredRecords.filter( + (record) => record[title] === null + ); + break; + case 'notnull': + expectedRecords = unfilteredRecords.filter( + (record) => record[title] !== null + ); + break; + case 'empty': + expectedRecords = unfilteredRecords.filter( + (record) => record[title] === '' + ); + break; + case 'notempty': + expectedRecords = unfilteredRecords.filter( + (record) => record[title] !== '' + ); + break; + case 'like': + expectedRecords = unfilteredRecords.filter((record) => + record[title]?.includes(filter.value) + ); + break; + case 'nlike': + expectedRecords = unfilteredRecords.filter( + (record) => !record[title]?.includes(filter.value) + ); + break; + case 'gt': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) > + (toFloat ? parseFloat(filter.value) : filter.value) && + record[title] !== null + ); + break; + case 'gte': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) >= + (toFloat ? parseFloat(filter.value) : filter.value) && + record[title] !== null + ); + break; + case 'lt': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) < + (toFloat ? parseFloat(filter.value) : filter.value) && + record[title] !== null + ); + break; + case 'lte': + expectedRecords = unfilteredRecords.filter( + (record) => + (toFloat ? parseFloat(record[title]) : record[title]) <= + (toFloat ? parseFloat(filter.value) : filter.value) && + record[title] !== null + ); + break; + case 'anyof': + expectedRecords = unfilteredRecords.filter((record) => { + const values = filter.value.split(','); + const recordValue = record[title]?.split(','); + return values.some((value) => recordValue?.includes(value)); + }); + break; + case 'nanyof': + expectedRecords = unfilteredRecords.filter((record) => { + const values = filter.value.split(','); + const recordValue = record[title]?.split(','); + return !values.some((value) => recordValue?.includes(value)); + }); + break; + case 'allof': + expectedRecords = unfilteredRecords.filter((record) => { + const values = filter.value.split(','); + return values.every((value) => record[title]?.includes(value)); + }); + break; + case 'nallof': + expectedRecords = unfilteredRecords.filter((record) => { + const values = filter.value.split(','); + return !values.every((value) => record[title]?.includes(value)); + }); + break; + } + + // retrieve filtered records + const response = await request(context.app) + .get(`/api/v1/db/data/noco/${project.id}/${table.id}`) + .set('xc-auth', context.token) + .query({ + filterArrJson: JSON.stringify([filter]), + }) + .expect(200); + + // validate + if (debugMode) { + if (response.body.pageInfo.totalRows !== expectedRecords.length) { + console.log(`Failed for filter: ${JSON.stringify(filter)}`); + console.log(`Expected: ${expectedRecords.length}`); + console.log(`Actual: ${response.body.pageInfo.totalRows}`); + throw new Error('fix me!'); + } + response.body.list.forEach((row, index) => { + if (row[title] !== expectedRecords[index][title]) { + console.log(`Failed for filter: ${JSON.stringify(filter)}`); + console.log(`Expected: ${expectedRecords[index][title]}`); + console.log(`Actual: ${row[title]}`); + throw new Error('fix me!'); + } + }); + } else { + expect(response.body.pageInfo.totalRows).to.equal(expectedRecords.length); + response.body.list.forEach((row, index) => { + expect(row[title] !== expectedRecords[index][title]); + }); + } +} + +let context; +let project: Project; +let table: Model; +let columns: any[]; +let unfilteredRecords: any[] = []; + +async function verifyFilters(dataType, columnId, filterList) { + const filter = { + fk_column_id: columnId, + status: 'create', + logical_op: 'and', + comparison_op: '', + value: '', + }; + + for (let i = 0; i < filterList.length; i++) { + filter.comparison_op = filterList[i].comparison_op; + filter.value = filterList[i].value; + await retrieveRecordsAndValidate(filter, dataType); + } +} + +function filterTextBased() { + // prepare data for test cases + beforeEach(async function () { + context = await init(); + project = await createProject(context); + table = await createTable(context, project, { + table_name: 'textBased', + title: 'TextBased', + columns: [ + { + column_name: 'Id', + title: 'Id', + uidt: UITypes.ID, + }, + { + column_name: 'SingleLineText', + title: 'SingleLineText', + uidt: UITypes.SingleLineText, + }, + { + column_name: 'MultiLineText', + title: 'MultiLineText', + uidt: UITypes.LongText, + }, + { + column_name: 'Email', + title: 'Email', + uidt: UITypes.Email, + }, + { + column_name: 'Phone', + title: 'Phone', + uidt: UITypes.PhoneNumber, + }, + { + column_name: 'Url', + title: 'Url', + uidt: UITypes.URL, + }, + ], + }); + + columns = await table.getColumns(); + + let rowAttributes = []; + for (let i = 0; i < 400; i++) { + let row = { + SingleLineText: rowMixedValue(columns[1], i), + MultiLineText: rowMixedValue(columns[2], i), + Email: rowMixedValue(columns[3], i), + Phone: rowMixedValue(columns[4], i), + Url: rowMixedValue(columns[5], i), + }; + rowAttributes.push(row); + } + + await createBulkRows(context, { + project, + table, + values: rowAttributes, + }); + unfilteredRecords = await listRow({ project, table }); + + // verify length of unfiltered records to be 400 + expect(unfilteredRecords.length).to.equal(400); + }); + + it('Type: Single Line Text', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'Afghanistan' }, + { comparison_op: 'neq', value: 'Afghanistan' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'empty', value: '' }, + // { comparison_op: 'notempty', value: '' }, + { comparison_op: 'like', value: 'Au' }, + { comparison_op: 'nlike', value: 'Au' }, + ]; + await verifyFilters('SingleLineText', columns[1].id, filterList); + }); + + it('Type: Multi Line Text', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'Aberdeen, United Kingdom' }, + { comparison_op: 'neq', value: 'Aberdeen, United Kingdom' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'empty', value: '' }, + // { comparison_op: 'notempty', value: '' }, + { comparison_op: 'like', value: 'abad' }, + { comparison_op: 'nlike', value: 'abad' }, + ]; + await verifyFilters('MultiLineText', columns[2].id, filterList); + }); + + it('Type: Email', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'leota@hotmail.com' }, + { comparison_op: 'neq', value: 'leota@hotmail.com' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'empty', value: '' }, + // { comparison_op: 'notempty', value: '' }, + { comparison_op: 'like', value: 'cox.net' }, + { comparison_op: 'nlike', value: 'cox.net' }, + ]; + await verifyFilters('Email', columns[3].id, filterList); + }); + + it('Type: Phone', async () => { + let filterList = [ + { comparison_op: 'eq', value: '504-621-8927' }, + { comparison_op: 'neq', value: '504-621-8927' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'empty', value: '' }, + // { comparison_op: 'notempty', value: '' }, + { comparison_op: 'like', value: '504' }, + { comparison_op: 'nlike', value: '504' }, + ]; + await verifyFilters('Phone', columns[4].id, filterList); + }); + + it('Type: Url', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'https://www.youtube.com' }, + { comparison_op: 'neq', value: 'https://www.youtube.com' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'empty', value: '' }, + // { comparison_op: 'notempty', value: '' }, + { comparison_op: 'like', value: 'e.com' }, + { comparison_op: 'nlike', value: 'e.com' }, + ]; + await verifyFilters('Url', columns[5].id, filterList); + }); +} + +function filterNumberBased() { + // prepare data for test cases + beforeEach(async function () { + context = await init(); + project = await createProject(context); + table = await createTable(context, project, { + table_name: 'numberBased', + title: 'numberBased', + columns: [ + { + column_name: 'Id', + title: 'Id', + uidt: UITypes.ID, + }, + { + column_name: 'Number', + title: 'Number', + uidt: UITypes.Number, + }, + { + column_name: 'Decimal', + title: 'Decimal', + uidt: UITypes.Decimal, + }, + { + column_name: 'Currency', + title: 'Currency', + uidt: UITypes.Currency, + }, + { + column_name: 'Percent', + title: 'Percent', + uidt: UITypes.Percent, + }, + { + column_name: 'Duration', + title: 'Duration', + uidt: UITypes.Duration, + }, + { + column_name: 'Rating', + title: 'Rating', + uidt: UITypes.Rating, + }, + ], + }); + + columns = await table.getColumns(); + + let rowAttributes = []; + for (let i = 0; i < 400; i++) { + let row = { + Number: rowMixedValue(columns[1], i), + Decimal: rowMixedValue(columns[2], i), + Currency: rowMixedValue(columns[3], i), + Percent: rowMixedValue(columns[4], i), + Duration: rowMixedValue(columns[5], i), + Rating: rowMixedValue(columns[6], i), + }; + rowAttributes.push(row); + } + + await createBulkRows(context, { + project, + table, + values: rowAttributes, + }); + unfilteredRecords = await listRow({ project, table }); + + // verify length of unfiltered records to be 400 + expect(unfilteredRecords.length).to.equal(400); + }); + + it('Type: Number', async () => { + let filterList = [ + { comparison_op: 'eq', value: '33' }, + { comparison_op: 'neq', value: '33' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '44' }, + { comparison_op: 'gte', value: '44' }, + { comparison_op: 'lt', value: '44' }, + { comparison_op: 'lte', value: '44' }, + ]; + await verifyFilters('Number', columns[1].id, filterList); + }); + + it('Type: Decimal', async () => { + let filterList = [ + { comparison_op: 'eq', value: '33.3' }, + { comparison_op: 'neq', value: '33.3' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '44.26' }, + { comparison_op: 'gte', value: '44.26' }, + { comparison_op: 'lt', value: '44.26' }, + { comparison_op: 'lte', value: '44.26' }, + ]; + await verifyFilters('Decimal', columns[2].id, filterList); + }); + + it('Type: Currency', async () => { + let filterList = [ + { comparison_op: 'eq', value: '33.3' }, + { comparison_op: 'neq', value: '33.3' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '44.26' }, + { comparison_op: 'gte', value: '44.26' }, + { comparison_op: 'lt', value: '44.26' }, + { comparison_op: 'lte', value: '44.26' }, + ]; + await verifyFilters('Decimal', columns[3].id, filterList); + }); + + it('Type: Percent', async () => { + let filterList = [ + { comparison_op: 'eq', value: '33' }, + { comparison_op: 'neq', value: '33' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '44' }, + { comparison_op: 'gte', value: '44' }, + { comparison_op: 'lt', value: '44' }, + { comparison_op: 'lte', value: '44' }, + ]; + await verifyFilters('Percent', columns[4].id, filterList); + }); + + it('Type: Duration', async () => { + let filterList = [ + { comparison_op: 'eq', value: '10' }, + { comparison_op: 'neq', value: '10' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '50' }, + { comparison_op: 'gte', value: '50' }, + { comparison_op: 'lt', value: '50' }, + { comparison_op: 'lte', value: '50' }, + ]; + await verifyFilters('Duration', columns[5].id, filterList); + }); + + it('Type: Rating', async () => { + let filterList = [ + { comparison_op: 'eq', value: '3' }, + { comparison_op: 'neq', value: '3' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'gt', value: '2' }, + { comparison_op: 'gte', value: '2' }, + { comparison_op: 'lt', value: '2' }, + { comparison_op: 'lte', value: '2' }, + ]; + await verifyFilters('Rating', columns[6].id, filterList); + }); +} + +function filterSelectBased() { + // prepare data for test cases + beforeEach(async function () { + context = await init(); + project = await createProject(context); + table = await createTable(context, project, { + table_name: 'selectBased', + title: 'selectBased', + columns: [ + { + column_name: 'Id', + title: 'Id', + uidt: UITypes.ID, + }, + { + column_name: 'SingleSelect', + title: 'SingleSelect', + uidt: UITypes.SingleSelect, + dtxp: "'jan','feb','mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'", + }, + { + column_name: 'MultiSelect', + title: 'MultiSelect', + uidt: UITypes.MultiSelect, + dtxp: "'jan','feb','mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'", + }, + ], + }); + + columns = await table.getColumns(); + + let rowAttributes = []; + for (let i = 0; i < 400; i++) { + let row = { + SingleSelect: rowMixedValue(columns[1], i), + MultiSelect: rowMixedValue(columns[2], i), + }; + rowAttributes.push(row); + } + + await createBulkRows(context, { + project, + table, + values: rowAttributes, + }); + unfilteredRecords = await listRow({ project, table }); + + // verify length of unfiltered records to be 400 + expect(unfilteredRecords.length).to.equal(400); + }); + + it('Type: Single select', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'jan' }, + { comparison_op: 'neq', value: 'jan' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'like', value: 'j' }, + { comparison_op: 'nlike', value: 'j' }, + { comparison_op: 'anyof', value: 'jan,feb,mar' }, + { comparison_op: 'nanyof', value: 'jan,feb,mar' }, + ]; + await verifyFilters('SingleSelect', columns[1].id, filterList); + }); + + it('Type: Multi select', async () => { + let filterList = [ + { comparison_op: 'eq', value: 'jan,feb,mar' }, + { comparison_op: 'neq', value: 'jan,feb,mar' }, + { comparison_op: 'null', value: '' }, + { comparison_op: 'notnull', value: '' }, + { comparison_op: 'like', value: 'jan' }, + { comparison_op: 'nlike', value: 'jan' }, + { comparison_op: 'anyof', value: 'jan,feb,mar' }, + { comparison_op: 'nanyof', value: 'jan,feb,mar' }, + { comparison_op: 'allof', value: 'jan,feb,mar' }, + { comparison_op: 'nallof', value: 'jan,feb,mar' }, + ]; + await verifyFilters('MultiSelect', columns[2].id, filterList); + }); +} + +export default function () { + describe('Filter: Text based', filterTextBased); + describe('Filter: Numerical', filterNumberBased); + describe('Filter: Select based', filterSelectBased); +}