-
Notifications
You must be signed in to change notification settings - Fork 23
Fix: Expand multi-value template variables in prefixPath into multiple paths #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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']); | ||
| }); | ||
|
Comment on lines
+94
to
+107
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above — fixed in the force-push. Tests now use |
||
|
|
||
|
|
||
| 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']); | ||
| }); | ||
|
jixuan1989 marked this conversation as resolved.
|
||
|
|
||
| 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']); | ||
| }); | ||
|
|
||
|
jixuan1989 marked this conversation as resolved.
|
||
| 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'); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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!; | ||
|
Comment on lines
+44
to
+48
|
||
| 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 { | ||
|
Comment on lines
+52
to
+65
|
||
| const allVars = templateSrv.getVariables() as any[]; | ||
|
jixuan1989 marked this conversation as resolved.
|
||
| 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; | ||
| } | ||
|
Comment on lines
+89
to
+92
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in commit |
||
| } | ||
| 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) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.