Repository: lightdash/lightdash
Pull request #14828: feat(api): support dashboard filters with column references in sql charts
Author: login: ZeRego
Labels: name: released
Created: 2025-05-15T10:49:54Z | Merged: 2025-05-20T12:47:27Z

<!-- Thanks so much for your PR, your contribution is appreciated! ❤️ -->

Closes: [#14709](https://github.com/lightdash/lightdash/issues/14709)<!-- reference the related issue e.g. #150 -->

### Description:
<!-- Add a description of the changes proposed in the pull request. -->

<!-- Even better add a screenshot / gif / loom -->
<img width="783" alt="Screenshot 2025-05-15 at 17 36 03" src="https://github.com/user-attachments/assets/d7425560-7708-48e3-ada8-cb6e02b84e4f" />



### Reviewer actions

- [ ] I have manually tested the changes in the preview environment
- [ ] I have reviewed the code
- [ ] I understand that "request changes" will block this PR from merging



Diff:
diff --git a/packages/backend/src/controllers/v2/QueryController.ts b/packages/backend/src/controllers/v2/QueryController.ts
index c64472d78baf7..cef8510abf28d 100644
--- a/packages/backend/src/controllers/v2/QueryController.ts
+++ b/packages/backend/src/controllers/v2/QueryController.ts
@@ -341,6 +341,7 @@ export class QueryController extends BaseController {
                 projectUuid,
                 invalidateCache: body.invalidateCache ?? false,
                 dashboardUuid: body.dashboardUuid,
+                tileUuid: body.tileUuid,
                 dashboardFilters: body.dashboardFilters,
                 dashboardSorts: body.dashboardSorts,
                 context: context ?? QueryExecutionContext.SQL_RUNNER,
diff --git a/packages/backend/src/generated/routes.ts b/packages/backend/src/generated/routes.ts
index b4ae1c44e5ff9..7829f34f0361b 100644
--- a/packages/backend/src/generated/routes.ts
+++ b/packages/backend/src/generated/routes.ts
@@ -1208,24 +1208,12 @@ const models: TsoaRoute.Models = {
         additionalProperties: true,
     },
     // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
-    DashboardReferenceTarget: {
-        dataType: 'refAlias',
-        type: {
-            dataType: 'nestedObjectLiteral',
-            nestedProperties: {
-                reference: { dataType: 'string', required: true },
-            },
-            validators: {},
-        },
-    },
-    // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
     DashboardTileTarget: {
         dataType: 'refAlias',
         type: {
             dataType: 'union',
             subSchemas: [
                 { ref: 'DashboardFieldTarget' },
-                { ref: 'DashboardReferenceTarget' },
                 { dataType: 'enum', enums: [false] },
             ],
             validators: {},
@@ -15259,6 +15247,7 @@ const models: TsoaRoute.Models = {
                             ref: 'DashboardFilters',
                             required: true,
                         },
+                        tileUuid: { dataType: 'string', required: true },
                         dashboardUuid: { dataType: 'string', required: true },
                     },
                 },
diff --git a/packages/backend/src/generated/swagger.json b/packages/backend/src/generated/swagger.json
index 496775c84ea4a..de48ddbccb817 100644
--- a/packages/backend/src/generated/swagger.json
+++ b/packages/backend/src/generated/swagger.json
@@ -1220,23 +1220,11 @@
                 "type": "object",
                 "additionalProperties": true
             },
-            "DashboardReferenceTarget": {
-                "properties": {
-                    "reference": {
-                        "type": "string"
-                    }
-                },
-                "required": ["reference"],
-                "type": "object"
-            },
             "DashboardTileTarget": {
                 "anyOf": [
                     {
                         "$ref": "#/components/schemas/DashboardFieldTarget"
                     },
-                    {
-                        "$ref": "#/components/schemas/DashboardReferenceTarget"
-                    },
                     {
                         "type": "boolean",
                         "enum": [false]
@@ -16058,6 +16046,9 @@
                             "dashboardFilters": {
                                 "$ref": "#/components/schemas/DashboardFilters"
                             },
+                            "tileUuid": {
+                                "type": "string"
+                            },
                             "dashboardUuid": {
                                 "type": "string"
                             }
@@ -16065,6 +16056,7 @@
                         "required": [
                             "dashboardSorts",
                             "dashboardFilters",
+                            "tileUuid",
                             "dashboardUuid"
                         ],
                         "type": "object"
diff --git a/packages/backend/src/queryBuilder.class.mocks.ts b/packages/backend/src/queryBuilder.class.mocks.ts
new file mode 100644
index 0000000000000..ae7fc814ccc14
--- /dev/null
+++ b/packages/backend/src/queryBuilder.class.mocks.ts
@@ -0,0 +1,69 @@
+import {
+    DashboardFilterRule,
+    DimensionType,
+    FilterOperator,
+    SupportedDbtAdapter,
+    WeekDay,
+} from '@lightdash/common';
+
+// Common configuration for QueryBuilder
+export const DEFAULT_CONFIG = {
+    fieldQuoteChar: '"',
+    stringQuoteChar: "'",
+    escapeStringQuoteChar: "'",
+    startOfWeek: WeekDay.MONDAY,
+    adapterType: SupportedDbtAdapter.POSTGRES,
+};
+
+// Common reference maps
+export const SIMPLE_REFERENCE_MAP = {
+    test_field: {
+        type: DimensionType.STRING,
+        sql: '"test_table"."test_field"',
+    },
+};
+
+export const MULTI_FIELD_REFERENCE_MAP = {
+    field1: { type: DimensionType.STRING, sql: '"table"."field1"' },
+    field2: { type: DimensionType.NUMBER, sql: '"table"."field2"' },
+    field3: { type: DimensionType.BOOLEAN, sql: '"table"."field3"' },
+};
+
+// Common filter rules
+export const SIMPLE_FILTER_RULE = {
+    id: 'filter1',
+    target: { fieldId: 'test_field', tableName: 'test_table' },
+    operator: FilterOperator.EQUALS,
+    values: ['test_value'],
+    settings: {},
+    disabled: false,
+    label: undefined,
+} as DashboardFilterRule;
+
+export const SECOND_FILTER_RULE = {
+    id: 'filter2',
+    target: { fieldId: 'test_field', tableName: 'test_table' },
+    operator: FilterOperator.NOT_NULL,
+    values: [],
+    settings: {},
+    disabled: false,
+    label: undefined,
+} as DashboardFilterRule;
+
+// Expected SQL strings
+export const SIMPLE_QUERY_SQL =
+    'SELECT\n"test_table"."test_field" AS "test_field"\nFROM "test_table"';
+
+export const QUERY_WITH_FILTER_SQL =
+    'SELECT\n"test_table"."test_field" AS "test_field"\nFROM "test_table"\nWHERE ((\n("test_table"."test_field") IN (\'test_value\')\n))';
+
+export const QUERY_WITH_TWO_FILTERS_SQL =
+    'SELECT\n"test_table"."test_field" AS "test_field"\nFROM "test_table"\nWHERE ((\n("test_table"."test_field") IN (\'test_value\')\n) AND (\n("test_table"."test_field") IS NOT NULL\n))';
+
+export const QUERY_WITH_SUBQUERY_SQL =
+    'SELECT\n"test_field" AS "test_field"\nFROM (\nSELECT test_field FROM source_table WHERE test_field IS NOT NULL\n) AS "subquery"';
+
+export const QUERY_WITH_NESTED_FILTERS_SQL =
+    'SELECT\n"table"."field1" AS "field1",\n"table"."field2" AS "field2",\n"table"."field3" AS "field3"\nFROM "table"\nWHERE ((\n("table"."field1") IN (\'value1\')\n) AND ((\n("table"."field2") > (10)\n) OR (\n("table"."field3") = true\n)))';
+
+export const QUERY_WITH_EMPTY_SELECT_SQL = 'SELECT\n*\nFROM "test_table"';
diff --git a/packages/backend/src/queryBuilder.class.test.ts b/packages/backend/src/queryBuilder.class.test.ts
new file mode 100644
index 0000000000000..ee8d865263ceb
--- /dev/null
+++ b/packages/backend/src/queryBuilder.class.test.ts
@@ -0,0 +1,197 @@
+import {
+    DashboardFilterRule,
+    DimensionType,
+    FilterOperator,
+} from '@lightdash/common';
+import { QueryBuilder } from './queryBuilder';
+import {
+    DEFAULT_CONFIG,
+    MULTI_FIELD_REFERENCE_MAP,
+    QUERY_WITH_EMPTY_SELECT_SQL,
+    QUERY_WITH_FILTER_SQL,
+    QUERY_WITH_NESTED_FILTERS_SQL,
+    QUERY_WITH_SUBQUERY_SQL,
+    QUERY_WITH_TWO_FILTERS_SQL,
+    SECOND_FILTER_RULE,
+    SIMPLE_FILTER_RULE,
+    SIMPLE_QUERY_SQL,
+    SIMPLE_REFERENCE_MAP,
+} from './queryBuilder.class.mocks';
+
+describe('QueryBuilder class', () => {
+    describe('constructor', () => {
+        it('should initialize with minimal arguments', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: {},
+                    select: [],
+                    from: { name: 'test_table' },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder).toBeDefined();
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_EMPTY_SELECT_SQL);
+        });
+
+        it('should initialize with all arguments', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: SIMPLE_REFERENCE_MAP,
+                    select: ['test_field'],
+                    from: { name: 'test_table' },
+                    filters: {
+                        id: 'filter_group_1',
+                        and: [SIMPLE_FILTER_RULE, SECOND_FILTER_RULE],
+                    },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder).toBeDefined();
+            // We're not testing the SQL output here as it will be covered in other tests
+        });
+    });
+
+    describe('SQL generation', () => {
+        it('should generate correct SQL for a simple query', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: SIMPLE_REFERENCE_MAP,
+                    select: ['test_field'],
+                    from: { name: 'test_table' },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(SIMPLE_QUERY_SQL);
+        });
+
+        it('should generate correct SQL for a query with filters', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: SIMPLE_REFERENCE_MAP,
+                    select: ['test_field'],
+                    from: { name: 'test_table' },
+                    filters: {
+                        id: 'filter_group_1',
+                        and: [SIMPLE_FILTER_RULE],
+                    },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_FILTER_SQL);
+        });
+
+        it('should generate correct SQL for a query with two filters', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: SIMPLE_REFERENCE_MAP,
+                    select: ['test_field'],
+                    from: { name: 'test_table' },
+                    filters: {
+                        id: 'filter_group_1',
+                        and: [SIMPLE_FILTER_RULE, SECOND_FILTER_RULE],
+                    },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_TWO_FILTERS_SQL);
+        });
+
+        it('should generate correct SQL for a query with a subquery in FROM', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: {
+                        test_field: {
+                            type: DimensionType.STRING,
+                            sql: '"test_field"',
+                        },
+                    },
+                    select: ['test_field'],
+                    from: {
+                        name: 'subquery',
+                        sql: 'SELECT test_field FROM source_table WHERE test_field IS NOT NULL',
+                    },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_SUBQUERY_SQL);
+        });
+    });
+
+    // toSql tests have been moved to the 'SQL generation' section
+
+    describe('complex scenarios', () => {
+        it('should generate SQL with nested filter groups', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: MULTI_FIELD_REFERENCE_MAP,
+                    select: ['field1', 'field2', 'field3'],
+                    from: { name: 'table' },
+                    filters: {
+                        id: 'filter_group_4',
+                        and: [
+                            {
+                                id: 'filter1',
+                                target: { fieldId: 'field1' },
+                                operator: FilterOperator.EQUALS,
+                                values: ['value1'],
+                                settings: {},
+                                disabled: false,
+                            } as DashboardFilterRule,
+                            {
+                                id: 'nested_or_group',
+                                or: [
+                                    {
+                                        id: 'filter2',
+                                        target: { fieldId: 'field2' },
+                                        operator: FilterOperator.GREATER_THAN,
+                                        values: [10],
+                                        settings: {},
+                                        disabled: false,
+                                    } as DashboardFilterRule,
+                                    {
+                                        id: 'filter3',
+                                        target: { fieldId: 'field3' },
+                                        operator: FilterOperator.EQUALS,
+                                        values: [true],
+                                        settings: {},
+                                        disabled: false,
+                                    } as DashboardFilterRule,
+                                ],
+                            },
+                        ],
+                    },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_NESTED_FILTERS_SQL);
+        });
+
+        it('should handle empty select list by selecting all columns', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: {},
+                    select: [],
+                    from: { name: 'test_table' },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(queryBuilder.toSql()).toBe(QUERY_WITH_EMPTY_SELECT_SQL);
+        });
+    });
+
+    describe('error handling', () => {
+        it('should throw an error for unknown reference', () => {
+            const queryBuilder = new QueryBuilder(
+                {
+                    referenceMap: {},
+                    select: ['unknown_field'],
+                    from: { name: 'test_table' },
+                },
+                DEFAULT_CONFIG,
+            );
+            expect(() => queryBuilder.toSql()).toThrow(
+                'Unknown reference: unknown_field',
+            );
+        });
+    });
+});
diff --git a/packages/backend/src/queryBuilder.ts b/packages/backend/src/queryBuilder.ts
index 31234d08810d2..54a083afb6233 100644
--- a/packages/backend/src/queryBuilder.ts
+++ b/packages/backend/src/queryBuilder.ts
@@ -10,6 +10,7 @@ import {
     CustomBinDimension,
     CustomDimension,
     DbtModelJoinType,
+    DimensionType,
     Explore,
     FieldId,
     FieldReferenceError,
@@ -37,6 +38,7 @@ import {
     ItemsMap,
     MetricFilterRule,
     parseAllReferences,
+    renderFilterRuleSql,
     renderFilterRuleSqlFromField,
     renderTableCalculationFilterRuleSql,
     SortField,
@@ -1387,3 +1389,134 @@ export const buildQuery = ({
             fields,
         };
     });
+
+type ReferenceObject = { type: DimensionType; sql: string };
+export type ReferenceMap = Record<string, ReferenceObject> | undefined;
+type From = { name: string; sql?: string };
+
+export class QueryBuilder {
+    // Column references, to be used in select, filters, etc
+    private readonly referenceMap: ReferenceMap;
+
+    // Select values are references
+    private readonly select: string[];
+
+    private readonly from: From;
+
+    private readonly filters: FilterGroup | undefined;
+
+    constructor(
+        args: {
+            referenceMap: ReferenceMap;
+            select: string[];
+            from: From;
+            filters?: FilterGroup;
+        },
+        private config: {
+            fieldQuoteChar: string;
+            stringQuoteChar: string;
+            escapeStringQuoteChar: string;
+            startOfWeek: WeekDay | null | undefined;
+            adapterType: SupportedDbtAdapter;
+            timezone?: string;
+        },
+    ) {
+        this.select = args.select;
+        this.from = args.from;
+        this.filters = args.filters;
+        this.referenceMap = args.referenceMap;
+    }
+
+    private quotedName(value: string) {
+        return `${this.config.fieldQuoteChar}${value}${this.config.fieldQuoteChar}`;
+    }
+
+    private getReference(reference: string): ReferenceObject {
+        const referenceObject = this.referenceMap?.[reference];
+        if (!referenceObject) {
+            throw new FieldReferenceError(`Unknown reference: ${reference}`);
+        }
+        return referenceObject;
+    }
+
+    private selectsToSql(): string | undefined {
+        let selectSQL = '*';
+        if (this.select.length > 0) {
+            selectSQL = this.select
+                .map((reference) => {
+                    const referenceObject = this.getReference(reference);
+                    return `${referenceObject.sql} AS ${this.quotedName(
+                        reference,
+                    )}`;
+                })
+                .join(',\n');
+        }
+        return `SELECT\n${selectSQL}`;
+    }
+
+    private fromToSql(): string {
+        return `FROM ${
+            this.from.sql ? `(\n${this.from.sql}\n) AS ` : ''
+        }${this.quotedName(this.from.name)}`;
+    }
+
+    private filtersToSql() {
+        // Recursive function to convert filters to SQL
+        const getNestedFilterSQLFromGroup = (
+            filterGroup: FilterGroup | undefined,
+        ): string | undefined => {
+            if (filterGroup) {
+                const operator = isAndFilterGroup(filterGroup) ? 'AND' : 'OR';
+                const items = isAndFilterGroup(filterGroup)
+                    ? filterGroup.and
+                    : filterGroup.or;
+                if (items.length === 0) return undefined;
+                const filterRules: string[] = items.reduce<string[]>(
+                    (sum, item) => {
+                        // Handle nested filters
+                        if (isFilterGroup(item)) {
+                            const nestedFilterSql =
+                                getNestedFilterSQLFromGroup(item);
+                            return nestedFilterSql
+                                ? [...sum, nestedFilterSql]
+                                : sum;
+                        }
+                        // Handle filter rule
+                        const reference = this.getReference(
+                            item.target.fieldId,
+                        );
+                        const filterSQl = `(\n${renderFilterRuleSql(
+                            item,
+                            reference.type,
+                            reference.sql,
+                            this.config.stringQuoteChar,
+                            this.config.escapeStringQuoteChar,
+                            this.config.startOfWeek,
+                            this.config.adapterType,
+                            this.config.timezone,
+                        )}\n)`;
+                        return [...sum, filterSQl];
+                    },
+                    [],
+                );
+                return filterRules.length > 0
+                    ? `(${filterRules.join(` ${operator} `)})`
+                    : undefined;
+            }
+            return undefined;
+        };
+
+        const filtersSql = getNestedFilterSQLFromGroup(this.filters);
+        if (filtersSql) {
+            return `WHERE ${filtersSql}`;
+        }
+        return undefined;
+    }
+
+    toSql(): string {
+        // Combine all parts of the query
+        return [this.selectsToSql(), this.fromToSql(), this.filtersToSql()]
+            .filter((l) => l !== undefined)
+            .join('\n');
+    }
+}
diff --git a/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts b/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
index 8f2cab3fae072..b7fcf04908141 100644
--- a/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
+++ b/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
@@ -27,8 +27,10 @@ import {
     ForbiddenError,
     formatRow,
     getDashboardFilterRulesForTables,
+    getDashboardFilterRulesForTileAndReferences,
     getDimensions,
     getErrorMessage,
+    getFieldQuoteChar,
     getIntrinsicUserAttributes,
     getItemId,
     getItemMap,
@@ -67,13 +69,18 @@ import {
 import { SshTunnel } from '@lightdash/warehouses';
 import { createInterface } from 'readline';
 import { Readable } from 'stream';
+import { v4 as uuidv4 } from 'uuid';
 import type { S3ResultsFileStorageClient } from '../../clients/ResultsFileStorageClients/S3ResultsFileStorageClient';
 import type { DbResultsCacheUpdate } from '../../database/entities/resultsFile';
 import { measureTime } from '../../logging/measureTime';
 import type { QueryHistoryModel } from '../../models/QueryHistoryModel';
 import { ResultsFileModel } from '../../models/ResultsFileModel/ResultsFileModel';
 import type { SavedSqlModel } from '../../models/SavedSqlModel';
-import { applyLimitToSqlQuery } from '../../queryBuilder';
+import {
+    applyLimitToSqlQuery,
+    QueryBuilder,
+    ReferenceMap,
+} from '../../queryBuilder';
 import { wrapSentryTransaction } from '../../utils';
 import type { ICacheService } from '../CacheService/ICacheService';
 import {
@@ -1958,6 +1965,7 @@ export class AsyncQueryService extends ProjectService {
         dashboardFilters,
         dashboardSorts,
         limit,
+        tileUuid,
     }: {
         user: SessionUser;
         projectUuid: string;
@@ -1968,6 +1976,7 @@ export class AsyncQueryService extends ProjectService {
         dashboardFilters?: ExecuteAsyncDashboardSqlChartArgs['dashboardFilters'];
         dashboardSorts?: ExecuteAsyncDashboardSqlChartArgs['dashboardSorts'];
         limit?: number;
+        tileUuid?: string;
     }) {
         const warehouseConnection = await this._getWarehouseClient(
             projectUuid,
@@ -2045,22 +2054,29 @@ export class AsyncQueryService extends ProjectService {
             limit: limit ?? 500,
         };
 
+        const fieldQuoteChar = getFieldQuoteChar(
+            warehouseConnection.warehouseClient.credentials.type,
+        );
+
+        // Create a referenceMap from vizColumns
+        const referenceMap: ReferenceMap = {};
+        vizColumns.forEach((col) => {
+            referenceMap[col.reference] = {
+                type: col.type,
+                sql: `${fieldQuoteChar}${col.reference}${fieldQuoteChar}`,
+            };
+        });
+
         let appliedDashboardFilters: DashboardFilters | undefined;
-        if (dashboardFilters) {
-            const tables = Object.keys(virtualView.tables);
+        if (dashboardFilters && tileUuid) {
             appliedDashboardFilters = {
-                dimensions: getDashboardFilterRulesForTables(
-                    tables,
+                dimensions: getDashboardFilterRulesForTileAndReferences(
+                    tileUuid,
+                    Object.keys(referenceMap),
                     dashboardFilters.dimensions,
                 ),
-                metrics: getDashboardFilterRulesForTables(
-                    tables,
-                    dashboardFilters.metrics,
-                ),
-                tableCalculations: getDashboardFilterRulesForTables(
-                    tables,
-                    dashboardFilters.tableCalculations,
-                ),
+                metrics: [],
+                tableCalculations: [],
             };
 
             // This override isn't used for anything at the moment since sql charts don't support filters, but it's here for future use
@@ -2070,6 +2086,11 @@ export class AsyncQueryService extends ProjectService {
                     appliedDashboardFilters,
                     virtualView,
                 ),
+            };
+        }
+        if (dashboardSorts) {
+            metricQuery = {
+                ...metricQuery,
                 sorts:
                     dashboardSorts && dashboardSorts.length > 0
                         ? dashboardSorts
@@ -2077,13 +2098,41 @@ export class AsyncQueryService extends ProjectService {
             };
         }
 
+        // Select all vizColumns
+        const selectColumns = vizColumns.map((col) => col.reference);
+
+        // Create and return the QueryBuilder instance
+        const queryBuilder = new QueryBuilder(
+            {
+                referenceMap,
+                select: selectColumns,
+                from: { name: 'sql_query', sql: sqlWithLimit },
+                filters: appliedDashboardFilters
+                    ? {
+                          id: uuidv4(),
+                          and: appliedDashboardFilters.dimensions,
+                      }
+                    : undefined,
+            },
+            {
+                fieldQuoteChar,
+                stringQuoteChar:
+                    warehouseConnection.warehouseClient.getStringQuoteChar(),
+                escapeStringQuoteChar:
+                    warehouseConnection.warehouseClient.getEscapeStringQuoteChar(),
+                startOfWeek:
+                    warehouseConnection.warehouseClient.getStartOfWeek(),
+                adapterType:
+                    warehouseConnection.warehouseClient.getAdapterType(),
+            },
+        );
         return {
             metricQuery,
             pivotConfiguration,
             virtualView,
             queryTags,
             warehouseConnection,
-            sql: sqlWithLimit,
+            sql: queryBuilder.toSql(),
             appliedDashboardFilters,
         };
     }
@@ -2171,6 +2220,7 @@ export class AsyncQueryService extends ProjectService {
         const {
             user,
             projectUuid,
+            tileUuid,
             context,
             invalidateCache,
             dashboardFilters,
@@ -2203,6 +2253,7 @@ export class AsyncQueryService extends ProjectService {
             organizationUuid: savedChart.organization.organizationUuid,
             sql: savedChart.sql,
             config: savedChart.config,
+            tileUuid,
             dashboardFilters,
             dashboardSorts,
             limit,
diff --git a/packages/backend/src/services/AsyncQueryService/types.ts b/packages/backend/src/services/AsyncQueryService/types.ts
index 4386f15be2756..28e8c405c917a 100644
--- a/packages/backend/src/services/AsyncQueryService/types.ts
+++ b/packages/backend/src/services/AsyncQueryService/types.ts
@@ -81,6 +81,7 @@ export type ExecuteAsyncSqlQueryArgs = CommonAsyncQueryArgs & {
 
 export type ExecuteAsyncDashboardSqlChartCommonArgs = CommonAsyncQueryArgs & {
     dashboardUuid: string;
+    tileUuid: string;
     dashboardFilters: DashboardFilters;
     dashboardSorts: SortField[];
 };
diff --git a/packages/common/src/types/api/paginatedQuery.ts b/packages/common/src/types/api/paginatedQuery.ts
index 932b537edfabe..32948c796bd8a 100644
--- a/packages/common/src/types/api/paginatedQuery.ts
+++ b/packages/common/src/types/api/paginatedQuery.ts
@@ -84,6 +84,7 @@ export const isExecuteAsyncSqlChartByUuidParams = (
 type ExecuteAsyncDashboardSqlChartCommonParams =
     CommonPaginatedQueryRequestParams & {
         dashboardUuid: string;
+        tileUuid: string;
         dashboardFilters: DashboardFilters;
         dashboardSorts: SortField[];
         limit?: number;
diff --git a/packages/common/src/types/filter.ts b/packages/common/src/types/filter.ts
index 4e3601e16ba21..c2e22704baeeb 100644
--- a/packages/common/src/types/filter.ts
+++ b/packages/common/src/types/filter.ts
@@ -80,21 +80,15 @@ export type DashboardFieldTarget = {
     tableName: string;
 };
 
-export const isDashboardFieldTarget = (target: DashboardTileTarget) =>
-    typeof target === 'object' && 'fieldId' in target && 'tableName' in target;
-
-// Used for references in SQL chart
-export type DashboardReferenceTarget = {
-    reference: string;
-};
-
-export const isDashboardReferenceTarget = (target: DashboardTileTarget) =>
-    typeof target === 'object' && 'reference' in target;
-
-export type DashboardTileTarget =
-    | DashboardFieldTarget
-    | DashboardReferenceTarget
-    | false;
+export const isDashboardFieldTarget = (
+    target: unknown,
+): target is DashboardFieldTarget =>
+    target !== null &&
+    typeof target === 'object' &&
+    'fieldId' in target &&
+    'tableName' in target;
+
+export type DashboardTileTarget = DashboardFieldTarget | false;
 
 export type DashboardFilterRule<
     O = ConditionalOperator,
diff --git a/packages/common/src/utils/filters.ts b/packages/common/src/utils/filters.ts
index 4794f2ea87ffb..2c72868f0cae8 100644
--- a/packages/common/src/utils/filters.ts
+++ b/packages/common/src/utils/filters.ts
@@ -629,6 +629,7 @@ export const deleteFilterRuleFromGroup = (
 export const getDashboardFilterRulesForTile = (
     tileUuid: string,
     rules: DashboardFilterRule[],
+    needsExplicitTileOverride: boolean = false, // If true, we don't apply the default tile targets to the filter rule'
 ): DashboardFilterRule[] =>
     rules
         .filter((rule) => !rule.disabled)
@@ -643,6 +644,10 @@ export const getDashboardFilterRulesForTile = (
             // we return the filter and don't treat this tile
             // differently.
             if (tileConfig === undefined) {
+                // If needs explicit override, we remove this filter
+                if (needsExplicitTileOverride) {
+                    return null;
+                }
                 return filter;
             }
 
@@ -696,6 +701,15 @@ export const getTabUuidsForFilterRules = (
     }, {});
 };
 
+export const getDashboardFilterRulesForTileAndReferences = (
+    tileUuid: string,
+    references: string[],
+    rules: DashboardFilterRule[],
+): DashboardFilterRule[] =>
+    getDashboardFilterRulesForTile(tileUuid, rules, true).filter((f) =>
+        references.includes(f.target.fieldId),
+    );
+
 export const getDashboardFilterRulesForTables = (
     tables: string[],
     rules: DashboardFilterRule[],
diff --git a/packages/frontend/src/features/queryRunner/sqlRunnerPivotQueries.ts b/packages/frontend/src/features/queryRunner/sqlRunnerPivotQueries.ts
index d6f809dfb1dca..b379c9189bc21 100644
--- a/packages/frontend/src/features/queryRunner/sqlRunnerPivotQueries.ts
+++ b/packages/frontend/src/features/queryRunner/sqlRunnerPivotQueries.ts
@@ -180,6 +180,7 @@ export const getPivotQueryFunctionForSqlChart = ({
 export const getPivotQueryFunctionForDashboard = ({
     projectUuid,
     dashboardUuid,
+    tileUuid,
     savedSqlUuid,
     limit,
     dashboardFilters,
@@ -188,6 +189,7 @@ export const getPivotQueryFunctionForDashboard = ({
 }: {
     projectUuid: string;
     dashboardUuid?: string;
+    tileUuid?: string;
     savedSqlUuid?: string;
     limit?: number;
     dashboardFilters: DashboardFilters;
@@ -200,7 +202,8 @@ export const getPivotQueryFunctionForDashboard = ({
         if (
             index === undefined ||
             dashboardUuid === undefined ||
-            savedSqlUuid === undefined
+            savedSqlUuid === undefined ||
+            tileUuid === undefined
         ) {
             return {
                 results: [],
@@ -216,6 +219,7 @@ export const getPivotQueryFunctionForDashboard = ({
             projectUuid,
             {
                 dashboardUuid,
+                tileUuid,
                 savedSqlUuid,
                 context,
                 dashboardFilters,
diff --git a/packages/frontend/src/features/sqlRunner/hooks/useSavedSqlChartResults.tsx b/packages/frontend/src/features/sqlRunner/hooks/useSavedSqlChartResults.tsx
index 8dd861caff749..4cc05f522e83f 100644
--- a/packages/frontend/src/features/sqlRunner/hooks/useSavedSqlChartResults.tsx
+++ b/packages/frontend/src/features/sqlRunner/hooks/useSavedSqlChartResults.tsx
@@ -28,6 +28,7 @@ type SavedSqlChartArgs = {
 
 type SavedSqlChartDashboardArgs = SavedSqlChartArgs & {
     dashboardUuid: string;
+    tileUuid: string;
     dashboardFilters: DashboardFilters;
     dashboardSorts: SortField[];
 };
@@ -96,6 +97,7 @@ export const useSavedSqlChartResults = (
                       columns: chartResults.columns,
                       projectUuid: projectUuid!,
                       dashboardUuid: args.dashboardUuid,
+                      tileUuid: args.tileUuid,
                       dashboardFilters: args.dashboardFilters,
                       dashboardSorts: args.dashboardSorts,
                       savedSqlUuid: chart.savedSqlUuid,
diff --git a/packages/frontend/src/features/sqlRunner/runners/SqlRunnerResultsRunnerFrontend.ts b/packages/frontend/src/features/sqlRunner/runners/SqlRunnerResultsRunnerFrontend.ts
index 3c45343b64830..2dfae0331a3c7 100644
--- a/packages/frontend/src/features/sqlRunner/runners/SqlRunnerResultsRunnerFrontend.ts
+++ b/packages/frontend/src/features/sqlRunner/runners/SqlRunnerResultsRunnerFrontend.ts
@@ -128,6 +128,7 @@ export class SqlRunnerResultsRunnerDashboard extends BaseResultsRunner {
         savedSqlUuid,
         limit,
         dashboardUuid,
+        tileUuid,
         dashboardFilters,
         dashboardSorts,
     }: {
@@ -135,6 +136,7 @@ export class SqlRunnerResultsRunnerDashboard extends BaseResultsRunner {
         rows: RawResultRow[];
         projectUuid: string;
         dashboardUuid: string;
+        tileUuid: string;
         dashboardFilters: DashboardFilters;
         dashboardSorts: SortField[];
         savedSqlUuid?: string;
@@ -159,6 +161,7 @@ export class SqlRunnerResultsRunnerDashboard extends BaseResultsRunner {
             runPivotQuery: getPivotQueryFunctionForDashboard({
                 projectUuid,
                 dashboardUuid,
+                tileUuid,
                 savedSqlUuid,
                 limit,
                 dashboardFilters,


Reviews:
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T18:47:39Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T18:49:42Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T18:49:45Z
user: login: almeidabbm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:45:36Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:52:02Z

Review comments:
diff_hunk: @@ -80,21 +80,15 @@ export type DashboardFieldTarget = {
     tableName: string;
 };
 
-export const isDashboardFieldTarget = (target: DashboardTileTarget) =>
-    typeof target === 'object' && 'fieldId' in target && 'tableName' in target;
-
-// Used for references in SQL chart
-export type DashboardReferenceTarget = {
-    reference: string;
-};
-
-export const isDashboardReferenceTarget = (target: DashboardTileTarget) =>
-    typeof target === 'object' && 'reference' in target;
-
-export type DashboardTileTarget =
-    | DashboardFieldTarget
-    | DashboardReferenceTarget
-    | false;
+export const isDashboardFieldTarget = (
+    target: unknown,
+): target is DashboardFieldTarget =>
+    target !== null &&
+    typeof target === 'object' &&
+    'fieldId' in target &&
+    'tableName' in target;
+
+export type DashboardTileTarget = DashboardFieldTarget | false;
path: packages/common/src/types/filter.ts
user: login: ZeRego
body: Rollback filter type change as we would need more type and logic changes to use this. We need to use the fieldId+tablename for now.
created_at: 2025-05-15T18:47:39Z
author_association: COLLABORATOR
diff_hunk: @@ -629,6 +629,7 @@ export const deleteFilterRuleFromGroup = (
 export const getDashboardFilterRulesForTile = (
     tileUuid: string,
     rules: DashboardFilterRule[],
+    needsExplicitTileOverride: boolean = false, // If true, we don't apply the default tile targets to the filter rule'
path: packages/common/src/utils/filters.ts
user: login: ZeRego
body: We don't want to have a match if the field Id is "orders_state" and there is a column with the same name in a SQL chart. SQL charts only have filters if explicitly defined in the filter chart tile override.
created_at: 2025-05-15T18:49:41Z
author_association: COLLABORATOR
diff_hunk: @@ -629,6 +629,7 @@ export const deleteFilterRuleFromGroup = (
 export const getDashboardFilterRulesForTile = (
     tileUuid: string,
     rules: DashboardFilterRule[],
+    needsExplicitTileOverride: boolean = false, // If true, we don't apply the default tile targets to the filter rule'
path: packages/common/src/utils/filters.ts
user: login: ZeRego
body: We don't want to have a match if the field Id is "orders_state" and there is a column with the same name in a SQL chart. SQL charts only have filters if explicitly defined in the filter chart tile override.
created_at: 2025-05-15T18:49:45Z
author_association: COLLABORATOR
diff_hunk: @@ -1387,3 +1389,134 @@ export const buildQuery = ({
             fields,
         };
     });
+
+type ReferenceObject = { type: DimensionType; sql: string };
+export type ReferenceMap = Record<string, ReferenceObject> | undefined;
+type From = { name: string; sql?: string };
+
+export class QueryBuilder {
+    // Column references, to be used in select, filters, etc
+    private readonly referenceMap: ReferenceMap;
+
+    // Select values are references
+    private readonly select: string[];
+
+    private readonly from: From;
+
+    private readonly filters: FilterGroup | undefined;
+
+    constructor(
+        args: {
+            referenceMap: ReferenceMap;
+            select: string[];
+            from: From;
+            filters?: FilterGroup;
+        },
+        private config: {
+            fieldQuoteChar: string;
+            stringQuoteChar: string;
+            escapeStringQuoteChar: string;
+            startOfWeek: WeekDay | null | undefined;
+            adapterType: SupportedDbtAdapter;
+            timezone?: string;
+        },
+    ) {
+        this.select = args.select;
+        this.from = args.from;
+        this.filters = args.filters;
+        this.referenceMap = args.referenceMap;
+    }
+
+    private quotedName(value: String) {
path: packages/backend/src/queryBuilder.ts
user: login: graphite-app[bot]
body: The parameter type should be the primitive `string` rather than the object wrapper `String`. Using the primitive type is the TypeScript convention and avoids potential issues with type inference and method availability. This change will maintain consistency with TypeScript best practices throughout the codebase.
```suggestion
    private quotedName(value: string) {
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on [custom rules](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment&view=settings))*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-19T13:52:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: ZeRego
created_at: 2025-05-15T10:50:10Z
body: * **#14846** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14846?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 2  dependent PRs ([#14871](https://github.com/lightdash/lightdash/pull/14871) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14871?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>, [#14880](https://github.com/lightdash/lightdash/pull/14880) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14880?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#14828** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14828?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14828?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#14845** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14845?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14844** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14844?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14841** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14841?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#14842](https://github.com/lightdash/lightdash/pull/14842) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14842?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: COLLABORATOR
user: login: lightdash-bot
created_at: 2025-05-20T12:50:08Z
body: :tada: This PR is included in version 0.1635.0 :tada:

The release is available on:
- `0.1635.0`
- [GitHub release](https://github.com/lightdash/lightdash/releases/tag/0.1635.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR