diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index c7c11a7a08f3431ca2257227f58fd52029774029..f72aa65e406bb8cade9bacba30a29709ae80ca61 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -409,6 +409,7 @@ metabase.test.domain-entities test.de metabase.test.mock.util mock.util metabase.test.sync test.sync + metabase.test.util.js test.js metabase.test.util.timezone test.tz metabase.timeseries-query-processor-test.util tqpt metabase.transforms.core tf diff --git a/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js b/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js index 00a4acf71877e6b94573a2528a53f5d642ef5e38..e1095a3b79b1ad40a0c080c1617518b0a4114533 100644 --- a/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js +++ b/e2e/test/scenarios/dashboard/actions-on-dashboards.cy.spec.js @@ -110,7 +110,7 @@ const MODEL_NAME = "Test Action Model"; clickHelper("Update Score"); cy.findByRole("dialog").within(() => { - cy.findByLabelText("New score").type("55"); + cy.findByLabelText("New Score").type("55"); cy.button(ACTION_NAME).click(); }); diff --git a/e2e/test/scenarios/models/model-actions.cy.spec.js b/e2e/test/scenarios/models/model-actions.cy.spec.js index 0b0e05720fcb14ce376abfa7da4a16bba5fcf350..1bbebf6e3121f613c0e0276d48140a1c5686a449 100644 --- a/e2e/test/scenarios/models/model-actions.cy.spec.js +++ b/e2e/test/scenarios/models/model-actions.cy.spec.js @@ -294,7 +294,7 @@ describe( fillActionQuery("{{id}}"); cy.findByLabelText("#1-orders-model").should("not.exist"); cy.findByLabelText("101").should("not.exist"); - cy.findByLabelText("Id").should("be.visible"); + cy.findByLabelText("ID").should("be.visible"); }); }, ); diff --git a/frontend/src/metabase-lib/queries/NativeQuery.ts b/frontend/src/metabase-lib/queries/NativeQuery.ts index a6f4ff62310f2fee03f2b111721b1a203f8f61ec..56893803ce2bcd1421d41b67a9f509131ab01563 100644 --- a/frontend/src/metabase-lib/queries/NativeQuery.ts +++ b/frontend/src/metabase-lib/queries/NativeQuery.ts @@ -4,8 +4,7 @@ import { t } from "ttag"; import { assoc, assocIn, chain, getIn, updateIn } from "icepick"; import _ from "underscore"; import slugg from "slugg"; -import { humanize } from "metabase/lib/formatting"; -import Utils from "metabase/lib/utils"; +import * as ML from "cljs/metabase.lib.js"; import { ParameterValuesConfig } from "metabase-types/api"; import { Card, @@ -25,7 +24,6 @@ import AtomicQuery from "metabase-lib/queries/AtomicQuery"; import { getTemplateTagParameter } from "metabase-lib/parameters/utils/template-tags"; import Variable from "metabase-lib/variables/Variable"; import TemplateTagVariable from "metabase-lib/variables/TemplateTagVariable"; -import { createTemplateTag } from "metabase-lib/queries/TemplateTag"; import ValidationError from "metabase-lib/ValidationError"; import { isFieldReference } from "metabase-lib/references"; import Dimension, { FieldDimension, TemplateTagDimension } from "../Dimension"; @@ -47,30 +45,8 @@ export const NATIVE_QUERY_TEMPLATE: NativeDatasetQuery = { /////////////////////////// // QUERY TEXT TAG UTILS -const VARIABLE_TAG_REGEX: RegExp = /\{\{\s*([A-Za-z0-9_\.]+)\s*\}\}/g; -const SNIPPET_TAG_REGEX: RegExp = /\{\{\s*(snippet:\s*[^}]+)\s*\}\}/g; export const CARD_TAG_REGEX: RegExp = /\{\{\s*(#([0-9]*)(-[a-z0-9-]*)?)\s*\}\}/g; -const TAG_REGEXES: RegExp[] = [ - VARIABLE_TAG_REGEX, - SNIPPET_TAG_REGEX, - CARD_TAG_REGEX, -]; - -// look for variable usage in the query (like '{{varname}}'). we only allow alphanumeric characters for the variable name -// a variable name can optionally end with :start or :end which is not considered part of the actual variable name -// expected pattern is like mustache templates, so we are looking for something like {{category}} -// anything that doesn't match our rule is ignored, so {{&foo!}} would simply be ignored -// See unit tests for examples -export function recognizeTemplateTags(queryText: string): string[] { - const tagNames = TAG_REGEXES.flatMap(r => - Array.from(queryText.matchAll(r)), - ).map(m => m[1]); - return _.uniq(tagNames); -} - -// matches '#123-foo-bar' and '#123' but not '#123foo' -const CARD_TAG_NAME_REGEX: RegExp = /^#([0-9]*)(-[a-z0-9-]*)?$/; function tagRegex(tagName: string): RegExp { return new RegExp(`{{\\s*${tagName}\\s*}}`, "g"); @@ -87,23 +63,6 @@ function replaceTagName( return query.setQueryText(queryText); } -export function cardIdFromTagName(name: string): number | null { - const match = name.match(CARD_TAG_NAME_REGEX); - return parseInt(match?.[1]) || null; -} - -function isCardTagName(tagName: string): boolean { - return CARD_TAG_NAME_REGEX.test(tagName); -} - -function snippetNameFromTagName(name: string): string { - return name.slice("snippet:".length).trim(); -} - -function isSnippetTagName(name: string): boolean { - return name.startsWith("snippet:"); -} - export function updateCardTemplateTagNames( query: NativeQuery, cards: Card[], @@ -478,77 +437,9 @@ export default class NativeQuery extends AtomicQuery { * special handling for NATIVE cards to automatically detect parameters ... {{varname}} */ private _getUpdatedTemplateTags(queryText: string): TemplateTags { - if (queryText && this.supportsNativeParameters()) { - const tags = recognizeTemplateTags(queryText); - const existingTemplateTags = this.templateTagsMap(); - const existingTags = Object.keys(existingTemplateTags); - - // if we ended up with any variables in the query then update the card parameters list accordingly - if (tags.length > 0 || existingTags.length > 0) { - const newTags = _.difference(tags, existingTags); - - const oldTags = _.difference(existingTags, tags); - - const templateTags = { ...existingTemplateTags }; - - if (oldTags.length === 1 && newTags.length === 1) { - // renaming - const newTag = { ...templateTags[oldTags[0]] }; - - if (newTag["display-name"] === humanize(oldTags[0])) { - newTag["display-name"] = humanize(newTags[0]); - } - - newTag.name = newTags[0]; - - if (isCardTagName(newTag.name)) { - newTag.type = "card"; - newTag["card-id"] = cardIdFromTagName(newTag.name); - } else if (isSnippetTagName(newTag.name)) { - newTag.type = "snippet"; - newTag["snippet-name"] = snippetNameFromTagName(newTag.name); - } - - templateTags[newTag.name] = newTag; - delete templateTags[oldTags[0]]; - } else { - // remove old vars - for (const name of oldTags) { - delete templateTags[name]; - } - - // create new vars - for (const tagName of newTags) { - templateTags[tagName] = createTemplateTag(tagName); - - // parse card ID from tag name for card query template tags - if (isCardTagName(tagName)) { - templateTags[tagName] = Object.assign(templateTags[tagName], { - type: "card", - "card-id": cardIdFromTagName(tagName), - }); - } else if (isSnippetTagName(tagName)) { - // extract snippet name from snippet tag - templateTags[tagName] = Object.assign(templateTags[tagName], { - type: "snippet", - "snippet-name": snippetNameFromTagName(tagName), - }); - } - } - } - - // ensure all tags have an id since we need it for parameter values to work - for (const tag: TemplateTag of Object.values(templateTags)) { - if (tag.id == null) { - tag.id = Utils.uuid(); - } - } - - return templateTags; - } - } - - return {}; + return queryText && this.supportsNativeParameters() + ? ML.template_tags(queryText, this.templateTagsMap()) + : {}; } dependentMetadata(): DependentMetadataItem[] { diff --git a/frontend/src/metabase-lib/queries/TemplateTag.ts b/frontend/src/metabase-lib/queries/TemplateTag.ts deleted file mode 100644 index eae0aeeecc7de76c7a2cb23f8997f65b46076c3d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase-lib/queries/TemplateTag.ts +++ /dev/null @@ -1,12 +0,0 @@ -import Utils from "metabase/lib/utils"; -import { humanize } from "metabase/lib/formatting"; -import { TemplateTag } from "metabase-types/types/Query"; - -export const createTemplateTag = (tagName: string): TemplateTag => { - return { - id: Utils.uuid(), - name: tagName, - "display-name": humanize(tagName), - type: "text", - }; -}; diff --git a/frontend/src/metabase-lib/queries/TemplateTag.unit.spec.ts b/frontend/src/metabase-lib/queries/TemplateTag.unit.spec.ts deleted file mode 100644 index c36819b96077804f2969d72475de4a342d44976c..0000000000000000000000000000000000000000 --- a/frontend/src/metabase-lib/queries/TemplateTag.unit.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createTemplateTag } from "metabase-lib/queries/TemplateTag"; - -describe("createTemplateTag", () => { - it("should create a proper template tag", () => { - const tag = createTemplateTag("stars"); - expect(tag.name).toEqual("stars"); - expect(tag.type).toEqual("text"); - expect(typeof tag.id).toEqual("string"); - expect(tag["display-name"]).toEqual("Stars"); - }); -}); diff --git a/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js b/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js index fab98aab68aa7c77e596de291aee8df981f7eb4d..5897dee4f49ffdb226757223ac13491bd5897f94 100644 --- a/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/NativeQuery.unit.spec.js @@ -7,8 +7,6 @@ import { } from "__support__/sample_database_fixture"; import NativeQuery, { - recognizeTemplateTags, - cardIdFromTagName, updateCardTemplateTagNames, } from "metabase-lib/queries/NativeQuery"; @@ -177,7 +175,7 @@ describe("NativeQuery", () => { ); const tagMaps = newQuery.templateTagsMap(); expect(tagMaps["max_price"].name).toEqual("max_price"); - expect(tagMaps["max_price"]["display-name"]).toEqual("Max price"); + expect(tagMaps["max_price"]["display-name"]).toEqual("Max Price"); }); }); @@ -241,7 +239,7 @@ describe("NativeQuery", () => { { "snippet-name": snippetName, "display-name": displayName, type }, ] = q.templateTags(); expect(snippetName).toEqual("foo"); - expect(displayName).toEqual("Snippet: foo "); + expect(displayName).toEqual("Snippet: Foo"); expect(type).toEqual("snippet"); }); it("should update query text with new snippet names", () => { @@ -378,57 +376,4 @@ describe("NativeQuery", () => { expect(barTag["name"]).toEqual("#1234-bar"); // bar's name is the same }); }); - - describe("recognizeTemplateTags", () => { - it("should handle standard variable names", () => { - expect(recognizeTemplateTags("SELECT * from {{products}}")).toEqual([ - "products", - ]); - }); - - it("should allow duplicated variables", () => { - expect( - recognizeTemplateTags("SELECT {{col}} FROM {{t}} ORDER BY {{col}} "), - ).toEqual(["col", "t"]); - }); - - it("should ignore non-alphanumeric markers", () => { - expect(recognizeTemplateTags("SELECT * from X -- {{&universe}}")).toEqual( - [], - ); - }); - - it("should handle snippets", () => { - expect( - recognizeTemplateTags( - "SELECT * from {{snippet: A snippet name}} cross join {{ snippet: another-snippet with *&#) }}", - ), - ).toEqual([ - "snippet: A snippet name", - "snippet: another-snippet with *&#) ", - ]); - }); - - it("should handle card references", () => { - expect( - recognizeTemplateTags( - "SELECT * from {{#123}} cross join {{ #456-a-card-name }} cross join {{#not-this}} cross join {{#123or-this}}", - ), - ).toEqual(["#123", "#456-a-card-name"]); - }); - }); - - describe("cardIdFromTagName", () => { - it("should get card Ids from a card tag name", () => { - expect(cardIdFromTagName("#123-foo")).toEqual(123); - expect(cardIdFromTagName("#123-foo-456")).toEqual(123); - expect(cardIdFromTagName("#123")).toEqual(123); - }); - - it("should return null for invalid card tag names", () => { - expect(cardIdFromTagName("123-foo")).toEqual(null); - expect(cardIdFromTagName("#123foo")).toEqual(null); - expect(cardIdFromTagName("123")).toEqual(null); - }); - }); }); diff --git a/src/metabase/domain_entities/converters.cljs b/src/metabase/domain_entities/converters.cljs index 631df8c12b6990b08335f31cbab8939a0043c8a1..14b60ad080bf609544ad0e576e067ce9447ac909 100644 --- a/src/metabase/domain_entities/converters.cljs +++ b/src/metabase/domain_entities/converters.cljs @@ -5,27 +5,39 @@ [malli.transform :as mtx])) (defn- decode-map [schema _] - (let [drop-nil? (set (for [[map-key props val-schema] (mc/children schema) - :when (and (:optional props) - (not (#{'nil? :nil} (mc/type val-schema))))] - map-key))] + (let [by-prop (into {} (for [[map-key props] (mc/children schema)] + [(or (get props :js/prop) + (csk/->snake_case_string map-key)) + {:map-key map-key}]))] {:enter (fn [x] (cond - (map? x) x - (object? x) (into {} (for [prop (js/Object.keys x) - :let [js-val (unchecked-get x prop) - map-key (csk/->kebab-case-keyword prop)] - ;; If the value is nil, and it's both :optional and not a :maybe, - ;; we just discard the value. - :when (not (and (nil? js-val) - (drop-nil? map-key)))] - [map-key js-val])))) + (map? x) x + (object? x) + (into {} (for [prop (js-keys x) + :let [js-val (unchecked-get x prop) + map-key (or (get-in by-prop [prop :map-key]) + (csk/->kebab-case-keyword prop))]] + [map-key js-val])))) :leave (fn [x] - (if (map? x) - x + (if (object? x) (throw (ex-info "decode-map leaving with a JS object not a CLJS map" {:value x - :schema schema}))))})) + :schema (mc/form schema)})) + x))})) + +(defn- infer-child-decoder [schema _] + (let [mapping (into {} (for [c (mc/children schema)] + (if (keyword? c) + [(name c) c] + [c c])))] + {:enter #(mapping % %)})) + +(defn- infer-child-encoder [schema _] + (let [mapping (into {} (for [c (mc/children schema)] + (if (keyword? c) + [c (name c)] + [c c])))] + {:enter #(mapping % %)})) (defn- decode-map-of [keydec x] (cond @@ -42,6 +54,14 @@ #js {} x))) +(def ^:private identity-transformers + (-> ['string? :string + 'number? :number + 'int? :int + 'double? :double + 'float? :float] + (zipmap (repeat {:enter identity})))) + (def js-transformer "Malli transformer for converting JavaScript data to and from CLJS data. @@ -72,32 +92,44 @@ they are JS arrays." (mtx/transformer {:name :js - :decoders {:keyword keyword - 'keyword? keyword - :qualified-keyword keyword - :vector {:enter vec} - :sequential {:enter vec} - :tuple {:enter vec} - :map {:compile decode-map} - :map-of {:compile (fn [schema _] - (let [[key-schema] (mc/children schema) - keydec (mc/decoder key-schema js-transformer)] - {:enter #(decode-map-of keydec %)}))}} - :encoders {:keyword name - 'keyword? name - :qualified-keyword #(str (namespace %) "/" (name %)) - :vector {:leave clj->js} - :sequential {:leave clj->js} - :tuple {:leave clj->js} - :map {:compile - (fn [schema _] - (let [js-props (into {} (for [[k props] (mc/children schema) - :when (:js/prop props)] - [k (:js/prop props)])) - keyenc (fn [k] (or (get js-props k) - (csk/->snake_case_string k)))] - {:leave #(encode-map % keyenc)}))} - :map-of {:leave #(encode-map % name)}}})) + :decoders + (merge identity-transformers + {:keyword keyword + 'keyword? keyword + :qualified-keyword keyword + :uuid parse-uuid + :vector {:enter #(and % (vec %))} + :sequential {:enter #(and % (vec %))} + :tuple {:enter #(and % (vec %))} + :cat {:enter #(and % (vec %))} + :catn {:enter #(and % (vec %))} + :enum {:compile infer-child-decoder} + := {:compile infer-child-decoder} + :map {:compile decode-map} + :map-of {:compile (fn [schema _] + (let [[key-schema] (mc/children schema) + keydec (mc/decoder key-schema js-transformer)] + {:enter #(decode-map-of keydec %)}))}}) + :encoders + (merge identity-transformers + {:keyword name + 'keyword? name + :qualified-keyword #(str (namespace %) "/" (name %)) + :uuid str + :vector {:leave clj->js} + :sequential {:leave clj->js} + :tuple {:leave clj->js} + :enum {:compile infer-child-encoder} + := {:compile infer-child-encoder} + :map {:compile + (fn [schema _] + (let [js-props (into {} (for [[k props] (mc/children schema) + :when (:js/prop props)] + [k (:js/prop props)])) + keyenc (fn [k] (or (get js-props k) + (csk/->snake_case_string k)))] + {:leave #(encode-map % keyenc)}))} + :map-of {:leave #(encode-map % name)}})})) (defn incoming "Returns a function for converting a JS value into CLJS data structures, based on a schema." diff --git a/src/metabase/lib/core.cljc b/src/metabase/lib/core.cljc index b1f7100d344efe47c5402986e1c442ff5b3fea80..03f01df2858bc60d684bfed7d48b44b07d192e6a 100644 --- a/src/metabase/lib/core.cljc +++ b/src/metabase/lib/core.cljc @@ -14,6 +14,7 @@ [metabase.lib.limit :as lib.limit] [metabase.lib.metadata.calculation :as lib.metadata.calculation] [metabase.lib.metric :as lib.metric] + [metabase.lib.native :as lib.native] [metabase.lib.order-by :as lib.order-by] [metabase.lib.query :as lib.query] [metabase.lib.segment :as lib.segment] @@ -32,6 +33,7 @@ lib.limit/keep-me lib.metadata.calculation/keep-me lib.metric/keep-me + lib.native/keep-me lib.order-by/keep-me lib.query/keep-me lib.segment/keep-me @@ -136,6 +138,11 @@ describe-top-level-key display-name suggested-name] + [lib.native + #?@(:cljs [->TemplateTags + TemplateTags->]) + recognize-template-tags + template-tags] [lib.order-by order-by order-by-clause diff --git a/src/metabase/lib/js.cljs b/src/metabase/lib/js.cljs index 4c1c08312789e0ce73f696931f2f1183698d1953..9014717d41022f6285249b14bf27e690f09ad021 100644 --- a/src/metabase/lib/js.cljs +++ b/src/metabase/lib/js.cljs @@ -13,6 +13,38 @@ ;;; this is mostly to ensure all the relevant namespaces with multimethods impls get loaded. (comment lib.core/keep-me) +;; TODO: This pattern of "re-export some function and slap a `clj->js` at the end" is going to keep appearing. +;; Generalize the machinery in `metabase.domain-entities.malli` to handle this case, so we get schema-powered automatic +;; conversion for incoming args and outgoing return values. I'm imagining something like +;; `(mu/js-export lib.core/recognize-template-tags)` where that function has a Malli schema and it works like +;; `metabase.shared.util.namespaces/import-fn` plus wrapping it with conversion for all args and the return value. +(defn ^:export recognize-template-tags + "Given the text of a native query, extract a possibly-empty set of template tag strings from it. + + These looks like mustache templates. For variables, we only allow alphanumeric characters, eg. `{{foo}}`. + For snippets they start with `snippet:`, eg. `{{ snippet: arbitrary text here }}`. + And for card references either `{{ #123 }}` or with the optional human label `{{ #123-card-title-slug }}`. + + Invalid patterns are simply ignored, so something like `{{&foo!}}` is just disregarded." + [query-text] + (-> query-text + lib.core/recognize-template-tags + clj->js)) + +(defn ^:export template-tags + "Extract the template tags from a native query's text. + + If the optional map of existing tags previously parsed is given, this will reuse the existing tags where + they match up with the new one (in particular, it will preserve the UUIDs). + + See [[recognize-template-tags]] for how the tags are parsed." + ([query-text] (template-tags query-text {})) + ([query-text existing-tags] + (->> existing-tags + lib.core/->TemplateTags + (lib.core/template-tags query-text) + lib.core/TemplateTags->))) + (defn ^:export suggestedName "Return a nice description of a query." [query] diff --git a/src/metabase/lib/native.cljc b/src/metabase/lib/native.cljc new file mode 100644 index 0000000000000000000000000000000000000000..dd7e486ea3c30ab5cbc25cfcfa5738adb45fb620 --- /dev/null +++ b/src/metabase/lib/native.cljc @@ -0,0 +1,125 @@ +(ns metabase.lib.native + "Functions for working with native queries." + (:require + [clojure.set :as set] + [clojure.string :as str] + [medley.core :as m] + [metabase.lib.schema.common :as common] + [metabase.util.humanization :as u.humanization] + [metabase.util.malli :as mu] + #?@(:cljs ([metabase.domain-entities.converters :as converters])))) + +(def ^:private TemplateTag + [:map + [:type [:enum :text :snippet :card]] + [:id :uuid] + [:name ::common/non-blank-string] + [:display-name {:js/prop "display-name" :optional true} ::common/non-blank-string] + [:snippet-name {:js/prop "snippet-name" :optional true} ::common/non-blank-string] + [:card-id {:js/prop "card-id" :optional true} :int] + [:dimension {:optional true} :any] + [:widget-type {:js/prop "widget-type" :optional true} :string]]) + +(def ^:private TemplateTags + [:map-of :string TemplateTag]) + +(def ^:private variable-tag-regex + #"\{\{\s*([A-Za-z0-9_\.]+)\s*\}\}") + +(def ^:private snippet-tag-regex + #"\{\{\s*(snippet:\s*[^}]+)\s*\}\}") + +(def ^:private card-tag-regex + #"\{\{\s*(#([0-9]*)(-[a-z0-9-]*)?)\s*\}\}") + +(def ^:private tag-regexes + [variable-tag-regex snippet-tag-regex card-tag-regex]) + +(mu/defn recognize-template-tags :- [:set ::common/non-blank-string] + "Given the text of a native query, extract a possibly-empty set of template tag strings from it." + [query-text :- ::common/non-blank-string] + (into #{} + (comp (mapcat #(re-seq % query-text)) + (map second)) + tag-regexes)) + +(defn- tag-name->card-id [tag-name] + (when-let [[_ id-str] (re-matches #"^#(\d+)(-[a-z0-9-]*)?$" tag-name)] + (parse-long id-str))) + +(defn- tag-name->snippet-name [tag-name] + (when (str/starts-with? tag-name "snippet:") + (str/trim (subs tag-name (count "snippet:"))))) + +(defn- fresh-tag [tag-name] + {:type :text + :name tag-name + :id (m/random-uuid)}) + +(defn- finish-tag [{tag-name :name :as tag}] + (merge tag + (when-let [card-id (tag-name->card-id tag-name)] + {:type :card + :card-id card-id}) + (when-let [snippet-name (tag-name->snippet-name tag-name)] + {:type :snippet + :snippet-name snippet-name}) + (when-not (:display-name tag) + {:display-name (u.humanization/name->human-readable-name :simple tag-name)}))) + +(defn- rename-template-tag + [existing-tags old-name new-name] + (let [old-tag (get existing-tags old-name) + display-name (if (= (:display-name old-tag) + (u.humanization/name->human-readable-name :simple old-name)) + ;; Replace the display name if it was the default; keep it if customized. + (u.humanization/name->human-readable-name :simple new-name) + (:display-name old-tag)) + new-tag (-> old-tag + (dissoc :snippet-name :card-id) + (assoc :display-name display-name + :name new-name))] + (-> existing-tags + (dissoc old-name) + (assoc new-name new-tag)))) + +(defn- unify-template-tags + [query-tag-names existing-tags existing-tag-names] + (let [new-tags (set/difference query-tag-names existing-tag-names) + old-tags (set/difference existing-tag-names query-tag-names) + tags (if (= 1 (count new-tags) (count old-tags)) + ;; With exactly one change, we treat it as a rename. + (rename-template-tag existing-tags (first old-tags) (first new-tags)) + ;; With more than one change, just drop the old ones and add the new. + (merge (m/remove-keys old-tags existing-tags) + (m/index-by :name (map fresh-tag new-tags))))] + (update-vals tags finish-tag))) + +(mu/defn ^:export template-tags :- TemplateTags + "Extract the template tags from a native query's text. + + If the optional map of existing tags previously parsed is given, this will reuse the existing tags where + they match up with the new one (in particular, it will preserve the UUIDs). + + See [[recognize-template-tags]] for how the tags are parsed." + ([query-text :- ::common/non-blank-string] + (template-tags query-text nil)) + ([query-text :- ::common/non-blank-string + existing-tags :- [:maybe TemplateTags]] + (let [query-tag-names (not-empty (recognize-template-tags query-text)) + existing-tag-names (not-empty (set (keys existing-tags)))] + (if (or query-tag-names existing-tag-names) + ;; If there's at least some tags, unify them. + (unify-template-tags query-tag-names existing-tags existing-tag-names) + ;; Otherwise just an empty map, no tags. + {})))) + +#?(:cljs + (do + (def ->TemplateTags + "Converter to a map of `TemplateTag`s keyed by their string names." + (converters/incoming TemplateTags)) + + (def TemplateTags-> + "Converter from a map of `TemplateTag`s keyed by their string names to vanilla JS." + (converters/outgoing TemplateTags)))) diff --git a/test/metabase/domain_entities/converters_test.cljs b/test/metabase/domain_entities/converters_test.cljs index aa115f27d86c444d1670a668e03a81d409a8218f..af6c890bb36a8c558f03cb794623cc52cad2fcf8 100644 --- a/test/metabase/domain_entities/converters_test.cljs +++ b/test/metabase/domain_entities/converters_test.cljs @@ -1,7 +1,8 @@ (ns metabase.domain-entities.converters-test (:require - [clojure.test :refer [deftest is testing]] - [metabase.domain-entities.converters :as converters])) + [clojure.test :refer [deftest is testing]] + [metabase.domain-entities.converters :as converters] + [metabase.test.util.js :as test.js])) (deftest incoming-basics-test (testing "simple values are not transformed" @@ -37,7 +38,6 @@ (is (= kw (-> kw kw-> ->kw))) (is (= s (-> s ->kw kw->))))))) - (def HalfDeclared [:map [:declared-camel {:js/prop "declaredCamel"} string?] @@ -47,29 +47,6 @@ (def ->half-declared (converters/incoming HalfDeclared)) -(defmulti js= (fn [a b] - (let [ta (type a) - tb (type b)] - (if (= ta tb) - (.-name ta) - ::mismatched)))) - -(defmethod js= :default [a b] - (= a b)) - -(defmethod js= ::mismatched [_ _] - false) - -(defmethod js= "Array" [a b] - (and (= (count a) (count b)) - (empty? (filter false? (map js= a b))))) - -(defmethod js= "Object" [a b] - (and (every? (fn [k] (and (.hasOwnProperty b k) (js= (unchecked-get a k) (unchecked-get b k)))) - (js/Object.keys a)) - (every? (fn [k] (and (.hasOwnProperty a k) (js= (unchecked-get a k) (unchecked-get b k)))) - (js/Object.keys b)))) - (deftest map-basics-test (testing "incoming maps" (testing "become CLJS maps" @@ -125,13 +102,13 @@ (testing "are converted per the schema by :js/prop; defaulting to snake_case" (let [adjusted (assoc obj :declared-camel "no")] (is (not (identical? obj adjusted))) - (is (js= #js {"declaredCamel" "no" - "declared_snake" "also" - "declared-kebab" "finally" - "undeclared_camel" 7 - "undeclared_snake" 8 - "undeclared_kebab" 9} - ((converters/outgoing HalfDeclared) adjusted)))))))) + (is (test.js/= #js {"declaredCamel" "no" + "declared_snake" "also" + "declared-kebab" "finally" + "undeclared_camel" 7 + "undeclared_snake" 8 + "undeclared_kebab" 9} + ((converters/outgoing HalfDeclared) adjusted)))))))) (def Child [:map [:inner-value {:js/prop "innerValue"} string?]]) @@ -148,7 +125,7 @@ exp-clj {:parent {:child {:inner-value "asdf"}}} converted ((converters/incoming Grandparent) input)] (is (= exp-clj converted)) - (is (js= input ((converters/outgoing Grandparent) converted))))) + (is (test.js/= input ((converters/outgoing Grandparent) converted))))) (testing "nesting kitchen sink" (let [schema [:map @@ -208,7 +185,7 @@ (is (map? converted)) (is (= exp converted))) (testing "round-trips as expected" - (is (js= input (-> input ->sink sink->))))))) + (is (test.js/= input (-> input ->sink sink->))))))) (deftest idempotency-test (testing "CLJS maps are not further converted" @@ -236,3 +213,27 @@ (is (identical? js-obj (-> converted :wrapper :inner))) (is (identical? js-obj (let [^Object wrapper (.-wrapper returned)] (.-inner wrapper))))))) + +(deftest uuid-test + (testing "UUIDs are converted to strings in JS and back to #uuid objects in CLJS" + (let [uuid (random-uuid)] + (is (= (str uuid) + ((converters/outgoing :uuid) uuid))) + (is (= uuid + ((converters/incoming :uuid) (str uuid)))))) + + (testing "UUIDs nested in maps work too" + (let [uuid (random-uuid) + schema [:map [:id :uuid]]] + (is (test.js/= #js {:id (str uuid)} + ((converters/outgoing schema) {:id uuid}))) + (is (= {:id uuid} + ((converters/incoming schema) #js {:id (str uuid)}))))) + + (testing "UUIDs nested in maps inside a map-of work too" + (let [uuid (random-uuid) + schema [:map-of :string [:map [:id :uuid]]]] + (is (test.js/= #js{"abc" #js {:id (str uuid)}} + ((converters/outgoing schema) {"abc" {:id uuid}}))) + (is (= {"abc" {:id uuid}} + ((converters/incoming schema) #js {"abc" #js {:id (str uuid)}})))))) diff --git a/test/metabase/lib/native_test.cljc b/test/metabase/lib/native_test.cljc new file mode 100644 index 0000000000000000000000000000000000000000..f9da568fd8a1050dcfa364aa5a84ae579ee504af --- /dev/null +++ b/test/metabase/lib/native_test.cljc @@ -0,0 +1,163 @@ +(ns metabase.lib.native-test + (:require + [clojure.test :refer [are deftest is testing]] + [metabase.lib.native :as lib.native] + #?@(:clj ([medley.core :as m] + [metabase.util.humanization :as u.humanization]) + :cljs ([metabase.test.util.js :as test.js])))) + +(deftest ^:parallel variable-tag-test + (are [exp input] (= exp (lib.native/recognize-template-tags input)) + #{"foo"} "SELECT * FROM table WHERE {{foo}} AND some_field IS NOT NULL" + #{"foo" "bar"} "SELECT * FROM table WHERE {{foo}} AND some_field = {{bar}}" + ;; Duplicates are flattened. + #{"foo" "bar"} "SELECT * FROM table WHERE {{foo}} AND some_field = {{bar }} OR {{ foo}}" + ;; Ignoring non-alphanumeric vars + #{} "SELECT * FROM table WHERE {{&foo}}")) + +(deftest ^:parallel snippet-tag-test + (are [exp input] (= exp (lib.native/recognize-template-tags input)) + #{"snippet: foo "} "SELECT * FROM table WHERE {{snippet: foo }} AND some_field IS NOT NULL" + #{"snippet: foo *#&@"} "SELECT * FROM table WHERE {{snippet: foo *#&@}}" + ;; TODO: This logic should trim the whitespace and unify these two snippet names. + ;; I think this is a bug in the original code but am aiming to reproduce it exactly for now. + #{"snippet: foo" "snippet:foo"} "SELECT * FROM table WHERE {{snippet: foo}} AND {{snippet:foo}}")) + +(deftest ^:parallel card-tag-test + (are [exp input] (= exp (lib.native/recognize-template-tags input)) + #{"#123"} "SELECT * FROM table WHERE {{ #123 }} AND some_field IS NOT NULL" + ;; TODO: This logic should trim the whitespace and unify these two card tags. + ;; I think this is a bug in the original code but am aiming to reproduce it exactly for now. + #{"#123" "#123-with-slug"} "SELECT * FROM table WHERE {{ #123 }} AND {{ #123-with-slug }}" + #{"#123"} "SELECT * FROM table WHERE {{ #not-this }} AND {{#123}}" + #{} "{{ #123foo }}")) + +#?(:clj + ;; TODO: This is only CLJ-only because =? from Hawk is not available in CLJS currently. + ;; I don't think there's any reason why it can't work there too. + (deftest ^:parallel template-tags-test + (testing "snippet tags" + (is (=? {"snippet:foo" {:type :snippet + :name "snippet:foo" + :snippet-name "foo" + :id uuid?}} + (lib.native/template-tags "SELECT * FROM table WHERE {{snippet:foo}}"))) + (is (=? {"snippet:foo" {:type :snippet + :name "snippet:foo" + :snippet-name "foo" + :id uuid?} + "snippet: foo" {:type :snippet + :name "snippet: foo" + :snippet-name "foo" + :id uuid?}} + ;; TODO: This should probably be considered a bug - whitespace matters for the name. + (lib.native/template-tags "SELECT * FROM {{snippet: foo}} WHERE {{snippet:foo}}")))) + + (testing "renaming a variable" + (let [old-tag {:type :text + :name "foo" + :display-name "Foo" + :id (m/random-uuid)}] + (testing "changes display-name if the original is not customized" + (is (=? {"bar" {:type :text + :name "bar" + :display-name "Bar" + :id (:id old-tag)}} + (lib.native/template-tags "SELECT * FROM {{bar}}" + {"foo" old-tag})))) + (testing "keeps display-name if it's customized" + (is (=? {"bar" {:type :text + :name "bar" + :display-name "Custom Name" + :id (:id old-tag)}} + (lib.native/template-tags "SELECT * FROM {{bar}}" + {"foo" (assoc old-tag :display-name "Custom Name")})))) + + (testing "works with other variables present, if they don't change" + (let [other {:type :text + :name "other" + :display-name "Some Var" + :id (m/random-uuid)}] + (is (=? {"other" other + "bar" {:type :text + :name "bar" + :display-name "Bar" + :id (:id old-tag)}} + (lib.native/template-tags "SELECT * FROM {{bar}} AND field = {{other}}" + {"foo" old-tag + "other" other}))))))) + + (testing "general case, add and remove" + (let [mktag (fn [base] + (merge {:type :text + :display-name (u.humanization/name->human-readable-name :simple (:name base)) + :id uuid?} + base)) + v1 (mktag {:name "foo"}) + v2 (mktag {:name "bar"}) + v3 (mktag {:name "baz"}) + s1 (mktag {:name "snippet:first snippet" + :snippet-name "first snippet" + :type :snippet}) + s2 (mktag {:name "snippet:another snippet" + :snippet-name "another snippet" + :type :snippet}) + + c1 (mktag {:name "#123-card-1" + :type :card + :card-id 123}) + c2 (mktag {:name "#321" + :type :card + :card-id 321})] + (is (=? {"foo" v1 + "#123-card-1" c1 + "snippet:first snippet" s1} + (lib.native/template-tags + "SELECT * FROM {{#123-card-1}} WHERE {{foo}} AND {{ snippet:first snippet}}"))) + (is (=? {"bar" v2 + "baz" v3 + "snippet:another snippet" s2 + "#321" c2} + (lib.native/template-tags + "SELECT * FROM {{#321}} WHERE {{baz}} AND {{bar}} AND {{snippet:another snippet}}" + {"foo" (assoc v1 :id (random-uuid)) + "#123-card-1" (assoc c1 :id (random-uuid)) + "snippet:first snippet" (assoc s1 :id (random-uuid))}))))))) + +#?(:cljs + (deftest converters-test + (let [clj-tags {"a" {:id #uuid "c5ad010c-632a-4498-b667-9188fbe965f9" + :name "a" + :display-name "A" + :type :text} + "#123-foo" {:id #uuid "7e58e086-5d63-4986-8fe7-87e05dfa4089" + :name "#123-foo" + :display-name "#123-foo" + :type :card + :card-id 123} + "snippet:b" {:id #uuid "604131d0-a74c-4822-b113-8e9515b1a985" + :name "snippet:b" + :display-name "Snippet B" + :type :snippet + :snippet-name "b"}} + js-tags #js {"a" #js {"id" "c5ad010c-632a-4498-b667-9188fbe965f9" + "name" "a" + "display-name" "A" + "type" "text"} + "#123-foo" #js {"id" "7e58e086-5d63-4986-8fe7-87e05dfa4089" + "name" "#123-foo" + "display-name" "#123-foo" + "type" "card" + "card-id" 123} + "snippet:b" #js {"id" "604131d0-a74c-4822-b113-8e9515b1a985" + "name" "snippet:b" + "display-name" "Snippet B" + "type" "snippet" + "snippet-name" "b"}}] + (testing "incoming converter works" + (is (= clj-tags (#'lib.native/->TemplateTags js-tags)))) + (testing "outgoing converter works" + (is (test.js/= js-tags (#'lib.native/TemplateTags-> clj-tags)))) + (testing "round trips work" + (is (= clj-tags (-> clj-tags (#'lib.native/TemplateTags->) (#'lib.native/->TemplateTags)))) + (is (test.js/= js-tags (-> js-tags (#'lib.native/->TemplateTags) (#'lib.native/TemplateTags->)))))))) diff --git a/test/metabase/test/util/js.cljs b/test/metabase/test/util/js.cljs new file mode 100644 index 0000000000000000000000000000000000000000..ff5b0a8db9e55f62a19a4fc9ee94c1bc6d0672a6 --- /dev/null +++ b/test/metabase/test/util/js.cljs @@ -0,0 +1,26 @@ +(ns metabase.test.util.js + "Test utils for CLJS." + (:refer-clojure :exclude [=])) + +(defmulti = (fn [a b] + (let [ta (type a) + tb (type b)] + (if (clojure.core/= ta tb) + (.-name ta) + ::mismatched)))) + +(defmethod = :default [a b] + (clojure.core/= a b)) + +(defmethod = ::mismatched [_ _] + false) + +(defmethod = "Array" [a b] + (and (clojure.core/= (count a) (count b)) + (empty? (filter false? (map = a b))))) + +(defmethod = "Object" [a b] + (and (every? (fn [k] (and (.hasOwnProperty b k) (= (unchecked-get a k) (unchecked-get b k)))) + (js/Object.keys a)) + (every? (fn [k] (and (.hasOwnProperty a k) (= (unchecked-get a k) (unchecked-get b k)))) + (js/Object.keys b))))