codeant-ai-for-open-source[bot] commented on code in PR #35683:
URL: https://github.com/apache/superset/pull/35683#discussion_r2626794009
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts:
##########
@@ -27,3 +27,18 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200];
export const CUSTOM_AGG_FUNCS = {
queryTotal: 'Metric total',
};
+
+export const FILTER_POPOVER_OPEN_DELAY = 200;
+export const FILTER_INPUT_SELECTOR = 'input[data-ref="eInput"]';
+export const NOOP_FILTER_COMPARATOR = () => 0;
+
+export const FILTER_INPUT_POSITIONS = {
+ FIRST: 'first' as const,
+ SECOND: 'second' as const,
+ UNKNOWN: 'unknown' as const,
+} as const;
+
Review Comment:
**Suggestion:** `FILTER_INPUT_POSITIONS` is defined as a const object but no
exported type alias is provided; callers that need the union type of the
allowed values will have to repeat type logic or may accidentally widen the
type, leading to inconsistent usage. Export an explicit type alias derived from
the const to ensure consistent, type-safe usage across the codebase. [type
error]
**Severity Level:** Minor ⚠️
```suggestion
export type FilterInputPosition = typeof FILTER_INPUT_POSITIONS[keyof typeof
FILTER_INPUT_POSITIONS];
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Exporting a derived union type (e.g., FilterInputPosition) is a sensible,
non-breaking enhancement that makes downstream usage type-safe and avoids
repeated type derivations. It doesn't change runtime behavior and helps
maintainers.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts
**Line:** 40:40
**Comment:**
*Type Error: `FILTER_INPUT_POSITIONS` is defined as a const object but
no exported type alias is provided; callers that need the union type of the
allowed values will have to repeat type logic or may accidentally widen the
type, leading to inconsistent usage. Export an explicit type alias derived from
the const to ensure consistent, type-safe usage across the codebase.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts:
##########
@@ -27,3 +27,18 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200];
export const CUSTOM_AGG_FUNCS = {
queryTotal: 'Metric total',
};
+
+export const FILTER_POPOVER_OPEN_DELAY = 200;
+export const FILTER_INPUT_SELECTOR = 'input[data-ref="eInput"]';
+export const NOOP_FILTER_COMPARATOR = () => 0;
Review Comment:
**Suggestion:** The no-op comparator is defined without parameters and
without an explicit comparator type: `() => 0`. Comparator consumers expect a
function of two arguments that returns a number. Defining it without parameters
can cause TypeScript signature mismatches and makes intent unclear; also, some
call-sites may rely on the parameters being present. Add two parameters with
appropriate types and an explicit return type to match comparator signatures.
[type error]
**Severity Level:** Minor ⚠️
```suggestion
export const NOOP_FILTER_COMPARATOR = (_a: unknown, _b: unknown): number =>
0;
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Making the noop comparator explicitly accept two arguments and return a
number improves its type compatibility with typical comparator signatures and
documents intent.
The current () => 0 is valid JS/TS but can lead to subtle typing mismatches
at call sites that expect (a, b) => number. The proposed change is small, safe,
and doesn't change runtime behavior.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts
**Line:** 33:33
**Comment:**
*Type Error: The no-op comparator is defined without parameters and
without an explicit comparator type: `() => 0`. Comparator consumers expect a
function of two arguments that returns a number. Defining it without parameters
can cause TypeScript signature mismatches and makes intent unclear; also, some
call-sites may rely on the parameters being present. Add two parameters with
appropriate types and an explicit return type to match comparator signatures.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts:
##########
@@ -68,6 +95,101 @@ function getTextComparator(type: string, value: string):
string {
return value;
}
+/**
+ * Format a date string to ISO format for SQL queries, preserving local
timezone.
+ * @param dateStr - Date string from AG Grid filter
+ * @returns ISO formatted date string in local timezone
+ */
+function formatDateForSQL(dateStr: string): string {
+ const date = new Date(dateStr);
+ if (Number.isNaN(date.getTime())) {
+ return dateStr;
+ }
+
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
+}
+
+/**
+ * Get the start of day (00:00:00) for a given date string
+ */
+function getStartOfDay(dateStr: string): string {
+ const date = new Date(dateStr);
+ date.setHours(0, 0, 0, 0);
+ return formatDateForSQL(date.toISOString());
+}
+
+/**
+ * Get the end of day (23:59:59) for a given date string
+ */
+function getEndOfDay(dateStr: string): string {
+ const date = new Date(dateStr);
+ date.setHours(23, 59, 59, 999);
+ return formatDateForSQL(date.toISOString());
+}
+
+/**
+ * Converts a date filter to SQL clause.
+ * Handles both standard operators (equals, lessThan, etc.) and
+ * custom server-side operators (serverEquals, serverBefore, etc.).
+ *
+ * @param colId - Column identifier
+ * @param filter - AG Grid date filter object
+ * @returns SQL clause string or null if conversion not possible
+ */
+function convertDateFilterToSQL(
+ colId: string,
+ filter: AgGridFilter,
+): string | null {
+ const { type, dateFrom, dateTo } = filter;
+
+ if (!type) return null;
+
+ // Map custom server operators to standard ones
Review Comment:
**Suggestion:** Security/robustness: `convertDateFilterToSQL` calls
`getStartOfDay`/`getEndOfDay` with `dateFrom`/`dateTo` without validating they
are parseable dates; if an attacker provides a crafted string that doesn't
parse, the formatter currently returns the raw input and it will be
interpolated directly into SQL, enabling SQL injection or malformed queries;
validate `dateFrom`/`dateTo` (reject or return null) before using them.
[security]
**Severity Level:** Critical 🚨
```suggestion
// Validate date inputs early to avoid embedding unparsed user input into
SQL
if (dateFrom && Number.isNaN(new Date(dateFrom).getTime())) return null;
if (dateTo && Number.isNaN(new Date(dateTo).getTime())) return null;
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Adding early validation of dateFrom/dateTo before formatting is a sensible,
practical hardening: it prevents invalid or attacker-controlled strings from
reaching the formatter/SQL layer and avoids unexpected runtime errors. The
suggested checks are small, correct, and apply directly to the shown code path.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts
**Line:** 154:154
**Comment:**
*Security: Security/robustness: `convertDateFilterToSQL` calls
`getStartOfDay`/`getEndOfDay` with `dateFrom`/`dateTo` without validating they
are parseable dates; if an attacker provides a crafted string that doesn't
parse, the formatter currently returns the raw input and it will be
interpolated directly into SQL, enabling SQL injection or malformed queries;
validate `dateFrom`/`dateTo` (reject or return null) before using them.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts:
##########
@@ -476,27 +476,594 @@ describe('plugin-chart-ag-grid-table', () => {
});
});
- describe('buildQuery - metrics handling in different query modes', () => {
- test('should not include metrics in raw records mode', () => {
- const query = buildQuery({
- viz_type: VizType.Table,
- datasource: '11__table',
- query_mode: QueryMode.Raw,
- all_columns: ['state', 'city'],
- }).queries[0];
+ describe('buildQuery - AG Grid server-side filters', () => {
+ describe('Simple filters', () => {
+ it('should apply agGridSimpleFilters to query.filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [
+ { col: 'state', op: '==', val: 'CA' },
+ { col: 'city', op: 'ILIKE', val: '%San%' },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
Review Comment:
**Suggestion:** Missing null/undefined guard before asserting on
`query.filters` may cause matcher errors if `filters` is undefined; assert
`query.filters` is defined first before calling `toContainEqual`. [possible bug]
**Severity Level:** Critical 🚨
```suggestion
expect(query.filters).toBeDefined();
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Calling toContainEqual on an undefined value would throw during the test run.
Adding an explicit expect(query.filters).toBeDefined() makes the failure
clearer
(filters missing vs. contents mismatch) and prevents a TypeError. This
improves
test robustness and is directly applicable to the PR's tests.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts
**Line:** 497:497
**Comment:**
*Possible Bug: Missing null/undefined guard before asserting on
`query.filters` may cause matcher errors if `filters` is undefined; assert
`query.filters` is defined first before calling `toContainEqual`.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx:
##########
@@ -332,6 +351,56 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps>
= memo(
[onColumnStateChange],
);
+ const handleFilterChanged = useCallback(async () => {
+ filterOperationVersionRef.current += 1;
+ const currentVersion = filterOperationVersionRef.current;
+
+ const completeFilterState = await getCompleteFilterState(
+ gridRef,
+ metricColumns,
+ );
+
+ // Skip stale operations from rapid filter changes
+ if (currentVersion !== filterOperationVersionRef.current) {
+ return;
+ }
+
+ // Reject invalid filter states (e.g., text filter on numeric column)
+ if (completeFilterState.originalFilterModel) {
+ const filterModel = completeFilterState.originalFilterModel;
+ const hasInvalidFilterType = Object.entries(filterModel).some(
+ ([colId, filter]: [string, any]) => {
+ if (
+ filter?.filterType === 'text' &&
+ metricColumns?.includes(colId)
+ ) {
+ return true;
+ }
+ return false;
+ },
+ );
+
+ if (hasInvalidFilterType) {
+ return;
+ }
+ }
+
+ if (
+ !isEqual(
+ serverPaginationData?.agGridFilterModel,
+ completeFilterState.originalFilterModel,
+ )
+ ) {
+ if (onFilterChanged) {
+ onFilterChanged(completeFilterState);
+ }
+ }
Review Comment:
**Suggestion:** The async `handleFilterChanged` has no try/catch around the
await for `getCompleteFilterState` (or the subsequent logic). If
`getCompleteFilterState` rejects or any synchronous step throws, the promise
will be rejected with an unhandled exception from an event handler, which can
surface as an uncaught promise rejection and break filter change handling. Wrap
the async body in a try/catch and log or swallow errors as appropriate. [error
handling bug]
**Severity Level:** Minor ⚠️
```suggestion
try {
const completeFilterState = await getCompleteFilterState(
gridRef,
metricColumns,
);
// Skip stale operations from rapid filter changes
if (currentVersion !== filterOperationVersionRef.current) {
return;
}
// Reject invalid filter states (e.g., text filter on numeric column)
if (completeFilterState.originalFilterModel) {
const filterModel = completeFilterState.originalFilterModel;
const hasInvalidFilterType = Object.entries(filterModel).some(
([colId, filter]: [string, any]) => {
if (
filter?.filterType === 'text' &&
metricColumns?.includes(colId)
) {
return true;
}
return false;
},
);
if (hasInvalidFilterType) {
return;
}
}
if (
!isEqual(
serverPaginationData?.agGridFilterModel,
completeFilterState.originalFilterModel,
)
) {
if (onFilterChanged) {
onFilterChanged(completeFilterState);
}
}
} catch (error) {
// Prevent unhandled rejections from breaking the grid event loop
// Keep logging minimal to avoid noise in production
// eslint-disable-next-line no-console
console.warn('Error processing AG Grid filter change:', error);
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
This is a valid and practical improvement. getCompleteFilterState is awaited
inside an async callback wired to ag-Grid events — if it rejects, the rejected
promise bubbles up as an unhandled rejection. Wrapping the body in try/catch
avoids noisy uncaught rejections and matches patterns used elsewhere in the
file (e.g., state capture). The proposed change is minimal and fixes a real
error-handling gap.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
**Line:** 358:397
**Comment:**
*Error Handling Bug: The async `handleFilterChanged` has no try/catch
around the await for `getCompleteFilterState` (or the subsequent logic). If
`getCompleteFilterState` rejects or any synchronous step throws, the promise
will be rejected with an unhandled exception from an event handler, which can
surface as an uncaught promise rejection and break filter change handling. Wrap
the async body in a try/catch and log or swallow errors as appropriate.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts:
##########
@@ -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,
+ });
Review Comment:
**Suggestion:** Assertions rely on the order of `simpleFilters` elements
(index-based checks); because object key iteration order or conversion logic
may not guarantee a stable order, make the assertion order-independent by
checking the array contains both expected filter objects. [logic error]
**Severity Level:** Minor ⚠️
```suggestion
expect(result.simpleFilters).toEqual(
expect.arrayContaining([
{ col: 'name', op: '==', val: 'John' },
{ col: 'age', op: '>', val: 25 },
]),
);
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Making assertions order-independent (e.g. expect.arrayContaining) avoids
brittle tests if the conversion
implementation changes iteration order or internal sorting. While object
insertion order in JS is usually stable,
the conversion function might reorder filters; making the test tolerant is a
sensible improvement.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts
**Line:** 77:86
**Comment:**
*Logic Error: Assertions rely on the order of `simpleFilters` elements
(index-based checks); because object key iteration order or conversion logic
may not guarantee a stable order, make the assertion order-independent by
checking the array contains both expected filter objects.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts:
##########
@@ -476,27 +476,594 @@ describe('plugin-chart-ag-grid-table', () => {
});
});
- describe('buildQuery - metrics handling in different query modes', () => {
- test('should not include metrics in raw records mode', () => {
- const query = buildQuery({
- viz_type: VizType.Table,
- datasource: '11__table',
- query_mode: QueryMode.Raw,
- all_columns: ['state', 'city'],
- }).queries[0];
+ describe('buildQuery - AG Grid server-side filters', () => {
+ describe('Simple filters', () => {
+ it('should apply agGridSimpleFilters to query.filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [
+ { col: 'state', op: '==', val: 'CA' },
+ { col: 'city', op: 'ILIKE', val: '%San%' },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ expect(query.filters).toContainEqual({
+ col: 'city',
+ op: 'ILIKE',
+ val: '%San%',
+ });
+ });
+
+ it('should append simple filters to existing filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ clause: 'WHERE',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters?.length).toBeGreaterThan(1);
Review Comment:
**Suggestion:** Using optional chaining in
`expect(query.filters?.length).toBeGreaterThan(1)` can hide that `filters` is
undefined and will cause the matcher to receive `undefined`; check
`query.filters` exists, then assert on `.length`. [possible bug]
**Severity Level:** Critical 🚨
```suggestion
expect(query.filters).toBeDefined();
expect(query.filters.length).toBeGreaterThan(1);
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Using optional chaining here may feed undefined into Jest's matcher which is
confusing and can
mask whether filters were produced. Verifying filters is defined first and
then asserting on
.length produces clearer, deterministic test failures and is a safe,
targeted change.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts
**Line:** 531:531
**Comment:**
*Possible Bug: Using optional chaining in
`expect(query.filters?.length).toBeGreaterThan(1)` can hide that `filters` is
undefined and will cause the matcher to receive `undefined`; check
`query.filters` exists, then assert on `.length`.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts:
##########
@@ -476,27 +476,594 @@ describe('plugin-chart-ag-grid-table', () => {
});
});
- describe('buildQuery - metrics handling in different query modes', () => {
- test('should not include metrics in raw records mode', () => {
- const query = buildQuery({
- viz_type: VizType.Table,
- datasource: '11__table',
- query_mode: QueryMode.Raw,
- all_columns: ['state', 'city'],
- }).queries[0];
+ describe('buildQuery - AG Grid server-side filters', () => {
+ describe('Simple filters', () => {
+ it('should apply agGridSimpleFilters to query.filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [
+ { col: 'state', op: '==', val: 'CA' },
+ { col: 'city', op: 'ILIKE', val: '%San%' },
+ ],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ expect(query.filters).toContainEqual({
+ col: 'city',
+ op: 'ILIKE',
+ val: '%San%',
+ });
+ });
+
+ it('should append simple filters to existing filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ clause: 'WHERE',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters?.length).toBeGreaterThan(1);
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ });
+
+ it('should handle empty agGridSimpleFilters array', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toBeDefined();
+ });
+
+ it('should not apply simple filters when server pagination is disabled',
() => {
+ const query = buildQuery(basicFormData, {
+ ownState: {
+ agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }],
+ },
+ }).queries[0];
- expect(query.metrics).toBeUndefined();
+ expect(query.filters).not.toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ });
});
- test('should set metrics to empty array in aggregate mode when no metrics
specified', () => {
- const query = buildQuery({
- viz_type: VizType.Table,
- datasource: '11__table',
- query_mode: QueryMode.Aggregate,
- groupby: ['state'],
- }).queries[0];
+ describe('Complex WHERE clause', () => {
+ it('should apply agGridComplexWhere to query.extras.where', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridComplexWhere: '(age > 18 AND age < 65)',
+ },
+ },
+ ).queries[0];
+
+ expect(query.extras?.where).toBe('(age > 18 AND age < 65)');
+ });
+
+ it('should combine with existing WHERE clause using AND', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ adhoc_filters: [
+ {
+ expressionType: 'SQL',
+ clause: 'WHERE',
+ sqlExpression: 'country = "USA"',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridComplexWhere: '(age > 18 AND age < 65)',
+ },
+ },
+ ).queries[0];
+
+ expect(query.extras?.where).toContain('country = "USA"');
+ expect(query.extras?.where).toContain('(age > 18 AND age < 65)');
+ expect(query.extras?.where).toContain(' AND ');
+ });
+
+ it('should handle empty agGridComplexWhere', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridComplexWhere: '',
+ },
+ },
+ ).queries[0];
+
+ // Empty string should not set extras.where (undefined or empty string
both acceptable)
+ expect(query.extras?.where || undefined).toBeUndefined();
+ });
+
+ it('should not apply WHERE clause when server pagination is disabled',
() => {
+ const query = buildQuery(basicFormData, {
+ ownState: {
+ agGridComplexWhere: '(age > 18)',
+ },
+ }).queries[0];
+
+ // When server_pagination is disabled, AG Grid filters should not be
applied
+ expect(query.extras?.where || undefined).toBeUndefined();
+ });
+ });
+
+ describe('HAVING clause', () => {
+ it('should apply agGridHavingClause to query.extras.having', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['SUM(revenue)'],
+ },
+ {
+ ownState: {
+ agGridHavingClause: 'SUM(revenue) > 1000',
+ },
+ },
+ ).queries[0];
+
+ expect(query.extras?.having).toBe('SUM(revenue) > 1000');
+ });
+
+ it('should combine with existing HAVING clause using AND', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['SUM(revenue)', 'COUNT(*)'],
+ adhoc_filters: [
+ {
+ expressionType: 'SQL',
+ clause: 'HAVING',
+ sqlExpression: 'COUNT(*) > 10',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridHavingClause: 'SUM(revenue) > 1000',
+ },
+ },
+ ).queries[0];
+
+ expect(query.extras?.having).toContain('COUNT(*) > 10');
+ expect(query.extras?.having).toContain('SUM(revenue) > 1000');
+ expect(query.extras?.having).toContain(' AND ');
+ });
+
+ it('should handle metric filters correctly', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['AVG(score)', 'MAX(points)'],
+ },
+ {
+ ownState: {
+ agGridHavingClause: '(AVG(score) >= 90 AND MAX(points) < 100)',
+ },
+ },
+ ).queries[0];
+
+ expect(query.extras?.having).toBe(
+ '(AVG(score) >= 90 AND MAX(points) < 100)',
+ );
+ });
+
+ it('should not apply HAVING clause when server pagination is disabled',
() => {
+ const query = buildQuery(basicFormData, {
+ ownState: {
+ agGridHavingClause: 'SUM(revenue) > 1000',
+ },
+ }).queries[0];
+
+ // When server_pagination is disabled, AG Grid filters should not be
applied
+ expect(query.extras?.having || undefined).toBeUndefined();
+ });
+ });
+
+ describe('Totals query handling', () => {
+ it('should exclude AG Grid WHERE filters from totals query', () => {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ },
+ {
+ ownState: {
+ agGridComplexWhere: 'age > 18',
+ },
+ },
+ ).queries;
+
+ const mainQuery = queries[0];
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ expect(mainQuery.extras?.where).toBe('age > 18');
+ expect(totalsQuery.extras?.where).toBeUndefined();
+ });
+
+ it('should preserve non-AG Grid WHERE clauses in totals', () => {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ adhoc_filters: [
+ {
+ expressionType: 'SQL',
+ clause: 'WHERE',
+ sqlExpression: 'country = "USA"',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridComplexWhere: 'age > 18',
+ },
+ },
+ ).queries;
+
+ const mainQuery = queries[0];
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ expect(mainQuery.extras?.where).toContain('country = "USA"');
+ expect(mainQuery.extras?.where).toContain('age > 18');
+ expect(totalsQuery.extras?.where).toContain('country = "USA"');
+ expect(totalsQuery.extras?.where).not.toContain('age > 18');
+ });
+
+ it('should handle totals when AG Grid WHERE is only clause', () => {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ },
+ {
+ ownState: {
+ agGridComplexWhere: 'status = "active"',
+ },
+ },
+ ).queries;
+
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ expect(totalsQuery.extras?.where).toBeUndefined();
+ });
+
+ it('should handle totals with empty WHERE clause after removal', () => {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ adhoc_filters: [
+ {
+ expressionType: 'SQL',
+ clause: 'WHERE',
+ sqlExpression: 'country = "USA"',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridComplexWhere: 'country = "USA"',
+ },
+ },
+ ).queries;
+
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ // After removing AG Grid WHERE, totals should still have the adhoc
filter
+ expect(totalsQuery.extras).toBeDefined();
+ });
+
+ it('should not modify totals query when no AG Grid filters applied', ()
=> {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ },
+ {
+ ownState: {},
+ },
+ ).queries;
+
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ expect(totalsQuery.columns).toEqual([]);
+ expect(totalsQuery.row_limit).toBe(0);
+ });
+ });
+
+ describe('Integration - all filter types together', () => {
+ it('should apply simple, WHERE, and HAVING filters simultaneously', ()
=> {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ metrics: ['SUM(revenue)', 'COUNT(*)'],
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }],
+ agGridComplexWhere: '(age > 18 AND age < 65)',
+ agGridHavingClause: 'SUM(revenue) > 1000',
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ expect(query.extras?.where).toBe('(age > 18 AND age < 65)');
+ expect(query.extras?.having).toBe('SUM(revenue) > 1000');
+ });
+
+ it('should combine AG Grid filters with adhoc filters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ clause: 'WHERE',
+ },
+ {
+ expressionType: 'SQL',
+ clause: 'WHERE',
+ sqlExpression: 'region = "West"',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [
+ { col: 'state', op: 'IN', val: ['CA', 'OR', 'WA'] },
+ ],
+ agGridComplexWhere: "status = 'active'",
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'OR', 'WA'],
+ });
+ expect(query.extras?.where).toContain('region = "West"');
+ expect(query.extras?.where).toContain("status = 'active'");
+ });
+
+ it('should reset currentPage to 0 when filtering', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ currentPage: 5,
+ agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }],
+ },
+ },
+ ).queries[0];
+
+ // The query itself doesn't have page info, but ownState should be
updated
+ expect(query.filters).toContainEqual({
+ col: 'state',
+ op: '==',
+ val: 'CA',
+ });
+ });
+
+ it('should include filter metadata in ownState', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridFilterModel: {
+ state: { filterType: 'text', type: 'equals', filter: 'CA' },
+ },
+ lastFilteredColumn: 'state',
+ lastFilteredInputPosition: 'first',
+ },
+ },
+ ).queries[0];
+
+ // Query should be generated with the filter model metadata
+ expect(query).toBeDefined();
+ });
+
+ it('should handle complex real-world scenario', () => {
+ const queries = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ show_totals: true,
+ query_mode: QueryMode.Aggregate,
+ groupby: ['state', 'city'],
+ metrics: ['SUM(revenue)', 'AVG(score)', 'COUNT(*)'],
+ adhoc_filters: [
+ {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'USA',
+ clause: 'WHERE',
+ },
+ ],
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [
+ { col: 'state', op: 'IN', val: ['CA', 'NY', 'TX'] },
+ ],
+ agGridComplexWhere:
+ '(population > 100000 AND growth_rate > 0.05)',
+ agGridHavingClause:
+ '(SUM(revenue) > 1000000 AND AVG(score) >= 4.5)',
+ currentPage: 0,
+ pageSize: 50,
+ },
+ },
+ ).queries;
+
+ const mainQuery = queries[0];
+ const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2]
is totals
+
+ // Main query should have all filters
+ expect(mainQuery.filters).toContainEqual({
+ col: 'state',
+ op: 'IN',
+ val: ['CA', 'NY', 'TX'],
+ });
+ expect(mainQuery.extras?.where).toContain('population > 100000');
+ expect(mainQuery.extras?.having).toContain('SUM(revenue) > 1000000');
+
+ // Totals query should exclude AG Grid WHERE (since it's the only
WHERE clause, it should be undefined)
+ expect(totalsQuery.extras?.where).toBeUndefined();
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle null ownState gracefully', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {},
+ ).queries[0];
+
+ expect(query).toBeDefined();
+ });
+
+ it('should handle ownState without filter properties', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ currentPage: 0,
+ pageSize: 20,
+ },
+ },
+ ).queries[0];
- expect(query.metrics).toEqual([]);
+ expect(query).toBeDefined();
+ });
+
+ it('should handle filters with special SQL characters', () => {
+ const query = buildQuery(
+ {
+ ...basicFormData,
+ server_pagination: true,
+ },
+ {
+ ownState: {
+ agGridSimpleFilters: [{ col: 'name', op: '==', val: "O'Brien" }],
+ },
+ },
+ ).queries[0];
+
+ expect(query.filters).toContainEqual({
Review Comment:
**Suggestion:** The test asserts `toContainEqual` on `query.filters` without
ensuring it's defined; this can produce a misleading failure or runtime error
if `filters` is undefined — add an explicit `toBeDefined()` check first.
[possible bug]
**Severity Level:** Critical 🚨
```suggestion
expect(query.filters).toBeDefined();
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Same pattern as the other suggestions — asserting on a possibly undefined
array will throw.
Adding a toBeDefined() before toContainEqual yields clearer failures and
avoids runtime errors
in the test suite if buildQuery ever returns undefined filters.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts
**Line:** 1040:1040
**Comment:**
*Possible Bug: The test asserts `toContainEqual` on `query.filters`
without ensuring it's defined; this can produce a misleading failure or runtime
error if `filters` is undefined — add an explicit `toBeDefined()` check first.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts:
##########
@@ -0,0 +1,734 @@
+/**
+ * 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.
+ */
+
+export type AgGridFilterType = 'text' | 'number' | 'date' | 'set' | 'boolean';
+
+export type AgGridFilterOperator =
+ | 'equals'
+ | 'notEqual'
+ | 'contains'
+ | 'notContains'
+ | 'startsWith'
+ | 'endsWith'
+ | 'lessThan'
+ | 'lessThanOrEqual'
+ | 'greaterThan'
+ | 'greaterThanOrEqual'
+ | 'inRange'
+ | 'blank'
+ | 'notBlank'
+ // Custom server-side date filter operators (always pass client-side
filtering)
+ | 'serverEquals'
+ | 'serverNotEqual'
+ | 'serverBefore'
+ | 'serverAfter'
+ | 'serverInRange'
+ | 'serverBlank'
+ | 'serverNotBlank';
+
+export type AgGridLogicalOperator = 'AND' | 'OR';
+
+export const FILTER_OPERATORS = {
+ EQUALS: 'equals' as const,
+ NOT_EQUAL: 'notEqual' as const,
+ CONTAINS: 'contains' as const,
+ NOT_CONTAINS: 'notContains' as const,
+ STARTS_WITH: 'startsWith' as const,
+ ENDS_WITH: 'endsWith' as const,
+ LESS_THAN: 'lessThan' as const,
+ LESS_THAN_OR_EQUAL: 'lessThanOrEqual' as const,
+ GREATER_THAN: 'greaterThan' as const,
+ GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual' as const,
+ IN_RANGE: 'inRange' as const,
+ BLANK: 'blank' as const,
+ NOT_BLANK: 'notBlank' as const,
+ // Custom server-side date filter operators
+ SERVER_EQUALS: 'serverEquals' as const,
+ SERVER_NOT_EQUAL: 'serverNotEqual' as const,
+ SERVER_BEFORE: 'serverBefore' as const,
+ SERVER_AFTER: 'serverAfter' as const,
+ SERVER_IN_RANGE: 'serverInRange' as const,
+ SERVER_BLANK: 'serverBlank' as const,
+ SERVER_NOT_BLANK: 'serverNotBlank' as const,
+} as const;
+
+export const SQL_OPERATORS = {
+ EQUALS: '==',
Review Comment:
**Suggestion:** Logic error: SQL equality operator is incorrect —
`SQL_OPERATORS.EQUALS` is set to '==' (JavaScript equality) instead of SQL '=';
this will produce invalid SQL when equality comparisons are built. [logic error]
**Severity Level:** Minor ⚠️
```suggestion
EQUALS: '=',
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
This is a real logic/semantic bug: SQL uses '=' for equality, not '=='. The
PR maps AG Grid equals to SQL_OPERATORS.EQUALS and then emits that token into
generated SQL — producing invalid or nonstandard SQL. Replacing '==' with '='
fixes the generated SQL and is low risk.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts
**Line:** 72:72
**Comment:**
*Logic Error: Logic error: SQL equality operator is incorrect —
`SQL_OPERATORS.EQUALS` is set to '==' (JavaScript equality) instead of SQL '=';
this will produce invalid SQL when equality comparisons are built.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts:
##########
@@ -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 () => {
Review Comment:
**Suggestion:** Tests mutate document.activeElement repeatedly without
restoring it, which can leak state between tests and cause flakiness; capture
the original activeElement in beforeEach and restore it in afterEach (use a
small helper to set activeElement). [possible bug]
**Severity Level:** Critical 🚨
```suggestion
let originalActiveElement: Element | null = null;
const setActiveElement = (el: Element | null) => {
Object.defineProperty(document, 'activeElement', {
writable: true,
configurable: true,
value: el,
} as PropertyDescriptor);
};
beforeEach(() => {
originalActiveElement = document.activeElement;
});
afterEach(() => {
try {
setActiveElement(originalActiveElement);
} catch (e) {
// ignore environments where activeElement is not configurable
}
});
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
The tests repeatedly mutate document.activeElement (Object.defineProperty)
and never restore it.
That can leak state between tests and cause flakiness in other tests that
rely on document.activeElement.
Adding a beforeEach/afterEach to capture and restore the original active
element is a targeted, correct fix
that reduces test coupling. The proposed improved code is applicable to the
PR and will make the suite more robust.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts
**Line:** 26:26
**Comment:**
*Possible Bug: Tests mutate document.activeElement repeatedly without
restoring it, which can leak state between tests and cause flakiness; capture
the original activeElement in beforeEach and restore it in afterEach (use a
small helper to set activeElement).
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts:
##########
@@ -102,6 +104,73 @@ const getFilterType = (col: InputColumn) => {
}
};
+/**
+ * Filter value getter for temporal columns.
+ * Returns null for DateWithFormatter objects with null input,
+ * enabling AG Grid's blank filter to correctly identify null dates.
+ */
+const dateFilterValueGetter = (params: {
+ data: Record<string, unknown>;
+ colDef: { field?: string };
+}) => {
+ const value = params.data?.[params.colDef.field as string];
+ // Return null for DateWithFormatter with null input so AG Grid blank filter
works
+ if (value instanceof DateWithFormatter && value.input === null) {
+ return null;
Review Comment:
**Suggestion:** The current `dateFilterValueGetter` returns the
DateWithFormatter wrapper object for non-null values and uses `instanceof
DateWithFormatter` to detect it; this can cause AG Grid to receive a
non-primitive (wrapper) instead of the underlying Date/null and `instanceof`
can fail across realms or with plain objects. Return the underlying `input`
value when the wrapper is present and use a structural check to detect it.
[logic error]
**Severity Level:** Minor ⚠️
```suggestion
const field = params?.colDef?.field;
const value = field ? params.data?.[field] : undefined;
// If it's a wrapper-like object with `input`, return the underlying input
(Date or null)
if (value && typeof value === 'object' && 'input' in value) {
return (value as any).input === null ? null : (value as any).input;
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
This suggestion fixes a real interoperability issue: AG Grid date filters
expect a Date or null, not a wrapper object. The current code only returns null
for wrapper-with-null but returns the wrapper for non-null values, which can
break filtering or comparators and can fail `instanceof` across realms.
Returning the underlying `input` when present and using a structural check is
safer and directly addresses the bug.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts
**Line:** 116:119
**Comment:**
*Logic Error: The current `dateFilterValueGetter` returns the
DateWithFormatter wrapper object for non-null values and uses `instanceof
DateWithFormatter` to detect it; this can cause AG Grid to receive a
non-primitive (wrapper) instead of the underlying Date/null and `instanceof`
can fail across realms or with plain objects. Return the underlying `input`
value when the wrapper is present and use a structural check to detect it.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts:
##########
@@ -0,0 +1,734 @@
+/**
+ * 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.
+ */
+
+export type AgGridFilterType = 'text' | 'number' | 'date' | 'set' | 'boolean';
+
+export type AgGridFilterOperator =
+ | 'equals'
+ | 'notEqual'
+ | 'contains'
+ | 'notContains'
+ | 'startsWith'
+ | 'endsWith'
+ | 'lessThan'
+ | 'lessThanOrEqual'
+ | 'greaterThan'
+ | 'greaterThanOrEqual'
+ | 'inRange'
+ | 'blank'
+ | 'notBlank'
+ // Custom server-side date filter operators (always pass client-side
filtering)
+ | 'serverEquals'
+ | 'serverNotEqual'
+ | 'serverBefore'
+ | 'serverAfter'
+ | 'serverInRange'
+ | 'serverBlank'
+ | 'serverNotBlank';
+
+export type AgGridLogicalOperator = 'AND' | 'OR';
+
+export const FILTER_OPERATORS = {
+ EQUALS: 'equals' as const,
+ NOT_EQUAL: 'notEqual' as const,
+ CONTAINS: 'contains' as const,
+ NOT_CONTAINS: 'notContains' as const,
+ STARTS_WITH: 'startsWith' as const,
+ ENDS_WITH: 'endsWith' as const,
+ LESS_THAN: 'lessThan' as const,
+ LESS_THAN_OR_EQUAL: 'lessThanOrEqual' as const,
+ GREATER_THAN: 'greaterThan' as const,
+ GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual' as const,
+ IN_RANGE: 'inRange' as const,
+ BLANK: 'blank' as const,
+ NOT_BLANK: 'notBlank' as const,
+ // Custom server-side date filter operators
+ SERVER_EQUALS: 'serverEquals' as const,
+ SERVER_NOT_EQUAL: 'serverNotEqual' as const,
+ SERVER_BEFORE: 'serverBefore' as const,
+ SERVER_AFTER: 'serverAfter' as const,
+ SERVER_IN_RANGE: 'serverInRange' as const,
+ SERVER_BLANK: 'serverBlank' as const,
+ SERVER_NOT_BLANK: 'serverNotBlank' as const,
+} as const;
+
+export const SQL_OPERATORS = {
+ EQUALS: '==',
+ NOT_EQUALS: '!=',
+ ILIKE: 'ILIKE',
+ NOT_ILIKE: 'NOT ILIKE',
+ LESS_THAN: '<',
+ LESS_THAN_OR_EQUAL: '<=',
+ GREATER_THAN: '>',
+ GREATER_THAN_OR_EQUAL: '>=',
+ BETWEEN: 'BETWEEN',
+ IS_NULL: 'IS NULL',
+ IS_NOT_NULL: 'IS NOT NULL',
+ IN: 'IN',
+ TEMPORAL_RANGE: 'TEMPORAL_RANGE',
+} as const;
+
+export type FilterValue = string | number | boolean | Date | null;
+
+const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_. ()%*+\-/]+$/;
+
+export interface AgGridSimpleFilter {
+ filterType: AgGridFilterType;
+ type: AgGridFilterOperator;
+ filter?: FilterValue;
+ filterTo?: FilterValue;
+ // Date filter properties
+ dateFrom?: string | null;
+ dateTo?: string | null;
+}
+
+export interface AgGridCompoundFilter {
+ filterType: AgGridFilterType;
+ operator: AgGridLogicalOperator;
+ condition1: AgGridSimpleFilter;
+ condition2: AgGridSimpleFilter;
+ conditions?: AgGridSimpleFilter[];
+}
+
+export interface AgGridSetFilter {
+ filterType: 'set';
+ values: FilterValue[];
+}
+
+export type AgGridFilterModel = Record<
+ string,
+ AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter
+>;
+
+export interface SQLAlchemyFilter {
+ col: string;
+ op: string;
+ val: FilterValue | FilterValue[];
+}
+
+export interface ConvertedFilter {
+ simpleFilters: SQLAlchemyFilter[];
+ complexWhere?: string;
+ havingClause?: string;
+}
+
+const AG_GRID_TO_SQLA_OPERATOR_MAP: Record<AgGridFilterOperator, string> = {
+ [FILTER_OPERATORS.EQUALS]: SQL_OPERATORS.EQUALS,
+ [FILTER_OPERATORS.NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
+ [FILTER_OPERATORS.CONTAINS]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.NOT_CONTAINS]: SQL_OPERATORS.NOT_ILIKE,
+ [FILTER_OPERATORS.STARTS_WITH]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.ENDS_WITH]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.LESS_THAN]: SQL_OPERATORS.LESS_THAN,
+ [FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: SQL_OPERATORS.LESS_THAN_OR_EQUAL,
+ [FILTER_OPERATORS.GREATER_THAN]: SQL_OPERATORS.GREATER_THAN,
+ [FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]:
SQL_OPERATORS.GREATER_THAN_OR_EQUAL,
+ [FILTER_OPERATORS.IN_RANGE]: SQL_OPERATORS.BETWEEN,
+ [FILTER_OPERATORS.BLANK]: SQL_OPERATORS.IS_NULL,
+ [FILTER_OPERATORS.NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
+ // Server-side date filter operators (map to same SQL operators as standard
ones)
+ [FILTER_OPERATORS.SERVER_EQUALS]: SQL_OPERATORS.EQUALS,
+ [FILTER_OPERATORS.SERVER_NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
+ [FILTER_OPERATORS.SERVER_BEFORE]: SQL_OPERATORS.LESS_THAN,
+ [FILTER_OPERATORS.SERVER_AFTER]: SQL_OPERATORS.GREATER_THAN,
+ [FILTER_OPERATORS.SERVER_IN_RANGE]: SQL_OPERATORS.BETWEEN,
+ [FILTER_OPERATORS.SERVER_BLANK]: SQL_OPERATORS.IS_NULL,
+ [FILTER_OPERATORS.SERVER_NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
+};
+
+/**
+ * Escapes single quotes in SQL strings to prevent SQL injection
+ * @param value - String value to escape
+ * @returns Escaped string safe for SQL queries
+ */
+function escapeSQLString(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Validates a column name to prevent SQL injection
+ * Checks for: non-empty string, length limit, allowed characters
+ * @param columnName - Column name to validate
+ * @returns True if the column name is valid, false otherwise
+ */
+function validateColumnName(columnName: string): boolean {
+ if (!columnName || typeof columnName !== 'string') {
+ return false;
+ }
+
+ if (columnName.length > 255) {
+ return false;
+ }
+
+ if (!COLUMN_NAME_REGEX.test(columnName)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Validates a filter value for a given operator
+ * BLANK and NOT_BLANK operators don't require values
+ * @param value - Filter value to validate
+ * @param operator - AG Grid filter operator
+ * @returns True if the value is valid for the operator, false otherwise
+ */
+function validateFilterValue(
+ value: FilterValue | undefined,
+ operator: AgGridFilterOperator,
+): boolean {
+ if (
+ operator === FILTER_OPERATORS.BLANK ||
+ operator === FILTER_OPERATORS.NOT_BLANK
+ ) {
+ return true;
+ }
+
+ if (value === undefined) {
+ return false;
+ }
+
+ const valueType = typeof value;
+ if (
+ value !== null &&
+ valueType !== 'string' &&
+ valueType !== 'number' &&
+ valueType !== 'boolean' &&
+ !(value instanceof Date)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function formatValueForOperator(
+ operator: AgGridFilterOperator,
+ value: FilterValue,
+): FilterValue {
+ if (typeof value === 'string') {
+ if (
+ operator === FILTER_OPERATORS.CONTAINS ||
+ operator === FILTER_OPERATORS.NOT_CONTAINS
+ ) {
+ return `%${value}%`;
+ }
+ if (operator === FILTER_OPERATORS.STARTS_WITH) {
+ return `${value}%`;
+ }
+ if (operator === FILTER_OPERATORS.ENDS_WITH) {
+ return `%${value}`;
+ }
+ }
+ return value;
+}
+
+/**
+ * Convert a date filter to a WHERE clause
+ * @param columnName - Column name
+ * @param filter - AG Grid date filter
+ * @returns WHERE clause string for date filter
+ */
+function dateFilterToWhereClause(
+ columnName: string,
+ filter: AgGridSimpleFilter,
+): string {
+ const { type, dateFrom, dateTo, filter: filterValue, filterTo } = filter;
+
+ // Support both dateFrom/dateTo and filter/filterTo
+ const fromDate = dateFrom || (filterValue as string);
+ const toDate = dateTo || (filterTo as string);
+
+ // Convert based on operator type
+ switch (type) {
+ case FILTER_OPERATORS.EQUALS:
+ if (!fromDate) return '';
+ // For equals, check if date is within the full day range
+ return `(${columnName} >= '${getStartOfDay(fromDate)}' AND ${columnName}
<= '${getEndOfDay(fromDate)}')`;
+
+ case FILTER_OPERATORS.NOT_EQUAL:
+ if (!fromDate) return '';
+ // For not equals, exclude the full day range
+ return `(${columnName} < '${getStartOfDay(fromDate)}' OR ${columnName} >
'${getEndOfDay(fromDate)}')`;
+
+ case FILTER_OPERATORS.LESS_THAN:
+ if (!fromDate) return '';
+ return `${columnName} < '${getStartOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
+ if (!fromDate) return '';
+ return `${columnName} <= '${getEndOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.GREATER_THAN:
+ if (!fromDate) return '';
+ return `${columnName} > '${getEndOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
+ if (!fromDate) return '';
+ return `${columnName} >= '${getStartOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.IN_RANGE:
+ if (!fromDate || !toDate) return '';
+ return `${columnName} ${SQL_OPERATORS.BETWEEN}
'${getStartOfDay(fromDate)}' AND '${getEndOfDay(toDate)}'`;
+
+ case FILTER_OPERATORS.BLANK:
+ return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
+
+ case FILTER_OPERATORS.NOT_BLANK:
+ return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
+
+ default:
+ return '';
+ }
+}
+
+function simpleFilterToWhereClause(
+ columnName: string,
+ filter: AgGridSimpleFilter,
+): string {
+ // Check if this is a date filter and handle it specially
+ if (filter.filterType === 'date') {
+ return dateFilterToWhereClause(columnName, filter);
+ }
+
+ const { type, filter: value, filterTo } = filter;
+
+ const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type];
+ if (!operator) {
+ return '';
+ }
+
+ if (!validateFilterValue(value, type)) {
+ return '';
+ }
+
+ if (type === FILTER_OPERATORS.BLANK) {
+ return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
+ }
+
+ if (type === FILTER_OPERATORS.NOT_BLANK) {
+ return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
+ }
+
+ if (value === null || value === undefined) {
+ return '';
+ }
+
+ if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
+ return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
+ }
+
+ const formattedValue = formatValueForOperator(type, value!);
+
+ if (
+ operator === SQL_OPERATORS.ILIKE ||
+ operator === SQL_OPERATORS.NOT_ILIKE
+ ) {
+ return `${columnName} ${operator}
'${escapeSQLString(String(formattedValue))}'`;
+ }
+
+ if (typeof formattedValue === 'string') {
+ return `${columnName} ${operator} '${escapeSQLString(formattedValue)}'`;
+ }
+
+ return `${columnName} ${operator} ${formattedValue}`;
+}
+
+function isCompoundFilter(
+ filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter,
+): filter is AgGridCompoundFilter {
+ return (
+ 'operator' in filter && ('condition1' in filter || 'conditions' in filter)
+ );
+}
+
+function isSetFilter(
+ filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter,
+): filter is AgGridSetFilter {
+ return filter.filterType === 'set' && 'values' in filter;
+}
+
+function compoundFilterToWhereClause(
+ columnName: string,
+ filter: AgGridCompoundFilter,
+): string {
+ const { operator, condition1, condition2, conditions } = filter;
+
+ if (conditions && conditions.length > 0) {
+ const clauses = conditions
+ .map(cond => {
+ const clause = simpleFilterToWhereClause(columnName, cond);
+
+ return clause;
+ })
+ .filter(clause => clause !== '');
+
+ if (clauses.length === 0) {
+ return '';
+ }
+
+ if (clauses.length === 1) {
+ return clauses[0];
+ }
+
+ const result = `(${clauses.join(` ${operator} `)})`;
+
+ return result;
+ }
+
+ const clause1 = simpleFilterToWhereClause(columnName, condition1);
+ const clause2 = simpleFilterToWhereClause(columnName, condition2);
+
+ if (!clause1 && !clause2) {
+ return '';
+ }
+
+ if (!clause1) {
+ return clause2;
+ }
+
+ if (!clause2) {
+ return clause1;
+ }
+
+ const result = `(${clause1} ${operator} ${clause2})`;
+ return result;
+}
+
+/**
+ * Format a date string to ISO format expected by Superset, preserving local
timezone
+ * @param dateStr - Date string from AG Grid filter
+ * @returns ISO formatted date string in local timezone
+ */
+function formatDateForSuperset(dateStr: string): string {
+ // AG Grid typically provides dates in format: "YYYY-MM-DD HH:MM:SS"
+ // Superset expects: "YYYY-MM-DDTHH:MM:SS" in local timezone (not UTC)
+ const date = new Date(dateStr);
+ if (Number.isNaN(date.getTime())) {
+ return dateStr; // Return as-is if invalid
+ }
+
+ // Format date in local timezone, not UTC
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ const formatted = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
+ return formatted;
+}
+
+/**
+ * Get the start of day for a given date string
+ * @param dateStr - Date string from AG Grid filter
+ * @returns ISO formatted date string at 00:00:00
+ */
+function getStartOfDay(dateStr: string): string {
+ const date = new Date(dateStr);
+ date.setHours(0, 0, 0, 0);
+ return formatDateForSuperset(date.toISOString());
+}
+
+/**
+ * Get the end of day for a given date string
+ * @param dateStr - Date string from AG Grid filter
+ * @returns ISO formatted date string at 23:59:59
+ */
+function getEndOfDay(dateStr: string): string {
+ const date = new Date(dateStr);
+ date.setHours(23, 59, 59, 999);
+ return formatDateForSuperset(date.toISOString());
Review Comment:
**Suggestion:** Timezone/round-trip bug: `getStartOfDay` and `getEndOfDay`
build a Date, mutate it to local midnight/end, then pass date.toISOString()
into `formatDateForSuperset` which re-parses the UTC ISO string and converts
back to local time — this can shift the day and produce incorrect start/end
timestamps; format and return the local-date ISO directly from the Date object
instead. [logic error]
**Severity Level:** Minor ⚠️
```suggestion
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = '00';
const minutes = '00';
const seconds = '00';
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
}
/**
* Get the end of day for a given date string
* @param dateStr - Date string from AG Grid filter
* @returns ISO formatted date string at 23:59:59
*/
function getEndOfDay(dateStr: string): string {
const date = new Date(dateStr);
date.setHours(23, 59, 59, 999);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = '23';
const minutes = '59';
const seconds = '59';
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
The code creates a Date, mutates it to local midnight/end, then calls
toISOString() and passes that back into formatDateForSuperset which re-parses
it and interprets it as UTC -> local conversion, producing shifted timestamps.
That's a real timezone/round-trip bug. Formatting the local Date components
directly (or changing formatDateForSuperset to accept a Date) prevents the
shift.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts
**Line:** 448:459
**Comment:**
*Logic Error: Timezone/round-trip bug: `getStartOfDay` and
`getEndOfDay` build a Date, mutate it to local midnight/end, then pass
date.toISOString() into `formatDateForSuperset` which re-parses the UTC ISO
string and converts back to local time — this can shift the day and produce
incorrect start/end timestamps; format and return the local-date ISO directly
from the Date object instead.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts:
##########
@@ -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,
+ });
Review Comment:
**Suggestion:** Repeated direct Object.defineProperty calls are duplicated
and error-prone; use a helper function (added in the top-level describe) to set
the active element so all tests consistently mutate/restore
document.activeElement. [code duplication]
**Severity Level:** Minor ⚠️
```suggestion
setActiveElement(mockInput);
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Wrapping the repeated Object.defineProperty logic in a small helper (e.g.,
setActiveElement)
reduces duplication and centralizes configurable/writable descriptor usage,
making maintenance easier.
This suggestion assumes adding that helper (and probably the
beforeEach/afterEach to restore), which is appropriate here.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts
**Line:** 145:148
**Comment:**
*Code Duplication: Repeated direct Object.defineProperty calls are
duplicated and error-prone; use a helper function (added in the top-level
describe) to set the active element so all tests consistently mutate/restore
document.activeElement.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts:
##########
@@ -0,0 +1,734 @@
+/**
+ * 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.
+ */
+
+export type AgGridFilterType = 'text' | 'number' | 'date' | 'set' | 'boolean';
+
+export type AgGridFilterOperator =
+ | 'equals'
+ | 'notEqual'
+ | 'contains'
+ | 'notContains'
+ | 'startsWith'
+ | 'endsWith'
+ | 'lessThan'
+ | 'lessThanOrEqual'
+ | 'greaterThan'
+ | 'greaterThanOrEqual'
+ | 'inRange'
+ | 'blank'
+ | 'notBlank'
+ // Custom server-side date filter operators (always pass client-side
filtering)
+ | 'serverEquals'
+ | 'serverNotEqual'
+ | 'serverBefore'
+ | 'serverAfter'
+ | 'serverInRange'
+ | 'serverBlank'
+ | 'serverNotBlank';
+
+export type AgGridLogicalOperator = 'AND' | 'OR';
+
+export const FILTER_OPERATORS = {
+ EQUALS: 'equals' as const,
+ NOT_EQUAL: 'notEqual' as const,
+ CONTAINS: 'contains' as const,
+ NOT_CONTAINS: 'notContains' as const,
+ STARTS_WITH: 'startsWith' as const,
+ ENDS_WITH: 'endsWith' as const,
+ LESS_THAN: 'lessThan' as const,
+ LESS_THAN_OR_EQUAL: 'lessThanOrEqual' as const,
+ GREATER_THAN: 'greaterThan' as const,
+ GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual' as const,
+ IN_RANGE: 'inRange' as const,
+ BLANK: 'blank' as const,
+ NOT_BLANK: 'notBlank' as const,
+ // Custom server-side date filter operators
+ SERVER_EQUALS: 'serverEquals' as const,
+ SERVER_NOT_EQUAL: 'serverNotEqual' as const,
+ SERVER_BEFORE: 'serverBefore' as const,
+ SERVER_AFTER: 'serverAfter' as const,
+ SERVER_IN_RANGE: 'serverInRange' as const,
+ SERVER_BLANK: 'serverBlank' as const,
+ SERVER_NOT_BLANK: 'serverNotBlank' as const,
+} as const;
+
+export const SQL_OPERATORS = {
+ EQUALS: '==',
+ NOT_EQUALS: '!=',
+ ILIKE: 'ILIKE',
+ NOT_ILIKE: 'NOT ILIKE',
+ LESS_THAN: '<',
+ LESS_THAN_OR_EQUAL: '<=',
+ GREATER_THAN: '>',
+ GREATER_THAN_OR_EQUAL: '>=',
+ BETWEEN: 'BETWEEN',
+ IS_NULL: 'IS NULL',
+ IS_NOT_NULL: 'IS NOT NULL',
+ IN: 'IN',
+ TEMPORAL_RANGE: 'TEMPORAL_RANGE',
+} as const;
+
+export type FilterValue = string | number | boolean | Date | null;
+
+const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_. ()%*+\-/]+$/;
+
+export interface AgGridSimpleFilter {
+ filterType: AgGridFilterType;
+ type: AgGridFilterOperator;
+ filter?: FilterValue;
+ filterTo?: FilterValue;
+ // Date filter properties
+ dateFrom?: string | null;
+ dateTo?: string | null;
+}
+
+export interface AgGridCompoundFilter {
+ filterType: AgGridFilterType;
+ operator: AgGridLogicalOperator;
+ condition1: AgGridSimpleFilter;
+ condition2: AgGridSimpleFilter;
+ conditions?: AgGridSimpleFilter[];
+}
+
+export interface AgGridSetFilter {
+ filterType: 'set';
+ values: FilterValue[];
+}
+
+export type AgGridFilterModel = Record<
+ string,
+ AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter
+>;
+
+export interface SQLAlchemyFilter {
+ col: string;
+ op: string;
+ val: FilterValue | FilterValue[];
+}
+
+export interface ConvertedFilter {
+ simpleFilters: SQLAlchemyFilter[];
+ complexWhere?: string;
+ havingClause?: string;
+}
+
+const AG_GRID_TO_SQLA_OPERATOR_MAP: Record<AgGridFilterOperator, string> = {
+ [FILTER_OPERATORS.EQUALS]: SQL_OPERATORS.EQUALS,
+ [FILTER_OPERATORS.NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
+ [FILTER_OPERATORS.CONTAINS]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.NOT_CONTAINS]: SQL_OPERATORS.NOT_ILIKE,
+ [FILTER_OPERATORS.STARTS_WITH]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.ENDS_WITH]: SQL_OPERATORS.ILIKE,
+ [FILTER_OPERATORS.LESS_THAN]: SQL_OPERATORS.LESS_THAN,
+ [FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: SQL_OPERATORS.LESS_THAN_OR_EQUAL,
+ [FILTER_OPERATORS.GREATER_THAN]: SQL_OPERATORS.GREATER_THAN,
+ [FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]:
SQL_OPERATORS.GREATER_THAN_OR_EQUAL,
+ [FILTER_OPERATORS.IN_RANGE]: SQL_OPERATORS.BETWEEN,
+ [FILTER_OPERATORS.BLANK]: SQL_OPERATORS.IS_NULL,
+ [FILTER_OPERATORS.NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
+ // Server-side date filter operators (map to same SQL operators as standard
ones)
+ [FILTER_OPERATORS.SERVER_EQUALS]: SQL_OPERATORS.EQUALS,
+ [FILTER_OPERATORS.SERVER_NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS,
+ [FILTER_OPERATORS.SERVER_BEFORE]: SQL_OPERATORS.LESS_THAN,
+ [FILTER_OPERATORS.SERVER_AFTER]: SQL_OPERATORS.GREATER_THAN,
+ [FILTER_OPERATORS.SERVER_IN_RANGE]: SQL_OPERATORS.BETWEEN,
+ [FILTER_OPERATORS.SERVER_BLANK]: SQL_OPERATORS.IS_NULL,
+ [FILTER_OPERATORS.SERVER_NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL,
+};
+
+/**
+ * Escapes single quotes in SQL strings to prevent SQL injection
+ * @param value - String value to escape
+ * @returns Escaped string safe for SQL queries
+ */
+function escapeSQLString(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Validates a column name to prevent SQL injection
+ * Checks for: non-empty string, length limit, allowed characters
+ * @param columnName - Column name to validate
+ * @returns True if the column name is valid, false otherwise
+ */
+function validateColumnName(columnName: string): boolean {
+ if (!columnName || typeof columnName !== 'string') {
+ return false;
+ }
+
+ if (columnName.length > 255) {
+ return false;
+ }
+
+ if (!COLUMN_NAME_REGEX.test(columnName)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Validates a filter value for a given operator
+ * BLANK and NOT_BLANK operators don't require values
+ * @param value - Filter value to validate
+ * @param operator - AG Grid filter operator
+ * @returns True if the value is valid for the operator, false otherwise
+ */
+function validateFilterValue(
+ value: FilterValue | undefined,
+ operator: AgGridFilterOperator,
+): boolean {
+ if (
+ operator === FILTER_OPERATORS.BLANK ||
+ operator === FILTER_OPERATORS.NOT_BLANK
+ ) {
+ return true;
+ }
+
+ if (value === undefined) {
+ return false;
+ }
+
+ const valueType = typeof value;
+ if (
+ value !== null &&
+ valueType !== 'string' &&
+ valueType !== 'number' &&
+ valueType !== 'boolean' &&
+ !(value instanceof Date)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function formatValueForOperator(
+ operator: AgGridFilterOperator,
+ value: FilterValue,
+): FilterValue {
+ if (typeof value === 'string') {
+ if (
+ operator === FILTER_OPERATORS.CONTAINS ||
+ operator === FILTER_OPERATORS.NOT_CONTAINS
+ ) {
+ return `%${value}%`;
+ }
+ if (operator === FILTER_OPERATORS.STARTS_WITH) {
+ return `${value}%`;
+ }
+ if (operator === FILTER_OPERATORS.ENDS_WITH) {
+ return `%${value}`;
+ }
+ }
+ return value;
+}
+
+/**
+ * Convert a date filter to a WHERE clause
+ * @param columnName - Column name
+ * @param filter - AG Grid date filter
+ * @returns WHERE clause string for date filter
+ */
+function dateFilterToWhereClause(
+ columnName: string,
+ filter: AgGridSimpleFilter,
+): string {
+ const { type, dateFrom, dateTo, filter: filterValue, filterTo } = filter;
+
+ // Support both dateFrom/dateTo and filter/filterTo
+ const fromDate = dateFrom || (filterValue as string);
+ const toDate = dateTo || (filterTo as string);
+
+ // Convert based on operator type
+ switch (type) {
+ case FILTER_OPERATORS.EQUALS:
+ if (!fromDate) return '';
+ // For equals, check if date is within the full day range
+ return `(${columnName} >= '${getStartOfDay(fromDate)}' AND ${columnName}
<= '${getEndOfDay(fromDate)}')`;
+
+ case FILTER_OPERATORS.NOT_EQUAL:
+ if (!fromDate) return '';
+ // For not equals, exclude the full day range
+ return `(${columnName} < '${getStartOfDay(fromDate)}' OR ${columnName} >
'${getEndOfDay(fromDate)}')`;
+
+ case FILTER_OPERATORS.LESS_THAN:
+ if (!fromDate) return '';
+ return `${columnName} < '${getStartOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.LESS_THAN_OR_EQUAL:
+ if (!fromDate) return '';
+ return `${columnName} <= '${getEndOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.GREATER_THAN:
+ if (!fromDate) return '';
+ return `${columnName} > '${getEndOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL:
+ if (!fromDate) return '';
+ return `${columnName} >= '${getStartOfDay(fromDate)}'`;
+
+ case FILTER_OPERATORS.IN_RANGE:
+ if (!fromDate || !toDate) return '';
+ return `${columnName} ${SQL_OPERATORS.BETWEEN}
'${getStartOfDay(fromDate)}' AND '${getEndOfDay(toDate)}'`;
+
+ case FILTER_OPERATORS.BLANK:
+ return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
+
+ case FILTER_OPERATORS.NOT_BLANK:
+ return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
+
+ default:
+ return '';
+ }
+}
+
+function simpleFilterToWhereClause(
+ columnName: string,
+ filter: AgGridSimpleFilter,
+): string {
+ // Check if this is a date filter and handle it specially
+ if (filter.filterType === 'date') {
+ return dateFilterToWhereClause(columnName, filter);
+ }
+
+ const { type, filter: value, filterTo } = filter;
+
+ const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type];
+ if (!operator) {
+ return '';
+ }
+
+ if (!validateFilterValue(value, type)) {
+ return '';
+ }
+
+ if (type === FILTER_OPERATORS.BLANK) {
+ return `${columnName} ${SQL_OPERATORS.IS_NULL}`;
+ }
+
+ if (type === FILTER_OPERATORS.NOT_BLANK) {
+ return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`;
+ }
+
+ if (value === null || value === undefined) {
+ return '';
+ }
+
+ if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) {
+ return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`;
Review Comment:
**Suggestion:** Incorrect formatting for IN_RANGE values: the IN_RANGE
branch in `simpleFilterToWhereClause` inserts raw `value` and `filterTo`
without applying `formatValueForOperator` or escaping/quoting string values,
which can produce invalid SQL or allow injection-like issues; format both
endpoints, escape strings, and quote them when needed. [security]
**Severity Level:** Critical 🚨
```suggestion
const fromFormatted = formatValueForOperator(type, value!);
const toFormatted = formatValueForOperator(type, filterTo as
FilterValue);
const render = (v: FilterValue) =>
typeof v === 'string' ? `'${escapeSQLString(String(v))}'` : v;
return `${columnName} ${SQL_OPERATORS.BETWEEN} ${render(fromFormatted)}
AND ${render(toFormatted)}`;
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
The current code emits the raw value/filterTo into a BETWEEN clause without
applying formatValueForOperator, escaping single quotes, or quoting string
endpoints. That can produce invalid SQL (unquoted strings) and potential
injection-like issues. The suggested change to format, escape and quote string
endpoints addresses a real correctness/security concern.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts
**Line:** 335:335
**Comment:**
*Security: Incorrect formatting for IN_RANGE values: the IN_RANGE
branch in `simpleFilterToWhereClause` inserts raw `value` and `filterTo`
without applying `formatValueForOperator` or escaping/quoting string values,
which can produce invalid SQL or allow injection-like issues; format both
endpoints, escape strings, and quote them when needed.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts:
##########
@@ -0,0 +1,166 @@
+/**
+ * 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 type { RefObject } from 'react';
+import { GridApi } from 'ag-grid-community';
+import { convertAgGridFiltersToSQL } from './agGridFilterConverter';
+import type {
+ AgGridFilterModel,
+ SQLAlchemyFilter,
+} from './agGridFilterConverter';
+import type { AgGridReact } from
'@superset-ui/core/components/ThemedAgGridReact';
+import type { FilterInputPosition, AGGridFilterInstance } from '../types';
+import { FILTER_INPUT_POSITIONS, FILTER_CONDITION_BODY_INDEX } from
'../consts';
+
+export interface FilterState {
+ originalFilterModel: AgGridFilterModel;
+ simpleFilters: SQLAlchemyFilter[];
+ complexWhere?: string;
+ havingClause?: string;
+ lastFilteredColumn?: string;
+ inputPosition?: FilterInputPosition;
+}
+
+/**
+ * Detects which input position (first or second) was last modified in a filter
+ * @param gridApi - AG Grid API reference
+ * @param filterModel - Current filter model
+ * @param activeElement - The currently focused DOM element
+ * @returns Object containing lastFilteredColumn and inputPosition
+ */
+async function detectLastFilteredInput(
+ gridApi: GridApi,
+ filterModel: AgGridFilterModel,
+ activeElement: HTMLElement,
+): Promise<{
+ lastFilteredColumn?: string;
+ inputPosition: FilterInputPosition;
+}> {
+ let inputPosition: FilterInputPosition = FILTER_INPUT_POSITIONS.UNKNOWN;
+ let lastFilteredColumn: string | undefined;
+
+ // Loop through filtered columns to find which one contains the active
element
+ for (const [colId] of Object.entries(filterModel)) {
+ const filterInstance = (await gridApi.getColumnFilterInstance(
+ colId,
+ )) as AGGridFilterInstance | null;
+
+ if (!filterInstance) {
+ continue;
+ }
+
+ if (filterInstance.eConditionBodies) {
+ const conditionBodies = filterInstance.eConditionBodies;
+
+ // Check first condition body
+ if (
+ conditionBodies[FILTER_CONDITION_BODY_INDEX.FIRST]?.contains(
+ activeElement,
+ )
+ ) {
+ inputPosition = FILTER_INPUT_POSITIONS.FIRST;
+ lastFilteredColumn = colId;
+ break;
+ }
+
+ // Check second condition body
+ if (
+ conditionBodies[FILTER_CONDITION_BODY_INDEX.SECOND]?.contains(
+ activeElement,
+ )
+ ) {
+ inputPosition = FILTER_INPUT_POSITIONS.SECOND;
+ lastFilteredColumn = colId;
+ break;
+ }
+ }
+
+ if (filterInstance.eJoinAnds) {
+ for (const joinAnd of filterInstance.eJoinAnds) {
+ if (joinAnd.eGui?.contains(activeElement)) {
+ inputPosition = FILTER_INPUT_POSITIONS.FIRST;
+ lastFilteredColumn = colId;
+ break;
+ }
+ }
+ if (lastFilteredColumn) break;
+ }
+
+ if (filterInstance.eJoinOrs) {
+ for (const joinOr of filterInstance.eJoinOrs) {
+ if (joinOr.eGui?.contains(activeElement)) {
+ inputPosition = FILTER_INPUT_POSITIONS.FIRST;
+ lastFilteredColumn = colId;
+ break;
+ }
+ }
+ if (lastFilteredColumn) break;
+ }
+ }
+
+ return { lastFilteredColumn, inputPosition };
+}
+
+/**
+ * Gets complete filter state including SQL conversion and input position
detection
+ * @param gridRef - React ref to AG Grid component
+ * @param metricColumns - Array of metric column names for SQL conversion
+ * @returns Complete filter state object
+ */
+export async function getCompleteFilterState(
+ gridRef: RefObject<AgGridReact>,
+ metricColumns: string[],
+): Promise<FilterState> {
+ const activeElement = document.activeElement as HTMLElement;
+
+ if (!gridRef.current?.api) {
+ return {
+ originalFilterModel: {},
+ simpleFilters: [],
+ complexWhere: undefined,
+ havingClause: undefined,
+ lastFilteredColumn: undefined,
+ inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN,
+ };
+ }
+
+ const filterModel = gridRef.current.api.getFilterModel();
+
+ // Convert filters to SQL
+ const convertedFilters = convertAgGridFiltersToSQL(
+ filterModel,
+ metricColumns,
+ );
+
+ // Detect which input was last modified
+ const { lastFilteredColumn, inputPosition } = await detectLastFilteredInput(
+ gridRef.current.api,
+ filterModel,
+ activeElement,
+ );
Review Comment:
**Suggestion:** Runtime error when document.activeElement is null: the code
casts document.activeElement to HTMLElement and always passes it into
`detectLastFilteredInput`; if there is no active element this will be null and
cause `.contains` calls to throw. Fix by treating `document.activeElement` as
nullable, only calling `detectLastFilteredInput` when it is an HTMLElement, and
defaulting `lastFilteredColumn`/`inputPosition` otherwise. [null pointer]
**Severity Level:** Minor ⚠️
```suggestion
const activeElement = document.activeElement as Element | null;
if (!gridRef.current?.api) {
return {
originalFilterModel: {},
simpleFilters: [],
complexWhere: undefined,
havingClause: undefined,
lastFilteredColumn: undefined,
inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN,
};
}
const filterModel = gridRef.current.api.getFilterModel();
// Convert filters to SQL
const convertedFilters = convertAgGridFiltersToSQL(
filterModel,
metricColumns,
);
// Detect which input was last modified (only if the active element is an
HTMLElement)
let lastFilteredColumn: string | undefined;
let inputPosition: FilterInputPosition = FILTER_INPUT_POSITIONS.UNKNOWN;
if (activeElement instanceof HTMLElement) {
const result = await detectLastFilteredInput(
gridRef.current.api,
filterModel,
activeElement,
);
lastFilteredColumn = result.lastFilteredColumn;
inputPosition = result.inputPosition;
}
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
Defensive change — treating document.activeElement as nullable and only
calling the detector when it's an HTMLElement is safe and prevents a possible
runtime mismatch. In practice element.contains(null) usually returns false and
wouldn't throw, but the current code lies to TypeScript by force-casting to
HTMLElement and could hide real edge-cases (SVGElement vs HTMLElement, null).
The proposed change is sensible and low-risk.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts
**Line:** 130:156
**Comment:**
*Null Pointer: Runtime error when document.activeElement is null: the
code casts document.activeElement to HTMLElement and always passes it into
`detectLastFilteredInput`; if there is no active element this will be null and
cause `.contains` calls to throw. Fix by treating `document.activeElement` as
nullable, only calling `detectLastFilteredInput` when it is an HTMLElement, and
defaulting `lastFilteredColumn`/`inputPosition` otherwise.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
##########
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts:
##########
@@ -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);
Review Comment:
**Suggestion:** The test expects `getColumnFilterInstance` to be called at
most twice, but depending on iteration order the function might legitimately be
called fewer than three times but not exactly constrained to <=2; assert that
fewer than the total number of columns were called (i.e., strictly less than 3)
to express the intent more robustly. [logic error]
**Severity Level:** Minor ⚠️
```suggestion
// Should stop early after finding the active element (fewer than
total columns)
expect(callCount).toBeLessThan(3);
```
<details>
<summary><b>Why it matters? ⭐ </b></summary>
The intent is to assert the search stops early; using a bound expressed
relative to the total columns
(expect(callCount).toBeLessThan(3)) is semantically equivalent to <=2 but
reads clearer about intent.
It's a minor readability improvement and is safe to apply.
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts
**Line:** 436:437
**Comment:**
*Logic Error: The test expects `getColumnFilterInstance` to be called
at most twice, but depending on iteration order the function might legitimately
be called fewer than three times but not exactly constrained to <=2; assert
that fewer than the total number of columns were called (i.e., strictly less
than 3) to express the intent more robustly.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]