Files
metabase/frontend/src/metabase-lib/v1/Question.ts
Romeo Van Snick e4cc4a61ff Reduce the frontend to one memoize helper, and stop top-level caches leaking (#81866)
* Add a jest project for heap retention specs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Scope the parsed x value cache to one getXValues pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Assert heap retention on the settled pass, not the first touch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key the log timestamp cache on the log object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key the schema viewer flow graph on the response object

The memo key serialized every table and field of the schema, so each distinct
schema held a megabyte-scale string forever. RTK Query returns a stable
reference per cache entry, so a WeakMap keeps the identity that useGraphSync
depends on without retaining the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Key pie.rows on the dataset instead of a JSON copy of it

The memo key serialized every row and column, so each dataset the user viewed
held roughly its own size in cache key for the life of the tab. A WeakMap on
the dataset with a short settings subkey keeps the same hit rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Ban new uses of underscore's memoize

The 17 remaining call sites are allowlisted. The list may only shrink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Narrow the memoize rule to module scope

A cache built inside a function, a component render, a class field or a
constructor is released with whatever owns it. Only a module scope cache lives
for the life of the tab, so the rule now flags just those.

This drops the allowlist from 17 files to 5, and each remaining entry carries
the reason its set of keys stays small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Remove the memoize allowlist

Three of the five module scope caches are gone:

- getScrollBarSize takes no arguments, so it is a lazy value, not a cache.
- getScheduleExplanation returns a string that no caller compares by identity.
- cronToBuilderValue moves to the one caller that needs a stable identity.
  Schedule takes its value as a useMemo dependency, so StrategyForm now holds
  that memo itself. A user can write any number of cron strings, so a module
  cache for them was unbounded.

The remaining two carry an eslint-disable with the reason their set of keys
stays small.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Follow renderer_utils into the viz-core module

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Cover metabase/utils/memoize in the module scope rule

That memoize roots its WeakMap on the function it wraps, so it only releases
once that function does. At module scope the function never does, and the
nested Maps hold every argument ever passed. No call site does this today, but
the rule could not have caught one.

The rule now covers both sources, so it drops "underscore" from its name.
memoizeClass stays allowed: it keys on the instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop re-reselect from the dashboard selectors

createCachedSelector keyed getQuestionByCard and
getDashcardParameterMappingOptions on the card id, a primitive, in a
FlatObjectCache. That cache is a plain object with no eviction, so a session
kept one Question per card id it ever saw, and each Question pins the whole
Metadata snapshot it was built with.

reselect v5 defaults to weakMapMemoize, which keys on the card and the metadata
objects themselves. Entries are released when their card leaves the store or
the metadata is replaced, and identity still holds for every card in the
working set, which is what connect and useSelector compare on.

Measured over 300 cards, with the states dropped: 6.2 MB retained before,
0.0 MB after. Per entry, 21462 B before and 51 B after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop re-reselect from the lockfile

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Back metabase/utils/memoize with reselect's weakMapMemoize

The hand-rolled implementation had two problems. It allocated two arrays on
every call, including cache hits: 225 ms against 20 ms for weakMapMemoize over
two million hits. And its WeakMap was keyed on the wrapped function rather than
the wrapper, so a cache built around a module-level helper outlived the wrapper
that owned it. A plain Map would have been safer.

weakMapMemoize matches on the same terms, identity for objects and value for
primitives, so no call site changes. It also weak-keys object arguments, which
the old one held strongly.

definition-cache passed weakMapMemoize as the memoize and argsMemoize options.
reselect v5 already defaults to both, so those are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Route all memoization through metabase/utils/memoize

underscore's memoize is gone from the app. Its 16 call sites now use the house
helper, which is reselect's weakMapMemoize. Three of them were in metabase-lib,
which cannot import metabase/utils, and all three took no arguments, so they
became plain lazy fields instead.

Two rules keep it that way:

- no-direct-memoize-import bans memoize from underscore and weakMapMemoize or
  lruMemoize from reselect, in any scope. Only metabase/utils/memoize may reach
  for an implementation.
- no-module-level-memoize keeps its job, now that the house helper is the only
  one left to police.

measureTextWidthSafely took a style object that was rebuilt on every render, so
identity keying would never have hit. Its third argument is now the font family
string, which is the only field any caller ever set.

useMemoizedCallback returns the callable shape rather than casting to T, since
the memoized function also carries clearCache and resultsCount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Build memoizeClass on the house memoize

It kept a module-level WeakMap and walked a nested Map trie keyed on the
instance, the method, the argument count and then each argument, assembling
that path into an array on every call.

Keying weakMapMemoize on the instance and then the arguments gives the same
per-instance caching. One cache per decorated method replaces the shared trie,
so the method no longer needs to be part of the key, and neither does the
argument count. The file is down to 61 lines from 128.

A leak spec covers the property this rests on: an instance's results are
released with the instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Measure memoizeClass retention under load

2,000 instances per pass, 20 calls each, across a method with no arguments and
one taking a primitive and an object. Nothing is retained once the instances
are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete memoizeClass

It decorated a prototype method by rewriting the property descriptor, and put
the memoization 600 lines away from the method it applied to. Its one consumer
memoized a method whose body uses no `this`, so the same caching comes from
assigning a memoized arrow to a class field. The field is created per instance,
so the cache is released with the instance, and the memoization is now visible
at the definition.

metabase/utils/memoize is 14 lines, from 128.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Run the heap specs from their own jest config

They were a project inside jest.config.js, and a bare `jest` runs every
project, which is what CI does. So the sharded unit run picked them up, without
--expose-gc, and they failed as designed.

They now live in jest.memory.conf.js, the way the timezone specs live in their
own config. The shared base moves to jest.base.conf.js so neither file has to
duplicate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reduce the heap specs to one collection assertion each

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

* Follow the store mocks into test support

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:02:13 +10:00

811 lines
22 KiB
TypeScript

import { assoc, assocIn, chain, dissoc, getIn } from "icepick";
import slugg from "slugg";
import _ from "underscore";
import * as Lib from "metabase-lib";
import type Database from "metabase-lib/v1/metadata/Database";
import Metadata from "metabase-lib/v1/metadata/Metadata";
import { getQuestionVirtualTableId } from "metabase-lib/v1/metadata/utils/saved-questions";
import { getCardUiParameters } from "metabase-lib/v1/parameters/utils/cards";
import { getTemplateTagParametersFromCard } from "metabase-lib/v1/parameters/utils/template-tags";
import { InternalQuery } from "metabase-lib/v1/queries/InternalQuery";
import NativeQuery, {
NATIVE_QUERY_TEMPLATE,
} from "metabase-lib/v1/queries/NativeQuery";
import { STRUCTURED_QUERY_TEMPLATE } from "metabase-lib/v1/queries/StructuredQuery";
import type {
Card,
CardCreationType,
CardDashboardInfo,
CardDisplayType,
CardType,
Collection,
CollectionId,
DashCardId,
DashboardId,
DatabaseId,
DatasetData,
DatasetQuery,
Field,
LastEditInfo,
ParameterId,
Parameter as ParameterObject,
ParameterValuesMap,
ResultsMetadata,
TableId,
UserInfo,
VisualizationDisplay,
VisualizationSettings,
} from "metabase-types/api";
import type { Query } from "../query/types";
export type QuestionCreatorOpts = {
cardType?: CardType;
collectionId?: CollectionId;
dashboardId?: DashboardId;
metadata?: Metadata;
parameterValues?: ParameterValuesMap;
name?: string;
display?: CardDisplayType;
visualization_settings?: VisualizationSettings;
dataset_query?: DatasetQuery;
DEPRECATED_RAW_MBQL_type?: "query" | "native";
DEPRECATED_RAW_MBQL_databaseId?: DatabaseId;
DEPRECATED_RAW_MBQL_tableId?: TableId;
};
export type QuestionDashboardProps = {
dashboardId?: DashboardId;
dashcardId?: DashCardId;
};
/**
* This is a wrapper around a question/card object, which may contain one or more Query objects
*/
class Question {
/**
* 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: Card;
/**
* 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: ParameterValuesMap;
private __libQuery: Lib.Query | undefined;
private __libMetadataProvider: Lib.MetadataProvider | undefined;
/**
* Question constructor
*/
constructor(
// This deprecated wrapper is constructed from ~200 call sites with a mix of
// saved cards, unsaved/draft cards, and (in tests) partial card mocks, so the
// card boundary stays loosely typed. Internally it is treated as a `Card`,
// guarding the saved-only fields (id, name, type, ...) where they are read.
card: any,
metadata?: Metadata,
parameterValues?: ParameterValuesMap,
) {
this._card = card;
this._metadata = metadata || new Metadata();
this._parameterValues = parameterValues || {};
}
clone() {
return new Question(this._card, this._metadata, this._parameterValues);
}
metadata(): Metadata {
return this._metadata;
}
card() {
return this._doNotCallSerializableCard();
}
/**
* returns the card but normalizes the dataset_query field.
*/
cardWithNormalizedQuery() {
return {
...this.card(),
dataset_query: Lib.toJsQuery(this.query()),
};
}
_doNotCallSerializableCard() {
return this._card;
}
setCard(card: Card): Question {
const q = this.clone();
q._card = card;
return q;
}
withoutNameAndId() {
return this.setCard(
chain(this.card())
.dissoc("id")
.dissoc("name")
.dissoc("description")
.value(),
);
}
/**
* 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.
*/
_legacyNativeQuery = _.once((): NativeQuery | undefined => {
const datasetQuery = this._card.dataset_query;
if (this.isNative()) {
return new NativeQuery(this, datasetQuery);
}
const isVirtualDashcard = !this._card.id;
// The `dataset_query` is null for questions on a dashboard the user doesn't have access to
if (!isVirtualDashcard) {
const queryType =
datasetQuery != null && "type" in datasetQuery
? datasetQuery.type
: undefined;
console.warn("Unknown query type: " + queryType);
}
});
legacyNativeQuery(): NativeQuery | undefined {
return this._legacyNativeQuery();
}
/**
* Returns a new Question object with an updated query.
* The query is saved to the `dataset_query` field of the Card object.
*/
setLegacyQuery(newQuery: NativeQuery): Question {
if (this._card.dataset_query !== newQuery.datasetQuery()) {
return this.setCard(
assoc(this.card(), "dataset_query", newQuery.datasetQuery()),
);
}
return this;
}
datasetQuery(): DatasetQuery {
return this.card().dataset_query;
}
setDatasetQuery(newDatasetQuery: DatasetQuery): Question {
return this.setCard(assoc(this.card(), "dataset_query", newDatasetQuery));
}
isNative() {
if (
this.datasetQuery() == null ||
InternalQuery.isDatasetQueryType(this.datasetQuery())
) {
return false;
}
const queryInfo = Lib.queryDisplayInfo(this.query());
return queryInfo.isNative;
}
/**
* The visualization type of the question
*/
display(): CardDisplayType {
// `_card.display` is typed `VisualizationDisplay` because `Card` reuses
// `UnsavedCard`, but a Question is never a virtual dashcard, so its display
// is always a renderable `CardDisplayType`.
return (this._card && this._card.display) as CardDisplayType;
}
setDisplay(display: VisualizationDisplay) {
return this.setCard(assoc(this.card(), "display", display));
}
type(): CardType {
return this._card?.type ?? "question";
}
setType(type: CardType) {
return this.setCard(assoc(this.card(), "type", type));
}
isPersisted() {
return this._card && this._card.persisted;
}
setPersisted(isPersisted: boolean) {
return this.setCard(assoc(this.card(), "persisted", isPersisted));
}
setPinned(pinned: boolean) {
return this.setCard(
assoc(this.card(), "collection_position", pinned ? 1 : null),
);
}
setArchived(archived: boolean) {
return this.setCard(assoc(this.card(), "archived", archived));
}
// 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?.displayIsLocked ?? false;
}
maybeResetDisplay(
data: DatasetData,
sensibleDisplays: string[],
previousSensibleDisplays: string[] | undefined,
): Question {
const wasSensible =
previousSensibleDisplays == null ||
previousSensibleDisplays.includes(this.display());
const isSensible = sensibleDisplays.includes(this.display());
const shouldUnlock = wasSensible && !isSensible;
const defaultDisplay = this.setDefaultDisplay().display();
let question;
if (isSensible && defaultDisplay === "table") {
// any sensible display is better than the default table display
question = this;
} else if (shouldUnlock && this.displayIsLocked()) {
question = this.setDisplayIsLocked(false).setDefaultDisplay();
} else {
question = this.setDefaultDisplay();
}
return question._maybeSwitchToScalar(data);
}
// Switches display to scalar if the data is 1 row x 1 column
private _maybeSwitchToScalar({ rows, cols }: DatasetData): Question {
const isScalar = ["scalar", "progress", "gauge"].includes(this.display());
const isOneByOne = rows.length === 1 && cols.length === 1;
if (!isScalar && isOneByOne && !this.displayIsLocked()) {
return this.setDisplay("scalar");
}
return this;
}
setDefaultDisplay(): Question {
if (this.displayIsLocked()) {
return this;
}
const query = this.query();
const { display, settings = {} } = Lib.defaultDisplay(query);
return this.setDisplay(display).updateSettings(settings);
}
settings(): VisualizationSettings {
return (this._card && this._card.visualization_settings) || {};
}
setting(settingName: keyof VisualizationSettings, 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: Partial<VisualizationSettings>) {
return this.setSettings({ ...this.settings(), ...settings });
}
creationType(): CardCreationType | undefined {
return this.card().creationType;
}
/**
* How many filters or other widgets are this question's values used for?
*/
getParameterUsageCount(): number {
return this.card().parameter_usage_count || 0;
}
/**
* Question is valid (as far as we know) and can be executed
*/
canRun(): boolean {
const { isNative } = Lib.queryDisplayInfo(this.query());
return isNative
? (this.legacyNativeQuery()?.canRun() ?? false)
: Lib.canRun(this.query(), this.type());
}
canWrite(): boolean {
return this._card && this._card.can_write;
}
canWriteActions(): boolean {
const database = this.database();
return (
this.canWrite() &&
database != null &&
database.canWrite() &&
database.hasActionsEnabled()
);
}
supportsImplicitActions(): boolean {
const query = this.query();
// we want to check the metadata for the underlying table, not the model
const sourceTableId = Lib.sourceTableOrCardId(query);
const table = this.metadata().table(sourceTableId);
const hasSinglePk =
table?.fields?.filter((field) => field.isPK())?.length === 1;
const { isNative } = Lib.queryDisplayInfo(this.query());
return !isNative && !Lib.hasClauses(query, -1) && hasSinglePk;
}
canAutoRun(): boolean {
const db = this.database();
return (db && db.auto_run_queries) || false;
}
/**
* 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
*/
composeQuestion(): Question {
if (!this.isSaved()) {
return this;
}
const metadata = this.metadataProvider();
const tableId = getQuestionVirtualTableId(this.id());
const table = Lib.tableOrCardMetadata(metadata, tableId);
if (!table) {
return this;
}
const query = Lib.queryFromTableOrCardMetadata(metadata, table);
return this.setQuery(query);
}
composeQuestionAdhoc(options: QuestionCreatorOpts = {}): Question {
if (!this.isSaved()) {
return this;
}
const query = this.composeQuestion().query();
return Question.create({
metadata: this.metadata(),
dataset_query: Lib.toJsQuery(query),
...options,
});
}
/**
* 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));
}
collection(): Collection | null | undefined {
return this._card?.collection;
}
collectionId(): CollectionId | null | undefined {
return this._card && this._card.collection_id;
}
setCollectionId(collectionId: CollectionId | null | undefined) {
return this.setCard(assoc(this.card(), "collection_id", collectionId));
}
dashboard(): CardDashboardInfo | null {
return this._card.dashboard;
}
dashboardId(): DashboardId | null {
return this._card.dashboard_id;
}
dashboardName(): string | undefined {
return this._card?.dashboard?.name ?? undefined;
}
dashboardCount(): number | null {
return this._card.dashboard_count;
}
setDashboardId(dashboardId: DashboardId | null | undefined) {
return this.setCard(assoc(this.card(), "dashboard_id", dashboardId));
}
id(): number {
return this._card && this._card.id;
}
setId(id: number | undefined): Question {
return this.setCard(assoc(this.card(), "id", id));
}
markDirty(): Question {
return this.setCard(
dissoc(assoc(this.card(), "original_card_id", this.id()), "id"),
);
}
getDashboardProps(): QuestionDashboardProps {
const { dashboardId, dashcardId } = this.card();
return { dashboardId, dashcardId };
}
setDashboardProps({
dashboardId,
dashcardId,
}: QuestionDashboardProps): Question {
const card = chain(this.card())
.assoc("dashboardId", dashboardId)
.assoc("dashcardId", dashcardId)
.value();
return this.setCard(card);
}
description(): string | null {
return this._card && this._card.description;
}
setDescription(description: string | null) {
return this.setCard(assoc(this.card(), "description", description));
}
lastEditInfo(): LastEditInfo | undefined {
return this._card && this._card["last-edit-info"];
}
lastQueryStart() {
return this._card?.last_query_start;
}
isSaved(): boolean {
return !!this.id();
}
publicUUID(): string | null {
return this._card && this._card.public_uuid;
}
database(): Database | null {
const metadata = this.metadata();
const databaseId = this.databaseId();
const database = metadata.database(databaseId);
return database;
}
databaseId(): DatabaseId | null {
const query = this.query();
return Lib.databaseID(query);
}
isArchived(): boolean {
return this._card && this._card.archived;
}
getResultMetadata() {
return this.card().result_metadata ?? [];
}
setResultsMetadata(resultsMetadata: ResultsMetadata | null) {
const metadataColumns = resultsMetadata && resultsMetadata.columns;
return this.setCard({
...this.card(),
result_metadata: metadataColumns,
});
}
setResultMetadataDiff(metadataDiff: Record<string, Partial<Field>>) {
const metadata = this.getResultMetadata();
const newMetadata = metadata.map((column) => {
const columnDiff = metadataDiff[column.name];
return columnDiff ? { ...column, ...columnDiff } : column;
});
return this.setResultsMetadata({ columns: newMetadata });
}
/**
* Returns true if the questions are equivalent (including id, card, and parameters)
*/
isEqual(
other: Question | null | undefined,
{ compareResultsMetadata = true } = {},
) {
if (!other) {
return false;
}
if (this.id() !== other.id()) {
return false;
}
const card = this.card();
const otherCard = other.card();
const areCardsEqual = compareResultsMetadata
? _.isEqual(card, otherCard)
: _.isEqual(
_.omit(card, "result_metadata"),
_.omit(otherCard, "result_metadata"),
);
if (!areCardsEqual) {
return false;
}
if (!_.isEqual(this.parameters(), other.parameters())) {
return false;
}
return true;
}
setParameter(id: ParameterId, parameter: ParameterObject) {
const newParameters = this.parameters().map((oldParameter) =>
oldParameter.id === id ? parameter : oldParameter,
);
return this.setParameters(newParameters);
}
setParameters(parameters: ParameterObject[] | undefined) {
return this.setCard(assoc(this.card(), "parameters", parameters));
}
setParameterValues(parameterValues: ParameterValuesMap = {}) {
const question = this.clone();
question._parameterValues = parameterValues;
return question;
}
private _parameters?: ParameterObject[];
parameters(): ParameterObject[] {
if (this._parameters == null) {
this._parameters = getCardUiParameters(
this.card(),
this.metadata(),
this._parameterValues,
);
}
return this._parameters;
}
// predicate function that determines if the question is "dirty" compared to the given question
isDirtyComparedTo(originalQuestion: Question) {
if (!this.isSaved() && this.canRun() && originalQuestion == null) {
// If it's new, then it's dirty if it is runnable
return true;
} else {
// If it's saved, then it's dirty when the current card doesn't match the last saved version.
// Omit `entity_id` and `dataset_query` as they have randomized idents
const originalCard = originalQuestion?._getValueForComparison();
const currentCard = this._getValueForComparison();
if (!_.isEqual(originalCard, currentCard)) {
return true;
}
return !Lib.areLegacyQueriesEqual(
this.datasetQuery(),
originalQuestion.datasetQuery(),
);
}
}
isDirtyComparedToWithoutParameters(originalQuestion: Question) {
const [a, b] = [this, originalQuestion].map((q) => {
return (
q &&
new Question(q.card(), this.metadata())
.setParameters(
getTemplateTagParametersFromCard(q.card(), q.metadata()),
)
.setDashboardProps({
dashboardId: undefined,
dashcardId: undefined,
})
);
});
return a.isDirtyComparedTo(b);
}
isQueryDirtyComparedTo(originalQuestion: Question) {
return !Lib.areLegacyQueriesEqual(
this.datasetQuery(),
originalQuestion.datasetQuery(),
);
}
// Internal methods
_getValueForComparison(): Record<string, unknown> {
const value: Record<string, unknown> = {
...this._card,
...this._parameterValues,
};
const keys = [
"collection_id",
"dashboard_id",
"dashboardId",
"dashcardId",
"description",
"display",
"name",
"parameters",
"parameterValues",
"type",
"visualization_settings",
];
return Object.fromEntries(
keys.map((key) => [key, value[key] ?? undefined]),
);
}
query(): Query {
if (InternalQuery.isDatasetQueryType(this.datasetQuery())) {
throw new Error("Internal query is not supported by Lib");
}
this.__libQuery ??= Lib.fromJsQuery(
this.metadataProvider(),
this.datasetQuery(),
);
// Helpers for working with the current query from CLJS REPLs.
if (process.env.NODE_ENV === "development") {
window.__lib_metadata = this.__libMetadataProvider;
window.__lib_query = this.__libQuery;
window.Lib = Lib;
}
return this.__libQuery;
}
private metadataProvider(): Lib.MetadataProvider {
this.__libMetadataProvider ??= Lib.metadataProvider(
this.datasetQuery()?.database,
this.metadata(),
);
return this.__libMetadataProvider;
}
setQuery(query: Query): Question {
return this.setDatasetQuery(Lib.toJsQuery(query));
}
generateQueryDescription() {
const query = this.query();
return Lib.suggestedName(query);
}
getModerationReviews() {
return getIn(this, ["_card", "moderation_reviews"]) || [];
}
getCreator(): UserInfo {
return getIn(this, ["_card", "creator"]) || "";
}
getCreatedAt(): string {
return getIn(this, ["_card", "created_at"]) || "";
}
canManageDB(): boolean {
return this.card().can_manage_db || false;
}
/** Applies the template tag parameters from the card to the question. */
applyTemplateTagParameters(): Question {
const { isNative } = Lib.queryDisplayInfo(this.query());
if (!isNative) {
return this;
}
return this.setParameters(
getTemplateTagParametersFromCard(this.card(), this.metadata()),
);
}
/**
* TODO Atte Keinänen 6/13/17: Discussed with Tom that we could use the default Question constructor instead,
* but it would require changing the constructor signature so that `card` is an optional parameter and has a default value
*/
static create({
DEPRECATED_RAW_MBQL_type: type = "query",
DEPRECATED_RAW_MBQL_databaseId: databaseId,
DEPRECATED_RAW_MBQL_tableId: tableId,
collectionId,
dashboardId,
metadata,
parameterValues,
name,
display = "table",
visualization_settings = {},
cardType,
dataset_query = type === "native"
? NATIVE_QUERY_TEMPLATE
: STRUCTURED_QUERY_TEMPLATE,
}: QuestionCreatorOpts = {}) {
// `create` builds an unsaved draft that lacks the fields a saved Card has
// (id, entity_id, name, type, ...). The Question class nevertheless treats
// `_card` as a full Card, so we assert the draft shape here.
let card = {
name,
collection_id: collectionId,
dashboard_id: dashboardId,
display,
visualization_settings,
dataset_query,
type: cardType,
} as Card;
if (type === "native") {
card = assocIn(card, ["parameters"], []);
}
if (tableId != null) {
card = assocIn(card, ["dataset_query", "query", "source-table"], tableId);
}
if (databaseId != null) {
card = assocIn(card, ["dataset_query", "database"], databaseId);
}
return new Question(card, metadata, parameterValues);
}
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Question;