mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(ag-grid): Server Side Filtering for Column Level Filters (#35683)
This commit is contained in:
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { convertAgGridFiltersToSQL } from '../../src/utils/agGridFilterConverter';
|
||||
import type {
|
||||
AgGridFilterModel,
|
||||
AgGridSimpleFilter,
|
||||
AgGridCompoundFilter,
|
||||
AgGridSetFilter,
|
||||
} from '../../src/utils/agGridFilterConverter';
|
||||
|
||||
describe('agGridFilterConverter', () => {
|
||||
describe('Empty and invalid inputs', () => {
|
||||
it('should handle empty filter model', () => {
|
||||
const result = convertAgGridFiltersToSQL({});
|
||||
|
||||
expect(result.simpleFilters).toEqual([]);
|
||||
expect(result.complexWhere).toBeUndefined();
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle null filter model', () => {
|
||||
const result = convertAgGridFiltersToSQL(null as any);
|
||||
|
||||
expect(result.simpleFilters).toEqual([]);
|
||||
expect(result.complexWhere).toBeUndefined();
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip invalid column names', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
valid_column: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test',
|
||||
},
|
||||
'invalid; DROP TABLE users--': {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'malicious',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0].col).toBe('valid_column');
|
||||
});
|
||||
|
||||
it('should skip filters with invalid objects', () => {
|
||||
const filterModel = {
|
||||
column1: null,
|
||||
column2: 'invalid string',
|
||||
column3: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'valid',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel as any);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0].col).toBe('column3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Simple text filters', () => {
|
||||
it('should convert equals filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'John',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'name',
|
||||
op: '=',
|
||||
val: 'John',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert notEqual filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
status: {
|
||||
filterType: 'text',
|
||||
type: 'notEqual',
|
||||
filter: 'inactive',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'status',
|
||||
op: '!=',
|
||||
val: 'inactive',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert contains filter with wildcard', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
description: {
|
||||
filterType: 'text',
|
||||
type: 'contains',
|
||||
filter: 'urgent',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'description',
|
||||
op: 'ILIKE',
|
||||
val: '%urgent%',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert notContains filter with wildcard', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
description: {
|
||||
filterType: 'text',
|
||||
type: 'notContains',
|
||||
filter: 'spam',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'description',
|
||||
op: 'NOT ILIKE',
|
||||
val: '%spam%',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert startsWith filter with trailing wildcard', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
email: {
|
||||
filterType: 'text',
|
||||
type: 'startsWith',
|
||||
filter: 'admin',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'email',
|
||||
op: 'ILIKE',
|
||||
val: 'admin%',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert endsWith filter with leading wildcard', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
email: {
|
||||
filterType: 'text',
|
||||
type: 'endsWith',
|
||||
filter: '@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'email',
|
||||
op: 'ILIKE',
|
||||
val: '%@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Numeric filters', () => {
|
||||
it('should convert lessThan filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
type: 'lessThan',
|
||||
filter: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'age',
|
||||
op: '<',
|
||||
val: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert lessThanOrEqual filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
price: {
|
||||
filterType: 'number',
|
||||
type: 'lessThanOrEqual',
|
||||
filter: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'price',
|
||||
op: '<=',
|
||||
val: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert greaterThan filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
score: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 50,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'score',
|
||||
op: '>',
|
||||
val: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert greaterThanOrEqual filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
rating: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThanOrEqual',
|
||||
filter: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'rating',
|
||||
op: '>=',
|
||||
val: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert inRange filter to BETWEEN', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
type: 'inRange',
|
||||
filter: 18,
|
||||
filterTo: 65,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
// inRange creates a simple filter with BETWEEN operator
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'age',
|
||||
op: 'BETWEEN',
|
||||
val: 18,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Null/blank filters', () => {
|
||||
it('should convert blank filter to IS NULL', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
optional_field: {
|
||||
filterType: 'text',
|
||||
type: 'blank',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'optional_field',
|
||||
op: 'IS NULL',
|
||||
val: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert notBlank filter to IS NOT NULL', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
required_field: {
|
||||
filterType: 'text',
|
||||
type: 'notBlank',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'required_field',
|
||||
op: 'IS NOT NULL',
|
||||
val: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set filters', () => {
|
||||
it('should convert set filter to IN operator', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
status: {
|
||||
filterType: 'set',
|
||||
values: ['active', 'pending', 'approved'],
|
||||
} as AgGridSetFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'status',
|
||||
op: 'IN',
|
||||
val: ['active', 'pending', 'approved'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle set filter with numeric values', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
priority: {
|
||||
filterType: 'set',
|
||||
values: [1, 2, 3],
|
||||
} as AgGridSetFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'priority',
|
||||
op: 'IN',
|
||||
val: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip empty set filters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
status: {
|
||||
filterType: 'set',
|
||||
values: [],
|
||||
} as AgGridSetFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Compound filters', () => {
|
||||
it('should combine conditions with AND operator', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThanOrEqual',
|
||||
filter: 18,
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'number',
|
||||
type: 'lessThan',
|
||||
filter: 65,
|
||||
},
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.complexWhere).toBe('(age >= 18 AND age < 65)');
|
||||
});
|
||||
|
||||
it('should combine conditions with OR operator', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
status: {
|
||||
filterType: 'text',
|
||||
operator: 'OR',
|
||||
condition1: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'urgent',
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'critical',
|
||||
},
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.complexWhere).toBe(
|
||||
"(status = 'urgent' OR status = 'critical')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle compound filter with inRange', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
date: {
|
||||
filterType: 'date',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'date',
|
||||
type: 'inRange',
|
||||
filter: '2024-01-01',
|
||||
filterTo: '2024-12-31',
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'date',
|
||||
type: 'notBlank',
|
||||
},
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.complexWhere).toContain('BETWEEN');
|
||||
expect(result.complexWhere).toContain('IS NOT NULL');
|
||||
});
|
||||
|
||||
it('should handle compound filter with invalid conditions gracefully', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
field: {
|
||||
filterType: 'text',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'valid',
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
// Missing filter value - should be skipped
|
||||
} as AgGridSimpleFilter,
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
// Should only include valid condition
|
||||
expect(result.complexWhere).toBe("field = 'valid'");
|
||||
});
|
||||
|
||||
it('should handle multi-condition filters (conditions array)', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
category: {
|
||||
filterType: 'text',
|
||||
operator: 'OR',
|
||||
conditions: [
|
||||
{
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'A',
|
||||
},
|
||||
{
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'B',
|
||||
},
|
||||
{
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'C',
|
||||
},
|
||||
],
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.complexWhere).toBe(
|
||||
"(category = 'A' OR category = 'B' OR category = 'C')",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metric vs Dimension separation', () => {
|
||||
it('should put dimension filters in simpleFilters/complexWhere', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
state: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'CA',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, []);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should put metric filters in havingClause', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'SUM(revenue)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['SUM(revenue)']);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
expect(result.havingClause).toBe('SUM(revenue) > 1000');
|
||||
});
|
||||
|
||||
it('should separate mixed metric and dimension filters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
state: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'CA',
|
||||
},
|
||||
'SUM(revenue)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 1000,
|
||||
},
|
||||
city: {
|
||||
filterType: 'text',
|
||||
type: 'startsWith',
|
||||
filter: 'San',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['SUM(revenue)']);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(2);
|
||||
expect(result.simpleFilters[0].col).toBe('state');
|
||||
expect(result.simpleFilters[1].col).toBe('city');
|
||||
expect(result.havingClause).toBe('SUM(revenue) > 1000');
|
||||
});
|
||||
|
||||
it('should handle metric set filters in HAVING clause', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'AVG(score)': {
|
||||
filterType: 'set',
|
||||
values: [90, 95, 100],
|
||||
} as AgGridSetFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['AVG(score)']);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
expect(result.havingClause).toBe('AVG(score) IN (90, 95, 100)');
|
||||
});
|
||||
|
||||
it('should handle metric blank filters in HAVING clause', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'COUNT(*)': {
|
||||
filterType: 'number',
|
||||
type: 'blank',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['COUNT(*)']);
|
||||
|
||||
expect(result.havingClause).toBe('COUNT(*) IS NULL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple filters combination', () => {
|
||||
it('should handle both simple and compound filters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
status: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'active',
|
||||
},
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 18,
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'number',
|
||||
type: 'lessThan',
|
||||
filter: 65,
|
||||
},
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
// Simple filter goes to simpleFilters
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'status',
|
||||
op: '=',
|
||||
val: 'active',
|
||||
});
|
||||
|
||||
// Compound filter goes to complexWhere
|
||||
expect(result.complexWhere).toBe('(age > 18 AND age < 65)');
|
||||
});
|
||||
|
||||
it('should combine multiple HAVING filters with AND', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'SUM(revenue)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 1000,
|
||||
},
|
||||
'AVG(score)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThanOrEqual',
|
||||
filter: 90,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, [
|
||||
'SUM(revenue)',
|
||||
'AVG(score)',
|
||||
]);
|
||||
|
||||
expect(result.havingClause).toBe(
|
||||
'(SUM(revenue) > 1000 AND AVG(score) >= 90)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single WHERE filter without parentheses', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 18,
|
||||
},
|
||||
condition2: {
|
||||
filterType: 'number',
|
||||
type: 'lessThan',
|
||||
filter: 65,
|
||||
},
|
||||
} as AgGridCompoundFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.complexWhere).toBe('(age > 18 AND age < 65)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL injection prevention', () => {
|
||||
it('should escape single quotes in filter values', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: "O'Brien",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0].val).toBe("O'Brien");
|
||||
// The actual escaping happens in SQL generation, but value is preserved
|
||||
});
|
||||
|
||||
it('should escape single quotes in complex filters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
description: {
|
||||
filterType: 'text',
|
||||
type: 'contains',
|
||||
filter: "It's working",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
// For ILIKE filters, wildcards are added but value preserved
|
||||
expect(result.simpleFilters[0].val).toBe("%It's working%");
|
||||
});
|
||||
|
||||
it('should reject column names with SQL injection attempts', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
"name'; DROP TABLE users--": {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject column names with special characters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'column<script>alert(1)</script>': {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid column names with allowed special characters', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
valid_column_123: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test',
|
||||
},
|
||||
'Column Name With Spaces': {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test2',
|
||||
},
|
||||
'SUM(revenue)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle very long column names', () => {
|
||||
const longColumnName = 'a'.repeat(300);
|
||||
const filterModel: AgGridFilterModel = {
|
||||
[longColumnName]: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
// Should reject column names longer than 255 characters
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should skip filters with missing type', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
column: {
|
||||
filterType: 'text',
|
||||
filter: 'value',
|
||||
} as any,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip filters with unknown operator type', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
column: {
|
||||
filterType: 'text',
|
||||
type: 'unknownOperator' as any,
|
||||
filter: 'value',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip filters with invalid value types', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
column: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: { invalid: 'object' } as any,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle boolean filter values', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
is_active: {
|
||||
filterType: 'boolean',
|
||||
type: 'equals',
|
||||
filter: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'is_active',
|
||||
op: '=',
|
||||
val: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null filter values for blank operators', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
field: {
|
||||
filterType: 'text',
|
||||
type: 'blank',
|
||||
filter: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel);
|
||||
|
||||
expect(result.simpleFilters[0].val).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle metric filters with set filter', () => {
|
||||
const filterModel: AgGridFilterModel = {
|
||||
'SUM(amount)': {
|
||||
filterType: 'set',
|
||||
values: ['100', '200', '300'],
|
||||
} as AgGridSetFilter,
|
||||
};
|
||||
|
||||
const result = convertAgGridFiltersToSQL(filterModel, ['SUM(amount)']);
|
||||
|
||||
expect(result.havingClause).toBe("SUM(amount) IN ('100', '200', '300')");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getCompleteFilterState } from '../../src/utils/filterStateManager';
|
||||
import type { RefObject } from 'react';
|
||||
import type { AgGridReact } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { FILTER_INPUT_POSITIONS } from '../../src/consts';
|
||||
|
||||
describe('filterStateManager', () => {
|
||||
describe('getCompleteFilterState', () => {
|
||||
it('should return empty state when gridRef.current is null', async () => {
|
||||
const gridRef = { current: null } as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result).toEqual({
|
||||
originalFilterModel: {},
|
||||
simpleFilters: [],
|
||||
complexWhere: undefined,
|
||||
havingClause: undefined,
|
||||
lastFilteredColumn: undefined,
|
||||
inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty state when gridRef.current.api is undefined', async () => {
|
||||
const gridRef = {
|
||||
current: { api: undefined } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result).toEqual({
|
||||
originalFilterModel: {},
|
||||
simpleFilters: [],
|
||||
complexWhere: undefined,
|
||||
havingClause: undefined,
|
||||
lastFilteredColumn: undefined,
|
||||
inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert simple filters correctly', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'John' },
|
||||
age: { filterType: 'number', type: 'greaterThan', filter: 25 },
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.originalFilterModel).toEqual(filterModel);
|
||||
expect(result.simpleFilters).toHaveLength(2);
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'name',
|
||||
op: '=',
|
||||
val: 'John',
|
||||
});
|
||||
expect(result.simpleFilters[1]).toEqual({
|
||||
col: 'age',
|
||||
op: '>',
|
||||
val: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('should separate dimension and metric filters', async () => {
|
||||
const filterModel = {
|
||||
state: { filterType: 'text', type: 'equals', filter: 'CA' },
|
||||
'SUM(revenue)': {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, ['SUM(revenue)']);
|
||||
|
||||
// Dimension filter goes to simpleFilters
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0].col).toBe('state');
|
||||
|
||||
// Metric filter goes to havingClause
|
||||
expect(result.havingClause).toBe('SUM(revenue) > 1000');
|
||||
});
|
||||
|
||||
it('should detect first input position when active element is in first condition body', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockConditionBody1 = document.createElement('div');
|
||||
mockConditionBody1.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [mockConditionBody1],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('name');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST);
|
||||
});
|
||||
|
||||
it('should detect second input position when active element is in second condition body', async () => {
|
||||
const filterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: { filterType: 'number', type: 'greaterThan', filter: 18 },
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 65 },
|
||||
},
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockConditionBody1 = document.createElement('div');
|
||||
const mockConditionBody2 = document.createElement('div');
|
||||
mockConditionBody2.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [mockConditionBody1, mockConditionBody2],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('age');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.SECOND);
|
||||
});
|
||||
|
||||
it('should return unknown position when active element is not in any condition body', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockConditionBody = document.createElement('div');
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [mockConditionBody],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement as something outside the filter
|
||||
const outsideElement = document.createElement('div');
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: outsideElement,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN);
|
||||
expect(result.lastFilteredColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple filtered columns and detect the correct one', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'John' },
|
||||
age: { filterType: 'number', type: 'greaterThan', filter: 25 },
|
||||
status: { filterType: 'text', type: 'equals', filter: 'active' },
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockConditionBodyAge = document.createElement('div');
|
||||
mockConditionBodyAge.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstanceName = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [document.createElement('div')],
|
||||
};
|
||||
|
||||
const mockFilterInstanceAge = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [mockConditionBodyAge],
|
||||
};
|
||||
|
||||
const mockFilterInstanceStatus = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [document.createElement('div')],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn((colId: string) => {
|
||||
if (colId === 'name') return Promise.resolve(mockFilterInstanceName);
|
||||
if (colId === 'age') return Promise.resolve(mockFilterInstanceAge);
|
||||
if (colId === 'status')
|
||||
return Promise.resolve(mockFilterInstanceStatus);
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement in age filter
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('age');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST);
|
||||
});
|
||||
|
||||
it('should handle filter instance without eConditionBodies', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
// No eConditionBodies property
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN);
|
||||
expect(result.lastFilteredColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty filter model', async () => {
|
||||
const filterModel = {};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.originalFilterModel).toEqual({});
|
||||
expect(result.simpleFilters).toEqual([]);
|
||||
expect(result.complexWhere).toBeUndefined();
|
||||
expect(result.havingClause).toBeUndefined();
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should handle compound filters correctly', async () => {
|
||||
const filterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThanOrEqual',
|
||||
filter: 18,
|
||||
},
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 65 },
|
||||
},
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.complexWhere).toBe('(age >= 18 AND age < 65)');
|
||||
});
|
||||
|
||||
it('should handle set filters correctly', async () => {
|
||||
const filterModel = {
|
||||
status: {
|
||||
filterType: 'set',
|
||||
values: ['active', 'pending', 'approved'],
|
||||
},
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.simpleFilters).toHaveLength(1);
|
||||
expect(result.simpleFilters[0]).toEqual({
|
||||
col: 'status',
|
||||
op: 'IN',
|
||||
val: ['active', 'pending', 'approved'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should break detection loop after finding active element', async () => {
|
||||
const filterModel = {
|
||||
col1: { filterType: 'text', type: 'equals', filter: 'a' },
|
||||
col2: { filterType: 'text', type: 'equals', filter: 'b' },
|
||||
col3: { filterType: 'text', type: 'equals', filter: 'c' },
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockConditionBody = document.createElement('div');
|
||||
mockConditionBody.appendChild(mockInput);
|
||||
|
||||
let callCount = 0;
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn((colId: string) => {
|
||||
callCount++;
|
||||
// Return match on col2
|
||||
if (colId === 'col2') {
|
||||
return Promise.resolve({
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [mockConditionBody],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [document.createElement('div')],
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('col2');
|
||||
// Should not call getColumnFilterInstance for col3 after finding match
|
||||
expect(callCount).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should handle null filter instance gracefully', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN);
|
||||
expect(result.originalFilterModel).toEqual(filterModel);
|
||||
});
|
||||
|
||||
it('should maintain filter model reference integrity', async () => {
|
||||
const originalFilterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => originalFilterModel),
|
||||
getColumnFilterInstance: jest.fn(() => Promise.resolve(null)),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
// Should return the same reference
|
||||
expect(result.originalFilterModel).toBe(originalFilterModel);
|
||||
});
|
||||
|
||||
it('should detect active element in eJoinAnds array', async () => {
|
||||
const filterModel = {
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: { filterType: 'number', type: 'greaterThan', filter: 18 },
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 65 },
|
||||
},
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockJoinAndGui = document.createElement('div');
|
||||
mockJoinAndGui.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [
|
||||
document.createElement('div'),
|
||||
document.createElement('div'),
|
||||
],
|
||||
eJoinAnds: [{ eGui: mockJoinAndGui }],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement in join AND operator
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('age');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST);
|
||||
});
|
||||
|
||||
it('should detect active element in eJoinOrs array', async () => {
|
||||
const filterModel = {
|
||||
status: {
|
||||
filterType: 'text',
|
||||
operator: 'OR',
|
||||
condition1: { filterType: 'text', type: 'equals', filter: 'active' },
|
||||
condition2: { filterType: 'text', type: 'equals', filter: 'pending' },
|
||||
},
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockJoinOrGui = document.createElement('div');
|
||||
mockJoinOrGui.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [
|
||||
document.createElement('div'),
|
||||
document.createElement('div'),
|
||||
],
|
||||
eJoinOrs: [{ eGui: mockJoinOrGui }],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
// Mock activeElement in join OR operator
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('status');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST);
|
||||
});
|
||||
|
||||
it('should check condition bodies before join operators', async () => {
|
||||
const filterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockConditionBody2 = document.createElement('div');
|
||||
mockConditionBody2.appendChild(mockInput);
|
||||
|
||||
const mockJoinAndGui = document.createElement('div');
|
||||
// Input is NOT in join operator, only in condition body
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [document.createElement('div'), mockConditionBody2],
|
||||
eJoinAnds: [{ eGui: mockJoinAndGui }],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('name');
|
||||
// Should detect SECOND input position, not from join operator
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.SECOND);
|
||||
});
|
||||
|
||||
it('should handle multiple eJoinAnds elements', async () => {
|
||||
const filterModel = {
|
||||
score: { filterType: 'number', type: 'greaterThan', filter: 90 },
|
||||
};
|
||||
|
||||
const mockInput = document.createElement('input');
|
||||
const mockJoinAndGui2 = document.createElement('div');
|
||||
mockJoinAndGui2.appendChild(mockInput);
|
||||
|
||||
const mockFilterInstance = {
|
||||
eGui: document.createElement('div'),
|
||||
eConditionBodies: [document.createElement('div')],
|
||||
eJoinAnds: [
|
||||
{ eGui: document.createElement('div') },
|
||||
{ eGui: mockJoinAndGui2 },
|
||||
{ eGui: document.createElement('div') },
|
||||
],
|
||||
};
|
||||
|
||||
const mockApi = {
|
||||
getFilterModel: jest.fn(() => filterModel),
|
||||
getColumnFilterInstance: jest.fn(() =>
|
||||
Promise.resolve(mockFilterInstance),
|
||||
),
|
||||
};
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: mockInput,
|
||||
});
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
expect(result.lastFilteredColumn).toBe('score');
|
||||
expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import getInitialFilterModel from '../../src/utils/getInitialFilterModel';
|
||||
import type { AgGridChartState } from '@superset-ui/core';
|
||||
|
||||
describe('getInitialFilterModel', () => {
|
||||
describe('Priority: chartState > serverPaginationData', () => {
|
||||
it('should prioritize chartState.filterModel over serverPaginationData', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'from-chart-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'from-server' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(chartState.filterModel);
|
||||
});
|
||||
|
||||
it('should use serverPaginationData when chartState.filterModel is unavailable', () => {
|
||||
const chartState: Partial<AgGridChartState> = {};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'from-server' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
|
||||
it('should use serverPaginationData when chartState is undefined', () => {
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
status: { filterType: 'text', type: 'equals', filter: 'active' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty object handling', () => {
|
||||
it('should return undefined when chartState.filterModel is empty object', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {},
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
// Empty filterModel should be ignored, fall back to server
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
|
||||
it('should return undefined when serverPaginationData.agGridFilterModel is empty object', () => {
|
||||
const chartState: Partial<AgGridChartState> = {};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle both being empty objects', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {},
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undefined/null handling', () => {
|
||||
it('should return undefined when all inputs are undefined', () => {
|
||||
const result = getInitialFilterModel(undefined, undefined, true);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when chartState and serverPaginationData are undefined', () => {
|
||||
const result = getInitialFilterModel(undefined, undefined, false);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when serverPagination is disabled', () => {
|
||||
const chartState: Partial<AgGridChartState> = {};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use chartState even when serverPagination is disabled', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'equals',
|
||||
filter: 'from-chart-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'from-server' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
false,
|
||||
);
|
||||
|
||||
// chartState takes priority regardless of serverPagination flag
|
||||
expect(result).toEqual(chartState.filterModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex filter models', () => {
|
||||
it('should handle complex chartState filter model', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'John' },
|
||||
age: {
|
||||
filterType: 'number',
|
||||
operator: 'AND',
|
||||
condition1: {
|
||||
filterType: 'number',
|
||||
type: 'greaterThan',
|
||||
filter: 18,
|
||||
},
|
||||
condition2: { filterType: 'number', type: 'lessThan', filter: 65 },
|
||||
},
|
||||
status: { filterType: 'set', values: ['active', 'pending'] },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(chartState, undefined, true);
|
||||
|
||||
expect(result).toEqual(chartState.filterModel);
|
||||
});
|
||||
|
||||
it('should handle complex serverPaginationData filter model', () => {
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
category: { filterType: 'text', type: 'contains', filter: 'tech' },
|
||||
revenue: { filterType: 'number', type: 'greaterThan', filter: 1000 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should handle permalink scenario with chartState', () => {
|
||||
// User shares a permalink with saved filter state
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {
|
||||
state: { filterType: 'set', values: ['CA', 'NY', 'TX'] },
|
||||
revenue: { filterType: 'number', type: 'greaterThan', filter: 50000 },
|
||||
},
|
||||
columnState: [],
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(chartState, undefined, true);
|
||||
|
||||
expect(result).toEqual(chartState.filterModel);
|
||||
expect(result?.state).toBeDefined();
|
||||
expect(result?.revenue).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle fresh page load with server state', () => {
|
||||
// Fresh page load - no chartState, but has serverPaginationData from ownState
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
created_date: {
|
||||
filterType: 'date',
|
||||
type: 'greaterThan',
|
||||
filter: '2024-01-01',
|
||||
},
|
||||
},
|
||||
currentPage: 0,
|
||||
pageSize: 50,
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
|
||||
it('should handle chart without any filters applied', () => {
|
||||
// No filters applied anywhere
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
columnState: [],
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
currentPage: 0,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle transition from no filters to filters via permalink', () => {
|
||||
// User applies filters, creates permalink, then loads it
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: {
|
||||
name: { filterType: 'text', type: 'startsWith', filter: 'Admin' },
|
||||
},
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: undefined, // No server state yet
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(chartState.filterModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle null values in serverPaginationData', () => {
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: null as any,
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle serverPaginationData without agGridFilterModel key', () => {
|
||||
const serverPaginationData = {
|
||||
currentPage: 0,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData as any,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle chartState with null filterModel', () => {
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: null as any,
|
||||
};
|
||||
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
chartState,
|
||||
serverPaginationData,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual(serverPaginationData.agGridFilterModel);
|
||||
});
|
||||
|
||||
it('should handle serverPagination undefined (defaults to false)', () => {
|
||||
const serverPaginationData = {
|
||||
agGridFilterModel: {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'test' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(
|
||||
undefined,
|
||||
serverPaginationData,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve filter model structure without modification', () => {
|
||||
const originalFilterModel = {
|
||||
complexFilter: {
|
||||
filterType: 'number',
|
||||
operator: 'OR' as const,
|
||||
condition1: { filterType: 'number', type: 'equals', filter: 100 },
|
||||
condition2: { filterType: 'number', type: 'equals', filter: 200 },
|
||||
},
|
||||
};
|
||||
|
||||
const chartState: Partial<AgGridChartState> = {
|
||||
filterModel: originalFilterModel,
|
||||
};
|
||||
|
||||
const result = getInitialFilterModel(chartState, undefined, true);
|
||||
|
||||
// Should return exact same reference, not a copy
|
||||
expect(result).toBe(originalFilterModel);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user