Compare commits

...

15 Commits

Author SHA1 Message Date
Cam Saul
b1eba19cb5 Test fixes 🔧 2025-09-16 10:06:34 -07:00
Cam Saul
a9f683a4f4 Test fixes 🔧 2025-09-16 09:41:15 -07:00
Cam Saul
c29b7fe94e Restore the 'satanic field resolution' stuff 2025-09-15 20:49:29 -07:00
Cam Saul
fcb7f18aaf Test fixes 🔧 2025-09-15 20:22:33 -07:00
Cam Saul
af10b73290 Merge branch 'master' into lib-equality-improvements
# Conflicts:
#	src/metabase/lib/field/resolution.cljc
2025-09-15 19:38:30 -07:00
Cam Saul
ff21c04a89 Merge remote-tracking branch 'origin/master' into lib-equality-improvements 2025-09-15 17:08:22 -07:00
Cam Saul
d5e2e7ce81 Merge remote-tracking branch 'origin/master' into lib-equality-improvements 2025-09-05 18:14:29 -07:00
Cam Saul
b04a33dfac Don't use :lib/metadata from a stage with a source card as if it's card metadata 2025-09-05 17:00:24 -07:00
Cam Saul
bd4773f3b1 Code simplification 2025-09-05 14:14:07 -07:00
Cam Saul
3f532553f5 Kondo & cljfmt 2025-09-05 14:05:25 -07:00
Cam Saul
876a8ca9cf Test fixes 🔧 2025-09-05 14:02:52 -07:00
Cam Saul
4435f235b1 Test fixes 🔧 2025-09-05 10:30:33 -07:00
Cam Saul
c1ea67473d Test fix 🔧 2025-09-05 10:05:15 -07:00
Cam Saul
de22e1a1a2 Test fix & cljfmt 2025-09-04 22:31:40 -07:00
Cam Saul
9cb983832c Lib equality find-matching-column mega overhaul 2025-09-04 22:12:22 -07:00
72 changed files with 1131 additions and 1087 deletions

View File

@@ -272,6 +272,7 @@
clojure.java.jdbc/with-db-connection {:message "Use sql-jdbc.execute/do-with-connection-with-options (for drivers) or t2/with-connection (for the app DB) instead of jdbc/with-db-connection"}
clojure.java.jdbc/with-db-metadata {:message "Use sql-jdbc.execute/do-with-connection-with-options + .getMetaData instead of jdbc/with-db-metadata"}
clojure.pprint/pprint {:message "Use metabase.util.log instead of clojure.pprint/pprint"}
clojure.pprint/print-table {:message "Use metabase.util.log if you want to print something"}
clojure.string/lower-case {:message "Use metabase.util/lower-case-en instead of clojure.string/lower-case"}
clojure.string/upper-case {:message "Use metabase.util/upper-case-en instead of clojure.string/upper-case"}
compojure.core/context {:message "Use metabase.api.util.handlers/route-map-handler instead of compojure.core/context, it is much faster and supports OpenAPI spec generation"}

View File

@@ -85,7 +85,7 @@
#(-> % (dissoc :operators :field-values) col->index))
default-temporal-breakout (when with-default-temporal-breakout?
(->> breakouts
(map #(lib/find-matching-column % visible-cols))
(map #(lib/find-matching-column metric-query -1 % visible-cols {:find-matching-column/ignore-binning-and-bucketing? true}))
(m/find-first lib.types.isa/temporal?)))
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
(cond-> {:id id

View File

@@ -27,7 +27,6 @@
"Calls `ensure-audit-db-installed!` before and after `body` to ensure that the audit DB is installed and then
restored if necessary. Also disables audit content loading if it is already loaded."
`(let [audit-collection-exists?# (t2/exists? :model/Collection :type "instance-analytics")]
(clojure.pprint/print-table (t2/select [:model/Database :id :name]))
(mt/with-temp-env-var-value! [mb-load-analytics-content (not audit-collection-exists?#)]
(mbc/ensure-audit-db-installed!)
(try

View File

@@ -23,10 +23,17 @@ export function aggregationOperatorColumns(
}
export function selectedAggregationOperators(
query: Query,
stageIndex: number,
operators: AggregationOperator[],
clause: AggregationClause,
): AggregationOperator[] {
return ML.selected_aggregation_operators(operators, clause);
return ML.selected_aggregation_operators(
query,
stageIndex,
operators,
clause,
);
}
export function aggregate(

View File

@@ -123,7 +123,7 @@ function setup({
const baseOperators = Lib.availableAggregationOperators(query, stageIndex);
const operators = clause
? Lib.selectedAggregationOperators(baseOperators, clause)
? Lib.selectedAggregationOperators(query, stageIndex, baseOperators, clause)
: baseOperators;
const onQueryChange = jest.fn();

View File

@@ -19,6 +19,8 @@ export const getAggregationItems = ({
const { displayName } = Lib.displayInfo(query, stageIndex, aggregation);
const operators = Lib.selectedAggregationOperators(
query,
stageIndex,
Lib.availableAggregationOperators(query, stageIndex),
aggregation,
);

View File

@@ -98,7 +98,12 @@ function AggregationPopover({
const operators = useMemo(() => {
const baseOperators = Lib.availableAggregationOperators(query, stageIndex);
return isUpdate
? Lib.selectedAggregationOperators(baseOperators, clause)
? Lib.selectedAggregationOperators(
query,
stageIndex,
baseOperators,
clause,
)
: baseOperators;
}, [query, clause, stageIndex, isUpdate]);

View File

@@ -404,7 +404,9 @@
(mu/defn selected-aggregation-operators :- [:maybe [:sequential SelectedOperatorWithColumns]]
"Mark the operator and the column (if any) in `agg-operators` selected by `agg-clause`."
[agg-operators :- [:maybe [:sequential OperatorWithColumns]]
[query
stage-number
agg-operators :- [:maybe [:sequential OperatorWithColumns]]
agg-clause]
(when (seq agg-operators)
(let [[op _ agg-col] agg-clause
@@ -418,6 +420,8 @@
(fn [cols]
(if (lib.util/ref-clause? agg-col)
(let [cols (lib.equality/mark-selected-columns
query
stage-number
cols
[(lib.options/update-options agg-col dissoc :temporal-unit)])]
(mapv (fn [c]

View File

@@ -85,7 +85,7 @@
stage-number
(get existing-breakouts position)
columns
{:generous? true}))
{:find-matching-column/ignore-binning-and-bucketing? true}))
(range (count existing-breakouts)))]
(mapv #(let [positions (column->breakout-positions %)]
(cond-> %
@@ -96,27 +96,16 @@
"Returns existing breakouts (as MBQL expressions) for `column` in a stage if there are any. Returns `nil` if there
are no existing breakouts."
([query stage-number column]
(existing-breakouts query stage-number column nil))
(existing-breakouts query stage-number column {:find-matching-column/ignore-binning-and-bucketing? true}))
([query :- ::lib.schema/query
stage-number :- :int
column :- ::lib.schema.metadata/column
{:keys [same-binning-strategy?
same-temporal-bucket?], :as _options} :- [:maybe
[:map
[:same-binning-strategy? {:optional true, :default false} [:maybe :boolean]]
[:same-temporal-bucket? {:optional true, :default false} [:maybe :boolean]]]]]
([query :- ::lib.schema/query
stage-number :- :int
column :- ::lib.schema.metadata/column
options :- [:maybe ::lib.equality/find-matching-column.options]]
(not-empty
(into []
(filter (fn [a-breakout]
(and (lib.equality/find-matching-column query stage-number a-breakout [column])
(or (not same-temporal-bucket?)
(= (lib.temporal-bucket/temporal-bucket a-breakout)
(lib.temporal-bucket/temporal-bucket column)))
(or (not same-binning-strategy?)
(lib.binning/binning= (lib.binning/binning a-breakout)
(lib.binning/binning column))))))
(breakouts query stage-number)))))
(filterv (fn [a-breakout]
(lib.equality/find-matching-column query stage-number a-breakout [column] options))
(breakouts query stage-number)))))
(defn breakout-column?
"Returns if `column` is a breakout column of stage with `stage-number` of `query`."
@@ -148,14 +137,20 @@
([query :- ::lib.schema/query
stage-number :- :int
breakout-ref :- ::lib.schema.ref/ref]
(when-let [column (lib.equality/find-matching-column breakout-ref
(let [column (or (lib.equality/find-matching-column query
stage-number
breakout-ref
(breakoutable-columns query stage-number)
{:generous? true})]
(let [binning (lib.binning/binning breakout-ref)
bucket (lib.temporal-bucket/temporal-bucket breakout-ref)]
(cond-> column
binning (lib.binning/with-binning binning)
bucket (lib.temporal-bucket/with-temporal-bucket bucket))))))
{:find-matching-column/ignore-binning-and-bucketing? true})
(throw (ex-info "Failed to resolve breakout ref"
{:query query
:stage-number stage-number
:ref breakout-ref})))
binning (lib.binning/binning breakout-ref)
bucket (lib.temporal-bucket/temporal-bucket breakout-ref)]
(cond-> column
binning (lib.binning/with-binning binning)
bucket (lib.temporal-bucket/with-temporal-bucket bucket)))))
(mu/defn remove-all-breakouts :- ::lib.schema/query
"Remove all breakouts from a query stage."

View File

@@ -103,9 +103,9 @@
([query context]
(available-drill-thrus query -1 context))
([query :- ::lib.schema/query
stage-number :- :int
{:keys [column column-ref] :as context} :- ::lib.schema.drill-thru/context]
([query :- ::lib.schema/query
stage-number :- :int
{:keys [column] :as context} :- ::lib.schema.drill-thru/context]
(try
(into []
(when (and (lib.metadata/editable? query)
@@ -114,12 +114,12 @@
;; underlying table id instead of "card__blah", and that can cause issues with drills in certain
;; scenarios. Changing the returned column metadata would be a large and scary change, so we just
;; grab the equivalent column from returned-columns here instead
new-column (and column-ref
(->> (lib.metadata.calculation/returned-columns query stage-number)
(lib.equality/find-matching-column query stage-number column-ref)))
old-types (select-keys column [:base-type :effective-type :semantic-type])
context (cond-> context
(and column column-ref new-column) (assoc :column (merge new-column old-types)))
new-column (and column
(->> (lib.metadata.calculation/returned-columns query stage-number)
(lib.equality/find-matching-column query stage-number column)))
old-types (select-keys column [:base-type :effective-type :semantic-type])
context (cond-> context
(and column new-column) (assoc :column (merge new-column old-types)))
{:keys [query stage-number]} (lib.query/wrap-native-query-with-mbql
query stage-number (:card-id context))
context (context-with-dimensions-or-row-dimensions query context)

View File

@@ -21,9 +21,12 @@
Question transformation:
- None"
(:require
[better-cond.core :as b]
[metabase.lib.drill-thru.common :as lib.drill-thru.common]
[metabase.lib.field.util :as lib.field.util]
[metabase.lib.filter :as lib.filter]
[metabase.lib.filter.operator :as lib.filter.operator]
[metabase.lib.ref :as lib.ref]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.drill-thru :as lib.schema.drill-thru]
[metabase.lib.schema.metadata :as lib.schema.metadata]
@@ -52,9 +55,7 @@
column :- ::lib.schema.metadata/column
column-ref :- ::lib.schema.ref/ref
adding :- [:enum :filter :expression]]
(let [next-stage (->> (lib.util/canonical-stage-index query stage-number)
(lib.util/next-stage-number query))
base (cond
(let [base (b/cond
;; An extra stage is needed if:
;; - The target column is an aggregation
;; - OR the target column is a breakout AND we are adding a custom expression based on it.
@@ -64,17 +65,25 @@
(and (:lib/breakout? column)
(= adding :expression))))
{:query query
:stage-number stage-number}
:stage-number stage-number
:column-ref column-ref}
;; An extra stage is needed.
:let [next-stage-ref (-> column
lib.field.util/update-keys-for-col-from-previous-stage
lib.ref/ref)]
;; If there's a later stage, then use it.
next-stage {:query query
:stage-number next-stage}
:let [next-stage-number (lib.util/next-stage-number query stage-number)]
next-stage-number
{:query query
:stage-number next-stage-number
:column-ref next-stage-ref}
;; And if there isn't a later stage, add one.
:else {:query (lib.stage/append-stage query)
:stage-number -1})
:else
{:query (lib.stage/append-stage query)
:stage-number -1
:column-ref next-stage-ref})
filter-column (lib.drill-thru.common/matching-filterable-column
(:query base) (:stage-number base) column-ref column)]
(:query base) (:stage-number base) (:column-ref base) column)]
;; If we cannot find the matching column, don't allow to drill
(when filter-column
(assoc base :column filter-column))))

View File

@@ -137,7 +137,7 @@
column-ref :- ::lib.schema.ref/ref
column :- ::lib.schema.metadata/column]
(let [columns (lib.filter/filterable-columns query stage-number)]
(or (lib.equality/find-matching-column query stage-number column-ref columns)
(or (lib.equality/find-matching-column query stage-number column-ref columns {:find-matching-column/ignore-binning-and-bucketing? true})
(and (:lib/source-uuid column)
(m/find-first #(= (:lib/source-uuid %) (:lib/source-uuid column))
columns)))))

View File

@@ -35,12 +35,14 @@
(lib.equality/find-matching-column query
stage-number
column-ref
(lib.order-by/orderable-columns query stage-number)))
(lib.order-by/orderable-columns query stage-number)
{:find-matching-column/ignore-binning-and-bucketing? true}))
(mu/defn- existing-order-by-clause :- [:maybe ::lib.schema.order-by/order-by]
[query stage-number column]
(m/find-first (fn [[_direction _opts expr, :as _asc-or-desc-clause]]
(lib.equality/find-matching-column query stage-number expr [column]))
(lib.equality/find-matching-column query stage-number expr [column]
{:find-matching-column/ignore-binning-and-bucketing? true}))
(lib.order-by/order-bys query stage-number)))
(mu/defn- existing-order-by-direction :- [:maybe ::lib.schema.order-by/direction]

View File

@@ -103,8 +103,7 @@
stage-number :- :int
{{:keys [semantic-type] :as column} :column, :keys [value], :as _context} :- ::lib.schema.drill-thru/context]
(when (and column value (not= value :null)
(or (not (or (= semantic-type :type/Latitude)
(= semantic-type :type/Longitude)))
(or (not (#{:type/Latitude :type/Longitude} semantic-type))
(not (has-lat-lon? query stage-number))))
(when-let [existing-breakout (first (lib.breakout/existing-breakouts query
(lib.underlying/top-level-stage-number query)
@@ -158,10 +157,12 @@
;; filters to top-level-stage-number breaks the binning.
(let [top-level-stage-number (lib.underlying/top-level-stage-number query)
resolved-column (lib.drill-thru.common/breakout->resolved-column query stage-number column)
old-filters (filter (fn [[operator _opts filter-column]]
(and (#{:>= :<} operator)
(lib.equality/find-matching-column filter-column [column])))
(lib.filter/filters query stage-number))]
old-filters (filterv (fn [[operator _opts filter-expr]]
(and (#{:>= :<} operator)
(lib.equality/find-matching-column
query stage-number filter-expr [column]
{:find-matching-column/ignore-binning-and-bucketing? true})))
(lib.filter/filters query stage-number))]
(-> (reduce #(lib.remove-replace/remove-clause %1 stage-number %2) query old-filters)
(update-breakout top-level-stage-number column resolved-column new-binning)
(lib.filter/filter stage-number (lib.filter/>= resolved-column min-value))

View File

@@ -69,7 +69,7 @@
:when (and (lib.util/clause-of-type? breakout-ref :field)
(lib.temporal-bucket/temporal-bucket breakout-ref))
{:keys [column] :as dimension} dimensions
:when (and (lib.equality/find-matching-column breakout-ref [column])
:when (and (lib.equality/find-matching-column query stage-number breakout-ref [column])
(= (lib.temporal-bucket/raw-temporal-bucket breakout-ref)
(or (lib.temporal-bucket/raw-temporal-bucket column)
;; If query is multi-stage and column comes from a call

View File

@@ -4,23 +4,21 @@
(:require
[medley.core :as m]
[metabase.lib.binning :as lib.binning]
[metabase.lib.card :as lib.card]
[metabase.lib.convert :as lib.convert]
[metabase.lib.dispatch :as lib.dispatch]
[metabase.lib.field.util :as lib.field.util]
[metabase.lib.hierarchy :as lib.hierarchy]
[metabase.lib.join.util :as lib.join.util]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.calculation :as lib.metadata.calculation]
[metabase.lib.options :as lib.options]
[metabase.lib.ref :as lib.ref]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.schema.ref :as lib.schema.ref]
[metabase.lib.temporal-bucket :as lib.temporal-bucket]
[metabase.lib.util :as lib.util]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(defmulti =
"Determine whether two already-normalized pMBQL maps, clauses, or other sorts of expressions are equal. The basic rule
@@ -103,378 +101,170 @@
(sequential? x) ((get-method = :dispatch-type/sequential) x y)
:else (clojure.core/= x y)))
(defn- columns-equal-by-fn-when-non-nil-in-both [f col-1 col-2]
;; for debugging purposes the implementation of [[=]] for two columns return the "reason" things are not equal so we
;; can log this; [[=]] is basically just (not <reason>)
(defn- columns-not-equal-by-fn-when-non-nil-in-both
[f col-1 col-2]
(let [v1 (f col-1)
v2 (f col-2)]
(if (and v1 v2)
(clojure.core/= v1 v2)
true)))
(when (and v1 v2 (not= v1 v2))
f)))
(defn- columns-equal-by-fn [f col-1 col-2]
(clojure.core/= (f col-1) (f col-2)))
(defn- columns-not-equal-by-fn
[f col-1 col-2]
(when (not= (f col-1) (f col-2))
f))
(defn- ignore-default-temporal-bucket [bucket]
(when-not (clojure.core/= bucket :default)
bucket))
(defmethod = :metadata/column
(defn- columns-not-equal-reason
"Returns a keyword representing the reason why two columns fail an [[=]] check (for debugging purposes)."
[col-1 col-2]
(and
;; two column metadatas with different IDs are NEVER equal.
(columns-equal-by-fn-when-non-nil-in-both :id col-1 col-2)
;; from the same source.
(columns-equal-by-fn-when-non-nil-in-both :lib/source col-1 col-2)
;; same join alias
(columns-equal-by-fn lib.join.util/current-join-alias col-1 col-2)
;;
;; columns that don't have the same binning or temporal bucketing are never the same.
;;
;; same binning
(columns-equal-by-fn :metabase.lib.field/binning col-1 col-2)
;; same bucketing
(columns-equal-by-fn (comp ignore-default-temporal-bucket lib.temporal-bucket/raw-temporal-bucket) col-1 col-2)
;; check `:inherited-temporal-unit` as well if both columns have it.
(columns-equal-by-fn-when-non-nil-in-both (comp ignore-default-temporal-bucket :inherited-temporal-unit) col-1 col-2)
;; finally make sure they have the same `:lib/source-column-alias` (if both columns have it) or `:name` (if for
;; some reason they do not)
(or
;; two column metadatas with different IDs are NEVER equal.
(columns-not-equal-by-fn-when-non-nil-in-both :id col-1 col-2)
;; from the same source.
(columns-not-equal-by-fn-when-non-nil-in-both :lib/source col-1 col-2)
;; same join alias
(columns-not-equal-by-fn :metabase.lib.join/join-alias col-1 col-2)
;; same FK Field (for implicitly joined columns)
(columns-not-equal-by-fn-when-non-nil-in-both :fk-field-id col-1 col-2)
(columns-not-equal-by-fn :fk-join-alias col-1 col-2)
;; TODO (Cam 9/4/25) -- not super clear that this ought to be a reason for columns to be considered different since
;; `:fk-field-name` doesn't really seem to be super important... but this check seems to be needed, otherwise when
;; a there are multiple remappings from Col A => Col B (e.g. in a self-join) we'll potentially accidentally match
;; the wrong one. Maybe we can figure out a better way to make sure that doesn't happen.
(columns-not-equal-by-fn-when-non-nil-in-both :fk-field-name col-1 col-2)
;;
;; columns that don't have the same binning or temporal bucketing are never the same.
;;
;; same binning
(columns-not-equal-by-fn :metabase.lib.field/binning col-1 col-2)
;; same bucketing
(when (columns-not-equal-by-fn (comp ignore-default-temporal-bucket lib.temporal-bucket/raw-temporal-bucket) col-1 col-2)
'temporal-bucket)
;; check `:inherited-temporal-unit` as well if both columns have it.
(when (columns-not-equal-by-fn-when-non-nil-in-both (comp ignore-default-temporal-bucket :inherited-temporal-unit) col-1 col-2)
:inherited-temporal-unit)
;; finally make sure they have the same `:lib/source-column-alias` (if both columns have it) or `:name` (if for
;; some reason they do not)
(let [k (m/find-first (fn [k]
(and (k col-1)
(k col-2)))
[:lib/source-column-alias :name])]
(assert k "No key common to both columns")
(columns-equal-by-fn k col-1 col-2))))
(columns-not-equal-by-fn k col-1 col-2))))
(mu/defn- resolve-field-id-in-source-card :- ::lib.schema.metadata/column
"Integer Field ID: get metadata from the metadata provider. If this is the first stage of the query, merge in
Saved Question metadata if available.
(defmethod = :metadata/column
[col-1 col-2]
(let [not-equal-reason (columns-not-equal-reason col-1 col-2)]
(if not-equal-reason
(log/debugf "Columns are not equal. Reason: %s" (pr-str not-equal-reason))
(log/debug "Columns are equal."))
(not not-equal-reason)))
This doesn't really have a good home. It's used here and by [[metabase.lib.field]], but because it depends on eg.
[[metabase.lib.card]] and [[metabase.lib.convert]] it can't go in [[metabase.lib.metadata.calculation]]."
[query :- ::lib.schema/query
stage-number :- :int
field-id :- ::lib.schema.id/field]
(merge
(when (lib.util/first-stage? query stage-number)
(when-let [card-id (lib.util/source-card-id query)]
(when-let [card-metadata (lib.card/saved-question-metadata query card-id)]
(m/find-first #(clojure.core/= (:id %) field-id)
card-metadata))))
(try
(lib.metadata/field query field-id)
(catch #?(:clj Throwable :cljs :default) _
nil))))
(mr/def ::find-matching-column.options
[:map
{:closed true}
[:find-matching-column/ignore-binning-and-bucketing? {:optional true, :default false} [:maybe :any]]
;; TODO (Cam 9/4/25) -- this is sort of a hack, the only reason we really need it is that field resolution trips up
;; and guesses the source for a ref sometimes, especially inside join conditions as we're building the join (i.e.,
;; before we add appropriate join aliases). We don't want that to be a reason that we mark selected columns
;; incorrectly. If field resolution never messed up, or if we came up with a better way of resolving columns in
;; join conditions, we wouldn't need this key.
;;
;; PLEASE don't use this key outside of this namespace. That's why it starts with `-`. If you do I will hunt you
;; down. -- Cam
[:find-matching-column/-HACK-ignore-source? {:optional true, :default false} [:maybe :any]]])
(mu/defn- column-join-alias :- [:maybe :string]
[column :- ::lib.schema.metadata/column]
;; TODO (Cam 6/19/25) -- seems busted to be using joins that happened at ANY LEVEL previously for equality purposes
;; so lightly but removing this breaks stuff. We should just remove this and do smarter matching like we do
;; in [[plausible-matches-for-name-with-join-alias]] below.
((some-fn :metabase.lib.join/join-alias :lib/original-join-alias :source-alias) column))
(defn- find-matching-column-by-uuid
"Implementation for [[find-matching-column]]; attempt to do a quick and dirty match using
`:lib/uuid`/`:lib/source-uuid`.
(mu/defn- matching-join? :- :boolean
[[_ref-kind {:keys [join-alias source-field source-field-name
source-field-join-alias]} _ref-id] :- ::lib.schema.ref/ref
column :- ::lib.schema.metadata/column]
(if source-field
(and (clojure.core/= source-field (:fk-field-id column))
;; `source-field-name` is not available on old refs
(or (nil? source-field-name) (clojure.core/= source-field-name (:fk-field-name column)))
(clojure.core/= source-field-join-alias (:fk-join-alias column)))
(clojure.core/= (column-join-alias column) join-alias)))
This should never return a match that wouldn't be returned by [[find-matching-column-by-=]], and is thus here mainly
as a performance optimization: theoretically it should be possible to remove this entirely and still have things
work normally.
(defn- plausible-matches-for-name-no-join-alias [ref-name columns]
(some (fn [k]
(not-empty
(filter #(= (k %) ref-name)
columns)))
[;; We SHOULD be using the source column alias (aka the desired column alias from the previous stage) so try
;; that first.
:lib/source-column-alias
;; if that fails, maybe we're using the old QP results metadata deduplicated names, so look for matches with
;; that.
:lib/deduplicated-name
;; if that fails, fall back to looking for matches using the old broken ambiguous `:name` key.
:name]))
TODO (Cam 9/4/25) -- I considered removing this and taking the performance hit to simplify the code but it seems
like doing so actually causes test failures, which can only mean this does return matches that otherwise wouldn't
have been considered [[=]]. We should spend some time investigating why this is happening and fix whatever is going
on."
[ref-or-col columns]
(when-let [ref-or-col-uuid (if (map? ref-or-col)
(:lib/source-uuid ref-or-col)
(lib.options/uuid ref-or-col))]
;; a `:field` ref should never match an `:expression` definition, etc. This can happen if an expression definition
;; is a `:field` ref like
;;
;; [:field {:lib/expression-name "abc"} 123]
;;
;; in this case `returned-columns` will return metadata for expression `abc` when what you REALLY wanted was the
;; previous-stage metadata for Field 123. See for
;; example [[metabase.lib.remove-replace-test/remove-clause-adjust-ref-names-test]].
;;
;; We only need to filter these columns out here and not for [[find-matching-column-by-=]] because the `=` code is
;; smart enough not to get tripped up by these anyway.
(let [columns (filter (case (lib.dispatch/dispatch-value ref-or-col)
:field #(not (#{:source/expressions :source/aggregations} (:lib/source %)))
:expression #(= (:lib/source %) :source/expressions)
:aggregation #(= (:lib/source %) :source/aggregations)
:metadata/column identity)
columns)
matches (filter (fn [a-col]
(clojure.core/= (:lib/source-uuid a-col) ref-or-col-uuid))
columns)]
;; if there are multiple potential matches then fall back to matching with `=` -- this can happen if we
;; concatenate returned columns and visible columns together --
;; see [[metabase.lib.equality-test/find-matching-column-prefer-exact-matches-test]] for more info
(when (clojure.core/= (count matches) 1)
(let [col (first matches)]
(when-let [original-id (if (map? ref-or-col)
(:id ref-or-col)
(when (pos-int? (last ref-or-col))
(last ref-or-col)))]
(when-let [resolved-id (:id col)]
(assert (= resolved-id original-id)
(lib.util/format "Resolved column has different ID (%d) than original column (%d), despite having the same UUID (%s)!"
resolved-id original-id (pr-str ref-or-col-uuid)))))
col)))))
(defn- plausible-matches-for-name-with-join-alias [join-alias ref-name columns]
;; first, look for matches for a join that came from the current stage -- `:metabase.lib.join/join-alias`; if we
;; don't see any columns a match for that key, assume the join was from a previous stage and look at
;; `:lib/original-join-alias` instead.
(letfn [(plausible-matches [columns]
(or
;; ideally, the ref would be using the column name exported by the join... assuming `columns` is the set
;; of columns exported by the stage as a whole, then the `source-column-alias` for these columns is the
;; name exported by their source (i.e., this join)
(not-empty
(filter #(= (:lib/source-column-alias %) ref-name)
columns))
;; If we fail to find a match using source column alias, then look for a match using a deduplicated name
;; RELATIVE to the join, not the WHOLE STAGE!!!! We need to recalculate these. Consider this case. If we
;; have a stage that returns these three columns:
;;
;; | :id | :name | :join-alias | :deduplicated-name |
;; |-----+-------+-------------+--------------------|
;; | 1 | ID | | ID |
;; | 2 | ID | J | ID_2 |
;; | 3 | ID | J | ID_3 |
;;
;; which column does
;;
;; [:field {:join-alias "J"}, "ID_2"]
;;
;; refer to? The correct answer (IMO) is column 3, because `ID_2` and `ID_3` are 'exported' as as `ID`
;; and `ID_2` by the join itself!
(let [deduplicated-name->col (zipmap (map :lib/deduplicated-name (lib.field.util/add-deduplicated-names columns))
columns)]
(not-empty
(keep (fn [[deduplicated-name col]]
(when (clojure.core/= deduplicated-name ref-name)
col))
deduplicated-name->col)))
;; if we failed to find a deduplicated name match then try to match on original name
(not-empty
(filter #(= (:lib/original-name %) ref-name)
columns))
;; and if THAT still fails fall back to broken `:name`.
(not-empty
(filter #(= (:name %) ref-name)
columns))))]
(when-let [columns-from-join (some (fn [k]
(not-empty (filter #(= (k %) join-alias) columns)))
[:metabase.lib.join/join-alias
:lib/original-join-alias
;; use the `:source-alias` key which was traditionally set by QP result
;; metadata sometimes if neither one of the other keys had match(es)
:source-alias])]
(plausible-matches columns-from-join))))
(defn- find-matching-column-update-col-for-options [col options]
(cond-> col
(:find-matching-column/ignore-binning-and-bucketing? options) (-> (lib.binning/with-binning nil)
(lib.temporal-bucket/with-temporal-bucket nil))
(:find-matching-column/-HACK-ignore-source? options) (dissoc :lib/source)))
(mu/defn- plausible-matches-for-name :- [:maybe [:sequential ::lib.schema.metadata/column]]
[[_ref-kind opts ref-name :as _a-ref] :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]]
(or (when-let [join-alias (:join-alias opts)]
(or (plausible-matches-for-name-with-join-alias join-alias ref-name columns)
;; if there's no match for a join then fall back to trying to match by ignoring the join alias.
(do (log/warnf "Failed to find match for column %s with join alias %s, looking for match without join alias..."
(pr-str ref-name)
(pr-str join-alias))
nil)))
(plausible-matches-for-name-no-join-alias ref-name columns)))
(mu/defn- plausible-matches-for-id :- [:sequential ::lib.schema.metadata/column]
[[_ref-kind opts ref-id :as a-ref] :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]
generous? :- [:maybe :boolean]]
(or (not-empty (filter #(and (clojure.core/= (:id %) ref-id)
;; TODO: If the target ref has no join-alias, AND the source is card, the join alias on
;; the column can be ignored. QP can set it when it shouldn't. See #33972.
(or (and (not (:join-alias opts))
(= (:lib/source %) :source/card))
(matching-join? a-ref %)))
columns))
(when generous?
(not-empty (filter #(clojure.core/= (:id %) ref-id) columns)))
[]))
(defn- ambiguous-match-error [a-ref columns]
(ex-info "Ambiguous match! Implement more logic in disambiguate-matches."
{:ref a-ref
:columns columns}))
(mu/defn- expression-column? [column]
(or (clojure.core/= (:lib/source column) :source/expressions)
(:lib/expression-name column)))
(mu/defn- disambiguate-matches-dislike-field-refs-to-expressions :- [:maybe ::lib.schema.metadata/column]
"If a custom column is a simple wrapper for a field, that column gets `:id`, `:table_id`, etc.
A custom column should get a ref like `[:expression {} \"expr name\"]`, not `[:field {} 17]`.
If we got a `:field` ref, prefer matches which are not `:lib/source :source/expressions`."
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]]
(or (when (clojure.core/= (first a-ref) :field)
(when-let [non-exprs (not-empty (remove expression-column? columns))]
(when-not (next non-exprs)
(first non-exprs))))
;; In all other cases, this is an ambiguous match.
#_(throw (ambiguous-match-error a-ref columns))
#?(:cljs (js/console.warn (ambiguous-match-error a-ref columns))
:clj (log/warn (ambiguous-match-error a-ref columns)))))
(defn- matching-col-with-fn [columns col-fn]
(let [matching-columns (filter col-fn columns)]
(when (clojure.core/= (count matching-columns) 1)
(first matching-columns))))
(mu/defn- disambiguate-matches-find-match-with-same-binning :- [:maybe ::lib.schema.metadata/column]
"If there are multiple matching columns and `a-ref` has a binning value, check if only one column has that same
binning."
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential {:min 2} ::lib.schema.metadata/column]]
(or (let [binning (lib.binning/binning a-ref)]
(matching-col-with-fn columns #(lib.binning/binning= (lib.binning/binning %) binning)))
(when-let [original-binning (:lib/original-binning (lib.options/options a-ref))]
(matching-col-with-fn columns #(lib.binning/binning= (:lib/original-binning %) original-binning)))
(disambiguate-matches-dislike-field-refs-to-expressions a-ref columns)))
(mu/defn- disambiguate-matches-find-match-with-same-temporal-bucket :- [:maybe ::lib.schema.metadata/column]
"If there are multiple matching columns and `a-ref` has a temporal bucket, check if only one column has that same
unit."
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential {:min 2} ::lib.schema.metadata/column]]
(or (let [bucket (lib.temporal-bucket/raw-temporal-bucket a-ref)]
(matching-col-with-fn columns #(clojure.core/= (lib.temporal-bucket/raw-temporal-bucket %) bucket)))
(when-let [inherited-bucket (:inherited-temporal-unit (lib.options/options a-ref))]
(matching-col-with-fn columns #(clojure.core/= (:inherited-temporal-unit %) inherited-bucket)))
(disambiguate-matches-find-match-with-same-binning a-ref columns)))
(mu/defn- disambiguate-matches-prefer-explicit :- [:maybe ::lib.schema.metadata/column]
"Prefers table-default or explicitly joined columns over implicitly joinable ones."
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]]
(if-let [no-implicit (not-empty (remove :fk-field-id columns))]
(if-not (next no-implicit)
(first no-implicit)
(disambiguate-matches-find-match-with-same-temporal-bucket a-ref no-implicit))
nil))
(mu/defn- disambiguate-matches-ignoring-join-alias :- [:maybe ::lib.schema.metadata/column]
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]]
;; a-ref without :join-alias - if exactly one column has no :source-alias, that's the match.
;; ignore the source alias on columns with :source/card
(if-let [no-alias (not-empty (remove #(and (column-join-alias %)
(not= (:lib/source %) :source/card))
columns))]
;; At least 1 matching column with no :source-alias.
(if-not (next no-alias)
(first no-alias)
;; More than 1, keep digging.
(disambiguate-matches-prefer-explicit a-ref no-alias))
;; No columns are missing :source-alias - pass them all to the next stage.
;; TODO: I'm not certain this one is sound, but it's necessary to make `lib.join/select-home-column` work as
;; written. If this case causes issues, that logic may need rewriting.
nil))
(mu/defn- disambiguate-matches :- [:maybe ::lib.schema.metadata/column]
[a-ref :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]]
(let [{:keys [join-alias]} (lib.options/options a-ref)]
(or
;; try to find matches with the same join alias (which might be `nil` for both).
;;
;; TODO (Cam 6/26/25) -- we should first try this using just the `:metabase.lib.join/join-alias` (join alias from
;; this stage) and only then fall back to using `:lib/original-alias` and what not
(when-let [matches (not-empty (filter #(clojure.core/= (column-join-alias %) join-alias) columns))]
(if-not (next matches)
(first matches)
;; if there wasn't exactly 1 match then log a warning only if we had a join alias in the first place. Then
;; try again ignoring join alias.
(do
(when join-alias
(#?(:cljs js/console.warn :clj log/warn)
"Multiple plausible matches with the same :join-alias - more disambiguation needed"
{:ref a-ref
:matches matches}))
nil)))
(disambiguate-matches-ignoring-join-alias a-ref columns))))
(def ^:private FindMatchingColumnOptions
[:map [:generous? {:optional true} :boolean]])
(defn- find-matching-column-by-=
[query stage-number ref-or-col columns options]
(when-let [col (if (map? ref-or-col)
ref-or-col
(u/prog1 (lib.metadata.calculation/metadata query stage-number ref-or-col)
(log/debugf "Resolved ref\n%s\nto\n%s" (u/cprint-to-str ref-or-col) (u/cprint-to-str <>))))]
(let [col (find-matching-column-update-col-for-options col options)]
(m/find-first #(u/prog1 (= (find-matching-column-update-col-for-options % options) col)
(log/debugf "Matches?\n%s\n=> %s" (u/cprint-to-str %) (pr-str <>)))
columns))))
(mu/defn find-matching-column :- [:maybe ::lib.schema.metadata/column]
"Given `a-ref-or-column` and a list of `columns`, finds the column that best matches this ref or column.
"Find the column in `columns` that matches a `ref-or-col`. Matches with `:lib/uuid`/`:lib/source-uuid` if there is
exactly one match, otherwise resolves the ref if needed and returns the first match with [[=]].
Matching is based on finding the basically plausible matches first. There is often zero or one plausible matches, and
this can return quickly.
For less-strict matching you can pass `{:find-matching-column/ignore-binning-and-bucketing? true}`."
([query stage-number ref-or-col columns]
(find-matching-column query stage-number ref-or-col columns nil))
If there are multiple plausible matches, they are disambiguated by the most important extra included in the `ref`.
(`:join-alias` first, then `:temporal-unit`, etc.)
- Integer IDs in the `ref` are matched by ID; this usually is unambiguous.
- If there are multiple joins on one table (including possible implicit joins), check `:join-alias` next.
- If `a-ref` has a `:join-alias`, only a column which matches it can be the match, and it should be unique.
- If `a-ref` doesn't have a `:join-alias`, prefer the column with no `:join-alias`, and prefer already selected
columns over implicitly joinable ones.
- There may be broken cases where the ref has an ID but the column does not. Therefore the ID must be resolved to a
name or `:lib/desired-column-alias` and matched that way.
- `query` and `stage-number` are required for this case, since they're needed to resolve the correct name.
- Columns with `:id` set are dropped to prevent them matching. (If they didn't match by `:id` above they shouldn't
match by name due to a coincidence of column names in different tables.)
- String IDs are checked against `:lib/desired-column-alias` first.
- If that doesn't match any columns, `:name` is compared next.
- The same disambiguation (by `:join-alias` etc.) is applied if there are multiple plausible matches.
Returns the matching column, or nil if no match is found."
([a-ref columns]
(find-matching-column a-ref columns {}))
([[ref-kind _opts ref-id :as a-ref] :- ::lib.schema.ref/ref
columns :- [:sequential ::lib.schema.metadata/column]
{:keys [generous?]} :- FindMatchingColumnOptions]
(case ref-kind
;; Aggregations are referenced by the UUID of the column being aggregated.
:aggregation (m/find-first #(and (clojure.core/= (:lib/source %) :source/aggregations)
(clojure.core/= (:lib/source-uuid %) ref-id))
columns)
;; Expressions are referenced by name; fields by ID or name.
(:expression
:field) (let [plausible (if (string? ref-id)
(plausible-matches-for-name a-ref columns)
(plausible-matches-for-id a-ref columns generous?))]
(case (count plausible)
0 nil
1 (first plausible)
(disambiguate-matches a-ref plausible)))
(throw (ex-info "Unknown type of ref" {:ref a-ref}))))
([query stage-number a-ref-or-column columns]
(find-matching-column query stage-number a-ref-or-column columns {}))
([query :- [:maybe ::lib.schema/query]
stage-number :- :int
a-ref-or-column :- [:or ::lib.schema.metadata/column ::lib.schema.ref/ref]
columns :- [:sequential ::lib.schema.metadata/column]
opts :- FindMatchingColumnOptions]
;; if we're matching against column metadata then we can try to use [[=]] to match instead of trying to do
;; ref-based matching.
(or
(when (clojure.core/= (:lib/type a-ref-or-column) :metadata/column)
(let [col a-ref-or-column
matches (filter #(= % col) columns)]
;; only return the match found this way if it is unambiguous.
;;
;; TODO (Cam 8/19/25) -- we should update this to disambiguate matches for these instead of falling back to
;; the ref-based-resolution code, since finding matches between column metadata is more accurate
(when (clojure.core/= (count matches) 1)
(first matches))))
(let [[ref-kind ref-opts ref-id :as a-ref] (if (lib.util/clause? a-ref-or-column)
a-ref-or-column
(lib.ref/ref a-ref-or-column))]
(or (find-matching-column a-ref columns opts)
;; Aggregations are matched by `:source-uuid` but if we're comparing old columns to new refs or vice versa
;; the random UUIDs won't match up. This falls back to the `:lib/source-name` option on aggregation refs, if
;; present.
(when (and (clojure.core/= ref-kind :aggregation)
(:lib/source-name ref-opts))
(m/find-first #(and (clojure.core/= (:lib/source %) :source/aggregations)
(clojure.core/= (:name %) (:lib/source-name ref-opts)))
columns))
;; We failed to match by ID, so try again with the column's name. Any columns with `:id` set are dropped.
;; Why? Suppose there are two CREATED_AT columns in play - if one has an :id and it failed to match above, then
;; it certainly shouldn't match by name just because of the coincidence of column names!
(when (and query (number? ref-id))
(when-let [no-id-columns (not-empty (remove :id columns))]
(when-let [resolved (if (lib.util/clause? a-ref-or-column)
(resolve-field-id-in-source-card query stage-number ref-id)
a-ref-or-column)]
(find-matching-column (-> (assoc a-ref 2 (or (:lib/desired-column-alias resolved)
(:name resolved)))
;; make sure the :field ref has a `:base-type`, it's against the rules for a
;; nominal :field ref not to have a base-type -- this can fail schema
;; validation if it's missing in the Field ID ref we generate the nominal ref
;; from.
(lib.options/update-options (partial merge {:base-type :type/*})))
no-id-columns
opts)))))))))
([query :- ::lib.schema/query
stage-number :- :int
ref-or-col :- [:or
::lib.schema.metadata/column
::lib.schema.ref/ref]
columns :- [:sequential ::lib.schema.metadata/column]
options :- [:maybe ::find-matching-column.options]]
(or (find-matching-column-by-uuid ref-or-col columns)
(find-matching-column-by-= query stage-number ref-or-col columns options))))
(defn- ref-id-or-name [[_ref-kind _opts id-or-name]]
id-or-name)
@@ -536,16 +326,16 @@
(mu/defn find-column-for-legacy-ref :- [:maybe ::lib.schema.metadata/column]
"Like [[find-matching-column]], but takes a legacy MBQL reference. The name here is for consistency with other
FE names for similar functions."
([query legacy-ref metadatas]
(find-column-for-legacy-ref query -1 legacy-ref metadatas))
([query legacy-ref cols]
(find-column-for-legacy-ref query -1 legacy-ref cols))
([query :- ::lib.schema/query
stage-number :- :int
legacy-ref :- :some
metadatas :- [:maybe [:sequential ::lib.schema.metadata/column]]]
(find-matching-column query stage-number (lib.convert/legacy-ref->pMBQL query stage-number legacy-ref) metadatas)))
cols :- [:maybe [:sequential ::lib.schema.metadata/column]]]
(find-matching-column query stage-number (lib.convert/legacy-ref->pMBQL query stage-number legacy-ref) cols)))
(defn mark-selected-columns
(mu/defn mark-selected-columns
"Mark `columns` as `:selected?` if they appear in `selected-columns-or-refs`. Uses fuzzy matching with
[[find-matching-column]].
@@ -555,26 +345,32 @@
;;
;; return (visibile-columns query), but if any of those appear in `:fields`, mark then `:selected?`
(mark-selected-columns (visible-columns query) (:fields stage))"
([cols selected-columns-or-refs]
(mark-selected-columns nil -1 cols selected-columns-or-refs))
([query stage-number cols selected-columns-or-refs]
(when (> (count selected-columns-or-refs) (count cols))
(log/errorf "[mark-selected-columns] There are more selected columns (%d) than there are total columns (%d)"
(count selected-columns-or-refs) (count cols)))
(when (seq cols)
(let [matching-selected-cols (into #{}
(keep (fn [selected-col-or-ref]
(or (find-matching-column query stage-number selected-col-or-ref cols)
(do
(log/warnf "[mark-selected-columns] failed to find match for %s" (pr-str selected-col-or-ref))
nil))))
selected-columns-or-refs)]
(when-not (clojure.core/= (count selected-columns-or-refs) (count matching-selected-cols))
(log/warnf "[mark-selected-columns] %d refs are selected, but we found %d matches"
(count selected-columns-or-refs)
(count matching-selected-cols)))
(mapv #(assoc % :selected? (contains? matching-selected-cols %)) cols)))))
[query :- ::lib.schema/query
stage-number :- :int
cols :- [:sequential ::lib.schema.metadata/column]
selected-columns-or-refs :- [:or
[:sequential ::lib.schema.metadata/column]
[:sequential ::lib.schema.ref/ref]]]
(when (> (count selected-columns-or-refs) (count cols))
(log/errorf "[mark-selected-columns] There are more selected columns (%d) than there are total columns (%d)"
(count selected-columns-or-refs) (count cols)))
(when (seq cols)
(let [matching-selected-cols (into #{}
(keep (fn [selected-col-or-ref]
;; ignore temporal bucketing for marking columns as selected
;; purposes (#32920)
(or (find-matching-column query stage-number selected-col-or-ref cols
{:find-matching-column/ignore-binning-and-bucketing? true
:find-matching-column/-HACK-ignore-source? true})
(do
(log/warnf "[mark-selected-columns] failed to find match for %s" (pr-str selected-col-or-ref))
nil))))
selected-columns-or-refs)]
(when-not (clojure.core/= (count selected-columns-or-refs) (count matching-selected-cols))
(log/warnf "[mark-selected-columns] %d refs are selected, but we found %d matches"
(count selected-columns-or-refs)
(count matching-selected-cols)))
(mapv #(assoc % :selected? (contains? matching-selected-cols %)) cols))))
(mu/defn matching-column-sets? :- :boolean
"Returns true if the provided `refs` is the same set as the provided `columns`.

View File

@@ -1,9 +1,11 @@
(ns metabase.lib.expression
(:refer-clojure :exclude [+ - * / case coalesce abs time concat replace float])
(:require
[better-cond.core :as b]
[clojure.string :as str]
[medley.core :as m]
[metabase.lib.common :as lib.common]
[metabase.lib.field.util :as lib.field.util]
[metabase.lib.hierarchy :as lib.hierarchy]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.calculation :as lib.metadata.calculation]
@@ -15,6 +17,8 @@
[metabase.lib.schema.expression :as lib.schema.expression]
[metabase.lib.schema.expression.conditional :as lib.schema.expression.conditional]
[metabase.lib.schema.expression.temporal :as lib.schema.expression.temporal]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.mbql-clause :as lib.schema.mbql-clause]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.schema.temporal-bucketing :as lib.schema.temporal-bucketing]
[metabase.lib.temporal-bucket :as lib.temporal-bucket]
@@ -51,6 +55,48 @@
(m/find-first (comp #{expression-name} lib.util/expression-name)
(:expressions stage)))))
(mu/defn- -resolve-expression :- [:map
[:stage-number [:maybe :int]]
[:expression [:ref ::lib.schema.mbql-clause/clause]]
[:card-id {:optional true} ::lib.schema.id/card]]
[query stage-number expression-name :- :string]
(b/cond
:let [resolved (maybe-resolve-expression query stage-number expression-name)]
resolved
{:stage-number stage-number, :expression resolved}
:do
(log/warnf "Expression %s does not exist in stage %d"
(pr-str expression-name) (lib.util/canonical-stage-index query stage-number))
:let [previous-stage-number (lib.util/previous-stage-number query stage-number)]
previous-stage-number
(u/prog1 (-resolve-expression query previous-stage-number expression-name)
(when <>
(log/warn (u/format-color :red "Found expression %s in previous stage. Next time, use a :field name ref!"
(pr-str expression-name)))))
:let [source-card-id (when (lib.util/first-stage? query stage-number)
(lib.util/source-card-id query))
resolved-in-source-card (when source-card-id
(when-let [source-card (lib.metadata/card query source-card-id)]
(-resolve-expression ((#?(:clj requiring-resolve :cljs resolve) 'metabase.lib.query/query)
(lib.metadata/->metadata-provider query)
(:dataset-query source-card))
stage-number
expression-name)))]
resolved-in-source-card
(do
(log/warn (u/format-color :red "Found expression %s in source card %d. Next time, use a :field name ref!"
(pr-str expression-name) source-card-id))
(assoc resolved-in-source-card :stage-number nil, :card-id source-card-id))
:else
(throw (ex-info (i18n/tru "No expression named {0}" (pr-str expression-name))
{:expression-name expression-name
:query query
:stage-number stage-number}))))
(mu/defn resolve-expression :- ::lib.schema.expression/expression
"Find the expression with `expression-name` in a given stage of a `query`, or throw an Exception if it doesn't
exist."
@@ -60,26 +106,7 @@
([query :- ::lib.schema/query
stage-number :- :int
expression-name :- ::lib.schema.common/non-blank-string]
(or (maybe-resolve-expression query stage-number expression-name)
(log/warnf "Expression %s does not exist in stage %d" (pr-str expression-name) (lib.util/canonical-stage-index query stage-number))
(when-let [previous-stage-number (lib.util/previous-stage-number query stage-number)]
(u/prog1 (resolve-expression query previous-stage-number expression-name)
(when <>
(log/warnf "Found expression %s in previous stage" (pr-str expression-name)))))
(when (lib.util/first-stage? query stage-number)
(when-let [source-card-id (lib.util/source-card-id query)]
(when-let [source-card (lib.metadata/card query source-card-id)]
(u/prog1 (resolve-expression ((#?(:clj requiring-resolve :cljs resolve) 'metabase.lib.query/query)
(lib.metadata/->metadata-provider query)
(:dataset-query source-card))
expression-name)
(when <>
(log/warnf "Found expression %s in source card %d. Next time, use a :field name ref!"
(pr-str expression-name) source-card-id))))))
(throw (ex-info (i18n/tru "No expression named {0}" (pr-str expression-name))
{:expression-name expression-name
:query query
:stage-number stage-number})))))
(:expression (-resolve-expression query stage-number expression-name))))
(defmethod lib.metadata.calculation/type-of-method :expression
[query stage-number [_expression _opts expression-name, :as _expression-ref]]
@@ -88,21 +115,37 @@
(mu/defmethod lib.metadata.calculation/metadata-method :expression :- ::lib.metadata.calculation/visible-column
[query stage-number [_expression opts expression-name, :as expression-ref-clause]]
(merge {:lib/type :metadata/column
;; TODO (Cam 8/7/25) -- is the source UUID of an expression ref supposed to be the ID of the ref, or the ID
;; of the expression definition??
:lib/source-uuid (:lib/uuid opts)
:name expression-name
:lib/expression-name expression-name
:lib/source-column-alias expression-name
:display-name (lib.metadata.calculation/display-name query stage-number expression-ref-clause)
:base-type (lib.metadata.calculation/type-of query stage-number expression-ref-clause)
:lib/source :source/expressions}
(when-let [unit (lib.temporal-bucket/raw-temporal-bucket expression-ref-clause)]
{:metabase.lib.field/temporal-unit unit})
(when lib.metadata.calculation/*propagate-binning-and-bucketing*
(when-let [unit (lib.temporal-bucket/raw-temporal-bucket expression-ref-clause)]
{:inherited-temporal-unit unit}))))
(let [{resolved-stage-number :stage-number
resolved-expression :expression
resolved-card-id :card-id} (-resolve-expression query stage-number expression-name)
expr-type (or (:base-type opts)
(lib.metadata.calculation/type-of query stage-number resolved-expression))
col (merge {:lib/type :metadata/column
:lib/source-uuid (lib.options/uuid resolved-expression)
:name expression-name
:lib/expression-name expression-name
:lib/source-column-alias expression-name
:display-name (or (:display-name opts)
(lib.metadata.calculation/display-name query stage-number resolved-expression))
:base-type expr-type
:effective-type expr-type
:lib/source :source/expressions}
(when-let [converted-timezone (lib.util.match/match-one resolved-expression
:convert-timezone
(let [[_convert-timezone _opts _expr source-tz] &match]
source-tz))]
{:converted-timezone converted-timezone})
(when-let [unit (lib.temporal-bucket/raw-temporal-bucket expression-ref-clause)]
{:metabase.lib.field/temporal-unit unit})
(when lib.metadata.calculation/*propagate-binning-and-bucketing*
(when-let [unit (lib.temporal-bucket/raw-temporal-bucket expression-ref-clause)]
{:inherited-temporal-unit unit})))]
(cond-> col
(not= resolved-stage-number stage-number)
lib.field.util/update-keys-for-col-from-previous-stage
resolved-card-id
(assoc :lib/source :source/card, :lib/card-id resolved-card-id))))
(defmethod lib.temporal-bucket/available-temporal-buckets-method :expression
[query stage-number [_expression opts _expr-name, :as expr-clause]]

View File

@@ -132,9 +132,6 @@
stage-number
{join-alias :metabase.lib.join/join-alias
original-join-alias :lib/original-join-alias
;; TODO (Cam 6/19/25) -- `:source-alias` is deprecated, see description for column metadata
;; schema. Still getting set/used in a few places tho. Work on removing it altogether.
source-alias :source-alias
fk-field-id :fk-field-id
original-fk-field-id :lib/original-fk-field-id
table-id :table-id
@@ -142,8 +139,7 @@
style
display-name]
(let [join-alias (or join-alias
original-join-alias
source-alias)
original-join-alias)
fk-field-id (or fk-field-id original-fk-field-id)
join-display-name (when (and (= style :long)
;; don't prepend a join display name if `:display-name` already contains one! Legacy
@@ -419,19 +415,6 @@
options (merge {:lib/uuid (str (random-uuid))
:effective-type (column-metadata-effective-type metadata)}
(select-renamed-keys metadata field-ref-propagated-keys)
;; MEGA HACK! QP result metadata includes `:source-alias` (which is basically any
;; join alias that was ever used for the column); if that is present then we need to
;; generate field refs that use as a join alias because even tho that sounds
;; completely broken that is traditionally what we've done. Taking this out
;; breakouts [[metabase.lib.drill-thru.column-filter-test/column-filter-join-alias-test]].
;;
;; TODO (Cam 6/26/25) -- figure out if we can actually take this out or not.
(when-let [source-alias (and (not inherited-column?)
(not (:fk-field-id metadata))
(not= :source/implicitly-joinable
(:lib/source metadata))
(:source-alias metadata))]
{:join-alias source-alias})
(when-not inherited-column?
(select-renamed-keys metadata field-ref-propagated-keys-for-non-inherited-columns)))
id-or-name (or (lib.field.util/inherited-column-name metadata)
@@ -443,8 +426,7 @@
(case source
:source/aggregations (lib.aggregation/column-metadata->aggregation-ref metadata)
:source/expressions (lib.expression/column-metadata->expression-ref metadata)
#_else
(column-metadata->field-ref metadata)))
#_else (column-metadata->field-ref metadata)))
(defn- expression-columns
"Return the [[::lib.schema.metadata/column]] for all the expressions in a stage of a query."

View File

@@ -21,6 +21,7 @@
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.schema.ref :as lib.schema.ref]
[metabase.lib.util :as lib.util]
[metabase.lib.util.log :as lib.util.log]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
@@ -67,7 +68,7 @@
[metadata-providerable :- ::lib.schema.metadata/metadata-providerable
table-id :- [:maybe ::lib.schema.id/table]
id-or-name :- ::id-or-name]
(log/debugf "Resolving Field %s from metadata provider" (pr-str id-or-name))
(log/debugf "Resolving %s from metadata provider" (lib.util.log/format-field metadata-providerable id-or-name))
(when-some [col (cond
(pos-int? id-or-name)
(lib.metadata/field metadata-providerable id-or-name)
@@ -109,21 +110,25 @@
;; `id-or-name` is an ID
(or (m/find-first #(= (:id %) id-or-name) cols)
(do
(log/debugf "Failed to find column in metadata with ID %s" (pr-str id-or-name))
(log/debugf "Failed to find %s in metadata" (lib.util.log/format-field metadata-providerable id-or-name))
(when-some [field (lib.metadata/field metadata-providerable id-or-name)]
(log/debugf "Looking for match in metadata with name %s" (pr-str (:name field)))
(log/debugf "Looking for match in metadata for %s" (lib.util.log/format-field metadata-providerable field))
(when-some [col (resolve* (:name field))]
;; don't return a match that is definitely for a different column (has an ID, but it's for a
;; different column)
(when (or (not (:id col))
(= (:id col) id-or-name))
col)))))))]
(cond
(= (:id col) id-or-name)
col
;; if resolved col is missing ID, merge in metadata from the metadata provider
(not (:id col))
(merge-metadata (lib.metadata/field metadata-providerable id-or-name)
col))))))))]
(u/prog1 (resolve* id-or-name)
(if <>
(log/debugf "Found match %s"
(pr-str (select-keys <> [:id :lib/desired-column-alias :lib/deduplicated-name])))
(log/debugf "Failed to find match for %s. Found:\n%s"
(pr-str id-or-name)
(lib.util.log/format-field metadata-providerable id-or-name)
(u/pprint-to-str (map #(select-keys % [:id :lib/desired-column-alias :lib/deduplicated-name])
cols)))))))
@@ -297,18 +302,24 @@
join-alias :- ::lib.schema.join/alias
source-field :- [:maybe ::lib.schema.id/field]
id-or-name :- ::id-or-name]
(log/debugf "Resolving %s (join alias = %s) in joins in stage %s" (pr-str id-or-name) (pr-str join-alias) (pr-str stage-number))
(log/debugf "Resolving %s (join alias = %s) in joins in stage %d"
(lib.util.log/format-field query id-or-name)
(pr-str join-alias)
stage-number)
;; find the matching join.
(let [stage (lib.util/query-stage query stage-number)]
(if-some [join (m/find-first #(= (:alias %) join-alias)
(:joins stage))]
;; found matching join at this stage
(do
(log/debugf "Resolving %s in join %s in stage %s"
(pr-str id-or-name)
(log/debugf "Resolving %s in join %s in stage %d"
(lib.util.log/format-field query id-or-name)
(pr-str join-alias)
(pr-str stage-number))
(let [join-cols (cond->> (lib.join/join-returned-columns-relative-to-parent-stage query stage-number join nil)
stage-number)
;; TODO (Cam 9/4/25) -- we should almost certainly be passing `{:include-remaps? true}` everywhere we call
;; some variant of `returned-columns` in this namespace, or better yet forwarding it somehow from calls in
;; `returned-columns`. But just doing it here for now will have to do.
(let [join-cols (cond->> (lib.join/join-returned-columns-relative-to-parent-stage query stage-number join {:include-remaps? true})
source-field (remove (fn [col]
(when-some [col-source-field ((some-fn :fk-field-id :lib/original-fk-field-id) col)]
(not= col-source-field source-field)))))]
@@ -319,9 +330,7 @@
;; a join with this alias does not exist at this stage of the query... try looking recursively in previous
;; stage(s)
(do
(log/debugf "Join %s does not exist in stage %s, looking in previous stages"
(pr-str join-alias)
(pr-str stage-number))
(log/debugf "Join %s does not exist in stage %d, looking in previous stages" (pr-str join-alias) stage-number)
(if-some [source-cols (or (when-some [previous-stage-number (lib.util/previous-stage-number query stage-number)]
(lib.metadata.calculation/returned-columns query previous-stage-number))
(when-some [source-card-id (:source-card (lib.util/query-stage query stage-number))]
@@ -423,14 +432,14 @@
(let [[join join-stage-number] (find-reified-implicit-join-with-fk-field-id query stage-number source-field-id)]
(when (and join-stage-number
(not= join-stage-number stage-number))
(log/errorf (str "Field ref %s in stage %s specifies :source-field-id %s, but we found the implicit join %s in"
" earlier stage %s, which doesn't return this column. Query almost certainly won't work"
(log/errorf (str "Field ref %s in stage %d specifies :source-field-id %s, but we found the implicit join %s in"
" earlier stage %d, which doesn't return this column. Query almost certainly won't work"
" correctly.")
(pr-str id-or-name)
(pr-str stage-number)
(pr-str source-field-id)
(lib.util.log/format-field query id-or-name)
stage-number
(lib.util.log/format-field query source-field-id)
(pr-str (:alias join))
(pr-str join-stage-number))
join-stage-number)
(when-some [col (resolve-in-implicit-join-current-stage query source-field-id id-or-name)]
(-> col
lib.field.util/update-keys-for-col-from-previous-stage
@@ -445,8 +454,8 @@
stage-number :- :int
source-field-id :- ::lib.schema.id/field
id-or-name :- ::id-or-name]
(log/debugf "Resolving implicitly joined %s (source Field ID = %s) in stage %s"
(pr-str id-or-name) (pr-str source-field-id) (pr-str stage-number))
(log/debugf "Resolving implicitly joined %s (source Field ID = %s) in stage %d"
(lib.util.log/format-field query id-or-name) (pr-str source-field-id) stage-number)
(or (resolve-in-implicit-join-previous-stage query stage-number source-field-id id-or-name)
(resolve-unreturned-column-in-reified-implicit-join-in-previous-stage query stage-number source-field-id id-or-name)
;; if there is no previous stage or we were unable to find the column in a previous stage then that means the
@@ -457,7 +466,7 @@
[query :- ::lib.schema/query
previous-stage-number :- :int
id-or-name :- ::id-or-name]
(log/debugf "Resolving %s in previous stage returned columns" (pr-str id-or-name))
(log/debugf "Resolving %s in previous stage returned columns" (lib.util.log/format-field query id-or-name))
(when-some [previous-stage-columns (lib.metadata.calculation/returned-columns query previous-stage-number)]
(resolve-in-previous-stage-metadata-and-update-keys query previous-stage-columns id-or-name)))
@@ -465,7 +474,7 @@
[query :- ::lib.schema/query
source-card-id :- ::lib.schema.id/card
id-or-name :- ::id-or-name]
(log/debugf "Resolving %s in source Card %s metadata" (pr-str id-or-name) (pr-str source-card-id))
(log/debugf "Resolving %s in source Card %s metadata" (lib.util.log/format-field query id-or-name) (pr-str source-card-id))
(when-some [card (lib.metadata/card query source-card-id)]
(let [card-metadata-columns (lib.metadata.calculation/returned-columns query card)]
(when-some [col (resolve-in-previous-stage-metadata-and-update-keys query card-metadata-columns id-or-name)]
@@ -476,7 +485,7 @@
[query :- ::lib.schema/query
stage-number :- :int
id-or-name :- ::id-or-name]
(log/debugf "Resolving %s in current stage metadata" (pr-str id-or-name))
(log/debugf "Resolving %s in current stage metadata" (lib.util.log/format-field query id-or-name))
(let [stage (lib.util/query-stage query stage-number)]
(if-some [current-stage-metadata-columns (not-empty (get-in stage [:lib/stage-metadata :columns]))]
;; TODO (Cam 8/7/25) -- sort of a hacc to add deduplicated names here just so we can properly
@@ -495,11 +504,11 @@
(when-some [source-card-id (:source-card (lib.util/query-stage query stage-number))]
(resolve-in-card-returned-columns query source-card-id id-or-name)))
(defn- fallback-metadata [id-or-name]
(defn- fallback-metadata [metadata-providerable id-or-name]
(log/warn (u/format-color :red
(str "We tried every trick we could think of and still failed to resolve a field"
" ref. If the query doesn't work, this is why. Returning fallback metadata for %s")
(pr-str id-or-name)))
(lib.util.log/format-field metadata-providerable id-or-name)))
(merge
{:lib/type :metadata/column
;; guess that the column came from the previous stage
@@ -552,7 +561,7 @@
(mu/defn- resolve-ref-missing-join-alias :- [:maybe ::lib.metadata.calculation/visible-column]
"Try finding a match in joins (field ref is missing `:join-alias`)."
[query stage-number id-or-name]
(log/debugf "Assuming %s is from a join, and missing :join-alias" (pr-str id-or-name))
(log/debugf "Assuming %s is from a join, and missing :join-alias" (lib.util.log/format-field query id-or-name))
(or (when (string? id-or-name)
(let [parts (str/split id-or-name #"__" 2)]
(when (= (count parts) 2)
@@ -564,17 +573,55 @@
(resolve-in-join query stage-number (:alias join) nil id-or-name))
(:joins (lib.util/query-stage query stage-number)))
(do
(log/debugf "Failed to find a match in one of the query's joins in stage %s" (pr-str stage-number))
(log/debugf "Failed to find a match in one of the query's joins in stage %d" stage-number)
nil)))
;;; This is mostly needed because of really messed up test cases in [[metabase.lib.drill-thru.test-util.canned]], see
;;; for example `:test.query/orders-count-by-created-at-and-product-category`
(defn- fallback-metadata-for-demonic-field-id-from-different-source-table
[query stage-number col]
(when-let [source-table-id (lib.util/source-table-id query)]
(log/warn (u/format-color :red (str "Field ref for %s in stage %d is from a different source table (%s) than"
" the query :source-table (%s), but it is missing :join-alias and"
" :source-field. This is not and has never been supported, but we will do"
" our best to make the query work anyway. If it doesn't work, this is"
" probably why. Refs like these might not work in the future.")
(lib.util.log/format-field query col)
stage-number
(lib.util.log/format-table query (:table-id col))
(lib.util.log/format-table query source-table-id)))
(if-let [col (m/find-first (fn [implicit-col]
(and (= (:id implicit-col) (:id col))
(:fk-field-id implicit-col)))
(lib.metadata.calculation/implicitly-joinable-columns
query
(lib.metadata.calculation/returned-columns query (lib.metadata/table query source-table-id))))]
{:lib/source :source/implicitly-joinable
:fk-field-id (:fk-field-id col)}
(do
(log/errorf "Failed to find a way to implicitly join %s" (lib.util.log/format-field query col))
nil))))
(defn- fallback-metadata-for-field [query stage-number id-or-name]
(let [source-table-id (:source-table (lib.util/query-stage query stage-number))]
(when-some [col (field-metadata query source-table-id id-or-name)]
(assoc col
::fallback-metadata? true
:lib/source (if (zero? (lib.util/canonical-stage-index query stage-number))
:source/table-defaults
:source/previous-stage)))))
(when-some [col (field-metadata query (:source-table (lib.util/query-stage query stage-number)) id-or-name)]
(merge-metadata
col
{::fallback-metadata? true}
(b/cond
(not (lib.util/first-stage? query stage-number))
{:lib/source :source/previous-stage}
:let [source-card-id (lib.util/source-card-id query)]
source-card-id
{:lib/source :source/card, :lib/card-id source-card-id}
:when-let [source-table-id (lib.util/source-table-id query)]
(= (:table-id col) source-table-id)
{:lib/source :source/table-defaults}
:else
(fallback-metadata-for-demonic-field-id-from-different-source-table query stage-number col)))))
(defn- maybe-resolve-expression-in-current-stage [query stage-number id-or-name]
(when (string? id-or-name)
@@ -583,7 +630,7 @@
(str "Resolved field %s to an expression. Please remember to use :expression references"
" for expressions in the current stage -- using a :field ref is unsupported and may"
" not be allowed in the future.")
(pr-str id-or-name)))
(lib.util.log/format-field query id-or-name)))
(-> (lib.expression/expression-metadata query stage-number expr)
(assoc :lib/source-column-alias id-or-name)))))
@@ -591,10 +638,12 @@
[query :- ::lib.schema/query
stage-number :- :int
id-or-name :- ::id-or-name]
(log/debugf "Resolving %s from previous stage, source table, or source card" (pr-str id-or-name))
(log/debugf "Resolving %s from previous stage, source table, or source card" (lib.util.log/format-field query id-or-name))
(let [col (or (resolve-from-previous-stage-or-source* query stage-number id-or-name)
(do
(log/debugf "Failed to resolve Field %s in stage %s. Trying other methods..." (pr-str id-or-name) (pr-str stage-number))
(log/debugf "Failed to resolve Field %s in stage %d. Trying other methods..."
(lib.util.log/format-field query id-or-name)
stage-number)
(resolve-ref-missing-join-alias query stage-number id-or-name))
;; if we haven't found a match yet try getting metadata from the metadata provider if this is a
;; Field ID ref. It's likely a ref that makes little or no sense (e.g. wrong table) but we can
@@ -604,7 +653,7 @@
;; expression.
(maybe-resolve-expression-in-current-stage query stage-number id-or-name)
;; if we STILL can't find a match, return made-up fallback metadata.
(fallback-metadata id-or-name))]
(fallback-metadata query id-or-name))]
(merge-metadata
col
(when col
@@ -619,7 +668,7 @@
[_tag {:keys [source-field join-alias], :as opts} id-or-name, :as #?(:clj field-ref :cljs _field-ref)] :- :mbql.clause/field]
;; this is just for easier debugging
(let [stage-number (lib.util/canonical-stage-index query stage-number)]
(log/debugf "Resolving %s in stage %s" (pr-str id-or-name) (pr-str stage-number))
(log/debugf "Resolving %s in stage %d" (lib.util.log/format-field query id-or-name) stage-number)
(-> (merge-metadata
{:lib/type :metadata/column}
(or (when join-alias
@@ -629,7 +678,7 @@
(resolve-from-previous-stage-or-source query stage-number id-or-name)
(merge
(or (fallback-metadata-for-field query stage-number id-or-name)
(fallback-metadata id-or-name))
(fallback-metadata query id-or-name))
(when (and join-alias
(contains? (into #{}
(map :alias)

View File

@@ -555,7 +555,7 @@
legacy-ref :- some?]
(let [a-ref (lib.convert/legacy-ref->pMBQL query stage-number legacy-ref)
columns (filterable-columns query stage-number)]
(lib.equality/find-matching-column a-ref columns))))
(lib.equality/find-matching-column query stage-number a-ref columns))))
(def ^:private FilterParts
[:map

View File

@@ -32,13 +32,13 @@
[metabase.util.malli.registry :as mr]
[metabase.util.time :as u.time]))
(defn- is-ref-for-column? [expr column]
(defn- is-ref-for-column? [query stage-number expr column]
(and (lib.util/clause-of-type? expr :field)
(lib.equality/find-matching-column expr [column])))
(lib.equality/find-matching-column query stage-number expr [column])))
(defn- contains-ref-for-column? [expr column]
(defn- contains-ref-for-column? [query stage-number expr column]
(letfn [(ref-for-column? [expr]
(is-ref-for-column? expr column))]
(is-ref-for-column? query stage-number expr column))]
(lib.util.match/match-lite-recursive expr
(x :guard ref-for-column?) true)))
@@ -46,10 +46,12 @@
[query :- ::lib.schema/query
stage-number :- :int
column :- ::lib.schema.metadata/column
matches? :- fn?]
matches? :- [:=>
[:cat ::lib.schema/query #_stage-number :int #_expr :any ::lib.schema.metadata/column]
:any]]
(reduce
(fn [query [_tag _opts expr :as filter-clause]]
(if (matches? expr column)
(if (matches? query stage-number expr column)
(lib.remove-replace/remove-clause query stage-number filter-clause)
query))
query
@@ -142,7 +144,7 @@
([{:keys [query]}]
query)
([{:keys [query has-seen-column?], :as m} breakout]
(if (is-ref-for-column? breakout column)
(if (is-ref-for-column? query stage-number breakout column)
(let [query' (if has-seen-column?
;; already seen a breakout for this column: remove other breakouts.
(lib.remove-replace/remove-clause query stage-number breakout)

View File

@@ -252,15 +252,12 @@
join-alias :- ::lib.schema.join/alias]
(-> col
(assoc
;; TODO (Cam 6/19/25) -- we need to get rid of `:source-alias` it's just causing confusion; don't need two
;; keys for join aliases.
:source-alias join-alias
:lib/original-join-alias join-alias
:lib/source :source/joins
::join-alias join-alias
:lib/original-name ((some-fn :lib/original-name :name) col)
:lib/original-display-name (or (:lib/original-display-name col)
(lib.metadata.calculation/display-name query stage-number (dissoc col ::join-alias :lib/original-join-alias :source-alias))))
(lib.metadata.calculation/display-name query stage-number (dissoc col ::join-alias :lib/original-join-alias))))
(set/rename-keys {:lib/expression-name :lib/original-expression-name})
(as-> $col (assoc $col :display-name (lib.metadata.calculation/display-name query stage-number $col)))))
@@ -340,7 +337,7 @@
identity))
(lib.metadata.calculation/returned-columns query stage-number join options))))
(mu/defn join-fields-to-add-to-parent-stage :- [:maybe [:sequential ::lib.metadata.calculation/column-metadata-with-source]]
(mu/defn join-fields-to-add-to-parent-stage :- [:maybe ::lib.metadata.calculation/visible-columns]
"The resolved `:fields` from a join, which we automatically append to the parent stage's `:fields`."
[query
stage-number
@@ -358,10 +355,14 @@
;; for an example of where this happens. Having it here will cause `lib.equality` to fail to
;; find a match.
;;
;; TODO (Cam 9/4/25) -- not sure if this is still true after the recent `lib.equality` rewrite.
;;
;; TODO (Cam 8/29/25) -- investigate how this is happening and consider whether Join `:fields`
;; should disallow refs with `:source-field`/remove it automatically.
:let [field-ref (lib.options/update-options field-ref dissoc :source-field)
match (or (lib.equality/find-matching-column field-ref cols)
match (or (lib.equality/find-matching-column query stage-number field-ref cols
{:find-matching-column/ignore-binning-and-bucketing? true})
(when-let [resolved (lib.metadata.calculation/metadata (-> (assoc query :stages (:stages join))
lib.stage.util/append-stage)
(with-join-alias field-ref nil))]
@@ -515,10 +516,10 @@
(u/assoc-dissoc joinable :fields fields)))
(defn- select-home-column
[home-cols cond-fields]
[query stage-number home-cols cond-fields]
(when (seq cond-fields)
(let [cond-home-cols (keep #(lib.equality/find-matching-column % home-cols) cond-fields)]
;; first choice: the leftmost FK or PK in the condition referring to a home column
(let [cond-home-cols (keep #(lib.equality/find-matching-column query stage-number % home-cols) cond-fields)]
;; first choice: the leftmost FK or PK in the condition referring to a home column
(or (m/find-first (some-fn lib.types.isa/foreign-key? lib.types.isa/primary-key?) cond-home-cols)
;; otherwise the leftmost home column in the condition
(first cond-home-cols)
@@ -587,10 +588,15 @@
(cond
;; no sides obviously belong to joined
(not (or lhs-alias rhs-alias))
(if (lib.equality/find-matching-column query stage-number rhs home-cols)
[op op-opts (with-join-alias lhs join-alias) rhs]
[op op-opts lhs (with-join-alias rhs join-alias)])
(let [potential-home-cols (cond-> #{}
(lib.equality/find-matching-column query stage-number lhs home-cols) (conj :lhs)
(lib.equality/find-matching-column query stage-number rhs home-cols) (conj :rhs))]
;; if there's still any ambiguity as to whether LHS or RHS is the home column, assume LHS. Only use RHS if
;; it's a potential home column and LHS is not.
(case potential-home-cols
(#{:lhs :rhs} #{:lhs} #{}) [op op-opts lhs (with-join-alias rhs join-alias)]
#{:rhs} [op op-opts (with-join-alias lhs join-alias) rhs]))
;; both sides seem to belong to joined assuming this resulted from
;; overly fuzzy matching, we remove the join alias from the LHS
;; unless the RHS seems to belong to home too while the LHS doesn't
@@ -620,7 +626,7 @@
home-cols (lib.metadata.calculation/visible-columns query stage-number)]
(default-alias query stage-number a-join stage home-cols)))
([query _stage-number a-join stage home-cols]
([query stage-number a-join stage home-cols]
(let [home-cols home-cols
cond-fields (into []
(mapcat (fn [condition]
@@ -629,7 +635,7 @@
(when (and (lib.util/field-clause? lhs) (lib.util/field-clause? rhs))
[lhs rhs]))))
(:conditions a-join))
home-col (select-home-column home-cols cond-fields)]
home-col (select-home-column query stage-number home-cols cond-fields)]
(-> (calculate-join-alias query a-join home-col)
(generate-unique-name (keep :alias (:joins stage)))))))
@@ -819,7 +825,9 @@
columns)]
(concat pk fk other)))
(defn- mark-selected-column [query stage-number existing-column-or-nil columns]
(defn- mark-selected-column
"Mark selected columns in join conditions."
[query stage-number existing-column-or-nil columns]
(if-not existing-column-or-nil
columns
(mapv (fn [column]
@@ -879,8 +887,7 @@
(contains? join-aliases-to-ignore col-join-alias))))
(mark-selected-column query
stage-number
(when (or (lib.util/clause-of-type? lhs-expression-or-nil :field)
(lib.util/clause-of-type? lhs-expression-or-nil :expression))
(when (lib.util/clause-of-type? lhs-expression-or-nil #{:field :expression})
lhs-expression-or-nil))
sort-join-condition-columns))))
@@ -1035,18 +1042,16 @@
(let [join-alias (lib.join.util/current-join-alias a-join)]
(map (fn [col]
(-> col
(with-join-alias join-alias)
;; TODO (Cam 6/25/25) -- remove `:source-alias` since it is busted
(assoc :source-alias join-alias))))))
(with-join-alias join-alias))))))
(defn- xform-mark-selected-joinable-columns
"Mark the column metadatas in `cols` as `:selected` if they appear in `a-join`'s `:fields`."
[a-join]
[query stage-number a-join]
(let [j-fields (join-fields a-join)]
(case j-fields
:all (map #(assoc % :selected? true))
(:none nil) (map #(assoc % :selected? false))
(mapcat #(lib.equality/mark-selected-columns [%] j-fields)))))
(mapcat #(lib.equality/mark-selected-columns query stage-number [%] j-fields)))))
(def ^:private xform-fix-source-for-joinable-columns
(map #(assoc % :lib/source :source/joins)))
@@ -1070,7 +1075,7 @@
(if a-join
(comp xform-fix-source-for-joinable-columns
(xform-add-join-alias a-join)
(xform-mark-selected-joinable-columns a-join))
(xform-mark-selected-joinable-columns query stage-number a-join))
identity)
cols)))

View File

@@ -901,8 +901,8 @@
Returns the same list of `agg-operators` with those adjustments made.
> **Code health:** Healthy"
[agg-operators agg-clause]
(to-array (lib.core/selected-aggregation-operators (seq agg-operators) agg-clause)))
[a-query stage-number agg-operators agg-clause]
(to-array (lib.core/selected-aggregation-operators a-query stage-number (seq agg-operators) agg-clause)))
;; # Filtering
;; Filters work in a similar way to aggregations and order-by, but are more complex since they can have several

View File

@@ -508,8 +508,11 @@
;;; if you pass in an integer assume it's a stage number; use the method for the query stage itself.
(defmethod returned-columns-method :dispatch-type/integer
[query _stage-number stage-number options]
(returned-columns-method query stage-number (lib.util/query-stage query stage-number) options))
[query stage-number x-stage-number options]
(assert (= (lib.util/canonical-stage-index query stage-number)
(lib.util/canonical-stage-index query x-stage-number))
(lib.util/format "Call to returned-columns with different stage numbers, %d and %d" stage-number x-stage-number))
(returned-columns-method query x-stage-number (lib.util/query-stage query x-stage-number) options))
(mu/defn returned-columns :- [:maybe ::returned-columns]
"Return a sequence of metadata maps for all the columns expected to be 'returned' at a query, stage of the query, or
@@ -534,7 +537,7 @@
(returned-columns query (lib.util/query-stage query -1)))
([query x]
(returned-columns query -1 x))
(returned-columns query (if (int? x) x -1) x))
([query stage-number x]
(returned-columns query stage-number x nil))
@@ -637,9 +640,10 @@
"Given a seq of columns, return metadata for any remapped columns, if the `:include-remaps?` option is set."
[query :- ::lib.schema/query
stage-number :- :int
source-cols :- [:maybe [:sequential ::lib.schema.metadata/column]]
source-cols :- [:maybe [:sequential ::column-metadata-with-source]]
{:keys [include-remaps?] :as _options} :- [:maybe ::returned-columns.options]]
(when (and include-remaps?
(not (get-in query [:middleware :disable-remaps?]))
(lib.util/first-stage? query stage-number))
(let [existing-ids (into #{} (keep :id) source-cols)]
(for [column source-cols
@@ -672,9 +676,8 @@
Does not include columns from any Tables that are already explicitly joined.
Does not include columns that would be implicitly joinable via multiple hops."
[query :- ::lib.schema/query
stage-number :- :int
cols :- [:sequential ::lib.schema.metadata/column]]
[query :- ::lib.schema/query
cols :- [:sequential ::lib.schema.metadata/column]]
(let [remap-target-ids (into #{} (keep (comp :field-id :lib/external-remap)) cols)
existing-table-ids (into #{} (comp (remove (comp remap-target-ids :id))
(map :table-id))
@@ -697,7 +700,7 @@
(into []
(mapcat (fn [{:keys [table-id], ::keys [fk-field-id fk-field-name fk-join-alias]}]
(let [table (id->table table-id)]
(for [field (returned-columns query stage-number table)]
(for [field (returned-columns query table)]
(m/assoc-some field
:fk-field-id fk-field-id
:fk-field-name fk-field-name

View File

@@ -17,7 +17,6 @@
[metabase.lib.aggregation :as lib.aggregation]
[metabase.lib.card :as lib.card]
[metabase.lib.convert :as lib.convert]
[metabase.lib.expression :as lib.expression]
[metabase.lib.field.util :as lib.field.util]
[metabase.lib.join :as lib.join]
[metabase.lib.join.util :as lib.join.util]
@@ -37,7 +36,7 @@
(mr/def ::col
;; TODO (Cam 6/19/25) -- I think we should actually namespace all the keys added here (to make it clear where they
;; came from) and then have the `annotate` middleware convert them to something else for QP results purposes. Then
;; we can 'ban' stuff like `:source-alias` and `:source` within Lib itself. See #59772 for some experimental work
;; we can 'ban' stuff like`:source` within Lib itself. See #59772 for some experimental work
;; there.
[:map
[:source {:optional true} ::lib.schema.metadata/column.legacy-source]
@@ -142,50 +141,8 @@
:base-type base-type
:effective-type base-type}))
;;; TODO (Cam 6/17/25) -- move this into [[metabase.lib.expression]] or something and have it be part of the mainline
;;; methods for metadata calculation
(mu/defn- add-converted-timezone :- [:sequential ::kebab-cased-map]
"Add `:converted-timezone` to columns that are from `:convert-timezone` expressions (or expressions wrapping them) --
this is used by [[metabase.query-processor.middleware.format-rows/format-rows-xform]]."
[query :- ::lib.schema/query
cols :- [:sequential ::kebab-cased-map]]
(mapv (fn [col]
(let [converted-timezone (when-let [expression-name ((some-fn :lib/expression-name :lib/original-expression-name) col)]
;; don't look inside expressions that happened inside of joins,
;; `lib.expression/resolve-expression` is unfortunately not smart enough to
;; figure out how to find them and will just log a noisy error
(when-not (:lib/original-join-alias col)
(when-let [expr (try
(lib.expression/resolve-expression query expression-name)
(catch #?(:clj Throwable :cljs :default) e
(log/error e "Column metadata has invalid :lib/expression-name (this was probably incorrectly propagated from a previous stage) (QUE-1342)")
(log/debugf "In query:\n%s" (u/pprint-to-str query))
nil))]
(lib.util.match/match-one expr
:convert-timezone
(let [[_convert-timezone _opts _expr source-tz] &match]
source-tz)))))]
(cond-> col
converted-timezone (assoc :converted-timezone converted-timezone))))
cols))
(defn- any-join-alias [col]
((some-fn lib.join.util/current-join-alias :source-alias :lib/original-join-alias) col))
(mu/defn- add-source-alias :- [:sequential ::kebab-cased-map]
"`:source-alias` (`:source_alias`) is still needed
for [[metabase.query-processor.middleware.remove-inactive-field-refs]]
and [[metabase.lib.equality/column-join-alias]] to work correctly. Why? Not 100% sure -- we should theoretically be
able to use `:metabase.lib.join/join-alias` for this purpose -- but that doesn't seem to work. Until I figure that
out, include the `:source-alias` key.
Note that this is no longer used on the FE -- see QUE-1355"
[cols :- [:sequential ::kebab-cased-map]]
(for [col cols]
(merge
(when-let [join-alias (any-join-alias col)]
{:source-alias join-alias})
col)))
((some-fn lib.join.util/current-join-alias :lib/original-join-alias) col))
(defn- implicit-join-aliases [query stage-number]
(let [aliases (into #{}
@@ -208,7 +165,7 @@
(= (:lib/source col) :source/joins)
(assoc :lib/source :source/implicitly-joinable)))
remove-aliases (fn [col]
(dissoc col :metabase.lib.join/join-alias :lib/original-join-alias :source-alias))
(dissoc col :metabase.lib.join/join-alias :lib/original-join-alias))
implicitly-joined? (fn [col]
(when-let [join-alias (any-join-alias col)]
(contains? implicit-aliases join-alias)))
@@ -391,9 +348,7 @@
((fn [cols]
(cond-> cols
(seq lib-cols) (merge-cols lib-cols))))
(add-converted-timezone query)
(remove-implicit-join-aliases query)
add-source-alias
add-legacy-source
deduplicate-names
(add-legacy-field-refs query)

View File

@@ -162,10 +162,12 @@
([database-id :- ::lib.schema.id/database
metadata-providerable :- ::lib.schema.metadata/metadata-providerable
stages]
{:lib/type :mbql/query
:lib/metadata (lib.metadata/->metadata-provider metadata-providerable)
:database database-id
:stages stages}))
(lib.normalize/normalize
::lib.schema/query
{:lib/type :mbql/query
:lib/metadata (lib.metadata/->metadata-provider metadata-providerable)
:database database-id
:stages stages})))
(defn- query-from-legacy-query
[metadata-providerable legacy-query]

View File

@@ -91,7 +91,7 @@
#(lib.util.match/replace
%
:field
(let [old-matching-column (lib.equality/find-matching-column &match old-columns)]
(let [old-matching-column (lib.equality/find-matching-column query-original stage-number &match old-columns)]
(if-let [new-column (some-> old-matching-column :lib/source-uuid source-uuid->new-column)]
(assoc &match 2 ((some-fn :lib/source-column-alias :name) new-column))
(do

View File

@@ -45,7 +45,9 @@
(mr/def ::stage.common
[:map
[:parameters {:optional true} [:ref ::lib.schema.parameter/parameters]]])
[:parameters {:optional true} [:ref ::lib.schema.parameter/parameters]]
;; TODO (Cam 9/4/25) -- make this non-`:maybe` and have normalization code remove it if it's `nil`
[:lib/stage-metadata {:optional true} [:maybe [:ref ::lib.schema.metadata/stage]]]])
(mr/def ::stage.native
[:and
@@ -59,7 +61,6 @@
(not (and (= k :collection)
(nil? v))))))}
[:lib/type [:= {:decode/normalize common/normalize-keyword} :mbql.stage/native]]
[:lib/stage-metadata {:optional true} [:maybe [:ref ::lib.schema.metadata/stage]]]
;; the actual native query, depends on the underlying database. Could be a raw SQL string or something like that.
;; Only restriction is that, if present, it is non-nil.
;; It is valid to have a blank query like `{:type :native}` in legacy.
@@ -203,9 +204,8 @@
[:merge
::stage.common
[:map
{:decode/normalize normalize-mbql-stage}
{:decode/normalize #'normalize-mbql-stage}
[:lib/type [:= {:decode/normalize common/normalize-keyword} :mbql.stage/mbql]]
[:lib/stage-metadata {:optional true} [:maybe [:ref ::lib.schema.metadata/stage]]]
[:joins {:optional true} [:ref ::join/joins]]
[:expressions {:optional true} [:ref ::expression/expressions]]
[:breakout {:optional true} [:ref ::breakouts]]
@@ -260,7 +260,7 @@
(mr/def ::stage
[:and
{:default {:lib/type :mbql.stage/mbql}
:decode/normalize normalize-stage
:decode/normalize #'normalize-stage
:encode/serialize #(dissoc %
;; this stuff is all added at runtime by QP middleware.
:params
@@ -369,7 +369,7 @@
(mr/def ::stages
[:and
[:sequential {:min 1
:decode/normalize normalize-stages
:decode/normalize #'normalize-stages
:default []}
[:ref ::stage]]
[:cat
@@ -393,7 +393,7 @@
[:and
[:map
{:decode/normalize common/normalize-map
:encode/serialize serialize-query}
:encode/serialize #'serialize-query}
[:lib/type [:=
{:decode/normalize common/normalize-keyword, :default :mbql/query}
:mbql/query]]

View File

@@ -154,7 +154,7 @@
;;; only support fixing really broken types like `:type/creationtime` to `:type/CreationTime` in prod... Malli checks
;;; will throw in dev. See [[metabase.lib.schema.common-test/normalize-base-type-test]] for more info
(mu/defn- normalize-base-type* :- [:ref ::base-type]
(mu/defn- normalize-base-type* :- [:maybe [:ref ::base-type]]
[x]
(normalize-keyword x))

View File

@@ -1,5 +1,6 @@
(ns metabase.lib.schema.metadata
(:require
[clojure.set :as set]
[clojure.string :as str]
[medley.core :as m]
[metabase.lib.schema.binning :as lib.schema.binning]
@@ -190,7 +191,14 @@
(and (:id m) (not (pos-int? (:id m))))
(dissoc :id)))
;; remove deprecated `:ident` and `:model/inner_ident` keys (normalized to `:model/inner-ident`)
(dissoc :ident :model/inner-ident))))
(dissoc :ident :model/inner-ident)
;; rename source-alias to the appropriate key
(as-> $col (cond-> $col
(:source-alias $col) (set/rename-keys {:source-alias (case (:lib/source $col)
(nil :source/joins :source/implicitly-joinable)
:metabase.lib.join/join-alias
#_else
:lib/original-join-alias)}))))))
(def ^:private column-validate-for-source-specs
"Schemas to use to validate columns with a given `:lib/source`. Since a lot of these schemas are applicable to
@@ -206,6 +214,12 @@
:schema [:fn
{:error/message ":lib/expression-name is required for columns with :source/expressions"}
:lib/expression-name]}
;; TODO (Cam 9/4/25) -- you would think a requirement like this would be obvious and easy to enable but it actually
;; breaks a handful of tests... I still want to enable it at some point but we need to go fix up all those tests.
#_{:include :source/table-defaults
:schema [:fn
{:error/message ":table-id is required for columns with :source/table-defaults"}
:table-id]}
;; Current stage join alias (`:metabase.lib.join/join-alias`) should only be set for columns whose `:lib/source` is
;; `:source/joins`
{:exclude :source/joins
@@ -318,7 +332,7 @@
[:and
[:map
{:error/message "Valid column metadata"
:decode/normalize normalize-column}
:decode/normalize #'normalize-column}
[:lib/type [:= {:decode/normalize lib.schema.common/normalize-keyword, :default :metadata/column} :metadata/column]]
;;
;; TODO (Cam 6/19/25) -- change all these comments to proper `:description`s like we have
@@ -356,18 +370,6 @@
;; an foreign key, and points to this Field ID. This is mostly used to determine how to add implicit joins by
;; the [[metabase.query-processor.middleware.add-implicit-joins]] middleware.
[:fk-target-field-id {:optional true} [:maybe ::lib.schema.id/field]]
;;
;; Join alias of the table we're joining against, if any. Not really 100% clear why we would need this on top
;; of [[metabase.lib.join/current-join-alias]], which stores the same info under a namespaced key. I think we can
;; remove it.
;;
;; TODO (Cam 6/19/25) -- yes, we should remove this key, I've tried to do so but a few places are still
;; setting (AND USING!) it. It actually appears that this gets propagated beyond the current stage where the join
;; has happened and has thus taken on a purposes as a 'previous stage join alias' column. We should use
;; `:lib/original-join-alias` instead to serve this purpose since `:source-alias` is not set or used correctly.
;; Check out experimental https://github.com/metabase/metabase/pull/59772 where I updated this schema to 'ban'
;; this key so we can root out anywhere trying to use it. (QUE-1403)
[:source-alias {:optional true} [:maybe ::lib.schema.common/non-blank-string]]
;; Join alias of the table we're joining against, if any. SHOULD ONLY BE SET IF THE JOIN HAPPENED AT THIS STAGE OF
;; THE QUERY! (Also ok within a join's conditions for previous joins within the parent stage, because a join is
;; allowed to join on the results of something else)
@@ -515,7 +517,9 @@
;;
;; TODO (Cam 6/13/25) -- go add this to some of the other metadata schemas as well.
::kebab-cased-map
[:ref ::column.validate-for-source]])
[:ref ::column.validate-for-source]
(lib.schema.common/disallowed-keys
{:source-alias ":source-alias is deprecated, set :metabase.lib.join/current-join-alias or :lib/original-join-alias instead"})])
(mr/def ::persisted-info.definition
"Definition spec for a cached table."

View File

@@ -263,6 +263,9 @@
(nil? l) (assoc :type :number/<=, :value [u])))
param))))
(mr/def ::id
::lib.schema.common/non-blank-string)
(mr/def ::parameter
"Schema for the *value* of a parameter (e.g. a Dashboard parameter or a native query template tag) as passed in as
part of the `:parameters` list in a query."
@@ -272,7 +275,7 @@
[:type [:ref ::type]]
;; TODO -- these definitely SHOULD NOT be optional but a ton of tests aren't passing them in like they should be.
;; At some point we need to go fix those tests and then make these keys required
[:id {:optional true} ::lib.schema.common/non-blank-string]
[:id {:optional true} [:ref ::id]]
[:target {:optional true} [:ref ::target]]
;; not specified if the param has no value. TODO - make this stricter; type of `:value` should be validated based
;; on the [[ParameterType]]

View File

@@ -115,7 +115,7 @@
(lib.hierarchy/derive :expression ::ref)
(mr/def ::aggregation-options
(mr/def ::aggregation.options
[:merge
::common/options
[:map
@@ -126,7 +126,7 @@
(mbql-clause/define-mbql-clause :aggregation
[:tuple
[:= {:decode/normalize common/normalize-keyword} :aggregation]
::aggregation-options
::aggregation.options
:string])
(defmethod expression/type-of-method :aggregation

View File

@@ -44,25 +44,20 @@
(mu/defn- existing-stage-metadata :- [:maybe ::lib.metadata.calculation/returned-columns]
"Return existing stage metadata attached to a stage if is already present: return it as-is, but only if this is a
native stage or a source-Card or a metric stage. If it's any other sort of stage then ignore the metadata, it's
probably wrong; we can recalculate the correct metadata anyway."
native stage. If it's any other sort of stage then ignore the metadata, it's probably wrong; we can recalculate the
correct metadata anyway."
[query :- ::lib.schema/query
stage-number :- :int]
(let [{stage-type :lib/type, :keys [source-card] :as stage} (lib.util/query-stage query stage-number)]
(when-let [metadata (:lib/stage-metadata stage)]
(when (or (= stage-type :mbql.stage/native)
source-card)
(let [source-type (case stage-type
:mbql.stage/native :source/native
:mbql.stage/mbql :source/card)]
(not-empty
(into []
(comp (map #(assoc % :lib/source source-type))
;; do not truncate the desired column aliases coming back from a native query, because if a
;; native query returns a 'crazy long' column name then we need to use that in the next stage.
;; See [[metabase.lib.stage-test/propagate-crazy-long-native-identifiers-test]]
(lib.field.util/add-source-and-desired-aliases-xform query (lib.util/non-truncating-unique-name-generator)))
(:columns metadata))))))))
(let [{stage-type :lib/type, :as stage} (lib.util/query-stage query stage-number)]
(when (= stage-type :mbql.stage/native)
(when-let [cols (not-empty (get-in stage [:lib/stage-metadata :columns]))]
(into []
(comp (map #(assoc % :lib/source :source/native))
;; do not truncate the desired column aliases coming back from a native query, because if a
;; native query returns a 'crazy long' column name then we need to use that in the next stage.
;; See [[metabase.lib.stage-test/propagate-crazy-long-native-identifiers-test]]
(lib.field.util/add-source-and-desired-aliases-xform query (lib.util/non-truncating-unique-name-generator)))
cols)))))
(mu/defn- breakouts-columns :- [:maybe ::lib.metadata.calculation/visible-columns]
[query :- ::lib.schema/query
@@ -261,7 +256,7 @@
(into (vec existing-columns)
;; add implicitly joinable columns if desired
(when include-implicitly-joinable?
(lib.metadata.calculation/implicitly-joinable-columns query stage-number existing-columns)))))
(lib.metadata.calculation/implicitly-joinable-columns query existing-columns)))))
(defn- add-cols-from-join
"The columns from `:fields` may contain columns from `:joins` -- so if the joins specify their own `:fields` we need

View File

@@ -10,11 +10,10 @@
[metabase.lib.field.util :as lib.field.util]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.calculation :as lib.metadata.calculation]
[metabase.lib.ref :as lib.ref]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.util :as lib.util]
[metabase.util.malli :as mu]))
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(mu/defn- pop-until-aggregation-or-breakout :- [:tuple [:maybe ::lib.schema/query] [:int {:max -1}]]
"Strips off any trailing stages that do not contain aggregations or breakouts.
@@ -56,7 +55,7 @@
(second (pop-until-aggregation-or-breakout query)))
-1))
(def ^:private TopLevelColumnOptions
(mr/def ::top-level-column.options
[:map
{:closed true}
[:rename-superflous-options? {:optional true} :boolean]])
@@ -71,12 +70,16 @@
([query :- ::lib.schema/query
column :- ::lib.schema.metadata/column]
(top-level-column query column :rename-superflous-options? true))
([query :- ::lib.schema/query
column :- ::lib.schema.metadata/column
& {:keys [rename-superflous-options?]} :- TopLevelColumnOptions]
& {:keys [rename-superflous-options?]} :- [:maybe ::top-level-column.options]]
(when (> (count (:stages query)) 1)
(assert (not= (:lib/source column) :source/table-defaults)
"Column metadata does not appear to be for the last stage... what stage is it from?"))
(let [top-query (top-level-query query)]
(if (= query top-query)
column ;; Unchanged if this is already a top-level query. That includes keeping the "superfluous" options!
column ; Unchanged if this is already a top-level query. That includes keeping the "superfluous" options!
(loop [query query
column column]
(if (= query top-query)
@@ -86,11 +89,11 @@
;; circumstances, you will not need them. On the off chance you do need them, they'll still be available.
(set/rename-keys {::lib.field/temporal-unit ::temporal-unit
::lib.field/binning ::binning}))
(let [prev-cols (for [col (lib.metadata.calculation/returned-columns query -2 (lib.util/previous-stage query -1))]
(let [prev-cols (for [col (lib.metadata.calculation/returned-columns query -2)]
(-> col
lib.field.util/update-keys-for-col-from-previous-stage
(assoc ::original col)))
prev-col (some-> (lib.equality/find-matching-column query -2 (lib.ref/ref column) prev-cols)
prev-col (some-> (lib.equality/find-matching-column query -2 column prev-cols)
::original)]
(when prev-col
(recur (update query :stages pop) prev-col)))))))))

View File

@@ -0,0 +1,37 @@
(ns metabase.lib.util.log
"EXPERIMENTAL helper functions for logging stuff. Very important when I spend all day
debugging [[metabase.lib.field.resolution]] -- Cam"
(:require
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.util :as lib.util]))
(defn- table-name
[metadata-providerable x]
(cond
(= (:lib/type x) :metadata/table) (:name x)
(pos-int? x) (recur metadata-providerable (lib.metadata/table metadata-providerable x))))
(defn format-table
"Render a Table metadata or ID in a nice way e.g.
Table 100 \"ORDERS\""
[metadata-providerable x]
(cond
(= (:lib/type x) :metadata/table) (lib.util/format "Table %d %s" (:id x) (pr-str (:name x)))
(pos-int? x) (recur metadata-providerable (lib.metadata/table metadata-providerable x))
:else (lib.util/format "Table %s" (pr-str x))))
(defn format-field
"Render a Column metadata or Field ID in a nice way e.g.
Field 200 \"ORDERS\".\"CREATED_AT\""
[metadata-providerable x]
(cond
(= (:lib/type x) :metadata/column) (str "Field "
(when (:id x)
(str (:id x) " "))
(when (:table-id x)
(str (pr-str (table-name metadata-providerable (:table-id x))) "."))
(pr-str ((some-fn :lib/source-column-alias :name) x)))
(pos-int? x) (recur metadata-providerable (lib.metadata/field metadata-providerable x))
:else (lib.util/format "Field %s" (pr-str x))))

View File

@@ -8,11 +8,12 @@
(:require
[clojure.string :as str]
[medley.core :as m]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.models.interface :as mi]
[metabase.parameters.params :as params]
[metabase.parameters.schema :as parameters.schema]
[metabase.query-processor :as qp]
[metabase.query-processor.util :as qp.util]
@@ -59,20 +60,31 @@
Maybe we should lower it for the sake of displaying a parameter dropdown."
1000)
(defn- values-from-card-query
[card value-field-ref {:keys [query-string] :as _opts}]
(let [metadata-provider (lib-be.metadata.jvm/application-database-metadata-provider (:database_id card))
query (lib/query metadata-provider (lib.metadata/card metadata-provider (:id card)))
value-column (lib/find-column-for-legacy-ref query value-field-ref (lib/visible-columns query))
textual? (lib.types.isa/string? value-column)
nonempty ((if textual? lib/not-empty lib/not-null) value-column)
query-filter (when query-string
(if textual?
(lib/contains (lib/lower value-column) (u/lower-case-en query-string))
(lib/= value-column query-string)))]
(mu/defn- values-from-card-query :- ::lib.schema/query
[card :- [:map
[:id ::lib.schema.id/card]
[:database_id ::lib.schema.id/database]]
value-field-ref
{:keys [query-string] :as _opts}]
;; query will use `:source-card` if Card is Model or Metric but will use the Card's query directly if it is a
(let [query (params/card->query card)
value-column (-> (or (lib/find-column-for-legacy-ref query value-field-ref (lib/returned-columns query))
(throw (ex-info (format "Failed to find column for legacy ref %s" (pr-str value-field-ref))
{:query query, :ref value-field-ref, :cols (lib/returned-columns query)})))
;; since the query we're building will add a breakout and filters using this column to a
;; subsequent stage, to "do this the right way" we should update this metadata so it is
;; ready for use in subsequent stages.
lib/update-keys-for-col-from-previous-stage)
textual? (lib.types.isa/string? value-column)
nonempty-filter ((if textual? lib/not-empty lib/not-null) value-column)
query-filter (when query-string
(if textual?
(lib/contains (lib/lower value-column) (u/lower-case-en query-string))
(lib/= value-column query-string)))]
(-> query
lib/append-stage
(lib/limit *max-rows*)
(lib/filter nonempty)
(lib/filter nonempty-filter)
(cond-> #_query query-filter (lib/filter query-filter))
(lib/breakout value-column)
;; TODO(Braden, 07/04/2025): This should probably become a lib helper? I suspect this isn't the only

View File

@@ -6,9 +6,9 @@
There are 3 mains ways to provide values to a parameter:
- chain-filter: see [metabase.parameters.chain-filter]
- field-values: see [metabase.parameters.field-values]
- custom-values: see [metabase.parameters.custom-values]"
- chain-filter: see [[metabase.parameters.chain-filter]]
- field-values: see [[metabase.parameters.field-values]]
- custom-values: see [[metabase.parameters.custom-values]]"
(:require
[clojure.set :as set]
[medley.core :as m]
@@ -19,8 +19,12 @@
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.common :as lib.schema.common]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.schema.parameter :as lib.schema.parameter]
[metabase.lib.util :as lib.util]
[metabase.lib.util.match :as lib.util.match]
[metabase.models.interface :as mi]
[metabase.parameters.schema :as parameters.schema]
@@ -176,38 +180,78 @@
;;; | DASHBOARD-SPECIFIC |
;;; +----------------------------------------------------------------------------------------------------------------+
(defn- filterable-columns-for-query
(mr/def ::context
[:map
[:param-id->field-ids [:map-of
::lib.schema.parameter/id
[:set ::lib.schema.id/field]]]
[:card-id->filterable-columns [:map-of
::lib.schema.id/card
[:map-of
#_stage-number :int
[:sequential ::lib.schema.metadata/column]]]]
[:card-id->query {:optional true} [:map-of ::lib.schema.id/card ::lib.schema/query]]])
(mu/defn card->query :- ::lib.schema/query
"Given a `card` (from the app DB) return a MBQL 5 query for parameter purposes.
This works slightly differently if the Card is a regular saved `:question` versus a `:model` or `:metric`."
[{database-id :database_id, :as card} :- [:map
[:id ::lib.schema.id/card]
[:database_id ::lib.schema.id/database]
[:type ::lib.schema.metadata/card.type]
[:dataset_query :map]]]
(let [mp (lib.metadata.jvm/application-database-metadata-provider database-id)]
;; Regular questions are used directly. If a model or metric has been used directly in this card, wrap it into
;; a query against that model or metric.
(if (= :question (:type card))
(let [{card-query :dataset_query, result-metadata :result_metadata} card]
(cond-> (lib/query mp card-query)
result-metadata (lib/update-query-stage -1 (fn [stage]
(->> (assoc stage :lib/stage-metadata (lib.util/->stage-metadata result-metadata))
(lib/normalize ::lib.schema/stage))))))
(lib/query mp (lib.metadata/card mp (u/the-id card))))))
(mu/defn- ensure-query-for-card :- ::context
[ctx :- ::context
{card-id :id, :as card} :- [:map
[:id ::lib.schema.id/card]]]
(cond-> ctx
(not (get-in ctx [:card-id->query card-id])) (assoc-in [:card-id->query card-id] (card->query card))))
(mu/defn- filterable-columns-for-card-query :- [:maybe [:sequential ::lib.schema.metadata/column]]
"Get filterable columns for query."
[database-id card stage-number]
(let [metadata-provider (lib.metadata.jvm/application-database-metadata-provider database-id)
;; Regular questions are used directly. If a model or metric has been used directly in this card, wrap it into
;; a query against that model or metric.
query (lib/query metadata-provider (if (= :question (:type card))
(:dataset_query card)
(lib.metadata/card metadata-provider (:id card))))
[ctx :- ::context
card :- [:map
[:id ::lib.schema.id/card]]
stage-number :- :int]
(let [query (get-in ctx [:card-id->query (:id card)])
_ (assert query "Missing card-id->query for Card")
;; for backward compatibility, append a filter stage only with explicit stage numbers
query (cond-> query (>= stage-number 0) lib/ensure-filter-stage)]
(when (and (>= stage-number -1) (< stage-number (lib/stage-count query)))
(lib/filterable-columns query stage-number))))
(defn- ensure-filterable-columns-for-card
[ctx
(mu/defn- ensure-filterable-columns-for-card :- ::context
[ctx :- ::context
{database-id :database_id
dataset-query :dataset_query
card-id :id
:as card}
stage-number]
:as card} :- :map
stage-number :- :int]
(cond-> ctx
(and (not (get-in ctx [:card-id->filterable-columns card-id stage-number]))
(seq dataset-query)
(pos-int? database-id))
(assoc-in [:card-id->filterable-columns card-id stage-number]
(or (filterable-columns-for-query database-id card stage-number) []))))
(or (filterable-columns-for-card-query ctx card stage-number) []))))
(defn- field-id-from-dashcards-filterable-columns
(mu/defn- field-id-from-dashcards-filterable-columns
"Update the `ctx` with `field-id`. This function is supposed to be used on params where target is a name field, in
reducing step of [[field-id-into-context-rf]], when it is certain that param target is no integer id field."
[ctx param-dashcard-info stage-number]
[ctx :- ::context
param-dashcard-info
stage-number :- :int]
(let [param-id (get-in param-dashcard-info [:param-mapping :parameter_id])
param-target (get-in param-dashcard-info [:param-mapping :target])
card-id (or (get-in param-dashcard-info [:param-mapping :card_id])
@@ -215,13 +259,31 @@
filterable-columns (get-in ctx [:card-id->filterable-columns card-id stage-number])
[_ dimension] (->> (mbql.normalize/normalize-tokens param-target :ignore-path)
(mbql.u/check-clause :dimension))]
(if-some [field-id (lib.util.match/match-one dimension
;; TODO it's basically a workaround for ignoring non-dimension parameter targets such as SQL variables
;; TODO code is misleading; let's check for :dimension and drop the match call here
[:field (field-name :guard string?) _]
(->> filterable-columns
(lib/find-matching-column (lib/->pMBQL dimension))
:id))]
(let [mbql5-dimension (lib/->pMBQL dimension)
query (get-in ctx [:card-id->query card-id])]
(assert query "Missing card-id->query for Card")
;; apparently we allow parameters for stages that don't exist yet, but just one additional
;; stage for so-called "post-aggregation" parameters. but we only allow parameters for
;; stage N+1, and no imaginary stages beyond that.
(-> (cond
(> stage-number (count (:stages query)))
(do
(log/warnf "Ignoring parameter %s with stage number %d: Card query doesn't have that many stages"
(pr-str dimension)
stage-number)
nil)
(= stage-number (count (:stages query)))
(lib/find-matching-column (lib/append-stage query) stage-number mbql5-dimension filterable-columns)
:else
(lib/find-matching-column query stage-number mbql5-dimension filterable-columns))
:id)))]
(-> ctx
(update :param-id->field-ids #(merge {param-id #{}} %))
(update-in [:param-id->field-ids param-id] conj field-id))
@@ -278,6 +340,7 @@
;; aggregates a native query model with a field that was mapped to a db field), we need to load metadata in
;; [[ensure-filterable-columns-for-card]] to find the originating field. (#42829)
(-> ctx
(ensure-query-for-card card)
(ensure-filterable-columns-for-card card stage-number)
(field-id-from-dashcards-filterable-columns param-dashcard-info stage-number)))))))
@@ -289,9 +352,10 @@
{:dashcard dashcard
:param-mapping mapping
:param-target-field (param-target->field-clause (:target mapping) (:card dashcard))}))]
(transduce (mapcat dashcard->param-dashcard-info)
field-id-into-context-rf
dashcards)))
(lib.metadata.jvm/with-metadata-provider-cache
(transduce (mapcat dashcard->param-dashcard-info)
field-id-into-context-rf
dashcards))))
(declare card->template-tag-param-id->field-ids)

View File

@@ -18,6 +18,8 @@
[metabase.util.i18n :refer [tru]]
[metabase.util.malli :as mu]))
;;; TODO (Cam 9/5/25) -- why do we this AND [[metabase.lib.query/query-from-legacy-inner-query]]? Can't we just use
;;; that everywhere?
(mu/defn legacy-inner-query->mlv2-query :- ::lib.schema/query
"Convert a legacy `inner-query` to an MLv2 query. Requires bound QP store."
[inner-query :- [:and

View File

@@ -143,7 +143,7 @@
;; this stage (expression/aggregation -> field, field-id to string)
(let [replacement (lib.util.match/replace replacement
[(tag :guard #{:expression :field :aggregation}) _ _]
(if-let [col (lib/find-matching-column &match columns)]
(if-let [col (lib/find-matching-column query stage-number &match columns)]
(lib/ref col)
;; This is probably due to a field-id where it shouldn't be
&match))]

View File

@@ -54,12 +54,14 @@
nil)))
(mu/defn- update-second-stage-refs :- ::lib.schema/stage
[stage :- ::lib.schema/stage
[query
stage-number
stage :- ::lib.schema/stage
first-stage-cols :- [:sequential ::lib.schema.metadata/column]]
(lib.util.match/replace stage
#{:field :expression}
(if-let [col (when-not (some #{:expressions} &parents)
(lib.equality/find-matching-column &match first-stage-cols))]
(lib.equality/find-matching-column query stage-number &match first-stage-cols))]
(-> col
lib/update-keys-for-col-from-previous-stage
update-temporal-bucket
@@ -157,7 +159,9 @@
first-stage-cols (lib.walk/apply-f-for-stage-at-path lib/returned-columns query path)]
(-> stage
(dissoc :joins :source-table :source-card :lib/stage-metadata :filters)
(update-second-stage-refs first-stage-cols)
(as-> $stage (lib.walk/apply-f-for-stage-at-path (fn [query stage-number]
(update-second-stage-refs query stage-number $stage first-stage-cols))
query path))
(dissoc :expressions)
add-implicit-breakout-order-bys)))

View File

@@ -3049,11 +3049,13 @@
:dataset_query (mt/mbql-query products)}
:model/Card card2 {:database_id (mt/id)
:table_id (mt/id :venues)
:dataset_query (-> (lib/query mp (lib.metadata/card mp (:id model)))
lib.convert/->legacy-MBQL)}
:dataset_query (lib/query mp (lib.metadata/card mp (:id model)))}
:model/DashboardCard dashcard {:card_id (:id card)
:dashboard_id (:id dashboard)
:parameter_mappings [{:parameter_id "_CATEGORY_"
;; Note this Card ID is different from the one for the
;; DashCard itself... this parameter is meant for the
;; series
:card_id (:id card2)
:target [:dimension [:field "CATEGORY" {:base-type :type/Text}] {:stage-number 0}]}]}
:model/DashboardCardSeries _ {:card_id (:id card2)

View File

@@ -497,10 +497,10 @@
{:short :max
:requires-column? true
:columns scope-cols}]
(lib/selected-aggregation-operators aggregation-operators (second aggregations)))))
(lib/selected-aggregation-operators query -1 aggregation-operators (second aggregations)))))
(testing "selected-aggregation-operators w/ column"
(let [selected-operators (lib/selected-aggregation-operators
aggregation-operators (first aggregations))]
query -1 aggregation-operators (first aggregations))]
(is (=? [{:short :count
:requires-column? false}
{:short :sum
@@ -615,7 +615,7 @@
:effective-type :type/Integer
:semantic-type :type/Category
:lib/source :source/implicitly-joinable}]}]
(->> (lib/selected-aggregation-operators aggregation-operators (first aggregations))
(->> (lib/selected-aggregation-operators query -1 aggregation-operators (first aggregations))
(filterv :selected?))))))))
(deftest ^:parallel preserve-field-settings-metadata-test
@@ -850,10 +850,10 @@
;; Hawk's =? will think these are predicates and try to run them!
true (dissoc :display-info)
(= (:short op) :distinct) (assoc :selected? true)))
(lib/selected-aggregation-operators available (first (lib/aggregations query)))))
(lib/selected-aggregation-operators query -1 available (first (lib/aggregations query)))))
(is (thrown? #?(:clj Exception :cljs js/Error)
(with-redefs [lib.util/ref-clause? (constantly true)]
(lib/selected-aggregation-operators available (first (lib/aggregations query)))))))))
(lib/selected-aggregation-operators query -1 available (first (lib/aggregations query)))))))))
(deftest ^:parallel aggregable-columns-test
(let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :venues))

View File

@@ -266,20 +266,20 @@
{:id (meta/id :venues :latitude) :name "LATITUDE"}
{:id (meta/id :venues :longitude) :name "LONGITUDE"}
{:id (meta/id :venues :price) :name "PRICE"}
{:lib/type :metadata/column
:name "ID"
:display-name "ID"
:source-alias "Cat"
:id (meta/id :categories :id)
:table-id (meta/id :categories)
:base-type :type/BigInteger}
{:lib/type :metadata/column
:name "NAME"
:display-name "Name"
:source-alias "Cat"
:id (meta/id :categories :name)
:table-id (meta/id :categories)
:base-type :type/Text}]
{:lib/type :metadata/column
:name "ID"
:display-name "ID"
:metabase.lib.join/join-alias "Cat"
:id (meta/id :categories :id)
:table-id (meta/id :categories)
:base-type :type/BigInteger}
{:lib/type :metadata/column
:name "NAME"
:display-name "Name"
:metabase.lib.join/join-alias "Cat"
:id (meta/id :categories :name)
:table-id (meta/id :categories)
:base-type :type/Text}]
(lib/breakoutable-columns query))))))))
(deftest ^:parallel breakoutable-columns-source-card-test

View File

@@ -1,12 +1,14 @@
(ns metabase.lib.drill-thru.column-filter-test
(:require
#?@(:cljs ([metabase.test-runner.assert-exprs.approximately-equal]))
[clojure.set :as set]
[clojure.test :refer [deftest is testing]]
[medley.core :as m]
[metabase.lib.core :as lib]
[metabase.lib.drill-thru.test-util :as lib.drill-thru.tu]
[metabase.lib.drill-thru.test-util.canned :as canned]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]))
@@ -327,13 +329,17 @@
(meta/field-metadata :products :id))])))
columns (lib/returned-columns query)
category (-> (m/find-first #(= (:name %) "CATEGORY") columns)
(dissoc :join-alias :metabase.lib.join/join-alias :lib/source))
(set/rename-keys {:metabase.lib.join/join-alias :source-alias})
(dissoc :lib/source :join-alias :metabase.lib.join/join-alias)
(->> (lib/normalize ::lib.schema.metadata/column)))
context {:column category
:column-ref (lib/ref category)
:value nil}
drills (lib/available-drill-thrus query -1 context)
colfilter (m/find-first #(= (:type %) :drill-thru/column-filter) drills)]
(is (= "Products" (:source-alias category)))
(testing "Normalization should rename :source-alias to :metabase.lib.join/join-alias"
(is (= "Products"
(:metabase.lib.join/join-alias category))))
(is (= "Products" (-> context :column-ref second :join-alias)))
(is (some? (:column colfilter))))))

View File

@@ -144,7 +144,6 @@
(is (not (m/find-first #(= (:type %) :drill-thru/fk-details)
(lib/available-drill-thrus query -1 context)))))))
`preserve-filters-for-other-fks-forming-multi-column-pk-test
(deftest ^:parallel preserve-filters-for-other-fks-forming-multi-column-pk-test
(testing "with multiple FKs forming a multi-column PK on another table"
(let [provider (merged-mock/merged-mock-metadata-provider

View File

@@ -91,7 +91,7 @@
(->> (lib/returned-columns mbql-query)
(map #(-> %
(assoc :lib/source :source/native)
(dissoc :lib/breakout? :lib/expression-name :metabase.lib.join/join-alias))))
(dissoc :lib/breakout? :lib/expression-name :metabase.lib.join/join-alias :fk-field-id))))
(catch #?(:clj clojure.lang.ExceptionInfo :cljs js/Error) e
;; Not all the input queries passed to this are real. Some of them are skeletons used
;; as test expectations. If we fail to generate the returned columns, just return nil.
@@ -264,7 +264,7 @@
true (map #(assoc % ::was-breakout-in-original-query? (:lib/breakout? %)))
(= query-kind :native) (map #(-> %
(assoc :lib/source :source/native)
(dissoc :lib/breakout? :lib/expression-name :metabase.lib.join/join-alias))))
(dissoc :lib/breakout? :lib/expression-name :metabase.lib.join/join-alias :fk-field-id))))
by-name (m/index-by :name cols)
col (get by-name column-name)
refs (update-vals by-name lib/ref)
@@ -475,15 +475,13 @@
& variants]
(assert (even? (count variants)) "variants must come in variant-desc and variant-case pairs")
(when-not (= "SKIP" base-desc)
(testing base-desc
(test-fn base-case)))
(testing base-desc
(test-fn base-case))
(doseq [[variant-desc variant-case-or-fn] (partition 2 variants)]
(when-not (= "SKIP" variant-desc)
(testing variant-desc
(test-fn (merge base-case
(cond (fn? variant-case-or-fn) (variant-case-or-fn base-case)
(map? variant-case-or-fn) variant-case-or-fn
:else (throw (ex-info "Invalid variant case. Must be a fn or map."
{:variant-case variant-case-or-fn})))))))))
(testing variant-desc
(test-fn (merge base-case
(cond (fn? variant-case-or-fn) (variant-case-or-fn base-case)
(map? variant-case-or-fn) variant-case-or-fn
:else (throw (ex-info "Invalid variant case. Must be a fn or map."
{:variant-case variant-case-or-fn}))))))))

View File

@@ -141,6 +141,13 @@
{:query (-> (lib/query metadata-provider (meta/table-metadata :orders))
(lib/aggregate (lib/count))
(lib/breakout (meta/field-metadata :orders :created-at))
;; HEY! HOW ARE WE SUPPOSED TO BREAKOUT ON A COLUMN FROM ANOTHER TABLE! WE NEED THE VERSION
;; OF THE COLUMN WITH IMPLICIT JOIN INFO!
;;
;; TODO (Cam 9/15/25) -- because the tests are so busted here I had to write extra field
;; resolution code just to work around these tests.
;; See [[fallback-metadata-for-demonic-field-id-from-different-source-table]] for example.
;; Maybe we should fix the tests.
(lib/breakout (meta/field-metadata :products :category)))
:row {"CREATED_AT" "2022-12-01T00:00:00+02:00"
"CATEGORY" "Doohickey"

View File

@@ -7,6 +7,7 @@
[metabase.lib.drill-thru.test-util :as lib.drill-thru.tu]
[metabase.lib.drill-thru.test-util.canned :as canned]
[metabase.lib.drill-thru.zoom-in-bins :as zoom-in]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.test-metadata :as meta]))
#?(:cljs (comment metabase.test-runner.assert-exprs.approximately-equal/keep-me))
@@ -376,11 +377,10 @@
(lib/breakout people-orders-query people-orders-column)
people-orders-clicked-column
(-> (meta/field-metadata :orders :total)
(lib/with-binning {:strategy :num-bins, :min-value 0, :max-value 160, :num-bins 8, :bin-width 20})
;; I have to construct this weird column because I cannot find a way to reproduce
;; the columns that lib/existing-breakouts is being passed
(assoc :source-alias "Orders"))
(lib/normalize ::lib.schema.metadata/column
(-> (meta/field-metadata :orders :total)
(lib/with-binning {:strategy :num-bins, :min-value 0, :max-value 160, :num-bins 8, :bin-width 20})
(assoc :source-alias "Orders")))
people-orders-ref
(first (lib/breakouts people-orders-query-breakout))
@@ -393,10 +393,9 @@
:value 80})]
;; make sure we're testing the right thing
(assert (get-in people-orders-ref [1 :join-alias]))
(assert (nil? (:metabase.lib.join/join-alias people-orders-clicked-column)))
;; somehow the column (as printed out in console.log) has :source-alias but not :metabase.lib.join/join-alias
(assert (:source-alias people-orders-clicked-column))
;; `:source-alias` should get renamed to `:metabase.lib.join/join-alias` automatically when normalizing column metadata
(assert (nil? (:source-alias people-orders-clicked-column)))
(assert (:metabase.lib.join/join-alias people-orders-clicked-column))
(assert (nil? (get-in orders-people-ref [1 :join-alias])))
(assert (nil? (:metabase.lib.join/join-alias orders-people-clicked-column)))
(assert (nil? (:source-alias orders-people-clicked-column)))
@@ -404,4 +403,4 @@
(testing "zoom-in binning should not depend on join order"
(is (= orders-people-zoom
(some-> people-orders-zoom
(update :column dissoc :source-alias)))))))
(update :column dissoc :metabase.lib.join/join-alias)))))))

View File

@@ -210,12 +210,14 @@
(lib/aggregate (lib/count))
(lib/breakout (-> (meta/field-metadata :orders :created-at)
(lib/with-temporal-bucket :year))))
created-at-col (m/find-first #(= (:name %) "CREATED_AT")
(lib/returned-columns base-query))
_ (is (some? created-at-col))
created-at-col (fn [query]
{:post [(some? %)]}
(m/find-first #(= (:name %) "CREATED_AT")
(lib/returned-columns query)))
multi-stage-query (lib.drill-thru.tu/append-filter-stage base-query "count")]
(doseq [[query-type query] {"single-stage" base-query
"multi-stage" multi-stage-query}
:let [created-at-col (created-at-col query)]
[message context] {"pivot cell (no column, value = NULL) (#36173)"
{:value :null
:column nil

View File

@@ -624,25 +624,50 @@
;;; The tests below are adapted from frontend/src/metabase-lib/drills.unit.spec.ts
;;;
(deftest ^:parallel available-drill-thrus-test-1
(deftest ^:parallel available-drill-thrus-test-1-mbql
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :unaggregated
:column-name "ID"
:query-kinds [:mbql]
:expected [{:type :drill-thru/zoom
:object-id (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :row "ID"])
:many-pks? false}]}))
(deftest ^:parallel available-drill-thrus-test-2
(deftest ^:parallel available-drill-thrus-test-1-native
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :unaggregated
:column-name "ID"
:query-kinds [:native]
:expected [{:type :drill-thru/zoom
:object-id (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :row "ID"])
:many-pks? false}
{:type :drill-thru/quick-filter, :operators [{:name "="} {:name "≠"}]}]}))
(deftest ^:parallel available-drill-thrus-test-2-mbql
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :unaggregated
:column-name "USER_ID"
:query-kinds [:mbql]
:expected [{:type :drill-thru/fk-filter}
{:type :drill-thru/fk-details
:object-id (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :row "USER_ID"])
:many-pks? false}]}))
(deftest ^:parallel available-drill-thrus-test-2-native
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :unaggregated
:column-name "USER_ID"
:query-kinds [:native]
:expected [{:type :drill-thru/fk-filter}
{:type :drill-thru/fk-details
:object-id (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :row "USER_ID"])
:many-pks? false}
{:type :drill-thru/quick-filter, :operators [{:name "="} {:name "≠"}]}]}))
(deftest ^:parallel available-drill-thrus-test-3
(lib.drill-thru.tu/test-drill-variants-with-merged-args
lib.drill-thru.tu/test-available-drill-thrus
@@ -783,27 +808,28 @@
{:type :drill-thru/zoom-in.timeseries, :display-name "See this month by week"}]
:native-drills #{:drill-thru/quick-filter}})))
;; FIXME: quick-filter gets returned for non-metric column (#34443)
(deftest ^:parallel available-drill-thrus-test-11
#_(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :aggregated
:column-name "PRODUCT_ID"
:expected [{:type :drill-thru/fk-filter}
{:type :drill-thru/fk-details, :object-id 3, :many-pks? false}
{:row-count 2, :table-name "Orders", :type :drill-thru/underlying-records}]}))
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :aggregated
:column-name "PRODUCT_ID"
:expected [{:type :drill-thru/automatic-insights}
{:type :drill-thru/fk-filter}
{:type :drill-thru/fk-details, :object-id 3, :many-pks? false}
{:type :drill-thru/underlying-records, :row-count pos-int?, :table-name "Orders"}
{:type :drill-thru/zoom-in.timeseries}]}))
;; FIXME: quick-filter gets returned for non-metric column (#34443)
(deftest ^:parallel available-drill-thrus-test-12
#_(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :aggregated
:column-name "CREATED_AT"
:expected [{:type :drill-thru/quick-filter, :operators [{:name "<"}
{:name ">"}
{:name "="}
{:name "≠"}]}
{:row-count 3, :table-name "Orders", :type :drill-thru/underlying-records}]}))
(lib.drill-thru.tu/test-available-drill-thrus
{:click-type :cell
:query-type :aggregated
:column-name "CREATED_AT"
:expected [{:type :drill-thru/automatic-insights}
{:type :drill-thru/quick-filter, :operators [{:name "<"}
{:name ">"}
{:name "="}
{:name "≠"}]}
{:type :drill-thru/underlying-records, :row-count pos-int?, :table-name "Orders"}]}))
;; FIXME: for some reason the results for aggregated query are not correct (#34223, #34341)
(deftest ^:parallel available-drill-thrus-test-13

View File

@@ -153,7 +153,7 @@
(gen/fmap rand-nth
(mg/generator [:repeat {:min 2, :max 2} qualified-keyword?]))]))
(def map-pair-generator
(def ^:private map-pair-generator
"Generate a key-value pair using the rules discussed above."
(let [expr-generator (gen/recursive-gen
(fn [_gen] (mg/generator [:ref ::expr]))
@@ -238,7 +238,9 @@
haystack (lib.metadata.calculation/returned-columns query)]
(testing "Should find a matching column ignoring :temporal-unit if needed (#32920)"
(is (=? column
(lib.equality/find-matching-column needle haystack))))
(lib.equality/find-matching-column
query -1 needle haystack
{:find-matching-column/ignore-binning-and-bucketing? true}))))
(testing "Should find a matching ref ignoring :temporal-unit if needed (#32920)"
(is (=? [:field
{:lib/uuid string?
@@ -263,8 +265,6 @@
{:name "DISCOUNT", :selected? false}
{:name "CREATED_AT", :selected? true}
{:name "QUANTITY", :selected? false}]
(mapv #(select-keys % [:name :selected?])
(lib.equality/mark-selected-columns cols selected))
(mapv #(select-keys % [:name :selected?])
(lib.equality/mark-selected-columns query -1 cols selected)))))))
@@ -287,7 +287,6 @@
[:field {} (meta/id :categories :name)]]
refs))
(is (= (nth cols 7)
(lib.equality/find-matching-column a-ref cols)
(lib.equality/find-matching-column query -1 a-ref cols))))))
(deftest ^:parallel find-matching-column-from-column-test
@@ -324,7 +323,6 @@
[:name :lib/original-join-alias :lib/original-name :lib/deduplicated-name :lib/source-column-alias :lib/desired-column-alias]
cols)))
(are [a-ref expected-index] (= (nth cols expected-index)
(lib.equality/find-matching-column a-ref cols)
(lib.equality/find-matching-column query -1 a-ref cols))
;; Good ref.
[:field {:lib/uuid (str (random-uuid)), :base-type :type/Text}
@@ -366,26 +364,26 @@
(map #(dissoc % :name)))
(sorted (lib/returned-columns query))))
(testing "matches the defaults by ID"
(doseq [col-key (meta/fields :orders)]
(doseq [col-key (meta/fields :orders)
:let [a-ref (-> (meta/field-metadata :orders col-key)
table-col
lib/ref)]]
(is (=? (table-col (meta/field-metadata :orders col-key))
(-> (meta/field-metadata :orders col-key)
table-col
lib/ref
(lib.equality/find-matching-column visible))))))
(lib.equality/find-matching-column query -1 a-ref visible)))))
(testing "matches the joined columns by ID"
(doseq [col-key (meta/fields :orders)]
(doseq [col-key (meta/fields :orders)
:let [a-ref (-> (meta/field-metadata :orders col-key)
join-col
lib/ref)]]
(is (=? (join-col (meta/field-metadata :orders col-key))
(-> (meta/field-metadata :orders col-key)
join-col
lib/ref
(lib.equality/find-matching-column visible))))))
(lib.equality/find-matching-column query -1 a-ref visible)))))
(testing "the matches of default and joins are distinct"
(let [matches (for [col-key (meta/fields :orders)
col-fn [table-col join-col]]
(-> (meta/field-metadata :orders col-key)
col-fn
lib/ref
(lib.equality/find-matching-column visible)))]
col-fn [table-col join-col]
:let [a-ref (-> (meta/field-metadata :orders col-key)
col-fn
lib/ref)]]
(lib.equality/find-matching-column query -1 a-ref visible))]
(is (every? some? matches))
(is (= (count matches)
(count (set matches)))))))))
@@ -409,79 +407,23 @@
{:lib/desired-column-alias "Orders__ID"}]
(lib/returned-columns just-3)))
(testing "matching the four fields against"
(let [hr-own-id {:lib/type :metadata/column
:description "Own ID"
:base-type :type/BigInteger
:semantic-type :type/PK
:effective-type :type/BigInteger
:table-id (meta/id :orders)
:id (meta/id :orders :id)
:name "ID"
:lib/source :source/table-defaults
:fk-target-field-id nil
:parent-id nil
:display-name "ID"
:position 0}
hr-own-tax {:lib/type :metadata/column
:description "Own Tax"
:base-type :type/Float
:semantic-type nil
:effective-type :type/Float
:table-id (meta/id :orders)
:id (meta/id :orders :tax)
:name "TAX"
:lib/source :source/table-defaults
:fk-target-field-id nil
:parent-id nil
:display-name "Tax"
:position 4}
hr-join-id {:lib/type :metadata/column
:description "Join ID"
:base-type :type/BigInteger
:semantic-type :type/PK
:effective-type :type/BigInteger
:table-id (meta/id :orders)
:id (meta/id :orders :id)
:name "ID_2"
:metabase.lib.join/join-alias "Orders"
:lib/source :source/joins
:fk-target-field-id nil
:parent-id nil
:display-name "Orders → ID"
:position 0}
hr-join-tax {:lib/type :metadata/column
:description "Join Tax"
:base-type :type/Float
:semantic-type nil
:effective-type :type/Float
:table-id (meta/id :orders)
:id (meta/id :orders :tax)
:name "TAX_2"
:metabase.lib.join/join-alias "Orders"
:lib/source :source/joins
:fk-target-field-id nil
:parent-id nil
:display-name "Orders → Tax"
:position 4}
ret-4 [hr-own-id hr-own-tax hr-join-id hr-join-tax]
ret-3 [hr-own-id hr-own-tax hr-join-id]
refs (for [join-alias [nil "Orders"]
[column coltype] [[:id :type/Integer] [:tax :type/Float]]]
[:field (merge {:lib/uuid (str (random-uuid))
:base-type coltype
:effective-type coltype}
(when join-alias
{:join-alias join-alias}))
(meta/id :orders column)])
exp-4 [{:display-name "ID"}
{:display-name "Tax"}
{:display-name "Orders → ID"}
{:display-name "Orders → Tax"}]
exp-3 (update exp-4 3 (constantly nil))]
(let [ret-4 (binding [lib.metadata.calculation/*display-name-style* :long]
(lib/returned-columns all-4)) #_[hr-own-id hr-own-tax hr-join-id hr-join-tax]
ret-3 (take 3 ret-4)
refs (for [join-alias [nil "Orders"]
[column coltype] [[:id :type/Integer] [:tax :type/Float]]]
[:field (merge {:lib/uuid (str (random-uuid))
:base-type coltype
:effective-type coltype}
(when join-alias
{:join-alias join-alias}))
(meta/id :orders column)])
exp-4 [{:display-name "ID"}
{:display-name "Tax"}
{:display-name "Orders → ID"}
{:display-name "Orders → Tax"}]
exp-3 (update exp-4 3 (constantly nil))]
(testing "all-4 matches everything"
(is (=? exp-4
(->> refs
(map #(lib.equality/find-matching-column % ret-4)))))
(is (=? exp-4
(->> refs
(map #(lib.equality/find-matching-column all-4 -1 % ret-4)))))
@@ -489,9 +431,6 @@
#_{:clj-kondo/ignore [:discouraged-var]}
(lib.equality/find-column-indexes-for-refs all-4 -1 refs ret-4))))
(testing "just-3 does not match the joined TAX"
(is (=? exp-3
(->> refs
(map #(lib.equality/find-matching-column % ret-3)))))
(is (=? exp-3
(->> refs
(map #(lib.equality/find-matching-column just-3 -1 % ret-3)))))
@@ -503,18 +442,6 @@
(let [query (-> (lib.tu/venues-query)
(lib/aggregate (lib/count)))
[ag] (lib/aggregations query)]
(testing "without passing query"
(testing "matches with UUID"
(is (=? {:display-name "Count", :lib/source :source/aggregations}
(lib.equality/find-matching-column
[:aggregation {:lib/uuid (str (random-uuid))} (lib.options/uuid ag)]
(lib/returned-columns query)))))
(testing "fails with bad UUID but good source-name"
(is (nil? (lib.equality/find-matching-column
[:aggregation {:lib/uuid (str (random-uuid))
:lib/source-name "count"}
"this is a bad UUID"]
(lib/returned-columns query))))))
(testing "when passing query"
(testing "matches with UUID"
(is (=? {:display-name "Count", :lib/source :source/aggregations}
@@ -534,6 +461,8 @@
(deftest ^:parallel find-matching-column-expression-test
(is (=? {:name "expr", :lib/source :source/expressions}
(lib.equality/find-matching-column
(lib.tu/query-with-expression)
-1
[:expression {:lib/uuid (str (random-uuid))} "expr"]
(lib/visible-columns (lib.tu/query-with-expression))))))
@@ -599,26 +528,28 @@
query (-> base
(lib/breakout base-user-source)
(lib/breakout base-category))
returned (map #(assoc %1 :source-alias %2)
(lib/returned-columns query)
[nil "PEOPLE__via__USER_ID" "PRODUCTS__via__PRODUCT_ID" nil])]
returned (lib/returned-columns query)]
(is (= :source/implicitly-joinable (:lib/source base-user-source)))
(is (= :source/implicitly-joinable (:lib/source base-category)))
(is (= 4 (count returned)))
(is (= (map :name returned)
(for [col returned]
(:name (lib.equality/find-matching-column query -1 (lib/ref col) returned)))))))
(:name (lib.equality/find-matching-column query -1 (lib/ref col) returned))))))))
(deftest ^:parallel implicitly-joinable-columns-test-2
(testing "implicitly joinable columns via joins should be matched correctly"
(let [query (lib.tu/query-with-self-join)
visible-cols (lib/visible-columns query)]
(let [query (lib.tu/query-with-self-join)
visible-cols (lib/visible-columns query)]
(doseq [col visible-cols]
(is (= col (lib/find-matching-column (lib/ref col) visible-cols))))))
(is (= col (lib/find-matching-column query -1 (lib/ref col) visible-cols)))))))
(deftest ^:parallel implicitly-joinable-columns-test-3
(testing "implicitly joinable columns from the previous query stage are matched correctly"
(let [query (-> (lib.tu/query-with-self-join) lib/append-stage)
visible-cols (lib/visible-columns query)
implicit-cols (filter #(= :source/implicitly-joinable (:lib/source %)) visible-cols)]
(doseq [col implicit-cols]
(is (= col (lib.equality/find-matching-column (lib.ref/ref col) implicit-cols)))))))
(is (= col (lib.equality/find-matching-column query -1 (lib.ref/ref col) implicit-cols)))))))
(deftest ^:parallel field-refs-to-custom-expressions-test
(testing "custom columns that wrap a Field must not have `:id` (#44940)"
@@ -630,12 +561,11 @@
(testing "different columns"
(is (int? (:id created-at)))
(is (nil? (:id ca-expr))))
(testing "both refs should match correctly"
(is (= created-at
(lib.equality/find-matching-column (lib/ref created-at) columns)))
(lib.equality/find-matching-column query -1 (lib/ref created-at) columns)))
(is (= ca-expr
(lib.equality/find-matching-column (lib/ref ca-expr) columns)))))))
(lib.equality/find-matching-column query -1 (lib/ref ca-expr) columns)))))))
(deftest ^:parallel disambiguate-matches-using-temporal-unit-if-needed-test
(let [created-at-month (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :month)
@@ -644,6 +574,8 @@
created-at-year]]
(is (= col
(lib.equality/find-matching-column
(lib/query meta/metadata-provider (meta/table-metadata :orders))
-1
(lib/ref col)
[created-at-month
created-at-year]))))))
@@ -656,9 +588,13 @@
latitude-20]]
(is (= col
(lib.equality/find-matching-column
(lib/query meta/metadata-provider (meta/table-metadata :people))
-1
(lib/ref col)
[latitude-10
latitude-20]))))))
latitude-20])))))))
(deftest ^:parallel disambiguate-matches-using-binning-if-needed-test-2
(testing "'num-bins' binning strategy"
(let [total-10 (lib/with-binning (meta/field-metadata :orders :total) {:strategy :num-bins, :num-bins 10})
total-20 (lib/with-binning (meta/field-metadata :orders :total) {:strategy :num-bins, :num-bins 20})]
@@ -666,23 +602,26 @@
total-20]]
(is (= col
(lib.equality/find-matching-column
(lib/query meta/metadata-provider (meta/table-metadata :orders))
-1
(lib/ref col)
[total-10
total-20])))))))
(deftest ^:parallel find-matching-column-by-id-with-expression-aliasing-joined-column-test
(testing "find-matching-column should be able to find columns based on ID even when a joined column is aliased as an expression (#44940)"
(let [a-ref [:field {:lib/uuid (str (random-uuid))
:base-type :type/Text
:join-alias "Cat"}
(meta/id :categories :name)]
(let [a-ref (fn []
[:field {:lib/uuid (str (random-uuid))
:base-type :type/Text
:join-alias "Cat"}
(meta/id :categories :name)])
query (-> (lib.tu/query-with-join)
(lib/expression "Joined Name" a-ref))
(lib/expression "Joined Name" (a-ref)))
cols (lib/returned-columns query)]
(is (=? {:name "NAME_2"
:lib/desired-column-alias "Cat__NAME"
:id (meta/id :categories :name)}
(lib.equality/find-matching-column query -1 a-ref cols))))))
(lib.equality/find-matching-column query -1 (a-ref) cols))))))
(deftest ^:parallel find-matching-ref-multiple-breakouts-test
(testing "should be able to distinguish between multiple breakouts of the same column in the previous stage"
@@ -750,6 +689,8 @@
cols (lib/visible-columns query)]
(is (=? {:name "ID_2", :lib/source-column-alias "Products__ID"}
(lib.equality/find-matching-column
query
-1
[:field
{:base-type :type/BigInteger, :lib/uuid "00000000-0000-0000-0000-000000000002"}
"Products__ID"]
@@ -759,13 +700,14 @@
(let [field-ref [:field {:lib/uuid "86e6d41c-d693-4f08-ae30-7ad411da8ec7"
:effective-type :type/DateTime
:base-type :type/DateTime
:inherited-temporal-unit :month} 39]
:inherited-temporal-unit :month}
(meta/id :orders :created-at)]
cols [{:base-type :type/DateTime
:created-at "2025-06-24T20:58:39.593446-07:00"
:description "The date and time an order was submitted."
:display-name "Created At: Year"
:effective-type :type/DateTime
:id 39
:id (meta/id :orders :created-at)
:inherited-temporal-unit :year
:last-analyzed "2025-06-24T20:58:41.088313-07:00"
:lib/deduplicated-name "CREATED_AT"
@@ -778,30 +720,35 @@
:lib/type :metadata/column
:name "CREATED_AT"
:semantic-type :type/CreationTimestamp
:table-id 5
:table-id (meta/id :orders)
:updated-at "2025-06-24T20:58:41.088313-07:00"}
{:base-type :type/DateTime
:created-at "2025-06-24T20:58:39.593446-07:00"
:description "The date and time an order was submitted."
:display-name "Created At: Month"
:effective-type :type/DateTime
:id 39
:id (meta/id :orders :created-at)
:inherited-temporal-unit :month
:last-analyzed "2025-06-24T20:58:41.088313-07:00"
:lib/deduplicated-name "CREATED_AT_2"
:lib/desired-column-alias "CREATED_AT_2"
:lib/deduplicated-name "CREATED_AT"
:lib/desired-column-alias "CREATED_AT"
:lib/original-display-name "Created At"
:lib/original-name "CREATED_AT"
:lib/source :source/previous-stage
:lib/source-column-alias "CREATED_AT_2"
:lib/source-column-alias "CREATED_AT"
:lib/source-uuid "1f16a57a-2afd-4c92-8d6c-b41062235a49"
:lib/type :metadata/column
:name "CREATED_AT"
:semantic-type :type/CreationTimestamp
:table-id 5
:table-id (meta/id :orders)
:updated-at "2025-06-24T20:58:41.088313-07:00"}]]
(is (=? {:display-name "Created At: Month"}
(lib.equality/find-matching-column field-ref cols)))))
(lib.equality/find-matching-column
(-> (lib/query meta/metadata-provider (meta/table-metadata :orders))
lib/append-stage)
-1
field-ref
cols)))))
(deftest ^:parallel mark-selected-columns-works-for-js-use-cases-test
(testing "Does mark-selected-columns actually work for the uses cases in [[metabase.lib.js/visible-columns*]] now?"
@@ -840,13 +787,6 @@
{:display-name "Count"}]
(lib/visible-columns query)))
(testing `lib.equality/mark-selected-columns
(testing "2-arity"
(is (=? [{:display-name "Created At: Year"}
{:display-name "Created At: Month"}
{:display-name "Count"}]
(lib.equality/mark-selected-columns
(lib/visible-columns query)
(lib/returned-columns query)))))
(testing "4-arity"
(is (=? [{:display-name "Created At: Year"}
{:display-name "Created At: Month"}
@@ -893,13 +833,6 @@
{:display-name "Count"}]
(lib/visible-columns query)))
(testing `lib.equality/mark-selected-columns
(testing "2-arity"
(is (=? [{:display-name "Total: 10 bins"}
{:display-name "Total: 50 bins"}
{:display-name "Count"}]
(lib.equality/mark-selected-columns
(lib/visible-columns query)
(lib/returned-columns query)))))
(testing "4-arity"
(is (=? [{:display-name "Total: 10 bins"}
{:display-name "Total: 50 bins"}
@@ -961,7 +894,7 @@
(let [cols [{:base-type :type/DateTime
:display-name "Created At"
:effective-type :type/DateTime
:id 66
:id (meta/id :orders :created-at)
:lib/card-id 5
:lib/desired-column-alias "CREATED_AT"
:lib/model-display-name "Created At"
@@ -974,7 +907,7 @@
{:base-type :type/DateTime
:display-name "Products → Created At"
:effective-type :type/DateTime
:id 66
:id (meta/id :orders :created-at)
:lib/card-id 5
:lib/desired-column-alias "CREATED_AT_2"
:lib/model-display-name "Products → Created At"
@@ -989,9 +922,13 @@
:effective-type :type/DateTime
:base-type :type/DateTime
:metabase.lib.query/transformation-added-base-type true}
66]]
(is (=? {:id 66, :display-name "Created At"}
(lib.equality/find-matching-column a-ref cols))))))
(meta/id :orders :created-at)]
mp (lib.tu/mock-metadata-provider
meta/metadata-provider
{:cards [{:id 5
:dataset-query (lib/query meta/metadata-provider (meta/table-metadata :orders))}]})]
(is (=? {:id (meta/id :orders :created-at), :display-name "Created At"}
(lib.equality/find-matching-column (lib/query mp (lib.metadata/card mp 5)) -1 a-ref cols))))))
(deftest ^:parallel match-by-source-uuid-test
(let [col {:base-type :type/BigInteger
@@ -1009,7 +946,6 @@
:metabase.lib.join/join-alias "Orders"
:name "ID_2"
:semantic-type :type/PK
:source-alias "Orders"
:table-id 55060}
refs [[:field
{:lib/uuid "1c2a0643-f25c-4099-a2d5-7c7e790b632f"
@@ -1050,7 +986,11 @@
(deftest ^:parallel find-matching-column-prefer-exact-matches-test
(testing `lib.equality/find-matching-column
(testing "For field name refs, prefer matches that have the same [lack of] join alias and same source-column-alias"
(let [a-ref [:field
(let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :venues))
(lib/join (-> (lib/join-clause (meta/table-metadata :categories))
(lib/with-join-alias "Cat")))
lib/append-stage)
a-ref [:field
{:lib/uuid "00000000-0000-0000-0000-000000000000"
:effective-type :type/Text
:base-type :type/Text}
@@ -1085,7 +1025,7 @@
(doseq [cols [[col-1 col-2]
[col-2 col-1]]]
(is (= col-2
(lib.equality/find-matching-column a-ref cols))))))))
(lib.equality/find-matching-column query -1 a-ref cols))))))))
(deftest ^:parallel never-return-match-with-different-id-test
(testing "find-matching-ref should never return a match with a different :id"
@@ -1198,7 +1138,7 @@
{:lib/source-column-alias "BODY", :display-name "Body", :selected? true}
{:lib/source-column-alias "CREATED_AT", :display-name "Created At", :selected? true}
;; the following two Card 1 → ID should have :selected? true
{:lib/source-column-alias "ID", :display-name "Card 1 → ID", :selected? true} ; FIXME - these should be true
{:lib/source-column-alias "ID", :display-name "Card 1 → ID", :selected? true}
{:lib/source-column-alias "o__ID", :display-name "Card 1 → ID", :selected? true}
;; these are implicitly joinable fields, :selected? false is right
{:lib/source-column-alias "ID", :display-name "ID", :selected? false}

View File

@@ -178,11 +178,12 @@
(lib/query metadata-provider)
lib/returned-columns
(m/index-by :name))
query (-> (lib/query metadata-provider (meta/table-metadata :checkins))
query (-> (lib/query metadata-provider (meta/table-metadata :users))
(lib/join (lib/with-join-alias
(lib/join-clause (lib.metadata/card metadata-provider 1)
[(lib/= (meta/field-metadata :users :id)
(lib/ref (get cols "USER_ID")))])
[:field {:lib/uuid "00000000-0000-0000-0000-000000000000", :base-type :type/Integer}
"USER_ID"])])
"checkins_by_user"))
(lib/breakout (lib/with-temporal-bucket (meta/field-metadata :users :last-login) :month))
(lib/aggregate (lib/avg (lib/with-join-alias (lib/ref (get cols "count")) "checkins_by_user"))))]
@@ -420,9 +421,6 @@
:lib/original-display-name "Category"
:lib/original-name "CATEGORY"
:lib/original-join-alias "Products"
;; this key is DEPRECATED (see description in column metadata schema) but still used (FOR
;; NOW) (QUE-1403)
:source-alias "Products"
:lib/source :source/card ; or is it supposed to be `:source/table-defaults`
:lib/source-uuid (lib.options/uuid breakout-ref)
:lib/type :metadata/column
@@ -1485,7 +1483,6 @@
(is (=? {:display-name "ID → Name"
:id (meta/id :categories :name)
:semantic-type :type/Name
:source-alias "J"
:lib/deduplicated-name "NAME_2"
:lib/original-fk-field-id (meta/id :venues :id)
:lib/original-join-alias "J"
@@ -1499,7 +1496,6 @@
(is (=? {:display-name "Category → Name"
:id (meta/id :categories :name)
:semantic-type :type/Name
:source-alias "J"
:lib/deduplicated-name "NAME"
:lib/original-fk-field-id (meta/id :venues :category-id)
:lib/original-join-alias "J"
@@ -1508,3 +1504,34 @@
:lib/source-column-alias "CATEGORIES__via__CATEGORY_ID__NAME"
:metabase.lib.join/join-alias "J"}
(lib.field.resolution/resolve-field-ref query 0 field-ref)))))))))
(deftest ^:parallel satanic-resolution-of-implicitly-joined-ref-without-source-field-test
(testing "Can we resolve the 'ref from hell' from [[metabase.lib.drill-thru.zoom-in-bins-test/zoom-in-bins-available-test]]?"
(let [query (lib/query
meta/metadata-provider
{:lib/type :mbql/query
:database (meta/id)
:stages [{:lib/type :mbql.stage/mbql
:source-table (meta/id :orders)
:aggregation [[:count {:lib/uuid "cfdf2003-e7ca-4d09-b1b6-349afc76e70b"}]]
:breakout [[:field
{:lib/uuid "24803786-506f-4029-b298-c85b79cf26e4", :effective-type :type/Text, :base-type :type/Text}
(meta/id :products :category)]]}]})]
(is (=? {:base-type :type/Text
:display-name "Category"
:effective-type :type/Text
:fk-field-id (meta/id :orders :product-id)
:id (meta/id :products :category)
:name "CATEGORY"
:semantic-type :type/Category
:table-id (meta/id :products)
:lib/source :source/implicitly-joinable
:lib/source-column-alias "CATEGORY"
:lib/source-uuid "24803786-506f-4029-b298-c85b79cf26e4"
:lib/type :metadata/column
::lib.field.resolution/fallback-metadata? true}
(lib.field.resolution/resolve-field-ref
query 0
[:field
{:lib/uuid "24803786-506f-4029-b298-c85b79cf26e4", :effective-type :type/Text, :base-type :type/Text}
(meta/id :products :category)]))))))

View File

@@ -72,11 +72,19 @@
expression-ref (last (lib/fields query))]
(is (=? [:expression {} "CATEGORY"]
expression-ref))
;; should return a column with properties like:
;; {... :lib/source :source/expressions, :name "CATEGORY", :lib/desired-column-alias "CATEGORY_2"}
(is (nil? (lib.equality/find-matching-column
expression-ref
(lib/visible-columns query)))))))
(is (=? {:base-type :type/Text
:display-name "CATEGORY"
:effective-type :type/Text
:name "CATEGORY"
:lib/expression-name "CATEGORY"
:lib/source :source/expressions
:lib/source-column-alias "CATEGORY"
:lib/type :metadata/column}
(lib.equality/find-matching-column
query
-1
expression-ref
(lib/visible-columns query)))))))
(deftest ^:parallel returned-columns-bad-field-refs-test
(let [query (lib/query

View File

@@ -227,17 +227,22 @@
(let [date-field (assoc (meta/field-metadata :people :birth-date)
:base-type :type/Text
:effective-type :type/Date
:coercion-strategy :Coercion/ISO8601->Date)
:coercion-strategy :Coercion/ISO8601->Date
:table-id (meta/id :venues))
datetime-field (assoc (meta/field-metadata :reviews :created-at)
:table-id (meta/id :venues))
time-field (assoc (meta/field-metadata :orders :created-at)
:base-type :type/Time
:effective-type :type/Time)
:effective-type :type/Time
:table-id (meta/id :venues))
metadata-provider (lib.tu/mock-metadata-provider
meta/metadata-provider
{:fields [date-field
datetime-field
time-field]})
query (lib/query metadata-provider (meta/table-metadata :venues))]
{:fields {:date date-field
:datetime (meta/field-metadata :reviews :created-at)
:datetime datetime-field
:time time-field}
:metadata-provider metadata-provider
:query query}))
@@ -1397,15 +1402,18 @@
(lib.metadata.calculation/returned-columns hidden)))
(is (=? (map #(dissoc % :lib/source) exp-hidden)
(filter :selected? (lib.equality/mark-selected-columns
hidden
-1
(lib.metadata.calculation/visible-columns hidden)
(lib.metadata.calculation/returned-columns hidden)))))
(testing "and showing it again"
(let [shown (lib/add-field query -1 to-hide)]
(is (=? exp-shown
(lib.metadata.calculation/returned-columns shown)))
(is (=? (map #(dissoc % :lib/source) exp-shown)
(filter :selected? (lib.equality/mark-selected-columns
hidden
-1
(lib.metadata.calculation/visible-columns shown)
(lib.metadata.calculation/returned-columns shown)))))))))))))
@@ -1490,7 +1498,7 @@
(dissoc :id :table-id)))
join-cols [(-> (meta/field-metadata :products :category)
(assoc :lib/source :source/card
:source-alias "Products")
:lib/original-join-alias "Products")
(dissoc :id :table-id))]
implicit-cols (for [col (meta/fields :people)]
(-> (meta/field-metadata :people col)

View File

@@ -483,12 +483,12 @@
:effective-type :type/Boolean
:semantic-type nil))
(defn- check-display-names [tests]
(defn- check-display-names [source-table tests]
(let [metadata-provider (lib/composed-metadata-provider
(lib.tu/mock-metadata-provider
{:fields [last-online-time is-active]})
meta/metadata-provider)
query (lib/query metadata-provider (meta/table-metadata :venues))]
query (lib/query metadata-provider (meta/table-metadata source-table))]
(doseq [{exp :name [op & args] :clause options :options} tests]
(testing exp
(is (= exp (lib/display-name
@@ -501,6 +501,7 @@
(let [created-at (meta/field-metadata :products :created-at)
created-at-with #(lib/with-temporal-bucket created-at %1)]
(check-display-names
:products
[{:clause [:= (created-at-with :year) "2023-10-02T00:00:00.000Z"]
:name "Created At is Jan 1 – Dec 31, 2023"}
{:clause [:during created-at "2023-10-02" :year]
@@ -526,6 +527,7 @@
(let [created-at (meta/field-metadata :products :created-at)
created-at-with #(lib/with-temporal-bucket created-at %1)]
(check-display-names
:products
[{:clause [:= (created-at-with :hour-of-day) 0]
:name "Created At: Hour of day is on 12 AM"}
{:clause [:= (lib/get-hour created-at) 0]
@@ -644,6 +646,7 @@
(deftest ^:parallel time-frontend-filter-display-names-test
(check-display-names
:people
[{:clause [:< last-online-time "00:00:00.000"], :name "Last Online Time is before 12:00 AM"}
{:clause [:> last-online-time "12:00:00.000"], :name "Last Online Time is after 12:00 PM"}
{:clause [:between last-online-time "12:00:00.000" "00:00:00.000"],
@@ -654,6 +657,7 @@
(deftest ^:parallel pk-frontend-filter-display-names-test
(let [pk (meta/field-metadata :venues :id)]
(check-display-names
:venues
[{:clause [:= pk 1], :name "ID is 1"}
{:clause [:= pk 4], :name "ID is 4"}
{:clause [:= pk 1 2], :name "ID is 2 selections"}
@@ -673,12 +677,14 @@
(let [longitude (meta/field-metadata :venues :longitude)
latitude (meta/field-metadata :venues :latitude)]
(check-display-names
:venues
[{:clause [:inside longitude latitude 1 2 3 4],
:name "Longitude is between 3 and 1 and Latitude is between 2 and 4"}])))
(deftest ^:parallel fk-frontend-filter-display-names-test
(let [fk (meta/field-metadata :orders :user-id)]
(check-display-names
:orders
[{:clause [:= fk 1], :name "User ID is 1"}
{:clause [:= fk 11], :name "User ID is 11"}
{:clause [:= fk 1 2], :name "User ID is 2 selections"}
@@ -697,6 +703,7 @@
(deftest ^:parallel string-frontend-filter-display-names-test
(let [nam (meta/field-metadata :venues :name)]
(check-display-names
:venues
[{:clause [:= nam "ABC"], :name "Name is ABC"}
{:clause [:= nam "A" "B"], :name "Name is 2 selections"}
{:clause [:= nam "A" "B" "C"], :name "Name is 3 selections"}
@@ -720,6 +727,7 @@
(deftest ^:parallel boolean-frontend-filter-display-names-test
(check-display-names
:orders
[{:clause [:= is-active true], :name "Is Active is true"}
{:clause [:= is-active false], :name "Is Active is false"}
{:clause [:is-null is-active], :name "Is Active is empty"}
@@ -730,6 +738,7 @@
(deftest ^:parallel number-frontend-filter-display-names-test
(let [tax (meta/field-metadata :orders :tax)]
(check-display-names
:orders
[{:clause [:= tax 1], :name "Tax is equal to 1"}
{:clause [:= tax 7], :name "Tax is equal to 7"}
{:clause [:= tax 7 10], :name "Tax is equal to 2 selections"}
@@ -756,6 +765,7 @@
pos-clause (lib.expression/value pos-value)
neg-clause (lib.expression/value neg-value)]
(check-display-names
:orders
[{:clause [:= id pos-clause], :name (str "ID is " pos-value)}
{:clause [:!= id pos-clause], :name (str "ID is not " pos-value)}
{:clause [:> id pos-clause], :name (str "ID is greater than " pos-value)}
@@ -769,6 +779,7 @@
(deftest ^:parallel relative-datetime-frontend-filter-display-names-test
(let [created-at (meta/field-metadata :products :created-at)]
(check-display-names
:products
[{:clause [:time-interval created-at -1 :minute], :name "Created At is in the previous minute"}
{:clause [:time-interval created-at -3 :minute], :name "Created At is in the previous 3 minutes"}
{:clause [:time-interval created-at -1 :hour], :name "Created At is in the previous hour"}
@@ -831,6 +842,7 @@
(deftest ^:parallel specific-date-frontend-filter-display-names-test
(let [created-at (meta/field-metadata :products :created-at)]
(check-display-names
:products
[{:clause [:= created-at "2023-10-03"], :name "Created At is on Oct 3, 2023"}
{:clause [:= created-at "2023-10-03T12:30:00"],
:name "Created At is on Oct 3, 2023, 12:30 PM"}

View File

@@ -1271,13 +1271,11 @@
cols (lib/joinable-columns query -1 join)]
(is (=? [{:name "ID"
:metabase.lib.join/join-alias "Cat"
:source-alias "Cat"
:lib/source :source/joins
:lib/source-column-alias "ID"
:selected? id-selected?}
{:name "NAME"
:metabase.lib.join/join-alias "Cat"
:source-alias "Cat"
:lib/source :source/joins
:lib/source-column-alias "NAME"
:selected? name-selected?}]

View File

@@ -311,7 +311,7 @@
:lib/source :source/implicitly-joinable}))
(lib/visible-columns query))))))))
(def cols-fns [lib/visible-columns lib/filterable-columns lib/breakoutable-columns lib/orderable-columns])
(def ^:private cols-fns [#'lib/visible-columns #'lib/filterable-columns #'lib/breakoutable-columns #'lib/orderable-columns])
(deftest ^:parallel inherited-temporal-unit-stage-propagation-test
(let [unit :quarter
@@ -328,20 +328,27 @@
stage-2-query (lib/append-stage stage-1-query)]
(testing "0th stage `orderable-columns` do not contain inherited-temporal-unit"
(is ((complement contains?)
(u/prog1 (lib/find-matching-column stage-0-breakout (lib/orderable-columns stage-0-query))
(u/prog1 (lib/find-matching-column stage-0-query 0 stage-0-breakout (lib/orderable-columns stage-0-query))
(is (= :metadata/column (:lib/type <>))))
:inherited-temporal-unit)))
(testing "1st stage col function contain inherited-temporal-unit"
(doseq [cols-fn cols-fns
:let [stage-1-cols (cols-fn stage-1-query)
stage-1-col (lib/find-matching-column stage-0-breakout stage-1-cols)]]
(is (= unit (:inherited-temporal-unit stage-1-col)))))
:let [stage-1-cols (cols-fn stage-1-query)
stage-1-ref (lib/ref
(m/find-first #(= (:lib/source-column-alias %) "CREATED_AT")
stage-1-cols))
stage-1-col (lib/find-matching-column stage-1-query 1 stage-1-ref stage-1-cols)]]
(is (=? {:inherited-temporal-unit unit}
stage-1-col))))
(testing "inherited-temporal-unit is propagated into 2nd stage (and further)"
(doseq [cols-fn cols-fns
:let [stage-2-cols (cols-fn stage-2-query)
stage-1-ref (first (lib/fields stage-1-query))
stage-2-col (lib/find-matching-column stage-1-ref stage-2-cols)]]
(is (= unit (:inherited-temporal-unit stage-2-col)))))))
:let [stage-2-cols (cols-fn stage-2-query)
stage-2-ref (lib/ref
(m/find-first #(= (:lib/source-column-alias %) "CREATED_AT")
stage-2-cols))
stage-2-col (lib/find-matching-column stage-2-query 2 stage-2-ref stage-2-cols)]]
(is (=? {:inherited-temporal-unit unit}
stage-2-col))))))
(deftest ^:parallel inherited-temporal-unit-card-propagation-test
(let [unit :quarter
@@ -350,13 +357,13 @@
(lib/query (meta/table-metadata :orders))
(lib/aggregate (lib/count))
(lib/breakout (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) unit)))
breakout-ref (first (lib/breakouts card-query))
mp (lib.tu/metadata-provider-with-card-from-query card-id card-query)
query (lib/query mp (lib.metadata/card mp card-id))]
query (lib/query mp (lib.metadata/card mp card-id))
breakout-ref (lib/normalize [:field {:base-type :type/DateTime} "CREATED_AT"])]
(testing "_cols functions_ return :inherited-temporal-unit for a card source"
(doseq [cols-fn cols-fns]
(is (contains? (lib/find-matching-column breakout-ref (cols-fn query))
:inherited-temporal-unit))))))
(is (=? {:inherited-temporal-unit keyword?}
(lib/find-matching-column query -1 breakout-ref (cols-fn query))))))))
(deftest ^:parallel inherited-temporal-unit-propagation-from-expression-test
(let [expression-name "created at + 1 month"
@@ -372,12 +379,19 @@
:quarter))
(lib/append-stage $))]
(testing "_cols functions_ return :inherited-temporla-unit for bucketed expressions"
(doseq [cols-fn cols-fns]
(is (= :quarter (-> (lib/expression-ref query 0 expression-name)
(lib/find-matching-column (cols-fn query))
:inherited-temporal-unit)))))
(doseq [cols-fn cols-fns
;; very ILLEGAL to use an expression ref from the first stage for matching purposes in the second stage
;; but just see if we can figure this stuff out anyway.
:let [a-ref (lib/expression-ref query 0 expression-name)]]
(is (=? {:lib/source :source/previous-stage
:lib/original-expression-name "created at + 1 month"
:lib/expression-name (symbol "nil #_\"key is not present.\"")
:inherited-temporal-unit :quarter}
(lib/find-matching-column query 1 a-ref (cols-fn query))))))
(testing "orderable columns do not contain inherited-temporal-unit for expression"
(is (not (contains? (lib/find-matching-column (lib/expression-ref query 0 expression-name)
(is (not (contains? (lib/find-matching-column query
0
(lib/expression-ref query 0 expression-name)
(lib/orderable-columns query 0))
:inherited-temporal-unit))))))

View File

@@ -811,8 +811,8 @@
:aggregation [[:sum $orders.quantity]]}
:alias "Orders"
:condition [:= $id &Orders.orders.product-id]
;; we can get title since product-id is remapped to title
:fields [&Orders.title
;; we can get products.title since orders.product-id is remapped to products.title
:fields [&Orders.products.title
&Orders.*sum/Integer]}]
:fields [$title $category]
:order-by [[:asc $id]]

View File

@@ -344,20 +344,20 @@
{:id (meta/id :venues :latitude) :name "LATITUDE"}
{:id (meta/id :venues :longitude) :name "LONGITUDE"}
{:id (meta/id :venues :price) :name "PRICE"}
{:lib/type :metadata/column
:name "ID"
:display-name "ID"
:source-alias "Cat"
:id (meta/id :categories :id)
:table-id (meta/id :categories)
:base-type :type/BigInteger}
{:lib/type :metadata/column
:name "NAME"
:display-name "Name"
:source-alias "Cat"
:id (meta/id :categories :name)
:table-id (meta/id :categories)
:base-type :type/Text}]
{:lib/type :metadata/column
:name "ID"
:display-name "ID"
:metabase.lib.join/join-alias "Cat"
:id (meta/id :categories :id)
:table-id (meta/id :categories)
:base-type :type/BigInteger}
{:lib/type :metadata/column
:name "NAME"
:display-name "Name"
:metabase.lib.join/join-alias "Cat"
:id (meta/id :categories :name)
:table-id (meta/id :categories)
:base-type :type/Text}]
(lib/orderable-columns query)))))))
(deftest ^:parallel orderable-columns-source-card-test
@@ -645,7 +645,7 @@
{:display-name "ID", :lib/source :source/joins}
{:display-name "Name", :lib/source :source/joins}]
(lib/orderable-columns query)))
(let [query' (lib/order-by query (m/find-first #(and (= (:source-alias %) "Cat")
(let [query' (lib/order-by query (m/find-first #(and (= (:metabase.lib.join/join-alias %) "Cat")
(= (:display-name %) "Name"))
(lib/orderable-columns query)))]
(is (=? [{:display-name "ID", :lib/source :source/table-defaults}

View File

@@ -169,20 +169,20 @@
{:id (meta/id :venues :price), :name "PRICE", :lib/source :source/table-defaults}
{:name "ID + 1", :lib/source :source/expressions}
{:name "ID + 2", :lib/source :source/expressions}
{:id (meta/id :categories :id)
:name "ID_2"
:lib/source :source/joins
:source-alias "Cat"
:display-name "ID"
:lib/source-column-alias "ID"
:lib/desired-column-alias "Cat__ID"}
{:id (meta/id :categories :name)
:name "NAME_2"
:lib/source :source/joins
:source-alias "Cat"
:display-name "Name"
:lib/source-column-alias "NAME"
:lib/desired-column-alias "Cat__NAME"}]
{:id (meta/id :categories :id)
:name "ID_2"
:lib/source :source/joins
:metabase.lib.join/join-alias "Cat"
:display-name "ID"
:lib/source-column-alias "ID"
:lib/desired-column-alias "Cat__ID"}
{:id (meta/id :categories :name)
:name "NAME_2"
:lib/source :source/joins
:metabase.lib.join/join-alias "Cat"
:display-name "Name"
:lib/source-column-alias "NAME"
:lib/desired-column-alias "Cat__NAME"}]
metadata))
(testing ":long display names"
(is (= ["ID"

View File

@@ -145,7 +145,7 @@
(let [filter-cols (fn [query]
(let [cols (lib/visible-columns query)]
(for [[_< _opts agg _value] (lib/filters query -1)]
(lib/find-matching-column agg cols))))
(lib/find-matching-column query -1 agg cols))))
cols-before (filter-cols before)
cols-after (filter-cols after)]
(is (every? some? cols-before))
@@ -190,6 +190,6 @@
cols-after)))
(testing "the refs to those breakouts are still aimed at the same columns as before, despite the name change"
(let [[_- _opts _now days-ref] (first (lib/expressions after))]
(is (=? days-after (lib/find-matching-column days-ref cols-after))))
(is (=? days-after (lib/find-matching-column after -1 days-ref cols-after))))
(let [[_= _opts months-ref _value] (first (lib/filters after))]
(is (=? months-after (lib/find-matching-column months-ref cols-after))))))))
(is (=? months-after (lib/find-matching-column after -1 months-ref cols-after))))))))

View File

@@ -166,8 +166,7 @@
(add-step {:kind :breakout})
(defn- breakout-exists? [query stage-number column]
(boolean (lib.breakout/breakout-column? query stage-number column {:same-binning-strategy? true
:same-temporal-bucket? true})))
(boolean (lib.breakout/breakout-column? query stage-number column {:find-matching-column/ignore-binning-and-bucketing? false})))
(defmethod next-steps* :breakout [query _breakout]
(let [stage-number (choose-stage query)

View File

@@ -21,11 +21,12 @@
;; legacy result metadata will already include the Join name in the `:display-name`, so simulate that. Make
;; sure we're not including it twice.
(for [col (lib/returned-columns
(lib/query meta/metadata-provider legacy-card-query))]
(lib/query meta/metadata-provider legacy-card-query))
:let [any-join-alias ((some-fn :metabase.lib.join/join-alias :lib/original-join-alias) col)]]
(cond-> col
(:source-alias col)
any-join-alias
(update :display-name (fn [display-name]
(str (:source-alias col) " → " display-name))))))
(str any-join-alias " → " display-name))))))
(defn query-with-legacy-source-card
"An MLv2 query that has a `:source-card` that has a legacy query, and legacy metadata."

View File

@@ -111,30 +111,35 @@
(lib/breakout (-> (meta/field-metadata :orders :created-at)
(lib/with-temporal-bucket :month)))
(lib/breakout (-> (meta/field-metadata :orders :quantity)
(lib/with-binning {:strategy :num-bins, :num-bins 10}))))
(lib/with-binning {:strategy :num-bins, :num-bins 10})))
lib/append-stage)
temporal-col (m/find-first #(= (:name %) "CREATED_AT")
(lib/returned-columns query))
_ (is (some? temporal-col))
binned-col (m/find-first #(= (:name %) "QUANTITY")
(lib/returned-columns query))
_ (is (some? binned-col))
query (lib/append-stage query)]
(doseq [[col key-name] [[temporal-col "temporal-unit"]
[binned-col "binning"]]
rename? [true false]]
(let [orig-key (keyword "metabase.lib.field" key-name)
renamed-key (keyword "metabase.lib.underlying" key-name)
top-level-col (lib.underlying/top-level-column query col :rename-superflous-options? rename?)]
_ (is (some? temporal-col))
binned-col (m/find-first #(= (:name %) "QUANTITY")
(lib/returned-columns query))
_ (is (some? binned-col))]
(doseq [[col orig-key renamed-key expected] [[temporal-col
:metabase.lib.field/temporal-unit
:metabase.lib.underlying/temporal-unit
:month]
[binned-col
:metabase.lib.field/binning
:metabase.lib.underlying/binning
{:strategy :num-bins, :num-bins 10}]]
rename? [true false]]
(let [top-level-col (lib.underlying/top-level-column query col :rename-superflous-options? rename?)]
(testing (str "\nrename? " rename?
"\norig-key " orig-key
"\nrenamed-key " renamed-key
"\ntop-level-col " top-level-col)
(if rename?
(do (is (= (orig-key col) (renamed-key top-level-col)))
(is (= nil (orig-key top-level-col))))
(do (is (= (orig-key col) (orig-key top-level-col)))
(is (= nil (renamed-key top-level-col))))))))))))
"\ntop-level-col " top-level-col
"\n")
(is (=? (if rename?
{renamed-key expected
orig-key (symbol "nil #_\"key is not present.\"")}
{renamed-key (symbol "nil #_\"key is not present.\"")
orig-key expected})
top-level-col)))))))))
(deftest ^:parallel top-level-stage-number-test
(testing `lib.underlying/top-level-stage-number

View File

@@ -75,8 +75,7 @@
:effective-type :type/Integer
:id (mt/id :orders :product_id)
:lib/desired-column-alias "Orders__PRODUCT_ID"
:display-name "Orders → Product ID"
:source-alias "Orders"}
:display-name "Orders → Product ID"}
{:metabase.lib.join/join-alias "Orders"
:lib/type :metadata/column
:base-type :type/Integer
@@ -85,8 +84,7 @@
:lib/source-column-alias "sum"
:effective-type :type/Integer
:lib/desired-column-alias "Orders__sum"
:display-name "Orders → Sum of Quantity"
:source-alias "Orders"}]
:display-name "Orders → Sum of Quantity"}]
(binding [lib.metadata.calculation/*display-name-style* :long]
(lib.metadata.calculation/returned-columns mlv2-query))))))

View File

@@ -50,6 +50,7 @@
:values [["American"] ["Artisan"] ["Asian"]]}
(custom-values/values-from-card
card
;; This is referring to CATEGORIES.NAME (from the breakout)
[:field "NAME" {:base-type :type/Text}]))))
(testing "get values from aggregation column"
(is (= {:has_more_values true
@@ -70,8 +71,9 @@
(custom-values/values-from-card
card
[:field "NAME" {:base-type :type/Text}]
{:query-string "bakery"}))))))))
{:query-string "bakery"})))))))))
(deftest ^:parallel with-mbql-card-test-2b
(testing "source card is a question" ; Questions are transparent, so this can drop the aggregations and filter the original.
(binding [custom-values/*max-rows* 3]
(testing "has aggregation column"

View File

@@ -34,15 +34,17 @@
:fk_target_field_id nil}}
(-> (t2/select-one [:model/Field :name :table_id :semantic_type], :id (mt/id :venues :id))
(t2/hydrate :name_field)
mt/derecordize))))
mt/derecordize)))))
(deftest ^:parallel hydrate-name-field-test-2
(testing "make sure it works for multiple fields efficiently. Should only require one DB call to hydrate many Fields"
(let [venues-fields (t2/select :model/Field :table_id (mt/id :venues))]
(t2/with-call-count [call-count]
(t2/hydrate venues-fields :name_field)
(is (= 1
(call-count))))))
(call-count)))))))
(deftest ^:parallel hydrate-name-field-test-3
(testing "It shouldn't hydrate for Fields that aren't PKs"
(is (= {:name "PRICE"
:table_id (mt/id :venues)
@@ -50,8 +52,9 @@
:name_field nil}
(-> (t2/select-one [:model/Field :name :table_id :semantic_type], :id (mt/id :venues :price))
(t2/hydrate :name_field)
mt/derecordize))))
mt/derecordize)))))
(deftest ^:parallel hydrate-name-field-test-4
(testing "Or if it *is* a PK, but no name Field is available for that Table, it shouldn't hydrate"
(is (= {:name "ID"
:table_id (mt/id :checkins)
@@ -119,7 +122,9 @@
:dimensions []}]}
(-> (t2/hydrate dashboard :param_fields)
:param_fields
mt/derecordize)))))
mt/derecordize))))))
(deftest hydrate-param-fields-for-dashboard-test-2
(testing "should ignore invalid parameter mappings"
(mt/with-temporary-setting-values [enable-public-sharing true]
(mt/with-temp [:model/Dashboard dashboard {:parameters [{:id "p1" :type :number/=}
@@ -212,7 +217,9 @@
(params/get-linked-field-ids
[{:parameter_mappings
[{:parameter_id "foo" :target [:dimension [:field 256 nil]]}
{:parameter_id "bar" :target [:dimension [:field 267 nil]]}]}]))))
{:parameter_id "bar" :target [:dimension [:field 267 nil]]}]}])))))
(deftest ^:parallel get-linked-field-ids-test-2
(testing "get-linked-field-ids multiple fields to one param test"
(is (= {"foo" #{256 10}
"bar" #{267}}
@@ -221,7 +228,9 @@
[{:parameter_id "foo" :target [:dimension [:field 256 nil]]}
{:parameter_id "bar" :target [:dimension [:field 267 nil]]}]}
{:parameter_mappings
[{:parameter_id "foo" :target [:dimension [:field 10 nil]]}]}]))))
[{:parameter_id "foo" :target [:dimension [:field 10 nil]]}]}])))))
(deftest ^:parallel get-linked-field-ids-test-3
(testing "get-linked-field-ids-test misc fields"
(is (= {"1" #{1} "2" #{2} "3" #{3} "4" #{4} "5" #{5}}
(params/get-linked-field-ids
@@ -231,7 +240,9 @@
{:parameter_id "wow" :target [:dimension [:field "wow" {:base-type :type/Integer}]]}
{:parameter_id "3" :target [:dimension [:field 3 {:source-field 1}]]}
{:parameter_id "4" :target [:dimension [:field 4 {:binning {:strategy :num-bins, :num-bins 1}}]]}
{:parameter_id "5" :target [:dimension [:field 5]]}]}]))))
{:parameter_id "5" :target [:dimension [:field 5]]}]}])))))
(deftest ^:parallel get-linked-field-ids-test-4
(testing "get-linked-field-ids-test no fields"
(is (= {}
(params/get-linked-field-ids

View File

@@ -582,7 +582,7 @@
(meta/id :products :title)))
query (lib/query
mp
(mt/mbql-query orders
(lib.tu.macros/mbql-query orders
{:aggregation [[:sum [:field (meta/id :orders :total)]]]
:breakout [[:field
(meta/id :orders :product-id)

View File

@@ -50,7 +50,7 @@
;; Can't use [[normal-drivers-with-feature]] during test initialization, because it means we end up having to load
;; plugins and a bunch of other nonsense.
(mb.hawk.init/assert-tests-are-not-initializing (pr-str (list* 'normal-drivers-with-feature feature more-features)))
(let [features (set (cons feature more-features))]
(let [features (into #{feature} more-features)]
(set (for [driver (normal-drivers)
:let [driver (tx/the-driver-with-test-extensions driver)]
:when (driver/with-driver driver

View File

@@ -1143,20 +1143,18 @@
(testing "Make sure queries built with MLv2 that have source Cards with joins work correctly (#31769) (#33083)"
(let [metadata-provider (lib.tu.mocks-31769/mock-metadata-provider
(mt/metadata-provider)
mt/id)]
(qp.store/with-metadata-provider metadata-provider
(let [legacy-query (lib.convert/->legacy-MBQL
(lib.tu.mocks-31769/query metadata-provider))]
(mt/with-native-query-testing-context legacy-query
(let [results (qp/process-query legacy-query)]
(is (= [["Products → Category" "Products__CATEGORY"]
["Count" "count"]
["Card 2 - Products → Category → Category" "Card 2 - Products → Category__CATEGORY"]]
(map (juxt :display_name :lib/desired-column-alias)
(mt/cols results))))
(is (= [["Doohickey" 3976 "Doohickey"]
["Gadget" 4939 "Gadget"]]
(mt/rows results))))))))))
mt/id)
query (lib.tu.mocks-31769/query metadata-provider)]
(mt/with-native-query-testing-context query
(let [results (qp/process-query query)]
(is (= [["Products → Category" "Products__CATEGORY"]
["Count" "count"]
["Card 2 - Products → Category → Category" "Card 2 - Products → Category__CATEGORY"]]
(map (juxt :display_name :lib/desired-column-alias)
(mt/cols results))))
(is (= [["Doohickey" 3976 "Doohickey"]
["Gadget" 4939 "Gadget"]]
(mt/rows results))))))))
(deftest ^:parallel test-13000
(testing "Should join MBQL Saved Questions (#13000, #13649, #13744)"

View File

@@ -16,6 +16,7 @@
[metabase.lib.temporal-bucket :as lib.temporal-bucket]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.lib.test-util.notebook-helpers :as lib.tu.notebook]
[metabase.lib.util :as lib.util]
[metabase.models.interface :as mi]
[metabase.permissions.core :as perms]
@@ -1678,9 +1679,7 @@
[:field field-id nil]]}))))))))
(deftest ^:parallel space-names-test
(mt/test-drivers (set/intersection
(mt/normal-drivers-with-feature :identifiers-with-spaces)
(mt/normal-drivers-with-feature :left-join))
(mt/test-drivers (mt/normal-drivers-with-feature :identifiers-with-spaces :left-join)
(mt/dataset
crazy-names
(let [mp (mt/metadata-provider)
@@ -1693,8 +1692,7 @@
(lib/with-join-alias (lib.metadata/field mp (mt/id "space table" "space column"))
"Space Table Alias"))])))
(lib/breakout $q (m/find-first (every-pred (comp #{"Space Column"} :display-name) :source-alias)
(lib/breakoutable-columns $q)))
(lib/breakout $q (lib.tu.notebook/find-col-with-spec $q (lib/breakoutable-columns $q) {} {:display-name "Space Column"}))
(lib/append-stage $q)
(lib/breakout $q (first (lib/breakoutable-columns $q)))
(lib/aggregate $q (lib/max (first (lib/visible-columns $q)))))]
@@ -1702,9 +1700,7 @@
[int int] (qp/process-query query))))))))
(deftest ^:parallel space-names-question-test
(mt/test-drivers (set/intersection
(mt/normal-drivers-with-feature :identifiers-with-spaces)
(mt/normal-drivers-with-feature :left-join))
(mt/test-drivers (mt/normal-drivers-with-feature :identifiers-with-spaces :left-join)
(let [mp (mt/metadata-provider)
card-query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/order-by (lib.metadata/field mp (mt/id :orders :created_at)))

View File

@@ -84,11 +84,13 @@
(:definition_description (t2/hydrate segment-2 :definition_description))))))))
(deftest ^:parallel definition-description-missing-source-table-test
;; kind of a weird test since the Segment itself still is required to have `table_id`
(testing "Should work if `:definition` does not include `:source-table`"
(mt/with-temp [:model/Segment segment {:name "Expensive BBQ Spots"
:definition (mt/$ids venues
{:filter
[:= $price 4]})}]
[:= $price 4]})
:table_id (mt/id :venues)}]
(is (= "Filtered by Price is equal to 4"
(:definition_description (t2/hydrate segment :definition_description)))))))