This is an automated email from the ASF dual-hosted git repository.

CloudWise-Lukemiao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git


The following commit(s) were added to refs/heads/master by this push:
     new c94db39  Fix: Expand multi-value template variables in prefixPath into 
multiple paths (#109)
c94db39 is described below

commit c94db39edfc8fdfd754c5a40481c11642c787e6a
Author: HUANG Xiangdong <[email protected]>
AuthorDate: Mon Jun 1 11:06:49 2026 +0800

    Fix: Expand multi-value template variables in prefixPath into multiple 
paths (#109)
    
    * Expand multi-value template variables in prefixPath into multiple paths
    
    Fix two issues with variable expansion in Grafana 11.4 (Scenes 
architecture):
    
    1. Replace templateSrv.containsTemplate() with regex-based detection.
       In Grafana 11.4's Scenes mode, containsTemplate() returns false for
       ${var} syntax even when the variable exists. Using a regex pattern
       ensures reliable detection regardless of Grafana version.
    
    2. Process prefixPath expansion for queries without sqlType field.
       Previously only queries with sqlType === 'SQL: Full Customized' were
       processed. Queries provisioned via JSON (without explicit sqlType)
       were silently skipped.
    
    The expansion logic now:
    - Detects variables via regex pattern /\$\{(\w+)(?::[^}]*)?\}|\\b/
    - Resolves values from scopedVars first, then falls back to getVariables()
    - Handles $__all by expanding to all non-$__all options
    - Supports both single-value and multi-value (array) variables
    - Fully backward-compatible: literal paths pass through unchanged
    
    Closes #108
    
    * Address review: handle $__all in scopedVars and safer fallback
    
    - When scopedVars provides $__all, expand using options list instead of
      treating it as a literal path segment
    - When variable cannot be resolved, fallback to replace() on the whole
      path (preserving prior behavior) instead of only replacing the token
    - Add tests for both cases
    
    ---------
    
    Co-authored-by: xiangdong huang <[email protected]>
---
 connectors/grafana-plugin/README.md              |  21 ++
 connectors/grafana-plugin/src/datasource.test.ts | 240 +++++++++++++++++++++++
 connectors/grafana-plugin/src/datasource.ts      |  73 ++++++-
 3 files changed, 330 insertions(+), 4 deletions(-)

diff --git a/connectors/grafana-plugin/README.md 
b/connectors/grafana-plugin/README.md
index ce4f07b..974916b 100644
--- a/connectors/grafana-plugin/README.md
+++ b/connectors/grafana-plugin/README.md
@@ -123,6 +123,27 @@ Select a time series in the TIME-SERIES selection box, 
select a function in the
 
 Both SQL: Full Customized and SQL: Drop-down List input methods support the 
variable and template functions of grafana. In the following example, raw input 
method is used, and aggregation is similar.
 
+##### Multi-value variable expansion in FROM (prefixPath)
+
+When a multi-value template variable is used in the FROM input box 
(prefixPath), the plugin automatically expands it into multiple paths. This 
enables the common "global variable filter" pattern where a single dashboard 
dropdown controls all panels.
+
+For example, define a multi-select variable `device` with values `device1`, 
`device2`, ..., `device8`. Then in the FROM input box, enter:
+
+```
+root.application.${device}
+```
+
+When the user selects `device1` and `device2`, the plugin internally expands 
this to:
+
+```
+root.application.device1
+root.application.device2
+```
+
+Only the selected devices are queried from IoTDB — no client-side filtering or 
transformations needed.
+
+This works with any number of prefixPath entries. Literal paths (without 
variables) and single-value variables behave the same as before.
+
 After creating a new Panel, click the Settings button in the upper right 
corner:
 
 <img style="width:100%; max-width:800px; max-height:600px; margin-left:auto; 
margin-right:auto; display:block;" 
src="https://github.com/apache/iotdb-bin-resources/blob/main/docs/UserGuide/Ecosystem%20Integration/Grafana-plugin/setconf.png?raw=true";>
diff --git a/connectors/grafana-plugin/src/datasource.test.ts 
b/connectors/grafana-plugin/src/datasource.test.ts
new file mode 100644
index 0000000..61ee77d
--- /dev/null
+++ b/connectors/grafana-plugin/src/datasource.test.ts
@@ -0,0 +1,240 @@
+/*
+ * 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 { DataSource } from './datasource';
+import { IoTDBQuery } from './types';
+import { ScopedVars } from '@grafana/data';
+
+const mockReplace = jest.fn();
+const mockContainsTemplate = jest.fn();
+const mockGetVariables = jest.fn();
+
+jest.mock('@grafana/runtime', () => ({
+  DataSourceWithBackend: class {},
+  getTemplateSrv: () => ({
+    replace: mockReplace,
+    containsTemplate: mockContainsTemplate,
+    getVariables: mockGetVariables,
+  }),
+}));
+
+describe('DataSource', () => {
+  let ds: DataSource;
+
+  beforeEach(() => {
+    ds = new DataSource({ jsonData: { url: 'http://localhost:6667', username: 
'root' } } as any);
+    mockReplace.mockReset();
+    mockContainsTemplate.mockReset();
+    mockGetVariables.mockReset();
+    mockGetVariables.mockReturnValue([]);
+  });
+
+  describe('applyTemplateVariables - prefixPath expansion', () => {
+    const baseQuery: Partial<IoTDBQuery> = {
+      sqlType: 'SQL: Full Customized',
+      expression: [],
+      prefixPath: [],
+      condition: '',
+      control: '',
+    };
+    const scopedVars: ScopedVars = {};
+
+    it('should pass through literal paths without variables', () => {
+      mockContainsTemplate.mockReturnValue(false);
+      const query = { ...baseQuery, prefixPath: ['root.app.device1', 
'root.app.device2'] } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.app.device1', 
'root.app.device2']);
+      expect(mockReplace).not.toHaveBeenCalled();
+    });
+
+    it('should handle single-value variable without expansion', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([
+        { name: 'device', current: { value: 'device1' }, options: [{ value: 
'$__all' }, { value: 'device1' }] },
+      ]);
+      mockReplace.mockReturnValue('device1');
+      const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.app.device1']);
+    });
+
+    it('should expand multi-value variable into multiple paths', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([
+        {
+          name: 'device',
+          current: { value: ['device1', 'device2', 'device3'] },
+          options: [{ value: '$__all' }, { value: 'device1' }, { value: 
'device2' }, { value: 'device3' }],
+        },
+      ]);
+      const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.app.device1', 
'root.app.device2', 'root.app.device3']);
+    });
+
+    it('should handle mixed literal and template paths', () => {
+      mockContainsTemplate.mockImplementation((path: string) => 
path.includes('${'));
+      mockGetVariables.mockReturnValue([
+        { name: 'device', current: { value: ['device1', 'device2'] }, options: 
[{ value: '$__all' }, { value: 'device1' }, { value: 'device2' }] },
+      ]);
+      const query = {
+        ...baseQuery,
+        prefixPath: ['root.static.path', 'root.app.${device}'],
+      } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.static.path', 
'root.app.device1', 'root.app.device2']);
+    });
+
+    it('should handle multiple template paths each with multi-value 
variables', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([
+        { name: 'var1', current: { value: ['d1', 'd2'] }, options: [{ value: 
'$__all' }, { value: 'd1' }, { value: 'd2' }] },
+        { name: 'var2', current: { value: ['d3', 'd4'] }, options: [{ value: 
'$__all' }, { value: 'd3' }, { value: 'd4' }] },
+      ]);
+      const query = {
+        ...baseQuery,
+        prefixPath: ['root.a.${var1}', 'root.b.${var2}'],
+      } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.a.d1', 'root.a.d2', 
'root.b.d3', 'root.b.d4']);
+    });
+
+    it('should still replace expression fields normally', () => {
+      mockContainsTemplate.mockReturnValue(false);
+      mockReplace.mockImplementation((v: string) => v.replace('${metric}', 
'temperature'));
+      const query = {
+        ...baseQuery,
+        prefixPath: ['root.app.device1'],
+        expression: ['${metric}'],
+      } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.expression).toEqual(['temperature']);
+    });
+
+    it('should expand $__all using options list', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([
+        {
+          name: 'target',
+          current: { value: '$__all' },
+          options: [{ value: '$__all' }, { value: 'apache_iotdb' }, { value: 
'timecho' }, { value: 'influxdb' }],
+        },
+      ]);
+      const query = { ...baseQuery, prefixPath: 
['root.market_ops.pypi.${target}'] } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual([
+        'root.market_ops.pypi.apache_iotdb',
+        'root.market_ops.pypi.timecho',
+        'root.market_ops.pypi.influxdb',
+      ]);
+    });
+
+    it('should use scopedVars when variable is present there', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      const scoped: ScopedVars = { device: { text: 'Device 1', value: 
'device1' } };
+      const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scoped);
+
+      expect(result.prefixPath).toEqual(['root.app.device1']);
+    });
+
+    it('should use scopedVars array value when variable is present there', () 
=> {
+      mockContainsTemplate.mockReturnValue(true);
+      const scoped: ScopedVars = { device: { text: 'All', value: ['dev1', 
'dev2'] } } as any;
+      const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scoped);
+
+      expect(result.prefixPath).toEqual(['root.app.dev1', 'root.app.dev2']);
+    });
+
+    it('should expand $__all from scopedVars using options list', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([
+        {
+          name: 'device',
+          current: { value: '$__all' },
+          options: [{ value: '$__all' }, { value: 'device1' }, { value: 
'device2' }, { value: 'device3' }],
+        },
+      ]);
+      const scoped: ScopedVars = { device: { text: 'All', value: '$__all' } } 
as any;
+      const query = { ...baseQuery, prefixPath: ['root.app.${device}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scoped);
+
+      expect(result.prefixPath).toEqual(['root.app.device1', 
'root.app.device2', 'root.app.device3']);
+    });
+
+    it('should fallback to replace whole path when variable cannot be 
resolved', () => {
+      mockContainsTemplate.mockReturnValue(true);
+      mockGetVariables.mockReturnValue([]);
+      mockReplace.mockReturnValue('root.app.unknown');
+      const query = { ...baseQuery, prefixPath: ['root.app.${missing}'] } as 
IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.prefixPath).toEqual(['root.app.unknown']);
+      expect(mockReplace).toHaveBeenCalledWith('root.app.${missing}', 
scopedVars);
+    });
+
+    it('should still replace condition and control fields', () => {
+      mockContainsTemplate.mockReturnValue(false);
+      mockReplace.mockImplementation((v: string) => v.replace('${threshold}', 
'100'));
+      const query = {
+        ...baseQuery,
+        prefixPath: ['root.app.device1'],
+        condition: 'value > ${threshold}',
+        control: 'limit ${threshold}',
+      } as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, scopedVars);
+
+      expect(result.condition).toBe('value > 100');
+      expect(result.control).toBe('limit 100');
+    });
+  });
+
+  describe('applyTemplateVariables - SQL: Drop-down List', () => {
+    it('should replace groupBy and fillClauses fields', () => {
+      mockReplace.mockImplementation((v: string) => v.replace('${interval}', 
'1h'));
+      const query = {
+        sqlType: 'SQL: Drop-down List',
+        groupBy: { samplingInterval: '${interval}', step: '${interval}', 
groupByLevel: '1' },
+        fillClauses: 'previous',
+      } as unknown as IoTDBQuery;
+
+      const result = ds.applyTemplateVariables(query, {});
+
+      expect(result.groupBy?.samplingInterval).toBe('1h');
+      expect(result.groupBy?.step).toBe('1h');
+    });
+  });
+});
diff --git a/connectors/grafana-plugin/src/datasource.ts 
b/connectors/grafana-plugin/src/datasource.ts
index df71085..4fff0e8 100644
--- a/connectors/grafana-plugin/src/datasource.ts
+++ b/connectors/grafana-plugin/src/datasource.ts
@@ -30,16 +30,81 @@ export class DataSource extends 
DataSourceWithBackend<IoTDBQuery, IoTDBOptions>
     this.username = instanceSettings.jsonData.username;
   }
   applyTemplateVariables(query: IoTDBQuery, scopedVars: ScopedVars) {
-    if (query.sqlType === 'SQL: Full Customized') {
+    if (!query.sqlType || query.sqlType === 'SQL: Full Customized') {
       if (query.expression) {
         query.expression.map(
           (_, index) => (query.expression[index] = 
getTemplateSrv().replace(query.expression[index], scopedVars))
         );
       }
       if (query.prefixPath) {
-        query.prefixPath.map(
-          (_, index) => (query.prefixPath[index] = 
getTemplateSrv().replace(query.prefixPath[index], scopedVars))
-        );
+        const expanded: string[] = [];
+        const templateSrv = getTemplateSrv();
+        const varPattern = /\$\{(\w+)(?::[^}]*)?\}|\$(\w+)\b/;
+        for (const path of query.prefixPath) {
+          if (varPattern.test(path)) {
+            const varMatch = path.match(/\$\{(\w+)(?::[^}]*)?\}|\$(\w+)\b/);
+            if (varMatch) {
+              const varName = varMatch[1] || varMatch[2];
+              const idx = varMatch.index!;
+              const prefix = path.substring(0, idx);
+              const suffix = path.substring(idx + varMatch[0].length);
+              let values: string[] = [];
+              if (scopedVars && scopedVars[varName]) {
+                const val = scopedVars[varName].value;
+                if (val === '$__all') {
+                  const allVars = templateSrv.getVariables() as any[];
+                  const found = allVars.find((v: any) => v.name === varName);
+                  if (found && found.options) {
+                    values = found.options
+                      .filter((o: any) => o.value !== '$__all')
+                      .map((o: any) => o.value);
+                  }
+                } else {
+                  values = Array.isArray(val) ? val : [String(val)];
+                }
+              } else {
+                const allVars = templateSrv.getVariables() as any[];
+                const found = allVars.find((v: any) => v.name === varName);
+                if (found) {
+                  const current = found.current;
+                  if (current) {
+                    if (Array.isArray(current.value)) {
+                      values = current.value.filter((v: string) => v !== 
'$__all');
+                      if (values.length === 0 && found.options) {
+                        values = found.options
+                          .filter((o: any) => o.value !== '$__all')
+                          .map((o: any) => o.value);
+                      }
+                    } else if (current.value === '$__all') {
+                      if (found.options) {
+                        values = found.options
+                          .filter((o: any) => o.value !== '$__all')
+                          .map((o: any) => o.value);
+                      }
+                    } else {
+                      values = [current.value];
+                    }
+                  }
+                }
+                if (values.length === 0) {
+                  expanded.push(templateSrv.replace(path, scopedVars));
+                  continue;
+                }
+              }
+              const resolvedSuffix = suffix && varPattern.test(suffix)
+                ? templateSrv.replace(suffix, scopedVars)
+                : suffix;
+              for (const val of values) {
+                expanded.push(prefix + val + resolvedSuffix);
+              }
+            } else {
+              expanded.push(templateSrv.replace(path, scopedVars));
+            }
+          } else {
+            expanded.push(path);
+          }
+        }
+        query.prefixPath = expanded;
       }
      
       if (query.condition) {

Reply via email to