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

CritasWang 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 8e32ec4  Add database, table and column pickers to the Grafana 
table-model editor (#134)
8e32ec4 is described below

commit 8e32ec43f82c4f8f7787f8f2f9f62aa92a066c2f
Author: ZIHAN DAI <[email protected]>
AuthorDate: Sat Oct 10 18:45:16 2026 +1100

    Add database, table and column pickers to the Grafana table-model editor 
(#134)
    
    * Add database, table and column pickers to the Grafana table-model editor
    
    The table-model query editor asked for the database name as free text and
    gave no view of the server's tables or columns. It now lists them:
    
    - Three resource endpoints on the plugin backend run SHOW DATABASES,
      SHOW TABLES and DESC on the pooled native session. The table name only
      reaches the server as a quoted identifier.
    - DATABASE and TABLE become pickers filled from those endpoints. They still
      accept a typed name, such as a $database template variable, and the editor
      stays usable if metadata cannot be read.
    - Picking a table lists its columns by category (TAG, FIELD, ATTRIBUTE).
      "Use starter query" writes a query that selects the time, TAG and FIELD
      columns within the dashboard time range. Names are always quoted, so
      reserved words such as "order" and names with spaces stay valid, and a
      table without a time column gets no time filter.
    
    The SQL is still what runs; the picked table is only a helper.
    
    Signed-off-by: Zihan Dai <[email protected]>
    
    * Address review of the table-model pickers
    
    - Keep the option objects stable and pass the matching option as the
      value, so that opening a picker focuses the current entry and Tab or
      Enter keeps it. A typed or variable name is added to the options.
    - Commit a typed name when the picker loses focus, and allow typing while
      the list is still loading.
    - Key the lookups on the resolved names, expanded with the panel's scoped
      variables, so that a variable change fetches the new database's tables
      and columns.
    - Do not raise a global alert for a failed lookup; the editor already
      shows it inline. Show every failed lookup, not only the first.
    - Select only numeric FIELD columns in the starter query, or every FIELD
      when none is numeric.
    - Clear the table only when the database actually changes, refresh the
      database list when it is opened, derive element ids from the query's
      refId, and trim names once, when they are entered.
    
    Signed-off-by: Zihan Dai <[email protected]>
    
    * Keep the database picker's option objects stable across refreshes
    
    Opening the DATABASE picker re-runs SHOW DATABASES, and the new array
    produced new option objects. react-select finds the focused option by
    identity, so focus moved to the first database and Tab or Enter switched
    to it. Hand out one option object per name for the picker's lifetime,
    and add the reviewer's reproduction, for both Tab and Enter, as a
    component test.
    
    ---------
    
    Signed-off-by: Zihan Dai <[email protected]>
---
 .github/workflows/grafana-plugin.yml               |   2 +-
 connectors/grafana-plugin/README.md                |   9 +-
 .../pkg/plugin/iotdb_resource_handler.go           |   3 +
 .../grafana-plugin/pkg/plugin/table_metadata.go    | 168 +++++++++++++
 .../pkg/plugin/table_metadata_test.go              | 208 ++++++++++++++++
 connectors/grafana-plugin/src/QueryEditor.tsx      |  39 +--
 .../src/componments/TablePicker.test.tsx           | 122 ++++++++++
 .../grafana-plugin/src/componments/TablePicker.tsx | 265 +++++++++++++++++++++
 connectors/grafana-plugin/src/datasource.test.ts   |  16 ++
 connectors/grafana-plugin/src/datasource.ts        |  23 +-
 connectors/grafana-plugin/src/tableQuery.test.ts   |  92 +++++++
 connectors/grafana-plugin/src/tableQuery.ts        |  52 ++++
 connectors/grafana-plugin/src/types.ts             |  10 +
 .../grafana-plugin/tests/tableModelEditor.spec.ts  | 142 +++++++++++
 14 files changed, 1132 insertions(+), 19 deletions(-)

diff --git a/.github/workflows/grafana-plugin.yml 
b/.github/workflows/grafana-plugin.yml
index 1127d13..34cf4b0 100644
--- a/.github/workflows/grafana-plugin.yml
+++ b/.github/workflows/grafana-plugin.yml
@@ -141,7 +141,7 @@ jobs:
         run: |
           cd connectors/grafana-plugin/
           npx playwright install --with-deps chromium
-          npm run e2e -- tests/minimumGrafanaVersion.spec.ts
+          npm run e2e -- tests/minimumGrafanaVersion.spec.ts 
tests/tableModelEditor.spec.ts
 
       - name: Dump service logs on failure
         if: failure()
diff --git a/connectors/grafana-plugin/README.md 
b/connectors/grafana-plugin/README.md
index 7ad20cb..387e5a9 100644
--- a/connectors/grafana-plugin/README.md
+++ b/connectors/grafana-plugin/README.md
@@ -41,8 +41,9 @@ and loads the plugin there.
 
 CI does exactly that. The `minimum-grafana-version` job parses the floor out of
 `plugin.json`, boots that Grafana release together with IoTDB, and checks that 
the
-plugin's frontend loads and that a table-model query returns data through the
-native client. Raising or lowering the declared floor therefore changes what is
+plugin's frontend loads, that a table-model query returns data through the
+native client, and that the table-model editor lists databases, tables and
+columns from the server. Raising or lowering the declared floor therefore 
changes what is
 tested, with no separate file to keep in step.
 
 ### How to use Grafana-Plugin
@@ -146,7 +147,9 @@ SELECT time, device_id, temperature FROM table1 WHERE 
$__timeFilter(time)
 
 Unlike the tree-model modes, which go through the REST service, this mode 
connects to the IoTDB RPC port (6667 by default). The data source's `rpc 
address` option sets that endpoint explicitly; when it is left empty, the URL's 
host with port 6667 is used.
 
-DATABASE input box: the database to run the statement against. Required — 
every query first runs `USE <database>` on its pooled session, so results stay 
deterministic regardless of which session the pool hands out. Fully-qualified 
`database.table` references are still allowed and take precedence over the 
session database.
+DATABASE: the database to run the statement against, picked from the databases 
the server lists (a name can also be typed, for example a `$database` template 
variable). Required — every query first runs `USE <database>` on its pooled 
session, so results stay deterministic regardless of which session the pool 
hands out. Fully-qualified `database.table` references are still allowed and 
take precedence over the session database.
+
+TABLE (optional): picking a table lists its columns grouped by category (TAG, 
FIELD, ATTRIBUTE). **Use starter query** then replaces the SQL with a query 
that selects the table's time, TAG and numeric FIELD columns (every FIELD when 
none is numeric) within the dashboard time range, for example `SELECT "time", 
"region", "temperature" FROM "weather" WHERE $__timeFilter("time")`. Names are 
always double-quoted, so reserved words such as `order` and names with spaces 
stay valid. The table on [...]
 
 SQL input box: a single table-model SELECT statement. The following macros are 
expanded by the plugin before the statement is sent:
 
diff --git a/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go 
b/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
index 9154ac3..00fe5ec 100644
--- a/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
+++ b/connectors/grafana-plugin/pkg/plugin/iotdb_resource_handler.go
@@ -44,6 +44,9 @@ func (d *IoTDBDataSource) iotdbResourceHandler(authorization 
string, httpClient
 
        mux.Handle("/getVariables", d.getVariables(authorization, httpClient))
        mux.Handle("/getNodes", d.getNodes(authorization, httpClient))
+       mux.Handle("/tableDatabases", d.getTableDatabases())
+       mux.Handle("/tableTables", d.getTableTables())
+       mux.Handle("/tableColumns", d.getTableColumns())
 
        return httpadapter.New(mux)
 }
diff --git a/connectors/grafana-plugin/pkg/plugin/table_metadata.go 
b/connectors/grafana-plugin/pkg/plugin/table_metadata.go
new file mode 100644
index 0000000..6169456
--- /dev/null
+++ b/connectors/grafana-plugin/pkg/plugin/table_metadata.go
@@ -0,0 +1,168 @@
+/*
+ * 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.
+ */
+
+package plugin
+
+import (
+       "fmt"
+       "net/http"
+       "strings"
+
+       "github.com/grafana/grafana-plugin-sdk-go/backend/log"
+)
+
+// Resource handlers behind the table-model query editor's database, table and
+// column pickers. Each one runs a fixed metadata statement on a pooled native
+// session; request parameters only reach the server as quoted identifiers.
+
+// tableColumn describes one column of a table-model table as DESC reports it.
+type tableColumn struct {
+       Name     string `json:"name"`
+       DataType string `json:"dataType"`
+       Category string `json:"category"`
+}
+
+// getTableDatabases lists the databases visible to the datasource user.
+func (d *IoTDBDataSource) getTableDatabases() http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               dataSet, err := d.executeTableStatement(r.Context(), "", "SHOW 
DATABASES")
+               if err != nil {
+                       writeMetadataError(w, "SHOW DATABASES", err)
+                       return
+               }
+               names, err := columnStrings(dataSet, "Database")
+               if err != nil {
+                       writeMetadataError(w, "SHOW DATABASES", err)
+                       return
+               }
+               writeJSON(w, names)
+       })
+}
+
+// getTableTables lists the tables of the database named by the "database"
+// query parameter.
+func (d *IoTDBDataSource) getTableTables() http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               // The editor trims names when they are entered; here a blank 
name is only
+               // rejected, and the name is used as given.
+               database := r.URL.Query().Get("database")
+               if strings.TrimSpace(database) == "" {
+                       writeJSONError(w, http.StatusBadRequest, "the database 
parameter is required")
+                       return
+               }
+               dataSet, err := d.executeTableStatement(r.Context(), database, 
"SHOW TABLES")
+               if err != nil {
+                       writeMetadataError(w, "SHOW TABLES", err)
+                       return
+               }
+               names, err := columnStrings(dataSet, "TableName")
+               if err != nil {
+                       writeMetadataError(w, "SHOW TABLES", err)
+                       return
+               }
+               writeJSON(w, names)
+       })
+}
+
+// getTableColumns describes the table named by the "database" and "table"
+// query parameters, in the server's column order.
+func (d *IoTDBDataSource) getTableColumns() http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               database := r.URL.Query().Get("database")
+               table := r.URL.Query().Get("table")
+               if strings.TrimSpace(database) == "" || 
strings.TrimSpace(table) == "" {
+                       writeJSONError(w, http.StatusBadRequest, "the database 
and table parameters are required")
+                       return
+               }
+               dataSet, err := d.executeTableStatement(r.Context(), database, 
"DESC "+quoteTableIdentifier(table))
+               if err != nil {
+                       writeMetadataError(w, "DESC", err)
+                       return
+               }
+               columns, err := tableColumns(dataSet)
+               if err != nil {
+                       writeMetadataError(w, "DESC", err)
+                       return
+               }
+               writeJSON(w, columns)
+       })
+}
+
+func writeMetadataError(w http.ResponseWriter, statement string, err error) {
+       log.DefaultLogger.Error("table-model metadata query failed", 
"statement", statement, "err", err)
+       writeJSONError(w, http.StatusInternalServerError, err.Error())
+}
+
+// columnIndex finds a result column by name, ignoring case, since the header
+// names are server output rather than something the plugin controls.
+func columnIndex(dataSet *tableQueryDataSet, name string) (int, error) {
+       for i, column := range dataSet.ColumnNames {
+               if strings.EqualFold(column, name) {
+                       return i, nil
+               }
+       }
+       return -1, fmt.Errorf("the result has no %q column (columns: %s)", 
name, strings.Join(dataSet.ColumnNames, ", "))
+}
+
+// columnStrings returns the non-null values of one named column in server
+// order.
+func columnStrings(dataSet *tableQueryDataSet, name string) ([]string, error) {
+       index, err := columnIndex(dataSet, name)
+       if err != nil {
+               return nil, err
+       }
+       values := make([]string, 0, len(dataSet.Values))
+       for _, row := range dataSet.Values {
+               if index < len(row) && row[index] != nil {
+                       values = append(values, toString(row[index]))
+               }
+       }
+       return values, nil
+}
+
+// tableColumns reads a DESC result into column descriptions.
+func tableColumns(dataSet *tableQueryDataSet) ([]tableColumn, error) {
+       nameIndex, err := columnIndex(dataSet, "ColumnName")
+       if err != nil {
+               return nil, err
+       }
+       typeIndex, err := columnIndex(dataSet, "DataType")
+       if err != nil {
+               return nil, err
+       }
+       categoryIndex, err := columnIndex(dataSet, "Category")
+       if err != nil {
+               return nil, err
+       }
+       columns := make([]tableColumn, 0, len(dataSet.Values))
+       for _, row := range dataSet.Values {
+               if nameIndex >= len(row) || row[nameIndex] == nil {
+                       continue
+               }
+               column := tableColumn{Name: toString(row[nameIndex])}
+               if typeIndex < len(row) && row[typeIndex] != nil {
+                       column.DataType = toString(row[typeIndex])
+               }
+               if categoryIndex < len(row) && row[categoryIndex] != nil {
+                       column.Category = toString(row[categoryIndex])
+               }
+               columns = append(columns, column)
+       }
+       return columns, nil
+}
diff --git a/connectors/grafana-plugin/pkg/plugin/table_metadata_test.go 
b/connectors/grafana-plugin/pkg/plugin/table_metadata_test.go
new file mode 100644
index 0000000..b3f869c
--- /dev/null
+++ b/connectors/grafana-plugin/pkg/plugin/table_metadata_test.go
@@ -0,0 +1,208 @@
+/*
+ * 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.
+ */
+
+package plugin
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "net/http"
+       "net/http/httptest"
+       "reflect"
+       "strings"
+       "testing"
+)
+
+type recordedStatement struct {
+       database string
+       sql      string
+}
+
+// metadataDataSource returns a datasource whose table-model statements are
+// answered by result and recorded into calls.
+func metadataDataSource(result *tableQueryDataSet, err error, calls 
*[]recordedStatement) *IoTDBDataSource {
+       return &IoTDBDataSource{
+               tableExecutor: func(_ context.Context, database, sql string) 
(*tableQueryDataSet, error) {
+                       *calls = append(*calls, recordedStatement{database: 
database, sql: sql})
+                       return result, err
+               },
+       }
+}
+
+func serveMetadata(t *testing.T, handler http.Handler, target string) 
*httptest.ResponseRecorder {
+       t.Helper()
+       recorder := httptest.NewRecorder()
+       handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, 
nil))
+       return recorder
+}
+
+func TestTableDatabasesReturnsTheDatabaseColumn(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{
+               ColumnNames: []string{"Database", "TTL(ms)", 
"SchemaReplicationFactor"},
+               Values: [][]interface{}{
+                       {"grafana_demo", "INF", int32(1)},
+                       {"information_schema", "INF", nil},
+               },
+       }, nil, &calls)
+
+       recorder := serveMetadata(t, d.getTableDatabases(), "/tableDatabases")
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       var names []string
+       if err := json.Unmarshal(recorder.Body.Bytes(), &names); err != nil {
+               t.Fatalf("decode: %v", err)
+       }
+       if want := []string{"grafana_demo", "information_schema"}; 
!reflect.DeepEqual(names, want) {
+               t.Fatalf("names = %v, want %v", names, want)
+       }
+       if want := []recordedStatement{{database: "", sql: "SHOW DATABASES"}}; 
!reflect.DeepEqual(calls, want) {
+               t.Fatalf("statements = %v, want %v", calls, want)
+       }
+}
+
+func TestTableTablesRunsShowTablesInTheRequestedDatabase(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{
+               ColumnNames: []string{"TableName", "TTL(ms)"},
+               Values:      [][]interface{}{{"weather station", "INF"}, 
{"cpu", "INF"}},
+       }, nil, &calls)
+
+       recorder := serveMetadata(t, d.getTableTables(), 
"/tableTables?database=grafana_demo")
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       var names []string
+       if err := json.Unmarshal(recorder.Body.Bytes(), &names); err != nil {
+               t.Fatalf("decode: %v", err)
+       }
+       if want := []string{"weather station", "cpu"}; 
!reflect.DeepEqual(names, want) {
+               t.Fatalf("names = %v, want %v", names, want)
+       }
+       if want := []recordedStatement{{database: "grafana_demo", sql: "SHOW 
TABLES"}}; !reflect.DeepEqual(calls, want) {
+               t.Fatalf("statements = %v, want %v", calls, want)
+       }
+}
+
+func TestTableColumnsQuotesTheTableName(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{
+               ColumnNames: []string{"ColumnName", "DataType", "Category"},
+               Values: [][]interface{}{
+                       {"time", "TIMESTAMP", "TIME"},
+                       {"region", "STRING", "TAG"},
+                       {"model", "STRING", "ATTRIBUTE"},
+                       {"temperature", "FLOAT", "FIELD"},
+               },
+       }, nil, &calls)
+
+       recorder := serveMetadata(t, d.getTableColumns(), 
`/tableColumns?database=grafana_demo&table=weather%22station`)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       var columns []tableColumn
+       if err := json.Unmarshal(recorder.Body.Bytes(), &columns); err != nil {
+               t.Fatalf("decode: %v", err)
+       }
+       want := []tableColumn{
+               {Name: "time", DataType: "TIMESTAMP", Category: "TIME"},
+               {Name: "region", DataType: "STRING", Category: "TAG"},
+               {Name: "model", DataType: "STRING", Category: "ATTRIBUTE"},
+               {Name: "temperature", DataType: "FLOAT", Category: "FIELD"},
+       }
+       if !reflect.DeepEqual(columns, want) {
+               t.Fatalf("columns = %v, want %v", columns, want)
+       }
+       // An embedded double quote is doubled, so the name stays one 
identifier.
+       if want := []recordedStatement{{database: "grafana_demo", sql: `DESC 
"weather""station"`}}; !reflect.DeepEqual(calls, want) {
+               t.Fatalf("statements = %v, want %v", calls, want)
+       }
+}
+
+func TestTableColumnsUsesTheNamesAsGiven(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{
+               ColumnNames: []string{"ColumnName", "DataType", "Category"},
+               Values:      [][]interface{}{{"time", "TIMESTAMP", "TIME"}},
+       }, nil, &calls)
+
+       recorder := serveMetadata(t, d.getTableColumns(), 
`/tableColumns?database=grafana_demo&table=weather%20`)
+
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("status = %d, body = %s", recorder.Code, 
recorder.Body.String())
+       }
+       // The editor trims names when they are entered, so the handler does 
not trim
+       // again: the columns it lists are those of the table the starter query 
names.
+       if want := []recordedStatement{{database: "grafana_demo", sql: `DESC 
"weather "`}}; !reflect.DeepEqual(calls, want) {
+               t.Fatalf("statements = %v, want %v", calls, want)
+       }
+}
+
+func TestTableMetadataRequiresItsParameters(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{}, nil, &calls)
+
+       for _, tc := range []struct {
+               handler http.Handler
+               target  string
+       }{
+               {d.getTableTables(), "/tableTables"},
+               {d.getTableTables(), "/tableTables?database=%20"},
+               {d.getTableColumns(), "/tableColumns?database=grafana_demo"},
+               {d.getTableColumns(), "/tableColumns?table=cpu"},
+       } {
+               if recorder := serveMetadata(t, tc.handler, tc.target); 
recorder.Code != http.StatusBadRequest {
+                       t.Errorf("%s: status = %d, want %d", tc.target, 
recorder.Code, http.StatusBadRequest)
+               }
+       }
+       if len(calls) != 0 {
+               t.Fatalf("a request missing its parameters reached the server: 
%v", calls)
+       }
+}
+
+func TestTableMetadataReportsServerErrors(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(nil, errors.New("550: Table 'grafana_demo.nope' 
does not exist"), &calls)
+
+       recorder := serveMetadata(t, d.getTableColumns(), 
"/tableColumns?database=grafana_demo&table=nope")
+
+       if recorder.Code != http.StatusInternalServerError {
+               t.Fatalf("status = %d, want %d", recorder.Code, 
http.StatusInternalServerError)
+       }
+       if !strings.Contains(recorder.Body.String(), "does not exist") {
+               t.Fatalf("body = %s, want the server message", 
recorder.Body.String())
+       }
+}
+
+func TestTableMetadataRejectsAnUnexpectedResultShape(t *testing.T) {
+       var calls []recordedStatement
+       d := metadataDataSource(&tableQueryDataSet{
+               ColumnNames: []string{"name"},
+               Values:      [][]interface{}{{"x"}},
+       }, nil, &calls)
+
+       if recorder := serveMetadata(t, d.getTableDatabases(), 
"/tableDatabases"); recorder.Code != http.StatusInternalServerError {
+               t.Fatalf("status = %d, want %d", recorder.Code, 
http.StatusInternalServerError)
+       }
+}
diff --git a/connectors/grafana-plugin/src/QueryEditor.tsx 
b/connectors/grafana-plugin/src/QueryEditor.tsx
index 70ded1a..00f9e61 100644
--- a/connectors/grafana-plugin/src/QueryEditor.tsx
+++ b/connectors/grafana-plugin/src/QueryEditor.tsx
@@ -26,6 +26,7 @@ import { FromValue } from './componments/FromValue';
 import { WhereValue } from './componments/WhereValue';
 import { ControlValue } from './componments/ControlValue';
 import { FillValue } from './componments/FillValue';
+import { TablePicker } from './componments/TablePicker';
 import { Input, Segment, TextArea } from '@grafana/ui';
 import { toOption } from './functions';
 
@@ -144,20 +145,29 @@ export class QueryEditor extends PureComponent<Props, 
State> {
     onChange({ ...query, groupBy: g });
   };
 
-  onDatabaseChange = (event: ChangeEvent<HTMLInputElement>) => {
+  onDatabaseChange = (database: string) => {
     const { onChange, query } = this.props;
-    const database = event.target.value;
     this.setState({ database });
-    onChange({ ...query, database });
+    // A table belongs to one database, so switching database clears it. 
Picking
+    // the current database again is not a switch.
+    onChange({ ...query, database, table: database === query.database ? 
query.table : undefined });
   };
 
-  onSqlChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
+  onTableChange = (table: string) => {
+    const { onChange, query } = this.props;
+    onChange({ ...query, table: table || undefined });
+  };
+
+  onSqlValueChange = (sql: string) => {
     const { onChange, query } = this.props;
-    const sql = event.target.value;
     this.setState({ sql });
     onChange({ ...query, sql });
   };
 
+  onSqlChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
+    this.onSqlValueChange(event.target.value);
+  };
+
   onFormatChange = ({ value: value = tableFormats[0] }: 
SelectableValue<string>) => {
     const { onChange, query } = this.props;
     this.setState({ format: value });
@@ -411,15 +421,16 @@ export class QueryEditor extends PureComponent<Props, 
State> {
                     />
                   </QueryInlineField>
                 </div>
-                <div className="gf-form">
-                  <QueryInlineField label={'DATABASE'}>
-                    <Input
-                      value={query.database ?? this.state.database}
-                      placeholder={'database name (required)'}
-                      onChange={this.onDatabaseChange}
-                    />
-                  </QueryInlineField>
-                </div>
+                <TablePicker
+                  datasource={this.props.datasource}
+                  refId={query.refId}
+                  scopedVars={this.props.data?.request?.scopedVars}
+                  database={query.database ?? this.state.database}
+                  table={query.table ?? ''}
+                  onDatabaseChange={this.onDatabaseChange}
+                  onTableChange={this.onTableChange}
+                  onSqlChange={this.onSqlValueChange}
+                />
                 <div className="gf-form">
                   <QueryInlineField label={'SQL'}>
                     <TextArea
diff --git a/connectors/grafana-plugin/src/componments/TablePicker.test.tsx 
b/connectors/grafana-plugin/src/componments/TablePicker.test.tsx
new file mode 100644
index 0000000..9e6934f
--- /dev/null
+++ b/connectors/grafana-plugin/src/componments/TablePicker.test.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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 React from 'react';
+import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import { DataSource } from '../datasource';
+import { TablePicker } from './TablePicker';
+
+function fakeDatasource(variables: Record<string, string>) {
+  return {
+    resolveTemplate: jest.fn((value: string) => variables[value] ?? value),
+    getTableDatabases: jest.fn().mockResolvedValue(['db1', 'db2']),
+    getTableTables: jest.fn().mockResolvedValue([]),
+    getTableColumns: jest.fn().mockResolvedValue([]),
+  };
+}
+
+function renderPicker(ds: ReturnType<typeof fakeDatasource>, props: 
Partial<React.ComponentProps<typeof TablePicker>>) {
+  const all = {
+    datasource: ds as unknown as DataSource,
+    refId: 'A',
+    database: '',
+    table: '',
+    onDatabaseChange: jest.fn(),
+    onTableChange: jest.fn(),
+    onSqlChange: jest.fn(),
+    ...props,
+  };
+  return { ...render(<TablePicker {...all} />), props: all };
+}
+
+// jsdom has no IntersectionObserver, which the Select menu's scroll 
indicators use.
+beforeAll(() => {
+  (global as unknown as { IntersectionObserver: unknown 
}).IntersectionObserver = class {
+    observe() {}
+    unobserve() {}
+    disconnect() {}
+  };
+});
+
+describe('TablePicker', () => {
+  it('lists the tables again when the database variable resolves to another 
database', async () => {
+    const variables: Record<string, string> = { $db: 'db1' };
+    const ds = fakeDatasource(variables);
+    const { rerender, props } = renderPicker(ds, { database: '$db' });
+    await waitFor(() => expect(ds.getTableTables).toHaveBeenCalledWith('db1'));
+
+    variables.$db = 'db2';
+    rerender(<TablePicker {...props} />);
+
+    await waitFor(() => expect(ds.getTableTables).toHaveBeenCalledWith('db2'));
+  });
+
+  it('keeps a typed database name when the picker loses focus', async () => {
+    const ds = fakeDatasource({});
+    const { props } = renderPicker(ds, {});
+    const databaseInput = document.getElementById('iotdb-table-database-A') as 
HTMLInputElement;
+
+    await act(async () => {
+      fireEvent.focus(databaseInput);
+      fireEvent.change(databaseInput, { target: { value: '  $database  ' } });
+    });
+    await act(async () => {
+      fireEvent.blur(databaseInput);
+    });
+
+    expect(props.onDatabaseChange).toHaveBeenCalledWith('$database');
+  });
+
+  it('shows every failed lookup, not only the first', async () => {
+    const ds = fakeDatasource({});
+    ds.getTableDatabases.mockRejectedValue(new Error('cannot list databases'));
+    ds.getTableTables.mockRejectedValue(new Error('cannot list tables'));
+    renderPicker(ds, { database: 'db1' });
+
+    expect(await screen.findByText(/cannot list databases; cannot list 
tables/)).toBeTruthy();
+  });
+
+  it.each(['Tab', 'Enter'])('keeps the database on %s after the 
refresh-on-open settles', async (key) => {
+    const ds = fakeDatasource({});
+    // Like a real lookup, every response is a new array.
+    ds.getTableDatabases.mockImplementation(() => 
Promise.resolve(['information_schema', 'db1', 'db2']));
+    const { props } = renderPicker(ds, { database: 'db2' });
+    await waitFor(() => expect(ds.getTableDatabases).toHaveBeenCalledTimes(1));
+    await act(async () => {});
+    const databaseInput = document.getElementById('iotdb-table-database-A') as 
HTMLInputElement;
+
+    await act(async () => {
+      fireEvent.focus(databaseInput);
+      fireEvent.keyDown(databaseInput, { key: 'ArrowDown' });
+    });
+    await waitFor(() => expect(ds.getTableDatabases).toHaveBeenCalledTimes(2));
+    await act(async () => {});
+    await act(async () => {
+      fireEvent.keyDown(databaseInput, { key });
+    });
+
+    
expect(props.onDatabaseChange).not.toHaveBeenCalledWith('information_schema');
+  });
+
+  it('gives each query its own element ids', () => {
+    const ds = fakeDatasource({});
+    renderPicker(ds, { refId: 'B' });
+    expect(document.getElementById('iotdb-table-database-B')).toBeTruthy();
+    expect(document.getElementById('iotdb-table-table-B')).toBeTruthy();
+  });
+});
diff --git a/connectors/grafana-plugin/src/componments/TablePicker.tsx 
b/connectors/grafana-plugin/src/componments/TablePicker.tsx
new file mode 100644
index 0000000..6ad6333
--- /dev/null
+++ b/connectors/grafana-plugin/src/componments/TablePicker.tsx
@@ -0,0 +1,265 @@
+/*
+ * 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 
'react';
+import { ScopedVars, SelectableValue } from '@grafana/data';
+import { Button, Select } from '@grafana/ui';
+import { DataSource } from '../datasource';
+import { starterTableQuery } from '../tableQuery';
+import { TableColumn } from '../types';
+import { QueryInlineField } from './Form';
+
+interface Props {
+  datasource: DataSource;
+  // Identifies the query, so that two table-model queries in one panel get 
distinct element ids.
+  refId: string;
+  // The panel's scoped variables, so that names resolve as they will when the 
query runs.
+  scopedVars?: ScopedVars;
+  database: string;
+  table: string;
+  onDatabaseChange: (database: string) => void;
+  onTableChange: (table: string) => void;
+  onSqlChange: (sql: string) => void;
+}
+
+interface Loaded<T> {
+  value?: T;
+  loading: boolean;
+  error?: string;
+}
+
+const categories = ['TAG', 'FIELD', 'ATTRIBUTE'];
+
+// pickerOptions turns the listed names into options and appends the current
+// value when it is not among them (a typed or variable name), so that the
+// select can match its value to an option and open with that option focused.
+function pickerOptions(
+  names: string[] | undefined,
+  current: string,
+  optionFor: (name: string) => SelectableValue<string>
+): Array<SelectableValue<string>> {
+  const options = (names ?? []).map(optionFor);
+  return current && !options.some((o) => o.value === current) ? [...options, 
optionFor(current)] : options;
+}
+
+// useOptionCache hands out one option object per name for the picker's
+// lifetime. react-select finds the focused option by identity, so a list
+// refreshed on open must still contain the same object for the current value.
+function useOptionCache(): (name: string) => SelectableValue<string> {
+  const cache = useRef(new Map<string, SelectableValue<string>>());
+  return useCallback((name: string) => {
+    let option = cache.current.get(name);
+    if (!option) {
+      option = { label: name, value: name };
+      cache.current.set(name, option);
+    }
+    return option;
+  }, []);
+}
+
+function errorMessage(err: unknown): string {
+  const e = err as { data?: { message?: string }; message?: string };
+  return e?.data?.message ?? e?.message ?? String(err);
+}
+
+// useLoaded runs one metadata lookup whenever its key changes. State is only
+// written when a lookup settles, and a result is only used while its key is
+// current. A failed lookup keeps its message; the pickers still accept a typed
+// name, so the editor stays usable when metadata cannot be read. With
+// keepPrevious, the last value stays available while a new lookup is running.
+function useLoaded<T>(key: string, load: (() => Promise<T>) | undefined, 
keepPrevious = false): Loaded<T> {
+  const [result, setResult] = useState<{ key: string; value?: T; error?: 
string }>();
+  useEffect(() => {
+    if (!load) {
+      return;
+    }
+    let cancelled = false;
+    load().then(
+      (value) => {
+        if (!cancelled) {
+          setResult({ key, value });
+        }
+      },
+      (err) => {
+        if (!cancelled) {
+          setResult({ key, error: errorMessage(err) });
+        }
+      }
+    );
+    return () => {
+      cancelled = true;
+    };
+    // The key identifies the lookup; `load` is a fresh closure on every 
render.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [key]);
+  if (!load) {
+    return { loading: false };
+  }
+  if (result?.key !== key) {
+    return { value: keepPrevious ? result?.value : undefined, loading: true };
+  }
+  return { value: result.value, error: result.error, loading: false };
+}
+
+// useTypedName lets a typed name count when the picker loses focus. A select
+// only commits typed text on Enter, Tab or a click on the custom-value row, 
and
+// clears it on blur; react-select calls onBlur before clearing, so the text is
+// still known there.
+function useTypedName(current: string, commit: (name: string) => void) {
+  const typed = useRef('');
+  return {
+    onInputChange: (value: string, meta: { action: string }) => {
+      if (meta.action === 'input-change') {
+        typed.current = value;
+      } else if (meta.action === 'set-value' || meta.action === 'menu-close') {
+        typed.current = '';
+      }
+    },
+    onBlur: () => {
+      const name = typed.current.trim();
+      typed.current = '';
+      if (name && name !== current) {
+        commit(name);
+      }
+    },
+  };
+}
+
+// TablePicker lists the databases, tables and columns of the connected server
+// for the table-model editor, and can fill in a starter query for the chosen
+// table.
+export function TablePicker({
+  datasource,
+  refId,
+  scopedVars,
+  database,
+  table,
+  onDatabaseChange,
+  onTableChange,
+  onSqlChange,
+}: Props) {
+  // Lookups are keyed on the resolved names, so a dashboard variable that
+  // changes value fetches the new database's tables and columns.
+  const resolvedDatabase = database ? datasource.resolveTemplate(database, 
scopedVars) : '';
+  const resolvedTable = table ? datasource.resolveTemplate(table, scopedVars) 
: '';
+  // Opening the database list asks the server again, so a database created
+  // later, or a lookup that failed, is picked up without reopening the editor.
+  const [databaseRefresh, setDatabaseRefresh] = useState(0);
+  const databases = useLoaded(`databases|${databaseRefresh}`, () => 
datasource.getTableDatabases(), true);
+  const tables = useLoaded(
+    `tables|${resolvedDatabase}`,
+    resolvedDatabase ? () => datasource.getTableTables(resolvedDatabase) : 
undefined
+  );
+  const columns = useLoaded<TableColumn[]>(
+    `columns|${resolvedDatabase}|${resolvedTable}`,
+    resolvedDatabase && resolvedTable ? () => 
datasource.getTableColumns(resolvedDatabase, resolvedTable) : undefined
+  );
+  const errors = Array.from(new Set([databases.error, tables.error, 
columns.error].filter(Boolean)));
+
+  const databaseOption = useOptionCache();
+  const tableOption = useOptionCache();
+  const databaseOptions = useMemo(
+    () => pickerOptions(databases.value, database, databaseOption),
+    [databases.value, database, databaseOption]
+  );
+  const tableOptions = useMemo(
+    () => pickerOptions(tables.value, table, tableOption),
+    [tables.value, table, tableOption]
+  );
+  const commitDatabase = (name: string) => onDatabaseChange(name.trim());
+  const commitTable = (name: string) => onTableChange(name.trim());
+  const typedDatabase = useTypedName(database, commitDatabase);
+  const typedTable = useTypedName(table, commitTable);
+
+  return (
+    <>
+      <div className="gf-form">
+        <QueryInlineField label={'DATABASE'}>
+          {/* Select rather than Combobox: with Combobox, picking 
information_schema
+              crashed Explore on Grafana 12.3 with React error 185 raised from 
the
+              popover's floating-ui reference; Select does not. */}
+          <Select
+            inputId={`iotdb-table-database-${refId}`}
+            width={30}
+            options={databaseOptions}
+            value={databaseOptions.find((o) => o.value === database) ?? null}
+            isLoading={databases.loading}
+            allowCustomValue
+            allowCreateWhileLoading
+            placeholder={'database name (required)'}
+            onChange={(v) => commitDatabase(v?.value ?? '')}
+            onInputChange={typedDatabase.onInputChange}
+            onBlur={typedDatabase.onBlur}
+            onOpenMenu={() => setDatabaseRefresh((n) => n + 1)}
+          />
+        </QueryInlineField>
+        <QueryInlineField label={'TABLE'}>
+          <Select
+            inputId={`iotdb-table-table-${refId}`}
+            width={30}
+            options={tableOptions}
+            value={tableOptions.find((o) => o.value === table) ?? null}
+            isLoading={tables.loading}
+            disabled={!database}
+            allowCustomValue
+            allowCreateWhileLoading
+            isClearable
+            placeholder={'pick a table to list its columns'}
+            onChange={(v) => commitTable(v?.value ?? '')}
+            onInputChange={typedTable.onInputChange}
+            onBlur={typedTable.onBlur}
+          />
+        </QueryInlineField>
+      </div>
+      {columns.value && columns.value.length > 0 && (
+        <div className="gf-form">
+          <QueryInlineField label={'COLUMNS'}>
+            <div className="gf-form-label" style={{ flexWrap: 'wrap', height: 
'auto', gap: 8 }}>
+              {categories
+                .map((category) => ({
+                  category,
+                  names: columns.value!.filter((c) => c.category === 
category).map((c) => c.name),
+                }))
+                .filter(({ names }) => names.length > 0)
+                .map(({ category, names }) => (
+                  <span key={category}>
+                    <span className="query-keyword">{category}</span> 
{names.join(', ')}
+                  </span>
+                ))}
+            </div>
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => onSqlChange(starterTableQuery(table, 
columns.value!))}
+              tooltip="Replace the SQL below with a query that selects this 
table's tag and field columns"
+            >
+              Use starter query
+            </Button>
+          </QueryInlineField>
+        </div>
+      )}
+      {errors.length > 0 && (
+        <div className="gf-form">
+          <QueryInlineField label={''}>
+            <div className="gf-form-label">Could not read metadata: 
{errors.join('; ')}</div>
+          </QueryInlineField>
+        </div>
+      )}
+    </>
+  );
+}
diff --git a/connectors/grafana-plugin/src/datasource.test.ts 
b/connectors/grafana-plugin/src/datasource.test.ts
index 4012eb3..0faa85c 100644
--- a/connectors/grafana-plugin/src/datasource.test.ts
+++ b/connectors/grafana-plugin/src/datasource.test.ts
@@ -301,3 +301,19 @@ describe('query type model', () => {
     expect(getQueryType({ ...query, instant: true })).toBe('Instant');
   });
 });
+
+describe('table-model metadata lookups', () => {
+  it('do not raise a global error alert, since the editor shows failures 
inline', async () => {
+    const ds = new DataSource({ jsonData: { url: 'http://localhost:6667', 
username: 'root' } } as any);
+    const getResource = jest.fn().mockResolvedValue([]);
+    (ds as unknown as { getResource: jest.Mock }).getResource = getResource;
+    await ds.getTableDatabases();
+    await ds.getTableTables('db');
+    await ds.getTableColumns('db', 't');
+    expect(getResource.mock.calls).toEqual([
+      ['tableDatabases', undefined, { showErrorAlert: false }],
+      ['tableTables', { database: 'db' }, { showErrorAlert: false }],
+      ['tableColumns', { database: 'db', table: 't' }, { showErrorAlert: false 
}],
+    ]);
+  });
+});
diff --git a/connectors/grafana-plugin/src/datasource.ts 
b/connectors/grafana-plugin/src/datasource.ts
index 0e27c05..aff96a4 100644
--- a/connectors/grafana-plugin/src/datasource.ts
+++ b/connectors/grafana-plugin/src/datasource.ts
@@ -16,7 +16,7 @@
  */
 import { DataSourceInstanceSettings, MetricFindValue, ScopedVars } from 
'@grafana/data';
 
-import { IoTDBOptions, IoTDBQuery } from './types';
+import { IoTDBOptions, IoTDBQuery, TableColumn } from './types';
 import { toMetricFindValue } from './functions';
 import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
 
@@ -181,6 +181,27 @@ export class DataSource extends 
DataSourceWithBackend<IoTDBQuery, IoTDBOptions>
       .then((data) => data.map(toMetricFindValue));
   }
 
+  // resolveTemplate expands dashboard variables in a database or table name 
the
+  // same way applyTemplateVariables does when the query runs.
+  resolveTemplate(value: string, scopedVars?: ScopedVars): string {
+    return getTemplateSrv().replace(value, scopedVars);
+  }
+
+  // Metadata behind the table-model editor's pickers. Callers pass names that
+  // resolveTemplate has already expanded. The editor shows a failed lookup
+  // inline, so it is not also raised as a global error alert.
+  getTableDatabases(): Promise<string[]> {
+    return this.getResource('tableDatabases', undefined, { showErrorAlert: 
false });
+  }
+
+  getTableTables(database: string): Promise<string[]> {
+    return this.getResource('tableTables', { database }, { showErrorAlert: 
false });
+  }
+
+  getTableColumns(database: string, table: string): Promise<TableColumn[]> {
+    return this.getResource('tableColumns', { database, table }, { 
showErrorAlert: false });
+  }
+
   async getVariablesResult(sql: string) {
     if (this.url.substr(this.url.length - 1, 1) === '/') {
       this.url = this.url.substr(0, this.url.length - 1);
diff --git a/connectors/grafana-plugin/src/tableQuery.test.ts 
b/connectors/grafana-plugin/src/tableQuery.test.ts
new file mode 100644
index 0000000..cb310aa
--- /dev/null
+++ b/connectors/grafana-plugin/src/tableQuery.test.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 { quoteIdentifier, starterTableQuery } from './tableQuery';
+import { TableColumn } from './types';
+
+const column = (name: string, category: string, dataType = 'STRING'): 
TableColumn => ({ name, category, dataType });
+
+describe('quoteIdentifier', () => {
+  it('quotes every name, so reserved words and spaces stay valid', () => {
+    expect(quoteIdentifier('device_id')).toBe('"device_id"');
+    expect(quoteIdentifier('order')).toBe('"order"');
+    expect(quoteIdentifier('weather station')).toBe('"weather station"');
+  });
+
+  it('doubles an embedded double quote', () => {
+    expect(quoteIdentifier('a"b')).toBe('"a""b"');
+  });
+});
+
+describe('starterTableQuery', () => {
+  const columns = [
+    column('time', 'TIME', 'TIMESTAMP'),
+    column('region', 'TAG'),
+    column('model', 'ATTRIBUTE'),
+    column('temperature', 'FIELD', 'FLOAT'),
+    column('device_id', 'TAG'),
+    column('humidity', 'FIELD', 'DOUBLE'),
+  ];
+
+  it('selects time, then tags, then fields, and filters on the time range', () 
=> {
+    expect(starterTableQuery('weather', columns)).toBe(
+      'SELECT "time", "region", "device_id", "temperature", "humidity" FROM 
"weather" WHERE $__timeFilter("time")'
+    );
+  });
+
+  it('leaves attribute columns out', () => {
+    expect(starterTableQuery('weather', columns)).not.toContain('"model"');
+  });
+
+  it('quotes a table name that needs it', () => {
+    expect(starterTableQuery('weather station', columns)).toContain('FROM 
"weather station" WHERE');
+  });
+
+  it('leaves the time filter out for a table without a time column', () => {
+    const view = [column('database', 'TAG'), column('table_name', 'TAG'), 
column('status', 'ATTRIBUTE')];
+    expect(starterTableQuery('tables', view)).toBe('SELECT "database", 
"table_name" FROM "tables"');
+  });
+
+  it('selects everything when there is no tag or field column to pick', () => {
+    expect(starterTableQuery('t', [column('status', 
'ATTRIBUTE')])).toBe('SELECT * FROM "t"');
+  });
+
+  it('leaves string and boolean fields out when the table has numeric ones', 
() => {
+    const mixed = [
+      column('time', 'TIME', 'TIMESTAMP'),
+      column('device_id', 'TAG'),
+      column('temperature', 'FIELD', 'FLOAT'),
+      column('status', 'FIELD', 'STRING'),
+      column('online', 'FIELD', 'BOOLEAN'),
+      column('count', 'FIELD', 'INT64'),
+    ];
+    expect(starterTableQuery('weather', mixed)).toBe(
+      'SELECT "time", "device_id", "temperature", "count" FROM "weather" WHERE 
$__timeFilter("time")'
+    );
+  });
+
+  it('keeps every field when none of them is numeric', () => {
+    const text = [column('time', 'TIME', 'TIMESTAMP'), column('message', 
'FIELD', 'TEXT')];
+    expect(starterTableQuery('log', text)).toBe('SELECT "time", "message" FROM 
"log" WHERE $__timeFilter("time")');
+  });
+
+  it('uses the time column the table declares', () => {
+    const renamed = [column('ts', 'TIME', 'TIMESTAMP'), column('v', 'FIELD', 
'DOUBLE')];
+    expect(starterTableQuery('t', renamed)).toBe('SELECT "ts", "v" FROM "t" 
WHERE $__timeFilter("ts")');
+  });
+});
diff --git a/connectors/grafana-plugin/src/tableQuery.ts 
b/connectors/grafana-plugin/src/tableQuery.ts
new file mode 100644
index 0000000..4726f2b
--- /dev/null
+++ b/connectors/grafana-plugin/src/tableQuery.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 { TableColumn } from './types';
+
+// quoteIdentifier wraps a name in double quotes, doubling any embedded quote.
+// The names come from DESC, so quoting them never changes what they refer to,
+// and it keeps the query valid when a name is a reserved word such as "order"
+// or "group", or contains a space.
+export function quoteIdentifier(name: string): string {
+  return `"${name.replace(/"/g, '""')}"`;
+}
+
+// Numeric FIELD types. The Time series format plots these; string and boolean
+// fields are treated as factors and split each device into extra series.
+const numericTypes = ['INT32', 'INT64', 'FLOAT', 'DOUBLE'];
+
+// starterTableQuery builds a first query for a table: the time column, every
+// TAG column and the numeric FIELD columns, limited to the dashboard's time
+// range. ATTRIBUTE columns are left out because they do not change over time.
+// The TAG columns are what the Time series format uses to split the result 
into
+// one series per device. A table with no numeric field, such as the views in
+// information_schema, gets all of its fields instead, and a table without a
+// time column gets no time filter.
+export function starterTableQuery(table: string, columns: TableColumn[]): 
string {
+  const time = columns.find((c) => c.category === 'TIME')?.name;
+  const fields = columns.filter((c) => c.category === 'FIELD');
+  const numericFields = fields.filter((c) => 
numericTypes.includes(c.dataType.toUpperCase()));
+  const selected = [
+    ...(time ? [time] : []),
+    ...columns.filter((c) => c.category === 'TAG').map((c) => c.name),
+    ...(numericFields.length > 0 ? numericFields : fields).map((c) => c.name),
+  ].map(quoteIdentifier);
+  const projection = selected.length > 0 ? selected.join(', ') : '*';
+  const from = `SELECT ${projection} FROM ${quoteIdentifier(table)}`;
+  return time ? `${from} WHERE $__timeFilter(${quoteIdentifier(time)})` : from;
+}
diff --git a/connectors/grafana-plugin/src/types.ts 
b/connectors/grafana-plugin/src/types.ts
index ae0d85e..c0cba04 100644
--- a/connectors/grafana-plugin/src/types.ts
+++ b/connectors/grafana-plugin/src/types.ts
@@ -38,6 +38,9 @@ export interface IoTDBQuery extends DataQuery {
   // rendered either as time series (long results are pivoted into one series
   // per tag combination) or as a plain table.
   database?: string;
+  // The table picked in the editor. It only drives the column list and the
+  // starter query; the SQL is what runs.
+  table?: string;
   sql?: string;
   format?: string;
   legendFormat?: string;
@@ -47,6 +50,13 @@ export interface IoTDBQuery extends DataQuery {
   range?: boolean;
 }
 
+// One column of a table-model table, as the backend reports it from DESC.
+export interface TableColumn {
+  name: string;
+  dataType: string;
+  category: string;
+}
+
 export interface GroupBy {
   step: string;
   samplingInterval: string;
diff --git a/connectors/grafana-plugin/tests/tableModelEditor.spec.ts 
b/connectors/grafana-plugin/tests/tableModelEditor.spec.ts
new file mode 100644
index 0000000..e929e26
--- /dev/null
+++ b/connectors/grafana-plugin/tests/tableModelEditor.spec.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { expect, test } from '@grafana/plugin-e2e';
+
+const DATASOURCE_NAME = 'IoTDB';
+
+// information_schema exists on every IoTDB instance, so the pickers can be
+// exercised against the real server without seeding any data.
+test('the table-model editor lists databases, tables and columns from the 
server', async ({ page }) => {
+  await page.goto('/explore');
+
+  await page.getByText('SQL: Full Customized', { exact: true 
}).first().click();
+  await page.getByText('SQL: Table Model', { exact: true }).last().click();
+
+  await page.locator('#iotdb-table-database-A').click();
+  await page.getByRole('option', { name: 'information_schema' }).click();
+
+  await page.locator('#iotdb-table-table-A').click();
+  await page.getByRole('option', { name: 'tables', exact: true }).click();
+
+  // The COLUMNS row is built from DESC, grouped by category.
+  await expect(page.getByText('table_name', { exact: false })).toBeVisible({ 
timeout: 30_000 });
+
+  await page.getByRole('button', { name: 'Use starter query' }).click();
+  const sql = await page.locator('textarea').first().inputValue();
+  // information_schema.tables has no time column, so no time filter is added.
+  expect(sql).toBe('SELECT "database", "table_name" FROM "tables"');
+
+  // The generated query has to run, not just look right.
+  const datasource = await page.evaluate(async (name) => {
+    const res = await 
fetch(`/api/datasources/name/${encodeURIComponent(name)}`);
+    return res.json();
+  }, DATASOURCE_NAME);
+  const now = Date.now();
+  const result = await page.evaluate(
+    async (payload) => {
+      const res = await fetch('/api/ds/query', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload),
+      });
+      return { status: res.status, json: await res.json() };
+    },
+    {
+      from: String(now - 3_600_000),
+      to: String(now),
+      queries: [
+        {
+          refId: 'A',
+          datasource: { uid: datasource.uid, type: datasource.type },
+          sqlType: 'SQL: Table Model',
+          database: 'information_schema',
+          sql,
+          format: 'Table',
+          expression: [],
+          prefixPath: [],
+          paths: [],
+          options: [],
+          condition: '',
+          control: '',
+          fillClauses: '',
+          isDropDownList: false,
+          hide: false,
+        },
+      ],
+    }
+  );
+  expect(result.status).toBe(200);
+  const frame = result.json.results.A;
+  expect(frame.error, `query error: ${frame.error}`).toBeUndefined();
+  expect(frame.frames[0].data.values[0].length).toBeGreaterThan(0);
+});
+
+test('the table-model pickers keep their value on Tab, commit typed names on 
blur and report failures inline', async ({
+  page,
+}) => {
+  await page.goto('/explore');
+  await page.getByText('SQL: Full Customized', { exact: true 
}).first().click();
+  await page.getByText('SQL: Table Model', { exact: true }).last().click();
+
+  const table = page.locator('#iotdb-table-table-A');
+  await page.locator('#iotdb-table-database-A').click();
+  await page.getByRole('option', { name: 'information_schema' }).click();
+  await table.click();
+  await page.getByRole('option', { name: 'tables', exact: true }).click();
+  await expect(page.getByText('table_name', { exact: false })).toBeVisible({ 
timeout: 30_000 });
+
+  // Opening a picker and pressing Tab must keep its value. "tables" is not the
+  // first table of information_schema, so a picker that focused the first
+  // option on open would switch to "columns" here.
+  await table.click();
+  await expect(page.getByRole('option', { name: 'tables', exact: true 
})).toBeVisible();
+  await page.keyboard.press('Tab');
+  await expect(page.getByText('table_name', { exact: false })).toBeVisible();
+  await expect(page.getByText('column_name', { exact: false })).toHaveCount(0);
+
+  // A table name typed and left by clicking elsewhere is kept.
+  await table.click();
+  await page.keyboard.type('columns');
+  // Leave the picker the way a click elsewhere does (the open menu covers the 
labels).
+  await table.blur();
+  await expect(page.getByText('column_name', { exact: false })).toBeVisible({ 
timeout: 30_000 });
+
+  // Running the query from the editor sends the picked database and table.
+  const request = page.waitForRequest(
+    (r) => r.url().includes('/api/ds/query') && r.method() === 'POST' && 
(r.postData() ?? '').includes('Table Model')
+  );
+  await page.getByRole('button', { name: 'Use starter query' }).click();
+  await page.getByRole('button', { name: /run query/i }).first().click();
+  const sent = (await request).postDataJSON().queries[0];
+  expect(sent.database).toBe('information_schema');
+  expect(sent.table).toBe('columns');
+
+  // A failed lookup is reported next to the pickers and raises no global 
alert.
+  await table.click();
+  await page.keyboard.type('no_such_table');
+  // Leave the picker the way a click elsewhere does (the open menu covers the 
labels).
+  await table.blur();
+  await expect(page.getByText('Could not read metadata', { exact: false 
})).toBeVisible({ timeout: 30_000 });
+  // Explore's own default query can leave an unrelated alert when the page
+  // opens, so look for an alert about this lookup specifically.
+  await page.waitForTimeout(1_000);
+  await expect(page.getByTestId('data-testid Alert error').filter({ hasText: 
'no_such_table' })).toHaveCount(0);
+});
+

Reply via email to