Skip to content
Snippets Groups Projects
Question.ts 36.8 KiB
Newer Older
  • Learn to ignore specific revisions
  • // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-nocheck
    
    Tom Robinson's avatar
    Tom Robinson committed
    import _ from "underscore";
    
    import { assoc, assocIn, chain, dissoc, getIn } from "icepick";
    
    /* eslint-disable import/order */
    
    Tom Robinson's avatar
    Tom Robinson committed
    // NOTE: the order of these matters due to circular dependency issues
    
    Sameer Al-Sakran's avatar
    Sameer Al-Sakran committed
    import StructuredQuery, {
    
      STRUCTURED_QUERY_TEMPLATE,
    
    Tom Robinson's avatar
    Tom Robinson committed
    } from "metabase-lib/lib/queries/StructuredQuery";
    import NativeQuery, {
      NATIVE_QUERY_TEMPLATE,
    } from "metabase-lib/lib/queries/NativeQuery";
    import AtomicQuery from "metabase-lib/lib/queries/AtomicQuery";
    
    import InternalQuery from "metabase-lib/lib/queries/InternalQuery";
    
    Tom Robinson's avatar
    Tom Robinson committed
    import Query from "metabase-lib/lib/queries/Query";
    import Metadata from "metabase-lib/lib/metadata/Metadata";
    import Database from "metabase-lib/lib/metadata/Database";
    import Table from "metabase-lib/lib/metadata/Table";
    import Field from "metabase-lib/lib/metadata/Field";
    import {
      AggregationDimension,
    
    Tom Robinson's avatar
    Tom Robinson committed
    } from "metabase-lib/lib/Dimension";
    
    import { isFK } from "metabase-lib/lib/types/utils/isa";
    
    import { memoizeClass, sortObject } from "metabase-lib/lib/utils";
    
    Tom Robinson's avatar
    Tom Robinson committed
    // TODO: remove these dependencies
    import * as Urls from "metabase/lib/urls";
    
    import { getCardUiParameters } from "metabase-lib/lib/parameters/utils/cards";
    
    import {
      DashboardApi,
      CardApi,
      maybeUsePivotEndpoint,
      MetabaseApi,
    } from "metabase/services";
    
      Parameter as ParameterObject,
      ParameterValues,
    
    } from "metabase-types/types/Parameter";
    
    import { Card as CardObject, DatasetQuery } from "metabase-types/types/Card";
    import { VisualizationSettings } from "metabase-types/api/card";
    
    import { Column, Dataset, Value } from "metabase-types/types/Dataset";
    
    import { TableId } from "metabase-types/types/Table";
    import { DatabaseId } from "metabase-types/types/Database";
    
    import {
      ClickObject,
      DimensionValue,
    } from "metabase-types/types/Visualization";
    
    import { DependentMetadataItem } from "metabase-types/types/Query";
    
    import { utf8_to_b64url } from "metabase/lib/encoding";
    import { CollectionId } from "metabase-types/api";
    
    
    import {
      normalizeParameterValue,
      getParameterValuesBySlug,
    } from "metabase-lib/lib/parameters/utils/parameter-values";
    import { remapParameterValuesToTemplateTags } from "metabase-lib/lib/parameters/utils/template-tags";
    
    import { fieldFilterParameterToMBQLFilter } from "metabase-lib/lib/parameters/utils/mbql";
    
    import { getQuestionVirtualTableId } from "metabase-lib/lib/metadata/utils/saved-questions";
    
    import {
      aggregate,
      breakout,
      distribution,
      drillFilter,
      filter,
      pivot,
    } from "metabase-lib/lib/queries/utils/actions";
    
    import { isTransientId } from "metabase-lib/lib/queries/utils/card";
    
    import {
      findColumnIndexForColumnSetting,
      findColumnSettingIndexForColumn,
      syncTableColumnsToQuery,
    
    } from "metabase-lib/lib/queries/utils/dataset";
    
    Atte Keinänen's avatar
    Atte Keinänen committed
    import {
    
      ALERT_TYPE_PROGRESS_BAR_GOAL,
      ALERT_TYPE_ROWS,
      ALERT_TYPE_TIMESERIES_GOAL,
    
    Atte Keinänen's avatar
    Atte Keinänen committed
    } from "metabase-lib/lib/Alert";
    
    import { getBaseDimensionReference } from "metabase-lib/lib/references";
    
    export type QuestionCreatorOpts = {
      databaseId?: DatabaseId;
    
      tableId?: TableId;
    
      collectionId?: CollectionId;
    
      metadata?: Metadata;
      parameterValues?: ParameterValues;
      type?: "query" | "native";
      name?: string;
      display?: string;
      visualization_settings?: VisualizationSettings;
      dataset_query?: DatasetQuery;
    };
    
    
    /**
     * This is a wrapper around a question/card object, which may contain one or more Query objects
     */
    
    class QuestionInner {
    
      /**
       * The plain object presentation of this question, equal to the format that Metabase REST API understands.
       * It is called `card` for both historical reasons and to make a clear distinction to this class.
       */
      _card: CardObject;
    
    
      /**
       * The Question wrapper requires a metadata object because the queries it contains (like {@link StructuredQuery})
       * need metadata for accessing databases, tables and metrics.
       */
      _metadata: Metadata;
    
    
      /**
       * Parameter values mean either the current values of dashboard filters or SQL editor template parameters.
       * They are in the grey area between UI state and question state, but having them in Question wrapper is convenient.
       */
      _parameterValues: ParameterValues;
    
      /**
       * Question constructor
       */
      constructor(
        card: CardObject,
    
        parameterValues?: ParameterValues,
      ) {
        this._card = card;
    
        this._metadata =
          metadata ||
          new Metadata({
            databases: {},
            tables: {},
            fields: {},
            metrics: {},
            segments: {},
    
        this._parameterValues = parameterValues || {};
    
        return new Question(this._card, this._metadata, this._parameterValues);
    
      }
    
      metadata(): Metadata {
        return this._metadata;
      }
    
      card() {
        return this._card;
      }
    
      setCard(card: CardObject): Question {
    
        const q = this.clone();
        q._card = card;
        return q;
      }
    
    
      withoutNameAndId() {
        return this.setCard(
          chain(this.card())
            .dissoc("id")
            .dissoc("name")
            .dissoc("description")
            .value(),
        );
      }
    
    
      omitTransientCardIds() {
        let question = this;
    
        const card = question.card();
        const { id, original_card_id } = card;
        if (isTransientId(id)) {
          question = question.setCard(_.omit(question.card(), "id"));
        }
        if (isTransientId(original_card_id)) {
          question = question.setCard(_.omit(question.card(), "original_card_id"));
        }
    
        return question;
      }
    
    
      /**
       * A question contains either a:
       * - StructuredQuery for queries written in MBQL
       * - NativeQuery for queries written in data source's native query language
       *
       * This is just a wrapper object, the data is stored in `this._card.dataset_query` in a format specific to the query type.
       */
    
        const datasetQuery = this._card.dataset_query;
    
    
    Paul Rosenzweig's avatar
    Paul Rosenzweig committed
        for (const QueryClass of [StructuredQuery, NativeQuery, InternalQuery]) {
    
          if (QueryClass.isDatasetQueryType(datasetQuery)) {
            return new QueryClass(this, datasetQuery);
          }
        }
    
        throw new Error("Unknown query type: " + datasetQuery.type);
      }
    
      isNative(): boolean {
        return this.query() instanceof NativeQuery;
      }
    
    
    Tom Robinson's avatar
    Tom Robinson committed
      isStructured(): boolean {
        return this.query() instanceof StructuredQuery;
      }
    
    
      /**
       * Returns a new Question object with an updated query.
       * The query is saved to the `dataset_query` field of the Card object.
       */
      setQuery(newQuery: Query): Question {
        if (this._card.dataset_query !== newQuery.datasetQuery()) {
          return this.setCard(
            assoc(this.card(), "dataset_query", newQuery.datasetQuery()),
          );
        }
    
        return this;
      }
    
    
    Tom Robinson's avatar
    Tom Robinson committed
      datasetQuery(): DatasetQuery {
        return this.card().dataset_query;
      }
    
    
      setDatasetQuery(newDatasetQuery: DatasetQuery): Question {
        return this.setCard(assoc(this.card(), "dataset_query", newDatasetQuery));
      }
    
      /**
       * Returns a list of atomic queries (NativeQuery or StructuredQuery) contained in this question
       */
      atomicQueries(): AtomicQuery[] {
        const query = this.query();
    
        if (query instanceof AtomicQuery) {
          return [query];
        }
    
        return [];
      }
    
      /**
       * The visualization type of the question
       */
      display(): string {
        return this._card && this._card.display;
      }
    
      setDisplay(display) {
        return this.setCard(assoc(this.card(), "display", display));
      }
    
    
      cacheTTL(): number | null {
        return this._card?.cache_ttl;
      }
    
      setCacheTTL(cache) {
        return this.setCard(assoc(this.card(), "cache_ttl", cache));
      }
    
    
      /**
       * returns whether this question is a model
       * @returns boolean
       */
    
      isDataset() {
        return this._card && this._card.dataset;
      }
    
    
      isPersisted() {
        return this._card && this._card.persisted;
      }
    
    
      isAction() {
        return this._card && this._card.is_write;
      }
    
    
      setPersisted(isPersisted) {
        return this.setCard(assoc(this.card(), "persisted", isPersisted));
      }
    
    
      setDataset(dataset) {
        return this.setCard(assoc(this.card(), "dataset", dataset));
      }
    
    
      setPinned(pinned: boolean) {
        return this.setCard(
          assoc(this.card(), "collection_position", pinned ? 1 : null),
        );
      }
    
    
      setIsAction(isAction) {
        return this.setCard(assoc(this.card(), "is_write", isAction));
      }
    
    
      // locking the display prevents auto-selection
      lockDisplay(): Question {
        return this.setDisplayIsLocked(true);
    
      setDisplayIsLocked(locked: boolean): Question {
        return this.setCard(assoc(this.card(), "displayIsLocked", locked));
    
      displayIsLocked(): boolean {
        return this._card && this._card.displayIsLocked;
    
      // If we're locked to a display that is no longer "sensible", unlock it
      // unless it was locked in unsensible
      maybeUnlockDisplay(sensibleDisplays, previousSensibleDisplays): Question {
        const wasSensible =
          previousSensibleDisplays == null ||
          previousSensibleDisplays.includes(this.display());
        const isSensible = sensibleDisplays.includes(this.display());
        const shouldUnlock = wasSensible && !isSensible;
        const locked = this.displayIsLocked() && !shouldUnlock;
    
        return this.setDisplayIsLocked(locked);
    
      }
    
      // Switches display based on data shape. For 1x1 data, we show a scalar. If
      // our display was a 1x1 type, but the data isn't 1x1, we show a table.
      switchTableScalar({ rows = [], cols }): Question {
    
        if (this.displayIsLocked()) {
          return this;
        }
    
        const display = this.display();
        const isScalar = ["scalar", "progress", "gauge"].includes(display);
        const isOneByOne = rows.length === 1 && cols.length === 1;
        const newDisplay =
    
          !isScalar && isOneByOne // if we have a 1x1 data result then this should always be viewed as a scalar
            ? "scalar"
            : isScalar && !isOneByOne // any time we were a scalar and now have more than 1x1 data switch to table view
            ? "table" // otherwise leave the display unchanged
            : display;
    
        return this.setDisplay(newDisplay);
      }
    
    
        if (this.displayIsLocked()) {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const query = this.query();
    
    Tom Robinson's avatar
    Tom Robinson committed
        if (query instanceof StructuredQuery) {
          // TODO: move to StructuredQuery?
          const aggregations = query.aggregations();
          const breakouts = query.breakouts();
          const breakoutDimensions = breakouts.map(b => b.dimension());
          const breakoutFields = breakoutDimensions.map(d => d.field());
    
    Tom Robinson's avatar
    Tom Robinson committed
          if (aggregations.length === 0 && breakouts.length === 0) {
            return this.setDisplay("table");
          }
    
    Tom Robinson's avatar
    Tom Robinson committed
          if (aggregations.length === 1 && breakouts.length === 0) {
            return this.setDisplay("scalar");
          }
    
    Tom Robinson's avatar
    Tom Robinson committed
          if (aggregations.length === 1 && breakouts.length === 1) {
            if (breakoutFields[0].isState()) {
              return this.setDisplay("map").updateSettings({
                "map.type": "region",
                "map.region": "us_states",
              });
            } else if (breakoutFields[0].isCountry()) {
              return this.setDisplay("map").updateSettings({
                "map.type": "region",
                "map.region": "world_countries",
              });
            }
          }
    
    Tom Robinson's avatar
    Tom Robinson committed
          if (aggregations.length >= 1 && breakouts.length === 1) {
            if (breakoutFields[0].isDate()) {
              if (
    
                breakoutDimensions[0] instanceof FieldDimension &&
                breakoutDimensions[0].temporalUnit() &&
                breakoutDimensions[0].isTemporalExtraction()
    
    Tom Robinson's avatar
    Tom Robinson committed
              ) {
                return this.setDisplay("bar");
              } else {
                return this.setDisplay("line");
              }
            }
    
            if (
              breakoutDimensions[0] instanceof FieldDimension &&
              breakoutDimensions[0].binningStrategy()
            ) {
    
    Tom Robinson's avatar
    Tom Robinson committed
              return this.setDisplay("bar");
            }
    
    Tom Robinson's avatar
    Tom Robinson committed
            if (breakoutFields[0].isCategory()) {
              return this.setDisplay("bar");
            }
          }
    
    Tom Robinson's avatar
    Tom Robinson committed
          if (aggregations.length === 1 && breakouts.length === 2) {
            if (_.any(breakoutFields, f => f.isDate())) {
              return this.setDisplay("line");
            }
    
    Tom Robinson's avatar
    Tom Robinson committed
            if (
              breakoutFields[0].isCoordinate() &&
              breakoutFields[1].isCoordinate()
            ) {
              return this.setDisplay("map").updateSettings({
                "map.type": "grid",
              });
            }
    
    Tom Robinson's avatar
    Tom Robinson committed
            if (_.all(breakoutFields, f => f.isCategory())) {
              return this.setDisplay("bar");
            }
          }
        }
    
    Tom Robinson's avatar
    Tom Robinson committed
        return this.setDisplay("table");
    
        return this.query().setDefaultQuery().question();
    
      settings(): VisualizationSettings {
    
    Tom Robinson's avatar
    Tom Robinson committed
        return (this._card && this._card.visualization_settings) || {};
      }
    
    Tom Robinson's avatar
    Tom Robinson committed
      setting(settingName, defaultValue = undefined) {
        const value = this.settings()[settingName];
        return value === undefined ? defaultValue : value;
    
      setSettings(settings: VisualizationSettings) {
    
        return this.setCard(assoc(this.card(), "visualization_settings", settings));
      }
    
      updateSettings(settings: VisualizationSettings) {
    
    Tom Robinson's avatar
    Tom Robinson committed
        return this.setSettings({ ...this.settings(), ...settings });
      }
    
      type(): string {
        return this.datasetQuery().type;
    
      creationType(): string {
        return this.card().creationType;
      }
    
    
      isEmpty(): boolean {
        return this.query().isEmpty();
      }
    
      /**
       * Question is valid (as far as we know) and can be executed
       */
      canRun(): boolean {
        return this.query().canRun();
      }
    
      canWrite(): boolean {
        return this._card && this._card.can_write;
      }
    
    
    Tom Robinson's avatar
    Tom Robinson committed
      canAutoRun(): boolean {
        const db = this.database();
        return (db && db.auto_run_queries) || false;
      }
    
    
      /**
       * Returns the type of alert that current question supports
       *
       * The `visualization_settings` in card object doesn't contain default settings,
       * so you can provide the complete visualization settings object to `alertType`
       * for taking those into account
       */
      alertType(visualizationSettings) {
        const display = this.display();
    
        if (!this.canRun()) {
          return null;
        }
    
        const isLineAreaBar =
          display === "line" || display === "area" || display === "bar";
    
        if (display === "progress") {
          return ALERT_TYPE_PROGRESS_BAR_GOAL;
        } else if (isLineAreaBar) {
          const vizSettings = visualizationSettings
            ? visualizationSettings
            : this.card().visualization_settings;
          const goalEnabled = vizSettings["graph.show_goal"];
          const hasSingleYAxisColumn =
            vizSettings["graph.metrics"] &&
            vizSettings["graph.metrics"].length === 1;
    
          // We don't currently support goal alerts for multiseries question
          if (goalEnabled && hasSingleYAxisColumn) {
            return ALERT_TYPE_TIMESERIES_GOAL;
          } else {
            return ALERT_TYPE_ROWS;
          }
        } else {
          return ALERT_TYPE_ROWS;
        }
      }
    
      /**
       * Visualization drill-through and action widget actions
       *
       * Although most of these are essentially a way to modify the current query, having them as a part
       * of Question interface instead of Query interface makes it more convenient to also change the current visualization
       */
    
      aggregate(a): Question {
        return aggregate(this, a) || this;
    
    
      breakout(b): Question | null | undefined {
    
        return breakout(this, b) || this;
    
      filter(operator, column, value): Question {
        return filter(this, operator, column, value) || this;
    
      pivot(breakouts = [], dimensions = []): Question {
        return pivot(this, breakouts, dimensions) || this;
    
      drillUnderlyingRecords(
        dimensions: DimensionValue[],
        column?: Column,
      ): Question {
    
        let query = this.query();
        if (!(query instanceof StructuredQuery)) {
          return this;
        }
    
    
        dimensions.forEach(({ value, column }) => {
          if (column.source !== "aggregation") {
            query = drillFilter(query, value, column);
          }
        });
    
    
        const dimension = column && query.parseFieldReference(column.field_ref);
        if (dimension instanceof AggregationDimension) {
          const aggregation = dimension.aggregation();
          const filters = aggregation ? aggregation.filters() : [];
          query = filters.reduce((query, filter) => query.filter(filter), query);
        }
    
        return query.question().toUnderlyingRecords();
    
      toUnderlyingRecords(): Question {
    
        const query = this.query();
        if (!(query instanceof StructuredQuery)) {
          return this;
        }
    
        return query
          .clearAggregations()
          .clearBreakouts()
          .clearSort()
          .clearLimit()
          .clearFields()
          .question()
          .setDisplay("table");
    
      toUnderlyingData(): Question {
        return this.setDisplay("table");
      }
    
      distribution(column): Question {
        return distribution(this, column) || this;
    
    Tom Robinson's avatar
    Tom Robinson committed
      }
    
      composeThisQuery(): Question | null | undefined {
    
        if (this.id()) {
          const card = {
            display: "table",
            dataset_query: {
              type: "query",
    
              query: {
    
                "source-table": getQuestionVirtualTableId(this.card()),
    
              },
            },
          };
          return this.setCard(card);
        }
      }
    
    
        if (!this.isDataset() || !this.isSaved()) {
    
        return this.setDatasetQuery({
          type: "query",
          database: this.databaseId(),
          query: {
    
            "source-table": getQuestionVirtualTableId(this.card()),
    
      drillPK(field: Field, value: Value): Question | null | undefined {
    
        const query = this.query();
    
    
        if (!(query instanceof StructuredQuery)) {
    
          if (this.isDataset()) {
            const drillQuery = Question.create({
              type: "query",
              databaseId: this.databaseId(),
              tableId: field.table_id,
              metadata: this.metadata(),
            }).query();
            return drillQuery.addFilter(["=", field.reference(), value]).question();
          }
    
    
        const otherPKFilters = query
          .filters()
          ?.filter(filter => {
            const filterField = filter?.field();
    
            if (!filterField) {
              return false;
            }
    
            const isNotSameField = filterField.id !== field.id;
            const isPKEqualsFilter =
              filterField.isPK() && filter.operatorName() === "=";
            const isFromSameTable = filterField.table.id === field.table.id;
            return isPKEqualsFilter && isNotSameField && isFromSameTable;
          })
          .map(filter => filter.raw());
        const filtersToApply = [
          ["=", ["field", field.id, null], value],
          ...otherPKFilters,
        ];
        const resultedQuery = filtersToApply.reduce((query, filter) => {
          return query.addFilter(filter);
        }, query.reset().setTable(field.table));
        return resultedQuery.question();
    
      _syncStructuredQueryColumnsAndSettings(previousQuestion, previousQuery) {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const query = this.query();
    
    Tom Robinson's avatar
    Tom Robinson committed
        if (
    
          !_.isEqual(
            previousQuestion.setting("table.columns"),
            this.setting("table.columns"),
          )
    
    Tom Robinson's avatar
    Tom Robinson committed
        ) {
    
        const addedColumnNames = _.difference(
          query.columnNames(),
          previousQuery.columnNames(),
        );
    
        const removedColumnNames = _.difference(
          previousQuery.columnNames(),
          query.columnNames(),
        );
    
        const graphMetrics = this.setting("graph.metrics");
    
          addedColumnNames.length > 0 &&
          removedColumnNames.length === 0
        ) {
          const addedMetricColumnNames = addedColumnNames.filter(
            name =>
              query.columnDimensionWithName(name) instanceof AggregationDimension,
          );
    
          if (addedMetricColumnNames.length > 0) {
    
    Tom Robinson's avatar
    Tom Robinson committed
            return this.updateSettings({
    
              "graph.metrics": [...graphMetrics, ...addedMetricColumnNames],
    
        const tableColumns = this.setting("table.columns");
    
          addedColumnNames.length > 0 &&
          removedColumnNames.length === 0
        ) {
          return this.updateSettings({
            "table.columns": [
    
              ...tableColumns.filter(
                column => !addedColumnNames.includes(column.name),
              ),
    
              ...addedColumnNames.map(name => {
                const dimension = query.columnDimensionWithName(name);
                return {
                  name: name,
    
                  field_ref: getBaseDimensionReference(dimension.mbql()),
    
                  enabled: true,
                };
              }),
            ],
          });
        }
    
        return this;
      }
    
      _syncNativeQuerySettings({ data: { cols = [] } = {} }) {
        const vizSettings = this.setting("table.columns") || [];
        // "table.columns" receive a value only if there are custom settings
        // e.g. some columns are hidden. If it's empty, it means everything is visible
        const isUsingDefaultSettings = vizSettings.length === 0;
    
        if (isUsingDefaultSettings) {
          return this;
        }
    
        let addedColumns = cols.filter(col => {
          const hasVizSettings =
            findColumnSettingIndexForColumn(vizSettings, col) >= 0;
          return !hasVizSettings;
        });
        const validVizSettings = vizSettings.filter(colSetting => {
          const hasColumn = findColumnIndexForColumnSetting(cols, colSetting) >= 0;
    
          const isMutatingColumn =
            findColumnIndexForColumnSetting(addedColumns, colSetting) >= 0;
          return hasColumn && !isMutatingColumn;
    
        });
        const noColumnsRemoved = validVizSettings.length === vizSettings.length;
    
        if (noColumnsRemoved && addedColumns.length === 0) {
          return this;
        }
    
        addedColumns = addedColumns.map(col => ({
          name: col.name,
          fieldRef: col.field_ref,
          enabled: true,
        }));
        return this.updateSettings({
          "table.columns": [...validVizSettings, ...addedColumns],
        });
      }
    
      syncColumnsAndSettings(previous, queryResults) {
        const query = this.query();
    
        const isQueryResultValid = queryResults && !queryResults.error;
    
        if (query instanceof NativeQuery && isQueryResultValid) {
    
          return this._syncNativeQuerySettings(queryResults);
        }
    
        const previousQuery = previous && previous.query();
    
        if (
          query instanceof StructuredQuery &&
          previousQuery instanceof StructuredQuery
        ) {
          return this._syncStructuredQueryColumnsAndSettings(
            previous,
            previousQuery,
          );
        }
    
    Tom Robinson's avatar
    Tom Robinson committed
        return this;
      }
    
      /**
       * returns the "top-level" {Question} for a nested structured query, e.x. with post-aggregation filters removed
       */
      topLevelQuestion(): Question {
        const query = this.query();
    
    Tom Robinson's avatar
    Tom Robinson committed
        if (query instanceof StructuredQuery && query !== query.topLevelQuery()) {
          return this.setQuery(query.topLevelQuery());
        } else {
          return this;
        }
      }
    
      /**
       * returns the {ClickObject} with all columns transformed to be relative to the "top-level" query
       */
      topLevelClicked(clicked: ClickObject): ClickObject {
        const query = this.query();
    
    Tom Robinson's avatar
    Tom Robinson committed
        if (query instanceof StructuredQuery && query !== query.topLevelQuery()) {
          return {
            ...clicked,
            column: clicked.column && query.topLevelColumn(clicked.column),
            dimensions:
              clicked.dimensions &&
              clicked.dimensions.map(dimension => ({
                ...dimension,
                column: dimension.column && query.topLevelColumn(dimension.column),
              })),
          };
        } else {
          return clicked;
        }
      }
    
    
      /**
       * A user-defined name for the question
       */
    
      displayName(): string | null | undefined {
    
        return this._card && this._card.name;
      }
    
    
      slug(): string | null | undefined {
        return this._card?.name && `${this._card.id}-${slugg(this._card.name)}`;
      }
    
    
      setDisplayName(name: string | null | undefined) {
    
        return this.setCard(assoc(this.card(), "name", name));
      }
    
    
      collectionId(): number | null | undefined {
    
        return this._card && this._card.collection_id;
      }
    
      setCollectionId(collectionId: number | null | undefined) {
    
        return this.setCard(assoc(this.card(), "collection_id", collectionId));
      }
    
      id(): number {
        return this._card && this._card.id;
      }
    
    
      markDirty(): Question {
        return this.setCard(
          dissoc(assoc(this.card(), "original_card_id", this.id()), "id"),
        );
    
      setDashboardProps({
        dashboardId,
        dashcardId,
      }:
        | { dashboardId: number; dashcardId: number }
        | { dashboardId: undefined; dashcardId: undefined }): Question {
        const card = chain(this.card())
          .assoc("dashboardId", dashboardId)
          .assoc("dashcardId", dashcardId)
          .value();
    
        return this.setCard(card);
    
      description(): string | null {
    
    Tom Robinson's avatar
    Tom Robinson committed
        return this._card && this._card.description;
      }
    
    
      setDescription(description) {
        return this.setCard(assoc(this.card(), "description", description));
      }
    
    
      lastEditInfo() {
        return this._card && this._card["last-edit-info"];
      }
    
    
      lastQueryStart() {
        return this._card?.last_query_start;
      }
    
    
      isSaved(): boolean {
        return !!this.id();
      }
    
      publicUUID(): string {
        return this._card && this._card.public_uuid;
      }
    
    
      database(): Database | null | undefined {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const query = this.query();
        return query && typeof query.database === "function"
          ? query.database()
          : null;
      }
    
    
      databaseId(): DatabaseId | null | undefined {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const db = this.database();
        return db ? db.id : null;
      }
    
    
      table(): Table | null | undefined {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const query = this.query();
        return query && typeof query.table === "function" ? query.table() : null;
      }
    
    
      tableId(): TableId | null | undefined {
    
    Tom Robinson's avatar
    Tom Robinson committed
        const table = this.table();
        return table ? table.id : null;
      }
    
      getUrl({
        originalQuestion,
        clean = true,
    
        originalQuestion?: Question;
        clean?: boolean;
        query?: Record<string, any>;
        includeDisplayIsLocked?: boolean;
    
        creationType?: string;
    
        const question = this.omitTransientCardIds();
    
    
    Tom Robinson's avatar
    Tom Robinson committed
        if (
    
          !question.id() ||
          (originalQuestion && question.isDirtyComparedTo(originalQuestion))
    
    Tom Robinson's avatar
    Tom Robinson committed
        ) {
    
          return Urls.question(null, {
            hash: question._serializeForUrl({
    
              clean,
              includeDisplayIsLocked,
              creationType,
            }),
    
    Tom Robinson's avatar
    Tom Robinson committed
        } else {
    
          return Urls.question(question.card(), { query });
    
    Tom Robinson's avatar
    Tom Robinson committed
        }
    
      getAutomaticDashboardUrl(
        filters,
        /*?: Filter[] = []*/
      ) {
    
        if (filters.length > 0) {
          const mbqlFilter = filters.length > 1 ? ["and", ...filters] : filters[0];
    
          cellQuery = `/cell/${utf8_to_b64url(JSON.stringify(mbqlFilter))}`;
    
        const questionId = this.id();
    
        if (questionId != null && !isTransientId(questionId)) {
          return `/auto/dashboard/question/${questionId}${cellQuery}`;
        } else {
    
            JSON.stringify(this.card().dataset_query),
          );
          return `/auto/dashboard/adhoc/${adHocQuery}${cellQuery}`;
        }
      }
    
    
      getComparisonDashboardUrl(
        filters,
        /*?: Filter[] = []*/
      ) {
    
        let cellQuery = "";
    
        if (filters.length > 0) {
          const mbqlFilter = filters.length > 1 ? ["and", ...filters] : filters[0];
    
          cellQuery = `/cell/${utf8_to_b64url(JSON.stringify(mbqlFilter))}`;
    
        const questionId = this.id();
        const query = this.query();
    
        if (query instanceof StructuredQuery) {
          const tableId = query.tableId();
    
          if (tableId) {
            if (questionId != null && !isTransientId(questionId)) {
              return `/auto/dashboard/question/${questionId}${cellQuery}/compare/table/${tableId}`;
            } else {
    
                JSON.stringify(this.card().dataset_query),
              );
              return `/auto/dashboard/adhoc/${adHocQuery}${cellQuery}/compare/table/${tableId}`;
            }
          }
        }
      }
    
    
      setResultsMetadata(resultsMetadata) {
    
        const metadataColumns = resultsMetadata && resultsMetadata.columns;
    
        return this.setCard({