/* * Licensed 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 { filterMap, QueryResult, SqlExpression, SqlQuery } from 'druid-query-toolkit'; import type { StoreApi } from 'zustand/vanilla'; import type { HostState, HostStorePersistOptions, RegisteredVisualModule, VisualModuleOverrides, } from './host-store'; import { createHostStore } from './host-store'; import type { ExpressionMeta } from './models'; import type { ParameterDefinitions } from './parameter'; import type { VisualModule } from './visual-module'; /** * Payload that a `sqlQuery` function should pass to Druid. */ export interface SqlQueryPayload { query: string; resultFormat: 'array'; header: true; typesHeader: true; sqlTypesHeader: true; context: Record; } /** * Visual module host. * * Every application using visual modules needs to have a host. * * A host manages state for a set of visual modules. This includes a common * WHERE clause and parameter values. It also notifies registered modules when * their parameter values change. * * Modules can use the host to execute SQL queries against Druid and make * changes to the common WHERE clause. Changes to this clause will affect all * modules registered with the host. */ export interface Host { /** * Executes a SQL query against Druid. * * The implementation of this method is provided by the application. * * @param query - Query to execute. * @param sqlQueryOptions - Options that will be passed to the implementation. This * may be used for passing application-specific options to * the query engine. */ sqlQuery(query: string | SqlQuery, sqlQueryOptions?: unknown): Promise; /** * Replace the current set of columns that belong to this table. * * TODO: should the host be responsible for querying this initially? * * @param columns - New set of columns. */ updateColumns(columns: ExpressionMeta[]): void; /** * Replace the common WHERE clause with the given expression. */ updateWhere(where: string | SqlExpression): void; // TODO: this is a bit weird changeClauseInWhere(clause: string | SqlExpression): void; toggleClauseInWhere(clause: string | SqlExpression): void; /** * Change the common table that modules will query against. * * @param table - New table SQL expression. */ updateTable(table: SqlExpression): void; /** * Replace the common HAVING clause with the given expression. */ updateHaving(having: string | SqlExpression): void; /** * Registers a visual module with optional control overrides. * * @param name - Name of the module. * @param module - Visual module to register. * @param overrides - Optional overrides for the module's parameters. */ registerVisualModule

( name: string, module: VisualModule

, overrides?: VisualModuleOverrides

, ): void; /** * Unregisters a visual module with the given name. */ unregisterVisualModule(name: string): void; /** * Returns a list of all registered visual modules. */ getRegisteredVisualModules(): RegisteredVisualModule[]; /** * Zustand store containing the host's state. */ readonly store: StoreApi; } export interface HostOptions { /** * Options for persisting the host store. * * If not defined, the host store will not be persisted. */ persist?: HostStorePersistOptions; /** * Query function that will be used to execute SQL queries against Druid. * * This should pass the payload to a Druid SQL endpoint and return a promise * containing the result. * * @param payload - Payload to pass to Druid. * @returns - Promise containing the result. */ sqlQuery: (payload: SqlQueryPayload) => Promise; /** * Initial value for common table that all visual modules will query against. */ table: SqlExpression; /** * Initial value for common WHERE clause that all visual modules will use. * * @default 'TRUE' */ where?: string | SqlExpression; /** * Initial value for common HAVING clause that all visual modules will use. * * @default 'TRUE' */ having?: string | SqlExpression; } export function Host(options: HostOptions): Host { const store = createHostStore(options); return { store, updateColumns(columns) { const state = store.getState(); // don't set the columns if they're already there if (JSON.stringify(state.columns) === JSON.stringify(columns)) { return; } store.setState({ columns }); }, updateWhere(where) { store.setState({ where: typeof where === 'string' ? SqlExpression.parse(where) : where, }); }, updateHaving(having) { store.setState({ having: typeof having === 'string' ? SqlExpression.parse(having) : having, }); }, updateTable(table) { store.setState({ table }); }, changeClauseInWhere(clause) { const state = store.getState(); const parsed = SqlExpression.parse(clause); const currentWhere: SqlExpression | undefined = state.where; if (String(currentWhere) === 'TRUE') { this.updateWhere(clause); } else { let currentClauses = currentWhere.decomposeViaAnd(); const usedColumnNames = parsed.getUsedColumnNames(); if (usedColumnNames.length === 1) { const clauseColumn = usedColumnNames[0]; currentClauses = currentClauses.filter(c => { const cUsedColumn = c.getUsedColumnNames(); return cUsedColumn.length !== 1 || cUsedColumn[0] !== clauseColumn; }); } this.updateWhere(SqlExpression.and(...currentClauses, parsed)); } }, toggleClauseInWhere(clause) { const state = store.getState(); const parsed = SqlExpression.parse(clause); const currentWhere: SqlExpression | undefined = state.where; if (String(currentWhere) === 'TRUE') { this.updateWhere(clause); } else { const currentClauses = currentWhere.decomposeViaAnd(); const currentClausesWithoutClause = currentClauses.filter(c => !c.equals(parsed)); if (currentClauses.length === currentClausesWithoutClause.length) { this.updateWhere(SqlExpression.and(...currentClauses, parsed)); } else { this.updateWhere(SqlExpression.and(...currentClausesWithoutClause)); } } }, async sqlQuery(query, sqlQueryOptions) { let parsedQuery: SqlQuery; try { parsedQuery = SqlQuery.parse(query); } catch (e: any) { throw new Error(`Could not parse query \`${query}\`: ${e.message}`); } const sqlQueryPayload: SqlQueryPayload = { query: parsedQuery.toString(), resultFormat: 'array', header: true, typesHeader: true, sqlTypesHeader: true, context: !!sqlQueryOptions && typeof sqlQueryOptions === 'object' && 'sqlTimeZone' in sqlQueryOptions ? { sqlTimeZone: sqlQueryOptions.sqlTimeZone, } : {}, }; const result = await options.sqlQuery(sqlQueryPayload); return QueryResult.fromRawResult(result, true, true, true, true) .attachQuery({}, parsedQuery) .inflateDatesFromSqlTypes(); }, getRegisteredVisualModules() { return filterMap(Object.values(store.getState().visualModules || []), m => m); }, registerVisualModule

( name: string, module: VisualModule

, overrides?: VisualModuleOverrides

, ) { store.getState().registerModule

(name, module, overrides); }, unregisterVisualModule(name) { store.getState().removeModule(name); }, }; }