Skip to content
Snippets Groups Projects
Unverified Commit 900651ca authored by Braden Shepherdson's avatar Braden Shepherdson Committed by GitHub
Browse files

[MLv2] Move template tag extraction into CLJC for use from BE (#29526)

This supports both parsing out the tags and constructing the template tags map, with `card-id` and all.

This is not quite a FE no-op because the humanization of text is slightly different between `metabase.util.humanization` and the JS humanization library.

This improves JS<->CLJS conversion to handle UUIDs and be smarter about enums, both of which were needed to handle converting template tag maps.

Extracted a `js=` test helper to `metabase.test.util.js/=` since many tests need to compare JS objects, which are not JS `==` to each other.
parent 1df0e1d1
No related branches found
No related tags found
No related merge requests found
Showing
with 472 additions and 272 deletions
...@@ -409,6 +409,7 @@ ...@@ -409,6 +409,7 @@
metabase.test.domain-entities test.de metabase.test.domain-entities test.de
metabase.test.mock.util mock.util metabase.test.mock.util mock.util
metabase.test.sync test.sync metabase.test.sync test.sync
metabase.test.util.js test.js
metabase.test.util.timezone test.tz metabase.test.util.timezone test.tz
metabase.timeseries-query-processor-test.util tqpt metabase.timeseries-query-processor-test.util tqpt
metabase.transforms.core tf metabase.transforms.core tf
......
...@@ -110,7 +110,7 @@ const MODEL_NAME = "Test Action Model"; ...@@ -110,7 +110,7 @@ const MODEL_NAME = "Test Action Model";
clickHelper("Update Score"); clickHelper("Update Score");
cy.findByRole("dialog").within(() => { cy.findByRole("dialog").within(() => {
cy.findByLabelText("New score").type("55"); cy.findByLabelText("New Score").type("55");
cy.button(ACTION_NAME).click(); cy.button(ACTION_NAME).click();
}); });
......
...@@ -294,7 +294,7 @@ describe( ...@@ -294,7 +294,7 @@ describe(
fillActionQuery("{{id}}"); fillActionQuery("{{id}}");
cy.findByLabelText("#1-orders-model").should("not.exist"); cy.findByLabelText("#1-orders-model").should("not.exist");
cy.findByLabelText("101").should("not.exist"); cy.findByLabelText("101").should("not.exist");
cy.findByLabelText("Id").should("be.visible"); cy.findByLabelText("ID").should("be.visible");
}); });
}, },
); );
......
...@@ -4,8 +4,7 @@ import { t } from "ttag"; ...@@ -4,8 +4,7 @@ import { t } from "ttag";
import { assoc, assocIn, chain, getIn, updateIn } from "icepick"; import { assoc, assocIn, chain, getIn, updateIn } from "icepick";
import _ from "underscore"; import _ from "underscore";
import slugg from "slugg"; import slugg from "slugg";
import { humanize } from "metabase/lib/formatting"; import * as ML from "cljs/metabase.lib.js";
import Utils from "metabase/lib/utils";
import { ParameterValuesConfig } from "metabase-types/api"; import { ParameterValuesConfig } from "metabase-types/api";
import { import {
Card, Card,
...@@ -25,7 +24,6 @@ import AtomicQuery from "metabase-lib/queries/AtomicQuery"; ...@@ -25,7 +24,6 @@ import AtomicQuery from "metabase-lib/queries/AtomicQuery";
import { getTemplateTagParameter } from "metabase-lib/parameters/utils/template-tags"; import { getTemplateTagParameter } from "metabase-lib/parameters/utils/template-tags";
import Variable from "metabase-lib/variables/Variable"; import Variable from "metabase-lib/variables/Variable";
import TemplateTagVariable from "metabase-lib/variables/TemplateTagVariable"; import TemplateTagVariable from "metabase-lib/variables/TemplateTagVariable";
import { createTemplateTag } from "metabase-lib/queries/TemplateTag";
import ValidationError from "metabase-lib/ValidationError"; import ValidationError from "metabase-lib/ValidationError";
import { isFieldReference } from "metabase-lib/references"; import { isFieldReference } from "metabase-lib/references";
import Dimension, { FieldDimension, TemplateTagDimension } from "../Dimension"; import Dimension, { FieldDimension, TemplateTagDimension } from "../Dimension";
...@@ -47,30 +45,8 @@ export const NATIVE_QUERY_TEMPLATE: NativeDatasetQuery = { ...@@ -47,30 +45,8 @@ export const NATIVE_QUERY_TEMPLATE: NativeDatasetQuery = {
/////////////////////////// ///////////////////////////
// QUERY TEXT TAG UTILS // 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 = export const CARD_TAG_REGEX: RegExp =
/\{\{\s*(#([0-9]*)(-[a-z0-9-]*)?)\s*\}\}/g; /\{\{\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 { function tagRegex(tagName: string): RegExp {
return new RegExp(`{{\\s*${tagName}\\s*}}`, "g"); return new RegExp(`{{\\s*${tagName}\\s*}}`, "g");
...@@ -87,23 +63,6 @@ function replaceTagName( ...@@ -87,23 +63,6 @@ function replaceTagName(
return query.setQueryText(queryText); 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( export function updateCardTemplateTagNames(
query: NativeQuery, query: NativeQuery,
cards: Card[], cards: Card[],
...@@ -478,77 +437,9 @@ export default class NativeQuery extends AtomicQuery { ...@@ -478,77 +437,9 @@ export default class NativeQuery extends AtomicQuery {
* special handling for NATIVE cards to automatically detect parameters ... {{varname}} * special handling for NATIVE cards to automatically detect parameters ... {{varname}}
*/ */
private _getUpdatedTemplateTags(queryText: string): TemplateTags { private _getUpdatedTemplateTags(queryText: string): TemplateTags {
if (queryText && this.supportsNativeParameters()) { return queryText && this.supportsNativeParameters()
const tags = recognizeTemplateTags(queryText); ? ML.template_tags(queryText, this.templateTagsMap())
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 {};
} }
dependentMetadata(): DependentMetadataItem[] { dependentMetadata(): DependentMetadataItem[] {
......
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",
};
};
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");
});
});
...@@ -7,8 +7,6 @@ import { ...@@ -7,8 +7,6 @@ import {
} from "__support__/sample_database_fixture"; } from "__support__/sample_database_fixture";
import NativeQuery, { import NativeQuery, {
recognizeTemplateTags,
cardIdFromTagName,
updateCardTemplateTagNames, updateCardTemplateTagNames,
} from "metabase-lib/queries/NativeQuery"; } from "metabase-lib/queries/NativeQuery";
...@@ -177,7 +175,7 @@ describe("NativeQuery", () => { ...@@ -177,7 +175,7 @@ describe("NativeQuery", () => {
); );
const tagMaps = newQuery.templateTagsMap(); const tagMaps = newQuery.templateTagsMap();
expect(tagMaps["max_price"].name).toEqual("max_price"); 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", () => { ...@@ -241,7 +239,7 @@ describe("NativeQuery", () => {
{ "snippet-name": snippetName, "display-name": displayName, type }, { "snippet-name": snippetName, "display-name": displayName, type },
] = q.templateTags(); ] = q.templateTags();
expect(snippetName).toEqual("foo"); expect(snippetName).toEqual("foo");
expect(displayName).toEqual("Snippet: foo "); expect(displayName).toEqual("Snippet: Foo");
expect(type).toEqual("snippet"); expect(type).toEqual("snippet");
}); });
it("should update query text with new snippet names", () => { it("should update query text with new snippet names", () => {
...@@ -378,57 +376,4 @@ describe("NativeQuery", () => { ...@@ -378,57 +376,4 @@ describe("NativeQuery", () => {
expect(barTag["name"]).toEqual("#1234-bar"); // bar's name is the same 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);
});
});
}); });
...@@ -5,27 +5,39 @@ ...@@ -5,27 +5,39 @@
[malli.transform :as mtx])) [malli.transform :as mtx]))
(defn- decode-map [schema _] (defn- decode-map [schema _]
(let [drop-nil? (set (for [[map-key props val-schema] (mc/children schema) (let [by-prop (into {} (for [[map-key props] (mc/children schema)]
:when (and (:optional props) [(or (get props :js/prop)
(not (#{'nil? :nil} (mc/type val-schema))))] (csk/->snake_case_string map-key))
map-key))] {:map-key map-key}]))]
{:enter (fn [x] {:enter (fn [x]
(cond (cond
(map? x) x (map? x) x
(object? x) (into {} (for [prop (js/Object.keys x) (object? x)
:let [js-val (unchecked-get x prop) (into {} (for [prop (js-keys x)
map-key (csk/->kebab-case-keyword prop)] :let [js-val (unchecked-get x prop)
;; If the value is nil, and it's both :optional and not a :maybe, map-key (or (get-in by-prop [prop :map-key])
;; we just discard the value. (csk/->kebab-case-keyword prop))]]
:when (not (and (nil? js-val) [map-key js-val]))))
(drop-nil? map-key)))]
[map-key js-val]))))
:leave (fn [x] :leave (fn [x]
(if (map? x) (if (object? x)
x
(throw (ex-info "decode-map leaving with a JS object not a CLJS map" (throw (ex-info "decode-map leaving with a JS object not a CLJS map"
{:value x {: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] (defn- decode-map-of [keydec x]
(cond (cond
...@@ -42,6 +54,14 @@ ...@@ -42,6 +54,14 @@
#js {} #js {}
x))) x)))
(def ^:private identity-transformers
(-> ['string? :string
'number? :number
'int? :int
'double? :double
'float? :float]
(zipmap (repeat {:enter identity}))))
(def js-transformer (def js-transformer
"Malli transformer for converting JavaScript data to and from CLJS data. "Malli transformer for converting JavaScript data to and from CLJS data.
...@@ -72,32 +92,44 @@ ...@@ -72,32 +92,44 @@
they are JS arrays." they are JS arrays."
(mtx/transformer (mtx/transformer
{:name :js {:name :js
:decoders {:keyword keyword :decoders
'keyword? keyword (merge identity-transformers
:qualified-keyword keyword {:keyword keyword
:vector {:enter vec} 'keyword? keyword
:sequential {:enter vec} :qualified-keyword keyword
:tuple {:enter vec} :uuid parse-uuid
:map {:compile decode-map} :vector {:enter #(and % (vec %))}
:map-of {:compile (fn [schema _] :sequential {:enter #(and % (vec %))}
(let [[key-schema] (mc/children schema) :tuple {:enter #(and % (vec %))}
keydec (mc/decoder key-schema js-transformer)] :cat {:enter #(and % (vec %))}
{:enter #(decode-map-of keydec %)}))}} :catn {:enter #(and % (vec %))}
:encoders {:keyword name :enum {:compile infer-child-decoder}
'keyword? name := {:compile infer-child-decoder}
:qualified-keyword #(str (namespace %) "/" (name %)) :map {:compile decode-map}
:vector {:leave clj->js} :map-of {:compile (fn [schema _]
:sequential {:leave clj->js} (let [[key-schema] (mc/children schema)
:tuple {:leave clj->js} keydec (mc/decoder key-schema js-transformer)]
:map {:compile {:enter #(decode-map-of keydec %)}))}})
(fn [schema _] :encoders
(let [js-props (into {} (for [[k props] (mc/children schema) (merge identity-transformers
:when (:js/prop props)] {:keyword name
[k (:js/prop props)])) 'keyword? name
keyenc (fn [k] (or (get js-props k) :qualified-keyword #(str (namespace %) "/" (name %))
(csk/->snake_case_string k)))] :uuid str
{:leave #(encode-map % keyenc)}))} :vector {:leave clj->js}
:map-of {:leave #(encode-map % name)}}})) :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 (defn incoming
"Returns a function for converting a JS value into CLJS data structures, based on a schema." "Returns a function for converting a JS value into CLJS data structures, based on a schema."
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
[metabase.lib.limit :as lib.limit] [metabase.lib.limit :as lib.limit]
[metabase.lib.metadata.calculation :as lib.metadata.calculation] [metabase.lib.metadata.calculation :as lib.metadata.calculation]
[metabase.lib.metric :as lib.metric] [metabase.lib.metric :as lib.metric]
[metabase.lib.native :as lib.native]
[metabase.lib.order-by :as lib.order-by] [metabase.lib.order-by :as lib.order-by]
[metabase.lib.query :as lib.query] [metabase.lib.query :as lib.query]
[metabase.lib.segment :as lib.segment] [metabase.lib.segment :as lib.segment]
...@@ -32,6 +33,7 @@ ...@@ -32,6 +33,7 @@
lib.limit/keep-me lib.limit/keep-me
lib.metadata.calculation/keep-me lib.metadata.calculation/keep-me
lib.metric/keep-me lib.metric/keep-me
lib.native/keep-me
lib.order-by/keep-me lib.order-by/keep-me
lib.query/keep-me lib.query/keep-me
lib.segment/keep-me lib.segment/keep-me
...@@ -136,6 +138,11 @@ ...@@ -136,6 +138,11 @@
describe-top-level-key describe-top-level-key
display-name display-name
suggested-name] suggested-name]
[lib.native
#?@(:cljs [->TemplateTags
TemplateTags->])
recognize-template-tags
template-tags]
[lib.order-by [lib.order-by
order-by order-by
order-by-clause order-by-clause
......
...@@ -13,6 +13,38 @@ ...@@ -13,6 +13,38 @@
;;; this is mostly to ensure all the relevant namespaces with multimethods impls get loaded. ;;; this is mostly to ensure all the relevant namespaces with multimethods impls get loaded.
(comment lib.core/keep-me) (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 (defn ^:export suggestedName
"Return a nice description of a query." "Return a nice description of a query."
[query] [query]
......
(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))))
(ns metabase.domain-entities.converters-test (ns metabase.domain-entities.converters-test
(:require (:require
[clojure.test :refer [deftest is testing]] [clojure.test :refer [deftest is testing]]
[metabase.domain-entities.converters :as converters])) [metabase.domain-entities.converters :as converters]
[metabase.test.util.js :as test.js]))
(deftest incoming-basics-test (deftest incoming-basics-test
(testing "simple values are not transformed" (testing "simple values are not transformed"
...@@ -37,7 +38,6 @@ ...@@ -37,7 +38,6 @@
(is (= kw (-> kw kw-> ->kw))) (is (= kw (-> kw kw-> ->kw)))
(is (= s (-> s ->kw kw->))))))) (is (= s (-> s ->kw kw->)))))))
(def HalfDeclared (def HalfDeclared
[:map [:map
[:declared-camel {:js/prop "declaredCamel"} string?] [:declared-camel {:js/prop "declaredCamel"} string?]
...@@ -47,29 +47,6 @@ ...@@ -47,29 +47,6 @@
(def ->half-declared (def ->half-declared
(converters/incoming HalfDeclared)) (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 (deftest map-basics-test
(testing "incoming maps" (testing "incoming maps"
(testing "become CLJS maps" (testing "become CLJS maps"
...@@ -125,13 +102,13 @@ ...@@ -125,13 +102,13 @@
(testing "are converted per the schema by :js/prop; defaulting to snake_case" (testing "are converted per the schema by :js/prop; defaulting to snake_case"
(let [adjusted (assoc obj :declared-camel "no")] (let [adjusted (assoc obj :declared-camel "no")]
(is (not (identical? obj adjusted))) (is (not (identical? obj adjusted)))
(is (js= #js {"declaredCamel" "no" (is (test.js/= #js {"declaredCamel" "no"
"declared_snake" "also" "declared_snake" "also"
"declared-kebab" "finally" "declared-kebab" "finally"
"undeclared_camel" 7 "undeclared_camel" 7
"undeclared_snake" 8 "undeclared_snake" 8
"undeclared_kebab" 9} "undeclared_kebab" 9}
((converters/outgoing HalfDeclared) adjusted)))))))) ((converters/outgoing HalfDeclared) adjusted))))))))
(def Child (def Child
[:map [:inner-value {:js/prop "innerValue"} string?]]) [:map [:inner-value {:js/prop "innerValue"} string?]])
...@@ -148,7 +125,7 @@ ...@@ -148,7 +125,7 @@
exp-clj {:parent {:child {:inner-value "asdf"}}} exp-clj {:parent {:child {:inner-value "asdf"}}}
converted ((converters/incoming Grandparent) input)] converted ((converters/incoming Grandparent) input)]
(is (= exp-clj converted)) (is (= exp-clj converted))
(is (js= input ((converters/outgoing Grandparent) converted))))) (is (test.js/= input ((converters/outgoing Grandparent) converted)))))
(testing "nesting kitchen sink" (testing "nesting kitchen sink"
(let [schema [:map (let [schema [:map
...@@ -208,7 +185,7 @@ ...@@ -208,7 +185,7 @@
(is (map? converted)) (is (map? converted))
(is (= exp converted))) (is (= exp converted)))
(testing "round-trips as expected" (testing "round-trips as expected"
(is (js= input (-> input ->sink sink->))))))) (is (test.js/= input (-> input ->sink sink->)))))))
(deftest idempotency-test (deftest idempotency-test
(testing "CLJS maps are not further converted" (testing "CLJS maps are not further converted"
...@@ -236,3 +213,27 @@ ...@@ -236,3 +213,27 @@
(is (identical? js-obj (-> converted :wrapper :inner))) (is (identical? js-obj (-> converted :wrapper :inner)))
(is (identical? js-obj (let [^Object wrapper (.-wrapper returned)] (is (identical? js-obj (let [^Object wrapper (.-wrapper returned)]
(.-inner wrapper))))))) (.-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)}}))))))
(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->))))))))
(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))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment