From 72a201819b3255c3753591297f90f05819d200cc Mon Sep 17 00:00:00 2001 From: lbrdnk <lbrdnk@users.noreply.github.com> Date: Thu, 1 Aug 2024 11:12:14 +0200 Subject: [PATCH] Add `:relative-time-interval` mbql function (#46211) * Add :relative-time-interval mbql function * Add relativeDateFilterPartsRelativeTimeInterval * Update display-name-method :relative-time-interval * Update desugar-relative-time-interval * Define relative-time-filter op * Add or update tests * Update tests * Update display-name-method * Update test * Fix positive relative-time-interval shift --- frontend/src/metabase-lib/filter.ts | 63 ++++++++++++++----- frontend/src/metabase-lib/types.ts | 1 + src/metabase/legacy_mbql/normalize.cljc | 6 ++ src/metabase/legacy_mbql/schema.cljc | 9 ++- src/metabase/legacy_mbql/util.cljc | 22 +++++++ src/metabase/lib/convert.cljc | 4 ++ src/metabase/lib/core.cljc | 1 + src/metabase/lib/filter.cljc | 13 ++++ src/metabase/lib/schema/filter.cljc | 12 ++++ test/metabase/legacy_mbql/normalize_test.cljc | 4 ++ test/metabase/legacy_mbql/util_test.cljc | 46 ++++++++++++++ test/metabase/lib/filter_test.cljc | 6 +- test/metabase/lib/js_test.cljs | 3 + test/metabase/lib/schema/filter_test.cljc | 1 + .../middleware/desugar_test.clj | 7 +++ .../date_bucketing_test.clj | 48 ++++++++++++++ 16 files changed, 230 insertions(+), 16 deletions(-) diff --git a/frontend/src/metabase-lib/filter.ts b/frontend/src/metabase-lib/filter.ts index 6d8a4720993..45d39967710 100644 --- a/frontend/src/metabase-lib/filter.ts +++ b/frontend/src/metabase-lib/filter.ts @@ -347,19 +347,12 @@ export function relativeDateFilterClause({ ); } - return expressionClause("between", [ - expressionClause("+", [ - columnWithoutBucket, - expressionClause("interval", [-offsetValue, offsetBucket]), - ]), - expressionClause("relative-datetime", [ - value !== "current" && value < 0 ? value : 0, - bucket, - ]), - expressionClause("relative-datetime", [ - value !== "current" && value > 0 ? value : 0, - bucket, - ]), + return expressionClause("relative-time-interval", [ + columnWithoutBucket, + value, + bucket, + offsetValue, + offsetBucket, ]); } @@ -371,7 +364,8 @@ export function relativeDateFilterParts( const filterParts = expressionParts(query, stageIndex, filterClause); return ( relativeDateFilterPartsWithoutOffset(filterParts) ?? - relativeDateFilterPartsWithOffset(filterParts) + relativeDateFilterPartsWithOffset(filterParts) ?? + relativeDateFilterPartsRelativeTimeInterval(filterParts) ); } @@ -820,6 +814,47 @@ function relativeDateFilterPartsWithOffset({ }; } +function relativeDateFilterPartsRelativeTimeInterval({ + operator, + args, + options, +}: ExpressionParts): RelativeDateFilterParts | null { + if (operator !== "relative-time-interval" || args.length !== 5) { + return null; + } + + const [column, value, bucket, offsetValue, offsetBucket] = args; + + if (!isColumnMetadata(column) || !isTemporal(column)) { + return null; + } + + if ( + !isNumberLiteral(value) || + !isStringLiteral(bucket) || + !isRelativeDateBucket(bucket) + ) { + return null; + } + + if ( + !isNumberLiteral(offsetValue) || + !isStringLiteral(offsetBucket) || + !isRelativeDateBucket(offsetBucket) + ) { + return null; + } + + return { + column, + bucket, + value, + offsetBucket, + offsetValue, + options, + }; +} + function serializeExcludeDatePart( value: number, bucketName: ExcludeDateBucketName, diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index 4fb05ecd52b..473224c3fe9 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -275,6 +275,7 @@ export type ExpressionOperatorName = | "concat" | "interval" | "time-interval" + | "relative-time-interval" | "relative-datetime" | "inside" | "segment" diff --git a/src/metabase/legacy_mbql/normalize.cljc b/src/metabase/legacy_mbql/normalize.cljc index ee7191e215d..68f7db1af26 100644 --- a/src/metabase/legacy_mbql/normalize.cljc +++ b/src/metabase/legacy_mbql/normalize.cljc @@ -159,6 +159,12 @@ (maybe-normalize-token amount)) (maybe-normalize-token unit)])) +(defmethod normalize-mbql-clause-tokens :relative-time-interval + [[_ col & [_value _bucket _offset-value _offset-bucket :as args]]] + (into [:relative-time-interval (normalize-tokens col :ignore-path)] + (map maybe-normalize-token) + args)) + (defmethod normalize-mbql-clause-tokens :relative-datetime ;; Normalize a `relative-datetime` clause. `relative-datetime` comes in two flavors: ;; diff --git a/src/metabase/legacy_mbql/schema.cljc b/src/metabase/legacy_mbql/schema.cljc index be2d2e6233c..d12e1102118 100644 --- a/src/metabase/legacy_mbql/schema.cljc +++ b/src/metabase/legacy_mbql/schema.cljc @@ -858,6 +858,13 @@ unit [:ref ::RelativeDatetimeUnit] options (optional TimeIntervalOptions)) +(defclause ^:sugar relative-time-interval + col Field + value :int + bucket [:ref ::RelativeDatetimeUnit] + offset-value :int + offset-bucket [:ref ::RelativeDatetimeUnit]) + ;; A segment is a special `macro` that saves some pre-definied filter clause, e.g. [:segment 1] ;; this gets replaced by a normal Filter clause in MBQL macroexpansion ;; @@ -890,7 +897,7 @@ ;; filters drivers must implement and or not = != < > <= >= between starts-with ends-with contains ;; SUGAR filters drivers do not need to implement - does-not-contain inside is-empty not-empty is-null not-null time-interval segment)]]) + does-not-contain inside is-empty not-empty is-null not-null relative-time-interval time-interval segment)]]) (def ^:private CaseClause [:tuple {:error/message ":case subclause"} Filter ExpressionArg]) diff --git a/src/metabase/legacy_mbql/util.cljc b/src/metabase/legacy_mbql/util.cljc index 4db4f6725a1..a9d802702db 100644 --- a/src/metabase/legacy_mbql/util.cljc +++ b/src/metabase/legacy_mbql/util.cljc @@ -234,6 +234,27 @@ [:relative-datetime 1 unit] [:relative-datetime n unit]])) +(defn desugar-relative-time-interval + "Transform `:relative-time-interval` to `:and` expression." + [m] + (lib.util.match/replace + m + [:relative-time-interval col value bucket offset-value offset-bucket] + (let [col-default-bucket (cond-> col (and (vector? col) (= 3 (count col))) + (update 2 assoc :temporal-unit :default)) + offset [:interval offset-value offset-bucket] + lower-bound (if (neg? value) + [:relative-datetime value bucket] + [:relative-datetime 1 bucket]) + upper-bound (if (neg? value) + [:relative-datetime 0 bucket] + [:relative-datetime (inc value) bucket]) + lower-with-offset [:+ lower-bound offset] + upper-with-offset [:+ upper-bound offset]] + [:and + [:>= col-default-bucket lower-with-offset] + [:< col-default-bucket upper-with-offset]]))) + (defn desugar-does-not-contain "Rewrite `:does-not-contain` filter clauses as simpler `[:not [:contains ...]]` clauses. @@ -376,6 +397,7 @@ desugar-multi-argument-comparisons desugar-does-not-contain desugar-time-interval + desugar-relative-time-interval desugar-is-null-and-not-null desugar-is-empty-and-not-empty desugar-inside diff --git a/src/metabase/lib/convert.cljc b/src/metabase/lib/convert.cljc index 93409bf88ab..f1bd5d8a3d7 100644 --- a/src/metabase/lib/convert.cljc +++ b/src/metabase/lib/convert.cljc @@ -290,6 +290,10 @@ [[_tag field n unit options]] (lib.options/ensure-uuid [:time-interval (or options {}) (->pMBQL field) n unit])) +(defmethod ->pMBQL :relative-time-interval + [[_tag & [_column _value _bucket _offset-value _offset-bucket :as args]]] + (lib.options/ensure-uuid (into [:relative-time-interval {}] (map ->pMBQL) args))) + ;; `:offset` is the same in legacy and pMBQL, but we need to update the expr it wraps. (defmethod ->pMBQL :offset [[tag opts expr n, :as clause]] diff --git a/src/metabase/lib/core.cljc b/src/metabase/lib/core.cljc index bf55a03ffef..e4458eb9e7a 100644 --- a/src/metabase/lib/core.cljc +++ b/src/metabase/lib/core.cljc @@ -216,6 +216,7 @@ is-empty not-empty starts-with ends-with contains does-not-contain + relative-time-interval time-interval segment] [lib.filter.update diff --git a/src/metabase/lib/filter.cljc b/src/metabase/lib/filter.cljc index 7b24892df89..f53ee387a4d 100644 --- a/src/metabase/lib/filter.cljc +++ b/src/metabase/lib/filter.cljc @@ -275,6 +275,18 @@ (lib.metadata.calculation/display-name query stage-number expr style) (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval n unit))))) +(defmethod lib.metadata.calculation/display-name-method :relative-time-interval + [query stage-number [_tag _opts column value bucket offset-value offset-bucket] style] + (if (neg? offset-value) + (i18n/tru "{0} is in the {1}, starting {2} ago" + (lib.metadata.calculation/display-name query stage-number column style) + (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval value bucket)) + (inflections/pluralize (abs offset-value) (name offset-bucket))) + (i18n/tru "{0} is in the {1}, starting {2} from now" + (lib.metadata.calculation/display-name query stage-number column style) + (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval value bucket)) + (inflections/pluralize (abs offset-value) (name offset-bucket))))) + (defmethod lib.metadata.calculation/display-name-method :relative-datetime [_query _stage-number [_tag _opts n unit] _style] (i18n/tru "{0}" (lib.temporal-bucket/describe-temporal-interval n unit))) @@ -302,6 +314,7 @@ (lib.common/defop ends-with [whole part]) (lib.common/defop contains [whole part]) (lib.common/defop does-not-contain [whole part]) +(lib.common/defop relative-time-interval [x value bucket offset-value offset-bucket]) (lib.common/defop time-interval [x amount unit]) (lib.common/defop segment [segment-id]) diff --git a/src/metabase/lib/schema/filter.cljc b/src/metabase/lib/schema/filter.cljc index f1530c1940a..86a614e1006 100644 --- a/src/metabase/lib/schema/filter.cljc +++ b/src/metabase/lib/schema/filter.cljc @@ -112,6 +112,18 @@ [false [:ref ::expression/integer]]] #_unit [:ref ::temporal-bucketing/unit.date-time.interval]]) +(mbql-clause/define-mbql-clause :relative-time-interval :- :type/Boolean + [:tuple + [:= {:decode/normalize common/normalize-keyword} :relative-time-interval] + ;; `relative-time-interval` does not support options to eg. include/exclude start or end point. Only int values + ;; are allowed for intervals. + ::common/options + #_col [:ref ::expression/temporal] + #_value :int + #_bucket [:ref ::temporal-bucketing/unit.date-time.interval] + #_offset-value :int + #_offset-bucket [:ref ::temporal-bucketing/unit.date-time.interval]]) + ;; segments are guaranteed to return valid filter clauses and thus booleans, right? (mbql-clause/define-mbql-clause :segment :- :type/Boolean [:tuple diff --git a/test/metabase/legacy_mbql/normalize_test.cljc b/test/metabase/legacy_mbql/normalize_test.cljc index 16a33bbfb09..20e2f1c1158 100644 --- a/test/metabase/legacy_mbql/normalize_test.cljc +++ b/test/metabase/legacy_mbql/normalize_test.cljc @@ -196,6 +196,10 @@ {{:query {"FILTER" ["time-interval" 10 -10 "day"]}} {:query {:filter [:time-interval 10 -10 :day]}}} + "relative-time-interval is correctly normalized" + {{:query {"FILTER" ["relative-time-interval" 10 "week" -10 "week"]}} + {:query {:filter [:relative-time-interval 10 :week -10 :week]}}} + "make sure we support time-interval options" {["TIME_INTERVAL" 10 -30 "DAY" {"include_current" true}] [:time-interval 10 -30 :day {:include-current true}]} diff --git a/test/metabase/legacy_mbql/util_test.cljc b/test/metabase/legacy_mbql/util_test.cljc index b446a086d24..8afebb7e1f5 100644 --- a/test/metabase/legacy_mbql/util_test.cljc +++ b/test/metabase/legacy_mbql/util_test.cljc @@ -276,6 +276,52 @@ (mbql.u/desugar-filter-clause [:time-interval [:expression "CC"] :current :week])) "keywords like `:current` should work correctly")) +(t/deftest ^:parallel desugar-relative-time-interval-negative-test + (t/testing "Desugaring relative-date-time produces expected [:and [:>=..] [:<..]] expression" + (let [value -10 + bucket :day + offset-value -8 + offset-bucket :week + exp-offset [:interval offset-value offset-bucket]] + (t/testing "expression reference is transformed correctly" + (let [expr-ref [:expression "cc"]] + (t/is (= [:and + [:>= expr-ref [:+ [:relative-datetime value bucket] exp-offset]] + [:< expr-ref [:+ [:relative-datetime 0 bucket] exp-offset]]] + (mbql.u/desugar-filter-clause + [:relative-time-interval expr-ref value bucket offset-value offset-bucket]))))) + (t/testing "field reference is transformed correctly" + (let [field-ref [:field 100 nil] + exp-field-ref (update field-ref 2 assoc :temporal-unit :default)] + (t/is (= [:and + [:>= exp-field-ref [:+ [:relative-datetime value bucket] exp-offset]] + [:< exp-field-ref [:+ [:relative-datetime 0 bucket] exp-offset]]] + (mbql.u/desugar-filter-clause + [:relative-time-interval exp-field-ref value bucket offset-value offset-bucket])))))))) + +(t/deftest ^:parallel desugar-relative-time-interval-positive-test + (t/testing "Desugaring relative-date-time produces expected [:and [:>=..] [:<..]] expression" + (let [value 10 + bucket :day + offset-value 8 + offset-bucket :week + exp-offset [:interval offset-value offset-bucket]] + (t/testing "expression reference is transformed correctly" + (let [expr-ref [:expression "cc"]] + (t/is (= [:and + [:>= expr-ref [:+ [:relative-datetime 1 bucket] exp-offset]] + [:< expr-ref [:+ [:relative-datetime (inc value) bucket] exp-offset]]] + (mbql.u/desugar-filter-clause + [:relative-time-interval expr-ref value bucket offset-value offset-bucket]))))) + (t/testing "field reference is transformed correctly" + (let [field-ref [:field 100 nil] + exp-field-ref (update field-ref 2 assoc :temporal-unit :default)] + (t/is (= [:and + [:>= exp-field-ref [:+ [:relative-datetime 1 bucket] exp-offset]] + [:< exp-field-ref [:+ [:relative-datetime (inc value) bucket] exp-offset]]] + (mbql.u/desugar-filter-clause + [:relative-time-interval exp-field-ref value bucket offset-value offset-bucket])))))))) + (t/deftest ^:parallel desugar-relative-datetime-with-current-test (t/testing "when comparing `:relative-datetime`to `:field`, it should take the temporal unit of the `:field`" (t/is (= [:= diff --git a/test/metabase/lib/filter_test.cljc b/test/metabase/lib/filter_test.cljc index a80283240dd..16c5ea786eb 100644 --- a/test/metabase/lib/filter_test.cljc +++ b/test/metabase/lib/filter_test.cljc @@ -701,7 +701,11 @@ (lib/expression-clause :interval [-1 :month] nil)] nil) (lib/expression-clause :relative-datetime [0 :month] nil) (lib/expression-clause :relative-datetime [1 :month] nil)], - :name "Created At is in the next month, starting 1 month from now"}]))) + :name "Created At is in the next month, starting 1 month from now"} + {:clause [:relative-time-interval created-at 10 :week 10 :week] + :name "Created At is in the next 10 weeks, starting 10 weeks from now"} + {:clause [:relative-time-interval created-at -10 :week -10 :week] + :name "Created At is in the previous 10 weeks, starting 10 weeks ago"}]))) (deftest ^:parallel specific-date-frontend-filter-display-names-test (let [created-at (meta/field-metadata :products :created-at)] diff --git a/test/metabase/lib/js_test.cljs b/test/metabase/lib/js_test.cljs index b4233c3d461..f034b28bea0 100644 --- a/test/metabase/lib/js_test.cljs +++ b/test/metabase/lib/js_test.cljs @@ -395,6 +395,9 @@ [:time-interval {} [:field {} int?] 10 :day] (lib.js/expression-clause "time-interval" [(meta/field-metadata :products :created-at) 10 "day"] nil) + [:relative-time-interval {} [:field {} int?] 10 :day 10 :month] + (lib.js/expression-clause "time-interval" [(meta/field-metadata :products :created-at) 10 "day" 10 "month"] nil) + [:relative-datetime {} :current :day] (lib.js/expression-clause "relative-datetime" ["current" "day"] nil) diff --git a/test/metabase/lib/schema/filter_test.cljc b/test/metabase/lib/schema/filter_test.cljc index 3c7fb225b07..fb94c789cf9 100644 --- a/test/metabase/lib/schema/filter_test.cljc +++ b/test/metabase/lib/schema/filter_test.cljc @@ -71,6 +71,7 @@ [:time-interval field :last :hour] [:time-interval field 4 :hour] [:time-interval {:include-current true} field :next :day] + [:relative-time-interval field 10 :day 10 :day] [:segment 1]]] (doseq [op (filter-ops filter-expr)] (is (not (identical? (get-method expression/type-of-method op) diff --git a/test/metabase/query_processor/middleware/desugar_test.clj b/test/metabase/query_processor/middleware/desugar_test.clj index a7ffbe9802f..b956ed1b792 100644 --- a/test/metabase/query_processor/middleware/desugar_test.clj +++ b/test/metabase/query_processor/middleware/desugar_test.clj @@ -16,6 +16,12 @@ [:field 2 {:temporal-unit :day}] [:relative-datetime -30 :day] [:relative-datetime -1 :day]] + [:>= + [:field 2 {:temporal-unit :default}] + [:+ [:relative-datetime -30 :day] [:interval -30 :day]]] + [:< + [:field 2 {:temporal-unit :default}] + [:+ [:relative-datetime 0 :day] [:interval -30 :day]]] [:!= [:field 3 nil] "(not set)"] [:!= [:field 3 nil] "url"] [:> [:temporal-extract [:field 4 nil] :year-of-era] [:/ [:/ 1 2] 3]]] @@ -38,6 +44,7 @@ :filter [:and [:= [:field 1 nil] "Run Query"] [:time-interval [:field 2 nil] -30 :day] + [:relative-time-interval [:field 2 nil] -30 :day -30 :day] [:!= [:field 3 nil] "(not set)" "url"] [:> [:get-year [:field 4 nil]] [:/ 1 2 3]]] :expressions {"year" [:+ diff --git a/test/metabase/query_processor_test/date_bucketing_test.clj b/test/metabase/query_processor_test/date_bucketing_test.clj index 6e469bb1526..c2f0ebe0961 100644 --- a/test/metabase/query_processor_test/date_bucketing_test.clj +++ b/test/metabase/query_processor_test/date_bucketing_test.clj @@ -1052,6 +1052,29 @@ (is (= 7 (count (mt/rows (qp/process-query query))))))))) +(deftest ^:parallel relative-time-interval-test + (mt/test-drivers + (disj (mt/normal-drivers-with-feature :date-arithmetics) :athena) + ;; Following verifies #45942 is solved. Changing the offset ensures that intervals do not overlap. + (testing "Syntactic sugar (`:relative-time-interval` clause) (#45942)" + (mt/dataset + checkins:1-per-day + (is (= 7 + (ffirst + (mt/formatted-rows + [int] + (mt/run-mbql-query + checkins + {:aggregation [[:count]] + :filter [:relative-time-interval $timestamp -1 :week -1 :week]}))) + (ffirst + (mt/formatted-rows + [int] + (mt/run-mbql-query + checkins + {:aggregation [[:count]] + :filter [:relative-time-interval $timestamp -1 :week 0 :week]}))))))))) + ;; Make sure that when referencing the same field multiple times with different units we return the one that actually ;; reflects the units the results are in. eg when we breakout by one unit and filter by another, make sure the results ;; and the col info use the unit used by breakout @@ -1407,6 +1430,31 @@ (get-in (qp/process-query (lib.convert/->pMBQL mbql-query)) [:data :native_form]) (get-in (qp/process-query query) [:data :native_form])))))))) +(deftest filter-by-expression-relative-time-interval-test + (testing "Datetime expressions can filter to a date range" + (mt/test-drivers + (disj (mt/normal-drivers-with-feature :date-arithmetics) :athena) + (mt/dataset + checkins:1-per-day + (let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id)) + query (as-> (lib/query mp (lib.metadata/table mp (mt/id :checkins))) $q + (lib/expression $q "customdate" (m/find-first (comp #{(mt/id :checkins :timestamp)} :id) + (lib/visible-columns $q))) + (lib/filter $q (lib/relative-time-interval + (lib/expression-ref $q "customdate") -1 :week -1 :week))) + mbql-query (mt/mbql-query + checkins + {:expressions {"customdate" $timestamp} + :filter [:relative-time-interval + [:expression "customdate" {:base-type :type/DateTime}] -1 :week -1 :week]}) + processed (qp/process-query query) + mbql-processed (qp/process-query mbql-query)] + (is (= 7 (count (mt/rows processed)))) + (is (= 7 (count (mt/rows mbql-processed)))) + (is (= (get-in (qp/process-query mbql-query) [:data :native_form]) + (get-in (qp/process-query (lib.convert/->pMBQL mbql-query)) [:data :native_form]) + (get-in (qp/process-query query) [:data :native_form])))))))) + ;; TODO -- is this really date BUCKETING? Does this BELONG HERE?! (deftest june-31st-test (testing "What happens when you try to add 3 months to March 31st? It should still work (#10072, #21968, #21969)" -- GitLab