Compare commits
48 Commits
python-sec
...
redshift-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a5c0540f | ||
|
|
a3b31c9a86 | ||
|
|
e90bab1b52 | ||
|
|
679a784fa0 | ||
|
|
1a8e1a83db | ||
|
|
c4840169e2 | ||
|
|
ff8978f4f2 | ||
|
|
f29487d8de | ||
|
|
ce902a565b | ||
|
|
f6689e7141 | ||
|
|
bc0e0d71d4 | ||
|
|
0c7853380e | ||
|
|
e1b60d8ae9 | ||
|
|
30ffc3c78d | ||
|
|
d32b3344f2 | ||
|
|
4f18ce8cc0 | ||
|
|
48c210ee4b | ||
|
|
d6292febc7 | ||
|
|
b46156bced | ||
|
|
818eb4b885 | ||
|
|
1011b38d6c | ||
|
|
0e8d73a14c | ||
|
|
dfacd7024d | ||
|
|
f47bed4fe7 | ||
|
|
47c6da750e | ||
|
|
8efb278158 | ||
|
|
21757f1cab | ||
|
|
fec5f7e2da | ||
|
|
1d0d1b554c | ||
|
|
7eca5c28f6 | ||
|
|
168cf78a00 | ||
|
|
e68a02c096 | ||
|
|
fc0cfd2c32 | ||
|
|
be522a846d | ||
|
|
582ef8db00 | ||
|
|
9441f1fdc0 | ||
|
|
71c47a7f63 | ||
|
|
2f6eae6de5 | ||
|
|
c65c8ec236 | ||
|
|
4e65155237 | ||
|
|
4137aa3f42 | ||
|
|
124417eb78 | ||
|
|
1e5a29bb10 | ||
|
|
7ca0281893 | ||
|
|
a1824f1ff8 | ||
|
|
3f38a8f727 | ||
|
|
b7f1d9888b | ||
|
|
23029186be |
@@ -122,291 +122,291 @@
|
||||
|
||||
:consistent-alias
|
||||
{:aliases
|
||||
{buddy.core.hash buddy-hash
|
||||
cheshire.generate json.generate
|
||||
clojure.tools.logging log
|
||||
clj-http.client http
|
||||
clj-ldap.client ldap
|
||||
clj-time.coerce time.coerce
|
||||
clj-time.format time.format
|
||||
clojure.core.async a
|
||||
clojure.core.match clojure.core.match ; Prefer :refer [match]
|
||||
clojure.data data
|
||||
clojure.math.combinatorics math.combo
|
||||
clojure.pprint pprint
|
||||
clojure.spec.alpha s
|
||||
clojure.string str
|
||||
clojure.test t
|
||||
clojure.tools.build.api b
|
||||
clojure.tools.namespace.dependency ns.deps
|
||||
clojure.tools.namespace.find ns.find
|
||||
clojure.tools.namespace.parse ns.parse
|
||||
colorize.core colorize
|
||||
environ.core env
|
||||
hf.depstar.api depstar
|
||||
hiccup.core hiccup
|
||||
hiccup.util hiccup.util
|
||||
honey.sql sql
|
||||
honey.sql.helpers sql.helpers
|
||||
honeysql.core hsql
|
||||
honeysql.format hformat
|
||||
honeysql.helpers hh
|
||||
honeysql.types htypes
|
||||
java-time t
|
||||
macaw.core mac
|
||||
malli.core mc
|
||||
malli.error me
|
||||
malli.experimental mx
|
||||
malli.generator mg
|
||||
malli.provider mp
|
||||
malli.transform mtx
|
||||
malli.util mut
|
||||
medley.core m
|
||||
metabase-enterprise.audit-app.pages.common common
|
||||
metabase-enterprise.sandbox.api.table table
|
||||
metabase-enterprise.test met
|
||||
metabase.analytics.stats stats
|
||||
metabase.api.activity api.activity
|
||||
metabase.api.alert api.alert
|
||||
metabase.api.automagic-dashboards api.magic
|
||||
metabase.api.bookmark api.bookmark
|
||||
metabase.api.card api.card
|
||||
metabase.api.card-test api.card-test
|
||||
metabase.api.collection api.collection
|
||||
metabase.api.common api
|
||||
metabase.api.dashboard api.dashboard
|
||||
metabase.api.dashboard-test api.dashboard-test
|
||||
metabase.api.database api.database
|
||||
metabase.api.dataset api.dataset
|
||||
metabase.api.email api.email
|
||||
metabase.api.embed api.embed
|
||||
metabase.api.field api.field
|
||||
metabase.api.geojson api.geojson
|
||||
metabase.api.ldap api.ldap
|
||||
metabase.api.logic-history api.login-history
|
||||
metabase.api.metric api.metric
|
||||
metabase.api.native-query-snippet api.native-query-snippet
|
||||
metabase.api.notify api.notify
|
||||
metabase.api.permission-graph api.permission-graph
|
||||
metabase.api.permissions api.permissions
|
||||
metabase.api.permissions-test-util perm-test-util
|
||||
metabase.api.pivots api.pivots
|
||||
metabase.api.premium-features api.premium-features
|
||||
metabase.api.preview-embed api.preview-embed
|
||||
metabase.api.public api.public
|
||||
metabase.api.pulse api.pulse
|
||||
metabase.api.revision api.revision
|
||||
metabase.api.search api.search
|
||||
metabase.api.segment api.segment
|
||||
metabase.api.session api.session
|
||||
metabase.api.setting api.setting
|
||||
metabase.api.setup api.setup
|
||||
metabase.api.slack api.slack
|
||||
metabase.api.table api.table
|
||||
metabase.api.task api.task
|
||||
metabase.api.tiles api.tiles
|
||||
metabase.api.timeline api.timeline
|
||||
metabase.api.timeline-event api.timeline-event
|
||||
metabase.api.transform api.transform
|
||||
metabase.api.user api.user
|
||||
metabase.api.util api.util
|
||||
metabase.async.streaming-response.thread-pool thread-pool
|
||||
metabase.automagic-dashboards.core magic
|
||||
metabase.automagic-dashboards.dashboard-templates dashboard-templates
|
||||
metabase.automagic-dashboards.populate populate
|
||||
metabase.cmd.copy.h2 copy.h2
|
||||
metabase.config config
|
||||
metabase.config.file config.file
|
||||
metabase.connection-pool connection-pool
|
||||
metabase.db.connection mdb.connection
|
||||
metabase.db.jdbc-protocols mdb.jdbc-protocols
|
||||
metabase.db.metadata-queries metadata-queries
|
||||
metabase.db.spec mdb.spec
|
||||
metabase.db.query mdb.query
|
||||
metabase.domain-entities.specs de.specs
|
||||
metabase.driver.common.parameters params
|
||||
metabase.driver.common.parameters.dates params.dates
|
||||
metabase.driver.common.parameters.operators params.ops
|
||||
metabase.driver.common.parameters.parse params.parse
|
||||
metabase.driver.common.parameters.values params.values
|
||||
metabase.driver.druid.client druid.client
|
||||
metabase.driver.druid.execute druid.execute
|
||||
metabase.driver.druid.js druid.js
|
||||
metabase.driver.druid.query-processor druid.qp
|
||||
metabase.driver.druid.sync druid.sync
|
||||
metabase.driver.googleanalytics.client ga.client
|
||||
metabase.driver.googleanalytics.execute ga.execute
|
||||
metabase.driver.googleanalytics.metadata ga.metadata
|
||||
metabase.driver.googleanalytics.query-processor ga.qp
|
||||
metabase.driver.impl driver.impl
|
||||
metabase.driver.mongo.execute mongo.execute
|
||||
metabase.driver.mongo.parameters mongo.params
|
||||
metabase.driver.mongo.query-processor mongo.qp
|
||||
metabase.driver.mongo.util mongo.util
|
||||
metabase.driver.sql driver.sql
|
||||
metabase.driver.sql-jdbc.common sql-jdbc.common
|
||||
metabase.driver.sql-jdbc.connection sql-jdbc.conn
|
||||
metabase.driver.sql-jdbc.execute sql-jdbc.execute
|
||||
metabase.driver.sql-jdbc.execute.diagnostic sql-jdbc.execute.diagnostic
|
||||
metabase.driver.sql-jdbc.execute.legacy-impl sql-jdbc.legacy
|
||||
metabase.driver.sql-jdbc.execute.old-impl sql-jdbc.execute.old
|
||||
metabase.driver.sql-jdbc.sync sql-jdbc.sync
|
||||
metabase.driver.sql-jdbc.sync.common sql-jdbc.sync.common
|
||||
metabase.driver.sql-jdbc.sync.describe-database sql-jdbc.describe-database
|
||||
metabase.driver.sql-jdbc.sync.describe-table sql-jdbc.describe-table
|
||||
metabase.driver.sql-jdbc.sync.interface sql-jdbc.sync.interface
|
||||
metabase.driver.sql-jdbc.test-util sql-jdbc.tu
|
||||
metabase.driver.sql.parameters.substitute sql.params.substitute
|
||||
metabase.driver.sql.parameters.substitution sql.params.substitution
|
||||
metabase.email-test et
|
||||
metabase.email.messages messages
|
||||
metabase.formatter formatter
|
||||
metabase.http-client client
|
||||
metabase.lib.util lib.util
|
||||
metabase.mbql.normalize mbql.normalize
|
||||
metabase.mbql.schema mbql.s
|
||||
metabase.mbql.util mbql.u
|
||||
metabase.models.activity activity
|
||||
metabase.models.application-permissions-revision a-perm-revision
|
||||
metabase.models.bookmark bookmark
|
||||
metabase.models.collection collection
|
||||
metabase.models.collection.graph graph
|
||||
metabase.models.collection-permission-graph-revision c-perm-revision
|
||||
metabase.models.dashboard-card dashboard-card
|
||||
metabase.models.database database
|
||||
metabase.models.dependency dependency
|
||||
metabase.models.field-values field-values
|
||||
metabase.models.interface mi
|
||||
metabase.models.moderation-review moderation-review
|
||||
metabase.models.native-query-snippet native-query-snippet
|
||||
metabase.models.params params
|
||||
metabase.models.permissions perms
|
||||
metabase.models.permissions-group perms-group
|
||||
metabase.models.permissions-group-membership perms-group-membership
|
||||
metabase.models.permissions-revision perms-revision
|
||||
metabase.models.permissions.parse perms-parse
|
||||
metabase.models.pulse pulse
|
||||
metabase.models.pulse-channel pulse-channel
|
||||
metabase.models.pulse-channel-recipient pulse-channel-recipient
|
||||
metabase.models.query.permissions query-perms
|
||||
metabase.models.setting.cache setting.cache
|
||||
metabase.models.timeline timeline
|
||||
metabase.models.timeline-event timeline-event
|
||||
metabase.native-query-analyzer query-analyzer
|
||||
metabase.plugins.initialize plugins.init
|
||||
metabase.public-settings public-settings
|
||||
metabase.public-settings.premium-features premium-features
|
||||
metabase.pulse pulse ; NB some conflicts with metabase.models.pulse
|
||||
metabase.pulse.markdown markdown
|
||||
metabase.pulse.render render
|
||||
metabase.pulse.render.body body
|
||||
metabase.pulse.render.style style
|
||||
metabase.pulse.test-util pulse.test-util
|
||||
metabase.pulse.parameters pulse-params
|
||||
metabase.query-processor-test qp.test
|
||||
metabase.query-processor.context qp.context
|
||||
metabase.query-processor.error-type qp.error-type
|
||||
metabase.query-processor.interface qp.i
|
||||
metabase.query-processor.middleware.add-dimension-projections qp.add-dimension-projections
|
||||
metabase.query-processor.middleware.add-implicit-clauses qp.add-implicit-clauses
|
||||
metabase.query-processor.middleware.add-implicit-joins qp.add-implicit-joins
|
||||
metabase.query-processor.middleware.auto-bucket-datetimes qp.auto-bucket-datetimes
|
||||
metabase.query-processor.middleware.constraints qp.constraints
|
||||
metabase.query-processor.middleware.cumulative-aggregations qp.cumulative-aggregations
|
||||
metabase.query-processor.middleware.parameters.mbql qp.mbql
|
||||
metabase.query-processor.middleware.parameters.native qp.native
|
||||
metabase.query-processor.middleware.permissions qp.perms
|
||||
metabase.query-processor.middleware.pre-alias-aggregations qp.pre-alias-aggregations
|
||||
metabase.query-processor.middleware.resolve-fields qp.resolve-fields
|
||||
metabase.query-processor.middleware.resolve-referenced qp.resolve-referenced
|
||||
metabase.query-processor.middleware.resolve-source-table qp.resolve-source-table
|
||||
metabase.query-processor.middleware.wrap-value-literals qp.wrap-value-literals
|
||||
metabase.query-processor.pivot qp.pivot
|
||||
metabase.query-processor.streaming qp.streaming
|
||||
metabase.query-processor.streaming.interface qp.si
|
||||
metabase.query-processor.streaming.xlsx qp.xlsx
|
||||
metabase.query-processor.timezone qp.timezone
|
||||
metabase.query-processor.util qp.util
|
||||
metabase.related related
|
||||
metabase.search.config search.config
|
||||
metabase.search.filter search.filter
|
||||
metabase.search.util search.util
|
||||
metabase.search.scoring scoring
|
||||
metabase.server.middleware.auth mw.auth
|
||||
metabase.server.middleware.browser-cookie mw.browser-cookie
|
||||
metabase.server.middleware.exceptions mw.exceptions
|
||||
metabase.server.middleware.json mw.json
|
||||
metabase.server.middleware.log mw.log
|
||||
metabase.server.middleware.misc mw.misc
|
||||
metabase.server.middleware.offset-paging mw.offset-paging
|
||||
metabase.server.middleware.security mw.security
|
||||
metabase.server.middleware.session mw.session
|
||||
metabase.server.middleware.ssl mw.ssl
|
||||
metabase.server.request.util req.util
|
||||
metabase.server.protocols server.protocols
|
||||
metabase.shared.util shared.u
|
||||
metabase.shared.util.currency currency
|
||||
metabase.sync.analyze.classifiers.category classifiers.category
|
||||
metabase.sync.analyze.classifiers.name classifiers.name
|
||||
metabase.sync.analyze.classifiers.no-preview-display classifiers.no-preview-display
|
||||
metabase.sync.analyze.classifiers.text-fingerprint classifiers.text-fingerprint
|
||||
metabase.sync.analyze.fingerprint fingerprint
|
||||
metabase.sync.analyze.fingerprint.fingerprinters fingerprinters
|
||||
metabase.sync.analyze.fingerprint.insights insights
|
||||
metabase.sync.analyze.query-results qr
|
||||
metabase.sync.field-values field-values
|
||||
metabase.sync.interface i
|
||||
metabase.sync.schedules sync.schedules
|
||||
metabase.sync.sync-metadata.fields.our-metadata fields.our-metadata
|
||||
metabase.sync.util sync-util
|
||||
metabase.sync.util-test sync.util-test
|
||||
metabase.task.sync-databases task.sync-databases
|
||||
metabase.test mt
|
||||
metabase.test.data.dataset-definitions defs
|
||||
metabase.test.data.env.impl tx.env.impl
|
||||
metabase.test.data.impl data.impl
|
||||
metabase.test.data.users test.users
|
||||
metabase.test.domain-entities test.de
|
||||
metabase.test.mock.util mock.util
|
||||
metabase.test.sync test.sync
|
||||
metabase.test.redefs test.redefs
|
||||
metabase.test.util.js test.js
|
||||
metabase.test.util.timezone test.tz
|
||||
metabase.timeseries-query-processor-test.util tqpt
|
||||
metabase.transforms.core tf
|
||||
metabase.transforms.materialize tf.materialize
|
||||
metabase.transforms.specs tf.specs
|
||||
metabase.util.cron u.cron
|
||||
metabase.util.date-2 u.date
|
||||
metabase.util.date-2.common u.date.common
|
||||
metabase.util.date-2.parse u.date.parse
|
||||
metabase.util.embed embed
|
||||
metabase.util.encryption encryption
|
||||
metabase.util.encryption-test encryption-test
|
||||
metabase.util.files u.files
|
||||
metabase.util.honey-sql-2 h2x
|
||||
metabase.util.i18n i18n
|
||||
metabase.util.i18n.impl i18n.impl
|
||||
metabase.util.jvm u.jvm
|
||||
metabase.util.log log
|
||||
metabase.util.macros u.macros
|
||||
metabase.util.malli mu
|
||||
metabase.util.malli.schema ms
|
||||
metabase.util.password u.password
|
||||
metabase.util.schema su
|
||||
metabase.util.ui-logic ui-logic
|
||||
metabase.util.urls urls
|
||||
metabuild-common.core u
|
||||
metabuild-common.output out
|
||||
metabuild-common.shell shell
|
||||
monger.collection mcoll
|
||||
ring.mock.request ring.mock
|
||||
ring.util.codec codec
|
||||
ring.util.response response
|
||||
ring.adapter.jetty9.servlet servlet
|
||||
saml20-clj.core saml
|
||||
toucan.db db
|
||||
toucan.models models}}
|
||||
{buddy.core.hash buddy-hash
|
||||
cheshire.generate json.generate
|
||||
clojure.tools.logging log
|
||||
clj-http.client http
|
||||
clj-ldap.client ldap
|
||||
clj-time.coerce time.coerce
|
||||
clj-time.format time.format
|
||||
clojure.core.async a
|
||||
clojure.core.match clojure.core.match ; Prefer :refer [match]
|
||||
clojure.data data
|
||||
clojure.math.combinatorics math.combo
|
||||
clojure.pprint pprint
|
||||
clojure.spec.alpha s
|
||||
clojure.string str
|
||||
clojure.test t
|
||||
clojure.tools.build.api b
|
||||
clojure.tools.namespace.dependency ns.deps
|
||||
clojure.tools.namespace.find ns.find
|
||||
clojure.tools.namespace.parse ns.parse
|
||||
colorize.core colorize
|
||||
environ.core env
|
||||
hf.depstar.api depstar
|
||||
hiccup.core hiccup
|
||||
hiccup.util hiccup.util
|
||||
honey.sql sql
|
||||
honey.sql.helpers sql.helpers
|
||||
honeysql.core hsql
|
||||
honeysql.format hformat
|
||||
honeysql.helpers hh
|
||||
honeysql.types htypes
|
||||
java-time t
|
||||
macaw.core mac
|
||||
malli.core mc
|
||||
malli.error me
|
||||
malli.experimental mx
|
||||
malli.generator mg
|
||||
malli.provider mp
|
||||
malli.transform mtx
|
||||
malli.util mut
|
||||
medley.core m
|
||||
metabase-enterprise.audit-app.pages.common common
|
||||
metabase-enterprise.sandbox.api.table table
|
||||
metabase-enterprise.test met
|
||||
metabase.analytics.stats stats
|
||||
metabase.api.activity api.activity
|
||||
metabase.api.alert api.alert
|
||||
metabase.api.automagic-dashboards api.magic
|
||||
metabase.api.bookmark api.bookmark
|
||||
metabase.api.card api.card
|
||||
metabase.api.card-test api.card-test
|
||||
metabase.api.collection api.collection
|
||||
metabase.api.common api
|
||||
metabase.api.dashboard api.dashboard
|
||||
metabase.api.dashboard-test api.dashboard-test
|
||||
metabase.api.database api.database
|
||||
metabase.api.dataset api.dataset
|
||||
metabase.api.email api.email
|
||||
metabase.api.embed api.embed
|
||||
metabase.api.field api.field
|
||||
metabase.api.geojson api.geojson
|
||||
metabase.api.ldap api.ldap
|
||||
metabase.api.logic-history api.login-history
|
||||
metabase.api.metric api.metric
|
||||
metabase.api.native-query-snippet api.native-query-snippet
|
||||
metabase.api.notify api.notify
|
||||
metabase.api.permission-graph api.permission-graph
|
||||
metabase.api.permissions api.permissions
|
||||
metabase.api.permissions-test-util perm-test-util
|
||||
metabase.api.pivots api.pivots
|
||||
metabase.api.premium-features api.premium-features
|
||||
metabase.api.preview-embed api.preview-embed
|
||||
metabase.api.public api.public
|
||||
metabase.api.pulse api.pulse
|
||||
metabase.api.revision api.revision
|
||||
metabase.api.search api.search
|
||||
metabase.api.segment api.segment
|
||||
metabase.api.session api.session
|
||||
metabase.api.setting api.setting
|
||||
metabase.api.setup api.setup
|
||||
metabase.api.slack api.slack
|
||||
metabase.api.table api.table
|
||||
metabase.api.task api.task
|
||||
metabase.api.tiles api.tiles
|
||||
metabase.api.timeline api.timeline
|
||||
metabase.api.timeline-event api.timeline-event
|
||||
metabase.api.transform api.transform
|
||||
metabase.api.user api.user
|
||||
metabase.api.util api.util
|
||||
metabase.async.streaming-response.thread-pool thread-pool
|
||||
metabase.automagic-dashboards.core magic
|
||||
metabase.automagic-dashboards.dashboard-templates dashboard-templates
|
||||
metabase.automagic-dashboards.populate populate
|
||||
metabase.cmd.copy.h2 copy.h2
|
||||
metabase.config config
|
||||
metabase.config.file config.file
|
||||
metabase.connection-pool connection-pool
|
||||
metabase.db.connection mdb.connection
|
||||
metabase.db.jdbc-protocols mdb.jdbc-protocols
|
||||
metabase.db.metadata-queries metadata-queries
|
||||
metabase.db.spec mdb.spec
|
||||
metabase.db.query mdb.query
|
||||
metabase.domain-entities.specs de.specs
|
||||
metabase.driver.common.parameters params
|
||||
metabase.driver.common.parameters.dates params.dates
|
||||
metabase.driver.common.parameters.operators params.ops
|
||||
metabase.driver.common.parameters.parse params.parse
|
||||
metabase.driver.common.parameters.values params.values
|
||||
metabase.driver.druid.client druid.client
|
||||
metabase.driver.druid.execute druid.execute
|
||||
metabase.driver.druid.js druid.js
|
||||
metabase.driver.druid.query-processor druid.qp
|
||||
metabase.driver.druid.sync druid.sync
|
||||
metabase.driver.googleanalytics.client ga.client
|
||||
metabase.driver.googleanalytics.execute ga.execute
|
||||
metabase.driver.googleanalytics.metadata ga.metadata
|
||||
metabase.driver.googleanalytics.query-processor ga.qp
|
||||
metabase.driver.impl driver.impl
|
||||
metabase.driver.mongo.execute mongo.execute
|
||||
metabase.driver.mongo.parameters mongo.params
|
||||
metabase.driver.mongo.query-processor mongo.qp
|
||||
metabase.driver.mongo.util mongo.util
|
||||
metabase.driver.sql driver.sql
|
||||
metabase.driver.sql-jdbc.common sql-jdbc.common
|
||||
metabase.driver.sql-jdbc.connection sql-jdbc.conn
|
||||
metabase.driver.sql-jdbc.execute sql-jdbc.execute
|
||||
metabase.driver.sql-jdbc.execute.diagnostic sql-jdbc.execute.diagnostic
|
||||
metabase.driver.sql-jdbc.execute.legacy-impl sql-jdbc.legacy
|
||||
metabase.driver.sql-jdbc.execute.old-impl sql-jdbc.execute.old
|
||||
metabase.driver.sql-jdbc.sync sql-jdbc.sync
|
||||
metabase.driver.sql-jdbc.sync.common sql-jdbc.sync.common
|
||||
metabase.driver.sql-jdbc.sync.describe-database sql-jdbc.describe-database
|
||||
metabase.driver.sql-jdbc.sync.describe-table sql-jdbc.describe-table
|
||||
metabase.driver.sql-jdbc.sync.interface sql-jdbc.sync.interface
|
||||
metabase.driver.sql-jdbc.test-util sql-jdbc.tu
|
||||
metabase.driver.sql.parameters.substitute sql.params.substitute
|
||||
metabase.driver.sql.parameters.substitution sql.params.substitution
|
||||
metabase.email-test et
|
||||
metabase.email.messages messages
|
||||
metabase.formatter formatter
|
||||
metabase.http-client client
|
||||
metabase.lib.util lib.util
|
||||
metabase.mbql.normalize mbql.normalize
|
||||
metabase.mbql.schema mbql.s
|
||||
metabase.mbql.util mbql.u
|
||||
metabase.models.activity activity
|
||||
metabase.models.application-permissions-revision a-perm-revision
|
||||
metabase.models.bookmark bookmark
|
||||
metabase.models.collection collection
|
||||
metabase.models.collection.graph graph
|
||||
metabase.models.collection-permission-graph-revision c-perm-revision
|
||||
metabase.models.dashboard-card dashboard-card
|
||||
metabase.models.database database
|
||||
metabase.models.dependency dependency
|
||||
metabase.models.field-values field-values
|
||||
metabase.models.interface mi
|
||||
metabase.models.moderation-review moderation-review
|
||||
metabase.models.native-query-snippet native-query-snippet
|
||||
metabase.models.params params
|
||||
metabase.models.permissions perms
|
||||
metabase.models.permissions-group perms-group
|
||||
metabase.models.permissions-group-membership perms-group-membership
|
||||
metabase.models.permissions-revision perms-revision
|
||||
metabase.models.permissions.parse perms-parse
|
||||
metabase.models.pulse pulse
|
||||
metabase.models.pulse-channel pulse-channel
|
||||
metabase.models.pulse-channel-recipient pulse-channel-recipient
|
||||
metabase.models.query.permissions query-perms
|
||||
metabase.models.setting.cache setting.cache
|
||||
metabase.models.timeline timeline
|
||||
metabase.models.timeline-event timeline-event
|
||||
metabase.native-query-analyzer query-analyzer
|
||||
metabase.plugins.initialize plugins.init
|
||||
metabase.public-settings public-settings
|
||||
metabase.public-settings.premium-features premium-features
|
||||
metabase.pulse pulse ; NB some conflicts with metabase.models.pulse
|
||||
metabase.pulse.markdown markdown
|
||||
metabase.pulse.render render
|
||||
metabase.pulse.render.body body
|
||||
metabase.pulse.render.style style
|
||||
metabase.pulse.test-util pulse.test-util
|
||||
metabase.pulse.parameters pulse-params
|
||||
metabase.query-processor-test qp.test
|
||||
metabase.query-processor.context qp.context
|
||||
metabase.query-processor.error-type qp.error-type
|
||||
metabase.query-processor.interface qp.i
|
||||
metabase.query-processor.middleware.add-dimension-projections qp.add-dimension-projections
|
||||
metabase.query-processor.middleware.add-implicit-clauses qp.add-implicit-clauses
|
||||
metabase.query-processor.middleware.add-implicit-joins qp.add-implicit-joins
|
||||
metabase.query-processor.middleware.auto-bucket-datetimes qp.auto-bucket-datetimes
|
||||
metabase.query-processor.middleware.constraints qp.constraints
|
||||
metabase.query-processor.middleware.cumulative-aggregations qp.cumulative-aggregations
|
||||
metabase.query-processor.middleware.parameters.mbql qp.mbql
|
||||
metabase.query-processor.middleware.parameters.native qp.native
|
||||
metabase.query-processor.middleware.permissions qp.perms
|
||||
metabase.query-processor.middleware.pre-alias-aggregations qp.pre-alias-aggregations
|
||||
metabase.query-processor.middleware.resolve-fields qp.resolve-fields
|
||||
metabase.query-processor.middleware.resolve-referenced qp.resolve-referenced
|
||||
metabase.query-processor.middleware.resolve-source-table qp.resolve-source-table
|
||||
metabase.query-processor.middleware.wrap-value-literals qp.wrap-value-literals
|
||||
metabase.query-processor.pivot qp.pivot
|
||||
metabase.query-processor.streaming qp.streaming
|
||||
metabase.query-processor.streaming.interface qp.si
|
||||
metabase.query-processor.streaming.xlsx qp.xlsx
|
||||
metabase.query-processor.timezone qp.timezone
|
||||
metabase.query-processor.util qp.util
|
||||
metabase.related related
|
||||
metabase.search.config search.config
|
||||
metabase.search.filter search.filter
|
||||
metabase.search.util search.util
|
||||
metabase.search.scoring scoring
|
||||
metabase.server.middleware.auth mw.auth
|
||||
metabase.server.middleware.browser-cookie mw.browser-cookie
|
||||
metabase.server.middleware.exceptions mw.exceptions
|
||||
metabase.server.middleware.json mw.json
|
||||
metabase.server.middleware.log mw.log
|
||||
metabase.server.middleware.misc mw.misc
|
||||
metabase.server.middleware.offset-paging mw.offset-paging
|
||||
metabase.server.middleware.security mw.security
|
||||
metabase.server.middleware.session mw.session
|
||||
metabase.server.middleware.ssl mw.ssl
|
||||
metabase.server.request.util req.util
|
||||
metabase.server.protocols server.protocols
|
||||
metabase.shared.util shared.u
|
||||
metabase.shared.util.currency currency
|
||||
metabase.sync.analyze.classifiers.category classifiers.category
|
||||
metabase.sync.analyze.classifiers.name classifiers.name
|
||||
metabase.sync.analyze.classifiers.no-preview-display classifiers.no-preview-display
|
||||
metabase.sync.analyze.classifiers.text-fingerprint classifiers.text-fingerprint
|
||||
metabase.sync.analyze.fingerprint fingerprint
|
||||
metabase.sync.analyze.fingerprint.fingerprinters fingerprinters
|
||||
metabase.sync.analyze.fingerprint.insights insights
|
||||
metabase.sync.analyze.query-results qr
|
||||
metabase.sync.field-values field-values
|
||||
metabase.sync.interface i
|
||||
metabase.sync.schedules sync.schedules
|
||||
metabase.sync.sync-metadata.fields.our-metadata fields.our-metadata
|
||||
metabase.sync.util sync-util
|
||||
metabase.sync.util-test sync.util-test
|
||||
metabase.task.sync-databases task.sync-databases
|
||||
metabase.test mt
|
||||
metabase.test.data.dataset-definitions defs
|
||||
metabase.test.data.env.impl tx.env.impl
|
||||
metabase.test.data.impl data.impl
|
||||
metabase.test.data.users test.users
|
||||
metabase.test.domain-entities test.de
|
||||
metabase.test.mock.util mock.util
|
||||
metabase.test.sync test.sync
|
||||
metabase.test.redefs test.redefs
|
||||
metabase.test.util.js test.js
|
||||
metabase.test.util.timezone test.tz
|
||||
metabase.timeseries-query-processor-test.util tqpt
|
||||
metabase.transforms.core tf
|
||||
metabase.transforms.materialize tf.materialize
|
||||
metabase.transforms.specs tf.specs
|
||||
metabase.util.cron u.cron
|
||||
metabase.util.date-2 u.date
|
||||
metabase.util.date-2.common u.date.common
|
||||
metabase.util.date-2.parse u.date.parse
|
||||
metabase.util.embed embed
|
||||
metabase.util.encryption encryption
|
||||
metabase.util.encryption-test encryption-test
|
||||
metabase.util.files u.files
|
||||
metabase.util.honey-sql-2 h2x
|
||||
metabase.util.i18n i18n
|
||||
metabase.util.i18n.impl i18n.impl
|
||||
metabase.util.jvm u.jvm
|
||||
metabase.util.log log
|
||||
metabase.util.macros u.macros
|
||||
metabase.util.malli mu
|
||||
metabase.util.malli.schema ms
|
||||
metabase.util.password u.password
|
||||
metabase.util.schema su
|
||||
metabase.util.ui-logic ui-logic
|
||||
metabase.util.urls urls
|
||||
metabuild-common.core u
|
||||
metabuild-common.output out
|
||||
metabuild-common.shell shell
|
||||
monger.collection mcoll
|
||||
ring.mock.request ring.mock
|
||||
ring.util.codec codec
|
||||
ring.util.response response
|
||||
ring.adapter.jetty9.servlet servlet
|
||||
saml20-clj.core saml
|
||||
toucan.db db
|
||||
toucan.models models}}
|
||||
|
||||
:metabase/validate-deftest {:level :warning}
|
||||
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
|
||||
@@ -423,6 +423,7 @@
|
||||
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.api.common/defroutes clojure.core/def
|
||||
metabase.api.search-test/do-test-users clojure.core/let
|
||||
metabase.async.api-response-test/with-response clojure.core/let
|
||||
metabase.dashboard-subscription-test/with-dashboard-sub-for-card clojure.core/let
|
||||
@@ -463,27 +464,27 @@
|
||||
metabase.test/with-open-channels clojure.core/let
|
||||
metabase.test/with-single-admin-user clojure.core/fn
|
||||
|
||||
metabase.test/with-temp-dir clojure.core/let
|
||||
metabase.test/with-temp-empty-app-db clojure.core/let
|
||||
metabase.test/with-temp-file clojure.core/let
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.upload-test/with-upload-table! clojure.core/let
|
||||
metabase.util.files/with-open-path-to-resource clojure.core/let
|
||||
metabase.util.malli.defn/defn schema.core/defn
|
||||
metabase.util.malli.fn/fn schema.core/fn
|
||||
metabase.util.malli/defmethod schema.core/defmethod
|
||||
metabase.util.malli/defn schema.core/defn
|
||||
metabase.util.malli/fn schema.core/fn
|
||||
metabase.util.ssh/with-ssh-tunnel clojure.core/let
|
||||
monger.operators/defoperator clojure.core/def
|
||||
potemkin.types/defprotocol+ clojure.core/defprotocol
|
||||
potemkin.types/defrecord+ clojure.core/defrecord
|
||||
potemkin.types/deftype+ clojure.core/deftype
|
||||
potemkin/defprotocol+ clojure.core/defprotocol
|
||||
potemkin/defrecord+ clojure.core/defrecord
|
||||
potemkin/deftype+ clojure.core/deftype
|
||||
toucan.db/with-call-counting clojure.core/fn
|
||||
toucan2.core/with-call-count clojure.core/fn}
|
||||
metabase.test/with-temp-dir clojure.core/let
|
||||
metabase.test/with-temp-empty-app-db clojure.core/let
|
||||
metabase.test/with-temp-file clojure.core/let
|
||||
metabase.test/with-user-in-groups clojure.core/let
|
||||
metabase.upload-test/with-upload-table! clojure.core/let
|
||||
metabase.util.files/with-open-path-to-resource clojure.core/let
|
||||
metabase.util.malli.defn/defn schema.core/defn
|
||||
metabase.util.malli.fn/fn schema.core/fn
|
||||
metabase.util.malli/defmethod schema.core/defmethod
|
||||
metabase.util.malli/defn schema.core/defn
|
||||
metabase.util.malli/fn schema.core/fn
|
||||
metabase.util.ssh/with-ssh-tunnel clojure.core/let
|
||||
monger.operators/defoperator clojure.core/def
|
||||
potemkin.types/defprotocol+ clojure.core/defprotocol
|
||||
potemkin.types/defrecord+ clojure.core/defrecord
|
||||
potemkin.types/deftype+ clojure.core/deftype
|
||||
potemkin/defprotocol+ clojure.core/defprotocol
|
||||
potemkin/defrecord+ clojure.core/defrecord
|
||||
potemkin/deftype+ clojure.core/deftype
|
||||
toucan.db/with-call-counting clojure.core/fn
|
||||
toucan2.core/with-call-count clojure.core/fn}
|
||||
|
||||
:hooks
|
||||
{:analyze-call
|
||||
@@ -493,7 +494,7 @@
|
||||
clojure.test/deftest hooks.clojure.test/deftest
|
||||
clojure.test/is hooks.clojure.test/is
|
||||
clojure.test/use-fixtures hooks.clojure.test/use-fixtures
|
||||
metabase-enterprise.advanced-config.caching-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.caching.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
|
||||
metabase-enterprise.audit-app.pages-test/with-temp-objects hooks.common/with-one-binding
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
@@ -504,9 +505,6 @@
|
||||
metabase.api.card-test/with-temp-native-card! hooks.common/with-two-bindings
|
||||
metabase.api.card-test/with-temp-native-card-with-params! hooks.common/with-two-bindings
|
||||
metabase.api.collection-test/with-french-user-and-personal-collection hooks.common/with-two-top-level-bindings
|
||||
metabase.api.common/defendpoint hooks.metabase.api.common/defendpoint
|
||||
metabase.api.common/defendpoint-async hooks.metabase.api.common/defendpoint
|
||||
metabase.api.common/defendpoint-async-schema hooks.metabase.api.common/defendpoint
|
||||
metabase.api.dashboard-test/with-chain-filter-fixtures hooks.common/let-one-with-optional-value
|
||||
metabase.api.dashboard-test/with-simple-dashboard-with-tabs hooks.common/with-one-binding
|
||||
metabase.api.card-test/with-card-param-values-fixtures hooks.common/let-one-with-optional-value
|
||||
@@ -590,6 +588,9 @@
|
||||
metabase.api.card-test/with-ordered-items macros.metabase.api.card-test/with-ordered-items
|
||||
metabase.api.collection-test/with-collection-hierarchy macros.metabase.api.collection-test/with-collection-hierarchy
|
||||
metabase.api.collection-test/with-some-children-of-collection macros.metabase.api.collection-test/with-some-children-of-collection
|
||||
metabase.api.common/defendpoint macros.metabase.api.common/defendpoint
|
||||
metabase.api.common/defendpoint-async macros.metabase.api.common/defendpoint
|
||||
metabase.api.common/defendpoint-async-schema macros.metabase.api.common/defendpoint
|
||||
metabase.api.common/define-routes macros.metabase.api.common/define-routes
|
||||
metabase.api.embed-test/with-embedding-enabled-and-temp-card-referencing macros.metabase.api.embed-test/with-embedding-enabled-and-temp-card-referencing
|
||||
metabase.api.embed-test/with-embedding-enabled-and-temp-dashcard-referencing macros.metabase.api.embed-test/with-embedding-enabled-and-temp-dashcard-referencing
|
||||
@@ -700,10 +701,10 @@
|
||||
qp-and-driver-source-namespaces
|
||||
{:linters
|
||||
{:discouraged-namespace
|
||||
{toucan.db {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan.models {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan.util.test {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan2.core {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}}
|
||||
{toucan.db {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan.models {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan.util.test {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
toucan2.core {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}}
|
||||
|
||||
:discouraged-var
|
||||
{metabase.db.query/query {:message "Don't use application database directly in QP code; use QP Store/metadata provider"}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
(ns hooks.metabase.api.common
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clojure.string :as str]
|
||||
[hooks.common :as common]))
|
||||
|
||||
(defn route-fn-name
|
||||
"route fn hook"
|
||||
[method route]
|
||||
(let [route (if (vector? route) (first route) route)]
|
||||
(-> (str (name method) route)
|
||||
(str/replace #"/" "_")
|
||||
symbol)))
|
||||
|
||||
(defn defendpoint
|
||||
"defendpoint hook"
|
||||
[{:keys [node]}]
|
||||
(let [[method route & body] (rest (:children node))]
|
||||
{:node
|
||||
(hooks/vector-node [;; register usage of compojure core var
|
||||
(common/with-macro-meta (hooks/token-node (symbol (str "compojure.core")
|
||||
(str method)))
|
||||
node)
|
||||
;; define function with route-fn-name
|
||||
(hooks/list-node
|
||||
(list* (common/with-macro-meta (hooks/token-node 'clojure.core/defn)
|
||||
node)
|
||||
(route-fn-name (hooks/sexpr method) (hooks/sexpr route))
|
||||
body))])}))
|
||||
@@ -1,5 +1,26 @@
|
||||
(ns macros.metabase.api.common)
|
||||
(ns macros.metabase.api.common
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defmacro define-routes [& args]
|
||||
(defmacro define-routes
|
||||
"Macro for api.common/define-routes"
|
||||
[& args]
|
||||
`(do (def ~'routes "docstring" nil)
|
||||
~@args))
|
||||
|
||||
(defn route-fn-name
|
||||
"route fn hook"
|
||||
[method route]
|
||||
(let [route (if (vector? route) (first route) route)]
|
||||
(-> (str (name method) route)
|
||||
(str/replace #"/" "_")
|
||||
symbol)))
|
||||
|
||||
(defmacro defendpoint
|
||||
"Macro for api.common/defendpoint*"
|
||||
[method route & body]
|
||||
(let [method-fn (symbol "compojure.core" (str method))
|
||||
route-name (route-fn-name method route)]
|
||||
`(do
|
||||
~method-fn
|
||||
(defn ~route-name ~@body))))
|
||||
|
||||
1
deps.edn
1
deps.edn
@@ -489,6 +489,7 @@
|
||||
"resources/frontend_client"
|
||||
"resources/frontend_shared"
|
||||
"resources/html-entities.edn"
|
||||
"resources/openapi/.*"
|
||||
"frontend/src/cljs"
|
||||
"frontend/test/metabase/lib/urls\\.unit\\.spec\\.js$"
|
||||
"frontend/test/metabase/lib/formatting\\.unit\\.spec\\.js$"
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
first arg:
|
||||
|
||||
(dev/query-jdbc-db
|
||||
[:sqlserver 'test-data-with-time]
|
||||
[:sqlserver 'time-test-data]
|
||||
[\"SELECT * FROM dbo.users WHERE dbo.users.last_login_time > ?\" (java-time/offset-time \"16:00Z\")])"
|
||||
{:arglists '([driver sql] [[driver dataset] sql]
|
||||
[driver honeysql-form] [[driver dataset] honeysql-form]
|
||||
|
||||
@@ -52,7 +52,7 @@ _* indicates endpoints used for features available on [paid plans](https://www.m
|
||||
- [LDAP](api/ldap.md)
|
||||
- [Login history](api/login-history.md)
|
||||
- [Metabot](api/metabot.md)
|
||||
- [Metric](api/metric.md)
|
||||
- [Metric](api/legacy-metric.md)
|
||||
- [Model index](api/model-index.md)
|
||||
- [Native query snippet](api/native-query-snippet.md)
|
||||
- [Notify](api/notify.md)
|
||||
@@ -82,4 +82,4 @@ _* indicates endpoints used for features available on [paid plans](https://www.m
|
||||
- [Timeline event](api/timeline-event.md)
|
||||
- [Transform](api/transform.md)
|
||||
- [User](api/user.md)
|
||||
- [Util](api/util.md)
|
||||
- [Util](api/util.md)
|
||||
|
||||
@@ -143,9 +143,9 @@ Utility endpoint for powering Dashboard UI. Given some set of `filtered` Field I
|
||||
|
||||
### PARAMS:
|
||||
|
||||
* **`filtered`** value must be an integer greater than zero., or one or more value must be an integer greater than zero.
|
||||
* **`filtered`** vector of value must be an integer greater than zero.
|
||||
|
||||
* **`filtering`** nullable value must be an integer greater than zero., or one or more value must be an integer greater than zero.
|
||||
* **`filtering`** nullable vector of value must be an integer greater than zero.
|
||||
|
||||
## `GET /api/dashboard/public`
|
||||
|
||||
@@ -369,4 +369,4 @@ Update a Dashboard, and optionally the `dashcards` and `tabs` of a Dashboard. Th
|
||||
|
||||
---
|
||||
|
||||
[<< Back to API index](../api-documentation.md)
|
||||
[<< Back to API index](../api-documentation.md)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: "Metric"
|
||||
summary: |
|
||||
/api/metric endpoints.
|
||||
/api/legacy-metric endpoints.
|
||||
---
|
||||
|
||||
# Metric
|
||||
|
||||
/api/metric endpoints.
|
||||
/api/legacy-metric endpoints.
|
||||
|
||||
## `DELETE /api/metric/:id`
|
||||
## `DELETE /api/legacy-metric/:id`
|
||||
|
||||
Archive a Metric. (DEPRECATED -- Just pass updated value of `:archived` to the `PUT` endpoint instead.).
|
||||
|
||||
@@ -18,11 +18,11 @@ Archive a Metric. (DEPRECATED -- Just pass updated value of `:archived` to the `
|
||||
|
||||
* **`revision_message`** value must be a non-blank string.
|
||||
|
||||
## `GET /api/metric/`
|
||||
## `GET /api/legacy-metric/`
|
||||
|
||||
Fetch *all* `Metrics`.
|
||||
|
||||
## `GET /api/metric/:id`
|
||||
## `GET /api/legacy-metric/:id`
|
||||
|
||||
Fetch `Metric` with ID.
|
||||
|
||||
@@ -30,7 +30,7 @@ Fetch `Metric` with ID.
|
||||
|
||||
* **`id`** value must be an integer greater than zero.
|
||||
|
||||
## `GET /api/metric/:id/related`
|
||||
## `GET /api/legacy-metric/:id/related`
|
||||
|
||||
Return related entities.
|
||||
|
||||
@@ -38,7 +38,7 @@ Return related entities.
|
||||
|
||||
* **`id`** value must be an integer greater than zero.
|
||||
|
||||
## `GET /api/metric/:id/revisions`
|
||||
## `GET /api/legacy-metric/:id/revisions`
|
||||
|
||||
Fetch `Revisions` for `Metric` with ID.
|
||||
|
||||
@@ -46,7 +46,7 @@ Fetch `Revisions` for `Metric` with ID.
|
||||
|
||||
* **`id`** value must be an integer greater than zero.
|
||||
|
||||
## `POST /api/metric/`
|
||||
## `POST /api/legacy-metric/`
|
||||
|
||||
Create a new `Metric`.
|
||||
|
||||
@@ -60,7 +60,7 @@ Create a new `Metric`.
|
||||
|
||||
* **`definition`** map
|
||||
|
||||
## `POST /api/metric/:id/revert`
|
||||
## `POST /api/legacy-metric/:id/revert`
|
||||
|
||||
Revert a `Metric` to a prior `Revision`.
|
||||
|
||||
@@ -70,7 +70,7 @@ Revert a `Metric` to a prior `Revision`.
|
||||
|
||||
* **`revision_id`** value must be an integer greater than zero.
|
||||
|
||||
## `PUT /api/metric/:id`
|
||||
## `PUT /api/legacy-metric/:id`
|
||||
|
||||
Update a `Metric` with ID.
|
||||
|
||||
@@ -96,7 +96,7 @@ Update a `Metric` with ID.
|
||||
|
||||
* **`how_is_this_calculated`** nullable string
|
||||
|
||||
## `PUT /api/metric/:id/important_fields`
|
||||
## `PUT /api/legacy-metric/:id/important_fields`
|
||||
|
||||
Update the important `Fields` for a `Metric` with ID.
|
||||
(This is used for the Getting Started guide).
|
||||
@@ -111,4 +111,4 @@ You must be a superuser to do this.
|
||||
|
||||
---
|
||||
|
||||
[<< Back to API index](../api-documentation.md)
|
||||
[<< Back to API index](../api-documentation.md)
|
||||
@@ -4,7 +4,17 @@ title: API changelog
|
||||
|
||||
# Breaking changes to the API interface
|
||||
|
||||
## Metabase 0.50.0
|
||||
|
||||
- `/api/metric`
|
||||
|
||||
The `/api/metric` endpoints has been renamed to `/api/legacy-metric` to reflect that fact it will not be used for the new version of metrics. The new version uses the `/api/card` endpoints.
|
||||
|
||||
## Metabase 0.49.0
|
||||
- `POST /api/card` and `PUT /api/card/:id`
|
||||
|
||||
The `dataset` key is deprecated and will be removed in a future version, most likely 50. In its place we have added a new key: `type` which is equivalent in that it distinguishes Models from Questions. `type="model"` is equivalent to `dataset=true` and `type="question"` is equivalent to `dataset=false`.
|
||||
The `dataset` key is deprecated and will be removed in a future version, most likely 50. In its place we have added a new key: `type` which is equivalent in that it distinguishes Models from Questions. `type="model"` is equivalent to `dataset=true` and `type="question"` is equivalent to `dataset=false`.
|
||||
|
||||
- all endpoints that return data (e.g. exports in JSON, XLSX, CSV, endpoints that end in "/query")
|
||||
|
||||
Starting from v49, we respond to the API calls with values formatted according to the instance localization options
|
||||
|
||||
@@ -20,6 +20,7 @@ To see what's new, check out all the [major release announcements](https://www.m
|
||||
## Metabase Enterprise Edition releases
|
||||
|
||||
- [v1.49.0](https://github.com/metabase/metabase/releases/tag/v1.49.0)
|
||||
- [v1.48.9](https://github.com/metabase/metabase/releases/tag/v1.48.9)
|
||||
- [v1.48.8](https://github.com/metabase/metabase/releases/tag/v1.48.8)
|
||||
- [v1.48.7](https://github.com/metabase/metabase/releases/tag/v1.48.7)
|
||||
- [v1.48.6](https://github.com/metabase/metabase/releases/tag/v1.48.6)
|
||||
@@ -150,6 +151,7 @@ To see what's new, check out all the [major release announcements](https://www.m
|
||||
## Metabase Open Source Edition releases
|
||||
|
||||
- [v0.49.0](https://github.com/metabase/metabase/releases/tag/v0.49.0)
|
||||
- [v0.48.9](https://github.com/metabase/metabase/releases/tag/v0.48.9)
|
||||
- [v0.48.8](https://github.com/metabase/metabase/releases/tag/v0.48.8)
|
||||
- [v0.48.7](https://github.com/metabase/metabase/releases/tag/v0.48.7)
|
||||
- [v0.48.6](https://github.com/metabase/metabase/releases/tag/v0.48.6)
|
||||
|
||||
61
e2e/support/helpers/e2e-search-helpers.js
Normal file
61
e2e/support/helpers/e2e-search-helpers.js
Normal file
@@ -0,0 +1,61 @@
|
||||
export function getSearchBar() {
|
||||
return cy.findByPlaceholderText("Search…");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the search results against expectedSearchValues, including descriptions,
|
||||
* collection names, and timestamps, depending on the given data.
|
||||
*
|
||||
* @param {Object} options - Options for the test.
|
||||
* @param {Object[]} options.expectedSearchResults - An array of search result items to compare against.
|
||||
* @param {string} options.expectedSearchResults[].name - The name of the search result item.
|
||||
* @param {string} options.expectedSearchResults[].description - The description of the search result item.
|
||||
* @param {string} options.expectedSearchResults[].collection - The collection label of the search result item.
|
||||
* @param {string} options.expectedSearchResults[].timestamp - The timestamp label of the search result item .
|
||||
* @param {boolean} [strict=true] - Whether to check if the contents AND length of search results are the same
|
||||
*/
|
||||
export function expectSearchResultContent({
|
||||
expectedSearchResults,
|
||||
strict = true,
|
||||
}) {
|
||||
const searchResultItemSelector = "[data-testid=search-result-item]";
|
||||
|
||||
const searchResultItems = cy.get(searchResultItemSelector);
|
||||
|
||||
searchResultItems.then($results => {
|
||||
if (strict) {
|
||||
// Check if the length of the search results is the same as the expected length
|
||||
expect($results).to.have.length(expectedSearchResults.length);
|
||||
}
|
||||
});
|
||||
|
||||
for (const expectedSearchResult of expectedSearchResults) {
|
||||
cy.contains(searchResultItemSelector, expectedSearchResult.name).within(
|
||||
() => {
|
||||
cy.findByTestId("search-result-item-name").findByText(
|
||||
expectedSearchResult.name,
|
||||
);
|
||||
|
||||
if (expectedSearchResult.description) {
|
||||
cy.findByTestId("result-description").findByText(
|
||||
expectedSearchResult.description,
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedSearchResult.collection) {
|
||||
cy.findAllByTestId("result-link-wrapper").first(() => {
|
||||
cy.findByText(expectedSearchResult.collection).should("exist");
|
||||
});
|
||||
}
|
||||
if (expectedSearchResult.timestamp) {
|
||||
cy.findByTestId("revision-history-button").findByText(
|
||||
expectedSearchResult.timestamp,
|
||||
);
|
||||
}
|
||||
if (expectedSearchResult.icon) {
|
||||
cy.icon(expectedSearchResult.icon);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export const expectGoodSnowplowEvents = count => {
|
||||
};
|
||||
|
||||
export const expectNoBadSnowplowEvents = () => {
|
||||
sendSnowplowRequest("micro/bad").its("body").should("have.length", 0);
|
||||
sendSnowplowRequest("micro/bad").its("body").should("deep.equal", []);
|
||||
};
|
||||
|
||||
const sendSnowplowRequest = url => {
|
||||
|
||||
@@ -5,7 +5,7 @@ export const createMetric = ({
|
||||
description = null,
|
||||
}) => {
|
||||
cy.log(`Create a metric: ${name}`);
|
||||
return cy.request("POST", "/api/metric", {
|
||||
return cy.request("POST", "/api/legacy-metric", {
|
||||
name,
|
||||
description,
|
||||
table_id,
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from "./e2e-embedding-helpers";
|
||||
export * from "./e2e-permissions-helpers";
|
||||
export * from "./e2e-question-helpers";
|
||||
export * from "./e2e-request-helpers";
|
||||
export * from "./e2e-search-helpers";
|
||||
export * from "./e2e-visual-tests-helpers";
|
||||
export * from "./e2e-users-helpers";
|
||||
export * from "./e2e-viz-settings-helpers";
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("issue 36422", () => {
|
||||
cy.findByLabelText("Name Your Metric").type("x");
|
||||
cy.findByLabelText("Describe Your Metric").type("x");
|
||||
|
||||
cy.intercept("POST", "/api/metric", req => req.reply(400));
|
||||
cy.intercept("POST", "/api/legacy-metric", req => req.reply(400));
|
||||
cy.button("Save changes").click();
|
||||
cy.findByRole("alert", { name: "An error occurred" }).should("be.visible");
|
||||
});
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("scenarios > question > custom column", () => {
|
||||
aggregation: [
|
||||
[
|
||||
"aggregation-options",
|
||||
["*", 1, 1],
|
||||
["*", ["count"], 1],
|
||||
{ name: CE_NAME, "display-name": CE_NAME },
|
||||
],
|
||||
],
|
||||
|
||||
@@ -2018,6 +2018,84 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not pass through null values to filters in custom url click behavior (metabase#25203)", () => {
|
||||
const DASHBOARD_NAME = "Click Behavior Custom URL Dashboard";
|
||||
const questionDetails = {
|
||||
name: "Orders",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [
|
||||
["sum", ["field", ORDERS.TOTAL, null]],
|
||||
["sum", ["field", ORDERS.DISCOUNT, null]],
|
||||
],
|
||||
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]],
|
||||
filter: ["=", ["field", ORDERS.USER_ID, null], 1],
|
||||
},
|
||||
display: "bar",
|
||||
};
|
||||
|
||||
cy.createQuestionAndDashboard({
|
||||
questionDetails,
|
||||
dashboardDetails: {
|
||||
name: DASHBOARD_NAME,
|
||||
},
|
||||
cardDetails: {
|
||||
size_x: 16,
|
||||
size_y: 8,
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
cy.wrap(dashboard_id).as("targetDashboardId");
|
||||
visitDashboard(dashboard_id);
|
||||
});
|
||||
|
||||
editDashboard();
|
||||
|
||||
getDashboardCard().realHover().icon("click").click();
|
||||
addUrlDestination();
|
||||
|
||||
modal().within(() => {
|
||||
const urlInput = cy.findAllByRole("textbox").eq(0);
|
||||
|
||||
cy.get("@targetDashboardId").then(targetDashboardId => {
|
||||
urlInput.type(
|
||||
`http://localhost:4000/dashboard/${targetDashboardId}?discount={{sum_2}}&total={{sum}}`,
|
||||
{
|
||||
parseSpecialCharSequences: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
cy.get("aside").button("Done").click();
|
||||
|
||||
saveDashboard();
|
||||
|
||||
// test that normal values still work properly
|
||||
getDashboardCard().within(() => {
|
||||
cy.get(".bar").eq(2).click();
|
||||
});
|
||||
cy.get("@targetDashboardId").then(targetDashboardId => {
|
||||
cy.location().should(({ pathname, search }) => {
|
||||
expect(pathname).to.equal(`/dashboard/${targetDashboardId}`);
|
||||
expect(search).to.equal(
|
||||
"?discount=15.070632139056723&total=298.9195210424866",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// test that null and "empty"s do not get passed through
|
||||
getDashboardCard().within(() => {
|
||||
cy.get(".bar").eq(1).click();
|
||||
});
|
||||
cy.get("@targetDashboardId").then(targetDashboardId => {
|
||||
cy.location().should(({ pathname, search }) => {
|
||||
expect(pathname).to.equal(`/dashboard/${targetDashboardId}`);
|
||||
expect(search).to.equal("?discount=&total=420.3189231596888");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,12 +71,20 @@ describe("scenarios > dashboard", () => {
|
||||
appBar().findByText("New").click();
|
||||
popover().findByText("Dashboard").should("be.visible").click();
|
||||
|
||||
cy.log(
|
||||
"pressing escape should only close the entity picker modal, not the new dashboard modal",
|
||||
);
|
||||
modal().findByTestId("collection-picker-button").click();
|
||||
entityPickerModal().findByText("Select a collection");
|
||||
cy.realPress("Escape");
|
||||
modal().findByText("New dashboard").should("be.visible");
|
||||
|
||||
cy.log("Create a new dashboard");
|
||||
modal().within(() => {
|
||||
// Without waiting for this, the test was constantly flaking locally.
|
||||
cy.findByText("Our analytics");
|
||||
|
||||
cy.findByLabelText("Name").type(dashboardName);
|
||||
cy.findByPlaceholderText(/name of your dashboard/i).type(dashboardName);
|
||||
cy.findByLabelText("Description").type(dashboardDescription, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("scenarios > question > filter", () => {
|
||||
aggregation: [
|
||||
[
|
||||
"aggregation-options",
|
||||
["+", 1, 1],
|
||||
["+", ["count"], 1],
|
||||
{ name: CE_NAME, "display-name": CE_NAME },
|
||||
],
|
||||
],
|
||||
|
||||
@@ -32,9 +32,9 @@ describe("issue 11480", () => {
|
||||
SQLFilter.chooseType("Number");
|
||||
cy.location("search").should("eq", "?x=");
|
||||
|
||||
// We cannot run the query unless the default value for a required parameter is set.
|
||||
// Although there's no default, we should be still able to run the query.
|
||||
cy.findByTestId("native-query-editor-sidebar")
|
||||
.button("Get Answer")
|
||||
.should("be.disabled");
|
||||
.should("not.be.disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ describe("scenarios > filters > sql filters > field filter", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("needs a default value to run or save the query", () => {
|
||||
it("needs a default value to save the query, but allows running it", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
SQLFilter.getRunQueryButton().should("be.disabled");
|
||||
SQLFilter.getRunQueryButton().should("not.be.disabled");
|
||||
SQLFilter.getSaveQueryButton().should("have.attr", "disabled");
|
||||
|
||||
SQLFilter.getSaveQueryButton().realHover();
|
||||
|
||||
@@ -49,9 +49,9 @@ describe("scenarios > filters > sql filters > basic filter types", () => {
|
||||
});
|
||||
|
||||
describe("required tag", () => {
|
||||
it("needs a default value to run or save the query", () => {
|
||||
it("needs a default value to save the query, but allows running it", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
SQLFilter.getRunQueryButton().should("be.disabled");
|
||||
SQLFilter.getRunQueryButton().should("not.be.disabled");
|
||||
SQLFilter.getSaveQueryButton().should("have.attr", "disabled");
|
||||
|
||||
SQLFilter.getSaveQueryButton().realHover();
|
||||
@@ -128,9 +128,9 @@ describe("scenarios > filters > sql filters > basic filter types", () => {
|
||||
});
|
||||
|
||||
describe("required tag", () => {
|
||||
it("needs a default value to run or save the query", () => {
|
||||
it("needs a default value to save the query, but allows running it", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
SQLFilter.getRunQueryButton().should("be.disabled");
|
||||
SQLFilter.getRunQueryButton().should("not.be.disabled");
|
||||
SQLFilter.getSaveQueryButton().should("have.attr", "disabled");
|
||||
|
||||
SQLFilter.getSaveQueryButton().realHover();
|
||||
@@ -230,9 +230,9 @@ describe("scenarios > filters > sql filters > basic filter types", () => {
|
||||
}
|
||||
|
||||
describe("required tag", () => {
|
||||
it("needs a default value for to run or save query", () => {
|
||||
it("needs a default value to save the query, but allows running it", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
SQLFilter.getRunQueryButton().should("be.disabled");
|
||||
SQLFilter.getRunQueryButton().should("not.be.disabled");
|
||||
SQLFilter.getSaveQueryButton().should("have.attr", "disabled");
|
||||
|
||||
SQLFilter.getSaveQueryButton().realHover();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { restore, runNativeQuery } from "e2e/support/helpers";
|
||||
import {
|
||||
restore,
|
||||
runNativeQuery,
|
||||
focusNativeEditor,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
describe("issue 30680", () => {
|
||||
beforeEach(() => {
|
||||
@@ -13,7 +17,7 @@ describe("issue 30680", () => {
|
||||
.findByText("Use a native query")
|
||||
.click();
|
||||
|
||||
cy.get(".ace_editor").type("select * from orders ");
|
||||
focusNativeEditor().type("select * from orders ", { delay: 100 });
|
||||
runNativeQuery();
|
||||
|
||||
cy.findByTestId("editor-tabs-metadata-name").click();
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("scenarios > reference > metrics", () => {
|
||||
restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
cy.request("POST", "/api/metric", {
|
||||
cy.request("POST", "/api/legacy-metric", {
|
||||
definition: {
|
||||
aggregation: ["count"],
|
||||
filter: ["<", ["field", ORDERS.TOTAL, null], 100],
|
||||
|
||||
1084
e2e/test/scenarios/onboarding/search/search-filters.cy.spec.js
Normal file
1084
e2e/test/scenarios/onboarding/search/search-filters.cy.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
312
e2e/test/scenarios/onboarding/search/search-snowplow.cy.spec.js
Normal file
312
e2e/test/scenarios/onboarding/search/search-snowplow.cy.spec.js
Normal file
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
describeEE,
|
||||
describeWithSnowplow,
|
||||
enableTracking,
|
||||
expectGoodSnowplowEvent,
|
||||
expectNoBadSnowplowEvents,
|
||||
getSearchBar,
|
||||
popover,
|
||||
resetSnowplow,
|
||||
restore,
|
||||
setTokenFeatures,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
describeWithSnowplow("scenarios > search > snowplow", () => {
|
||||
const SEARCH_RESULTS_FILTERED_NAME = "search_results_filtered";
|
||||
const NEW_SEARCH_QUERY_EVENT_NAME = "new_search_query";
|
||||
|
||||
beforeEach(() => {
|
||||
restore();
|
||||
resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
enableTracking();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("should send snowplow events for global search queries", () => {
|
||||
cy.visit("/");
|
||||
getSearchBar().type("Orders").blur();
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
});
|
||||
|
||||
describe("should send snowplow events for each filter when it is applied and removed", () => {
|
||||
describe("no filters", () => {
|
||||
it("should send a new_search_query snowplow event, but not search_results_filtered when a search with no filters is accessed from the URL", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("type filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&type=card");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("type-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findAllByTestId("type-filter-checkbox").each($el => {
|
||||
cy.wrap($el).click();
|
||||
});
|
||||
cy.findByText("Apply").click();
|
||||
});
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&type=card");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("type-search-filter")
|
||||
.findByLabelText("close icon")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("created_by filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&created_by=1");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("created_by-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Bobby Tables").click();
|
||||
cy.findByText("Apply").click();
|
||||
});
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&created_by=1");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("created_by-search-filter")
|
||||
.findByLabelText("close icon")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("last_edited_by filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&last_edited_by=1");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("last_edited_by-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Bobby Tables").click();
|
||||
cy.findByText("Apply").click();
|
||||
});
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&last_edited_by=1");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("last_edited_by-search-filter")
|
||||
.findByLabelText("close icon")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("created_at filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&created_at=thisday");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("created_at-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Today").click();
|
||||
});
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&created_at=thisday");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("created_at-search-filter")
|
||||
.findByLabelText("close icon")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("last_edited_at filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&last_edited_at=thisday");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("last_edited_at-search-filter").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("Today").click();
|
||||
});
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&last_edited_at=thisday");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("last_edited_at-search-filter")
|
||||
.findByLabelText("close icon")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describeEE("verified filter", () => {
|
||||
beforeEach(() => {
|
||||
setTokenFeatures("all");
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&verified=true");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("verified-search-filter")
|
||||
.findByText("Verified items only")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&verified=true");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("verified-search-filter")
|
||||
.findByText("Verified items only")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_native_query filter", () => {
|
||||
it("should send a snowplow event when a search filter is used in the URL", () => {
|
||||
cy.visit("/search?q=orders&search_native_query=true");
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is applied from the UI", () => {
|
||||
cy.visit("/search?q=orders");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 0);
|
||||
|
||||
cy.findByTestId("search_native_query-search-filter")
|
||||
.findByText("Search the contents of native queries")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
|
||||
it("should send a snowplow event when a search filter is removed from the UI", () => {
|
||||
cy.visit("/search?q=orders&search_native_query=true");
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 1);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
|
||||
cy.findByTestId("search_native_query-search-filter")
|
||||
.findByText("Search the contents of native queries")
|
||||
.click();
|
||||
|
||||
expectGoodSnowplowEvent({ event: NEW_SEARCH_QUERY_EVENT_NAME }, 2);
|
||||
expectGoodSnowplowEvent({ event: SEARCH_RESULTS_FILTERED_NAME }, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,20 +99,6 @@ describe("scenarios > setup", () => {
|
||||
// test database setup help card is NOT displayed before DB is selected
|
||||
cy.findByText("Need help connecting?").should("not.be.visible");
|
||||
|
||||
// test that you can return to user settings if you want
|
||||
cy.findByText("Hi, Testy. Nice to meet you!").click();
|
||||
cy.findByLabelText("Email").should(
|
||||
"have.value",
|
||||
"testy@metabase.test",
|
||||
);
|
||||
|
||||
// test database setup help card is NOT displayed on other steps
|
||||
cy.findByText("Need help connecting?").should("not.be.visible");
|
||||
|
||||
// now back to database setting
|
||||
cy.button("Next").click();
|
||||
cy.button("Next").click();
|
||||
|
||||
// check database setup card is visible
|
||||
cy.findByText("MySQL").click();
|
||||
cy.findByText("Need help connecting?").should("be.visible");
|
||||
@@ -208,9 +194,7 @@ describe("scenarios > setup", () => {
|
||||
});
|
||||
cy.location("pathname").should("eq", "/");
|
||||
|
||||
main()
|
||||
.findByText("Get started with Embedding Metabase in your app")
|
||||
.should("not.exist");
|
||||
main().findByText("Embed Metabase in your app").should("not.exist");
|
||||
});
|
||||
|
||||
// Values in this test are set through MB_USER_DEFAULTS environment variable!
|
||||
@@ -335,15 +319,7 @@ describe("scenarios > setup", () => {
|
||||
|
||||
cy.location("pathname").should("eq", "/");
|
||||
|
||||
main()
|
||||
.findByText("Get started with Embedding Metabase in your app")
|
||||
.should("exist");
|
||||
|
||||
cy.reload();
|
||||
|
||||
main()
|
||||
.findByText("Get started with Embedding Metabase in your app")
|
||||
.should("exist");
|
||||
main().findByText("Embed Metabase in your app").should("exist");
|
||||
|
||||
main()
|
||||
.findByRole("link", { name: "Learn more" })
|
||||
@@ -353,17 +329,10 @@ describe("scenarios > setup", () => {
|
||||
/https:\/\/www.metabase.com\/docs\/[^\/]*\/embedding\/start\.html\?utm_media=embed-minimal-homepage/,
|
||||
);
|
||||
|
||||
cy.icon("close").click();
|
||||
|
||||
main()
|
||||
.findByText("Get started with Embedding Metabase in your app")
|
||||
.should("not.exist");
|
||||
|
||||
cy.reload();
|
||||
|
||||
main()
|
||||
.findByText("Get started with Embedding Metabase in your app")
|
||||
.should("not.exist");
|
||||
// should only show up once
|
||||
main().findByText("Embed Metabase in your app").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
saveDashboard,
|
||||
editDashboard,
|
||||
visitDashboard,
|
||||
openColumnOptions,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import { setAdHocFilter } from "../../native-filters/helpers/e2e-date-filter-helpers";
|
||||
@@ -149,10 +150,15 @@ describe("issue 17514", () => {
|
||||
cy.findByText("Products").click();
|
||||
|
||||
cy.button("Visualize").click();
|
||||
|
||||
// wait until view results are done rendering
|
||||
cy.wait("@dataset");
|
||||
cy.findByTestId("query-builder-main").within(() => {
|
||||
cy.findByText("Doing science...").should("not.exist");
|
||||
});
|
||||
|
||||
// Cypress cannot click elements that are blocked by an overlay so this will immediately fail if the issue is not fixed
|
||||
cy.findByTextEnsureVisible("Subtotal").click();
|
||||
openColumnOptions("Subtotal");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Filter by this column");
|
||||
});
|
||||
|
||||
@@ -318,8 +318,9 @@ describe("scenarios > visualizations > table", () => {
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByText("Filter by this column").click();
|
||||
cy.findByPlaceholderText("Search by Password").type("e").blur();
|
||||
cy.findByPlaceholderText("Search by Password").type("e");
|
||||
cy.wait("@findSuggestions");
|
||||
cy.findByPlaceholderText("Search by Password").blur();
|
||||
});
|
||||
|
||||
popover().then($popover => {
|
||||
|
||||
@@ -5,21 +5,22 @@
|
||||
These routes should generally live under prefixes like `/api/ee/<feature>/` -- see the
|
||||
`enterprise/backend/README.md` for more details."
|
||||
(:require
|
||||
[compojure.core :as compojure]
|
||||
[metabase-enterprise.advanced-config.api.logs :as logs]
|
||||
[metabase-enterprise.advanced-permissions.api.routes
|
||||
:as advanced-permissions]
|
||||
[metabase-enterprise.api.routes.common :as ee.api.common]
|
||||
[metabase-enterprise.audit-app.api.routes :as audit-app]
|
||||
[metabase-enterprise.billing.api.routes :as billing]
|
||||
[metabase-enterprise.caching.api :as caching]
|
||||
[metabase-enterprise.content-verification.api.routes
|
||||
:as content-verification]
|
||||
[metabase-enterprise.llm.api :as llm.api]
|
||||
[metabase-enterprise.sandbox.api.routes :as sandbox]
|
||||
[metabase-enterprise.serialization.api :as api.serialization]
|
||||
[metabase.api.common :refer [context defroutes]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
|
||||
(compojure/defroutes ^{:doc "API routes only available when running Metabase® Enterprise Edition™."} routes
|
||||
(defroutes ^{:doc "API routes only available when running Metabase® Enterprise Edition™."} routes
|
||||
;; The following routes are NAUGHTY and do not follow the naming convention (i.e., they do not start with
|
||||
;; `/ee/<feature>/`).
|
||||
;;
|
||||
@@ -28,23 +29,26 @@
|
||||
sandbox/routes
|
||||
;; The following routes are NICE and do follow the `/ee/<feature>/` naming convention. Please add new routes here
|
||||
;; and follow the convention.
|
||||
(compojure/context
|
||||
(context
|
||||
"/ee" []
|
||||
(compojure/context
|
||||
(context
|
||||
"/billing" []
|
||||
billing/routes)
|
||||
(compojure/context
|
||||
(context
|
||||
"/audit-app" []
|
||||
(ee.api.common/+require-premium-feature :audit-app (deferred-tru "Audit app") audit-app/routes))
|
||||
(compojure/context
|
||||
(context
|
||||
"/advanced-permissions" []
|
||||
(ee.api.common/+require-premium-feature :advanced-permissions (deferred-tru "Advanced Permissions") advanced-permissions/routes))
|
||||
(compojure/context
|
||||
(context
|
||||
"/logs" []
|
||||
(ee.api.common/+require-premium-feature :audit-app (deferred-tru "Audit app") logs/routes))
|
||||
(compojure/context
|
||||
(context
|
||||
"/serialization" []
|
||||
(ee.api.common/+require-premium-feature :serialization (deferred-tru "Serialization") api.serialization/routes))
|
||||
(compojure/context
|
||||
(context
|
||||
"/autodescribe" []
|
||||
(ee.api.common/+require-premium-feature :llm-autodescription (deferred-tru "LLM Auto-description") llm.api/routes))))
|
||||
(ee.api.common/+require-premium-feature :llm-autodescription (deferred-tru "LLM Auto-description") llm.api/routes))
|
||||
(context
|
||||
"/caching" []
|
||||
(ee.api.common/+require-premium-feature :cache-granular-controls (deferred-tru "Caching") caching/routes))))
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
if we do not."
|
||||
[feature feature-name handler]
|
||||
(assert (i18n/localized-string? feature-name), "`feature-name` must be i18ned")
|
||||
(fn [request respond raise]
|
||||
(premium-features/assert-has-feature feature feature-name)
|
||||
(handler request respond raise)))
|
||||
(with-meta
|
||||
(fn [request respond raise]
|
||||
(premium-features/assert-has-feature feature feature-name)
|
||||
(handler request respond raise))
|
||||
(meta handler)))
|
||||
|
||||
(defn ^:deprecated +when-premium-feature
|
||||
"Wraps Ring `handler`. Only applies handler if we have a premium token with `feature`; if not, passes thru to the next
|
||||
|
||||
94
enterprise/backend/src/metabase_enterprise/caching/api.clj
Normal file
94
enterprise/backend/src/metabase_enterprise/caching/api.clj
Normal file
@@ -0,0 +1,94 @@
|
||||
(ns metabase-enterprise.caching.api
|
||||
(:require
|
||||
[compojure.core :refer [GET]]
|
||||
[metabase-enterprise.caching.strategies :as caching]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.common.validation :as validation]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.db.query :as mdb.query]
|
||||
[metabase.events :as events]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private CachingModel [:enum "root" "database" "dashboard" "question"])
|
||||
|
||||
(defn- assert-valid-models [model ids]
|
||||
(if (= model "root")
|
||||
(when-not (= ids [0])
|
||||
(throw (ex-info (tru "Root configuration is only valid with model_id = 0") {:status-code 404
|
||||
:model_id (first ids)})))
|
||||
(api/check-404 (t2/select-one (case model
|
||||
"database" :model/Database
|
||||
"dashboard" :model/Dashboard
|
||||
"question" :model/Card)
|
||||
:id [:in ids]))))
|
||||
|
||||
(defn- audit-caching-change! [id prev new]
|
||||
(events/publish-event!
|
||||
:event/caching-update
|
||||
{:user-id api/*current-user-id*
|
||||
:model :model/CacheConfig
|
||||
:model-id id
|
||||
:details {:model (or (:model prev) (:model new))
|
||||
:model-id (or (:model_id prev) (:model_id new))
|
||||
:old-value (dissoc prev :model :model_id)
|
||||
:new-value (dissoc new :model :model_id)}}))
|
||||
|
||||
(api/defendpoint GET "/"
|
||||
"Return cache configuration."
|
||||
[:as {{:strs [model collection]
|
||||
:or {model "root"}}
|
||||
:query-params}]
|
||||
{model (ms/QueryVectorOf CachingModel)
|
||||
;; note that `nil` in `collection` means all configurations not scoped to any particular collection
|
||||
collection [:maybe ms/PositiveInt]}
|
||||
(validation/check-has-application-permission :setting)
|
||||
(let [items (t2/select :model/CacheConfig
|
||||
:model [:in model]
|
||||
{:left-join [:report_card [:and
|
||||
[:= :model [:inline "question"]]
|
||||
[:= :model_id :report_card.id]
|
||||
[:= :report_card.collection_id collection]]
|
||||
:report_dashboard [:and
|
||||
[:= :model [:inline "dashboard"]]
|
||||
[:= :model_id :report_dashboard.id]
|
||||
[:= :report_dashboard.collection_id collection]]]
|
||||
:where [:case
|
||||
[:= :model [:inline "question"]] [:!= :report_card.id nil]
|
||||
[:= :model [:inline "dashboard"]] [:!= :report_dashboard.id nil]
|
||||
:else true]})]
|
||||
{:data (mapv caching/row->config items)}))
|
||||
|
||||
(api/defendpoint PUT "/"
|
||||
"Store cache configuration."
|
||||
[:as {{:keys [model model_id strategy] :as config} :body}]
|
||||
{model CachingModel
|
||||
model_id ms/IntGreaterThanOrEqualToZero
|
||||
strategy caching/CacheStrategyAPI}
|
||||
(validation/check-has-application-permission :setting)
|
||||
(assert-valid-models model [model_id])
|
||||
(t2/with-transaction [_tx]
|
||||
(let [data (caching/config->row config)
|
||||
current (t2/select-one :model/CacheConfig :model model :model_id model_id {:for :update})]
|
||||
{:id (u/prog1 (mdb.query/update-or-insert! :model/CacheConfig {:model model :model_id model_id}
|
||||
(constantly data))
|
||||
(audit-caching-change! <> current data))})))
|
||||
|
||||
(api/defendpoint DELETE "/"
|
||||
"Delete cache configuration."
|
||||
[:as {{:keys [model model_id]} :body}]
|
||||
{model CachingModel
|
||||
model_id (ms/QueryVectorOf ms/PositiveInt)}
|
||||
(validation/check-has-application-permission :setting)
|
||||
(assert-valid-models model model_id)
|
||||
(when-let [current (seq (t2/select :model/CacheConfig :model model :model_id [:in model_id]))]
|
||||
(t2/delete! :model/CacheConfig :model model :model_id [:in model_id])
|
||||
(doseq [item current]
|
||||
(audit-caching-change! (:id item)
|
||||
(select-keys item [:strategy :config :model :model_id])
|
||||
nil)))
|
||||
nil)
|
||||
|
||||
(api/define-routes +auth)
|
||||
@@ -0,0 +1,14 @@
|
||||
(ns metabase-enterprise.caching.config
|
||||
(:require [metabase.public-settings.premium-features :refer [defenterprise]]))
|
||||
|
||||
(defenterprise refreshable-states
|
||||
"States of `persisted_info` records which can be refreshed."
|
||||
:feature :cache-granular-controls
|
||||
[]
|
||||
#{"creating" "persisted" "error"})
|
||||
|
||||
(defenterprise prunable-states
|
||||
"States of `persisted_info` records which can be pruned."
|
||||
:feature :cache-granular-controls
|
||||
[]
|
||||
#{"deletable" "off"})
|
||||
@@ -0,0 +1,147 @@
|
||||
(ns metabase-enterprise.caching.strategies
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[java-time.api :as t]
|
||||
[medley.core :as m]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.public-settings.premium-features :refer [defenterprise defenterprise-schema]]
|
||||
[metabase.query-processor.middleware.cache-backend.db :as backend.db]
|
||||
[metabase.util.cron :as u.cron]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- drop-internal-fields [schema]
|
||||
(walk/prewalk
|
||||
(fn [x]
|
||||
(if (and (vector? x) (= (first x) :map))
|
||||
(into [] (remove #(:internal (meta %))) x)
|
||||
x))
|
||||
schema))
|
||||
|
||||
;; Data shape
|
||||
|
||||
(def CacheStrategy
|
||||
"Schema for a caching strategy"
|
||||
[:and
|
||||
[:map
|
||||
[:type [:enum :nocache :ttl :duration :schedule :query]]]
|
||||
[:multi {:dispatch :type}
|
||||
[:nocache [:map ;; not closed due to a way it's used in tests for clarity
|
||||
[:type keyword?]]]
|
||||
[:ttl [:map {:closed true}
|
||||
[:type [:= :ttl]]
|
||||
[:multiplier ms/PositiveInt]
|
||||
[:min_duration_ms ms/IntGreaterThanOrEqualToZero]]]
|
||||
[:duration [:map {:closed true}
|
||||
[:type [:= :duration]]
|
||||
[:duration ms/PositiveInt]
|
||||
[:unit [:enum "hours" "minutes" "seconds" "days"]]]]
|
||||
[:schedule [:map {:closed true}
|
||||
[:type [:= :schedule]]
|
||||
[:schedule u.cron/CronScheduleString]
|
||||
^:internal
|
||||
[:invalidated-at [:maybe some?]]]]
|
||||
[:query [:map {:closed true}
|
||||
[:type [:= :query]]
|
||||
[:field_id int?]
|
||||
[:aggregation [:enum "max" "count"]]
|
||||
[:schedule u.cron/CronScheduleString]
|
||||
^:internal
|
||||
[:invalidated-at [:maybe some?]]]]]])
|
||||
|
||||
(def CacheStrategyAPI
|
||||
"Schema for a caching strategy for the API"
|
||||
(drop-internal-fields CacheStrategy))
|
||||
|
||||
(defn row->config
|
||||
"Transform from how cache config is stored to how it's used/exposed in the API"
|
||||
[row]
|
||||
{:model (:model row)
|
||||
:model_id (:model_id row)
|
||||
:strategy (assoc (:config row) :type (:strategy row))})
|
||||
|
||||
(defn config->row
|
||||
"Transform cache config from API form into db storage from"
|
||||
[{:keys [model model_id strategy]}]
|
||||
{:model model
|
||||
:model_id model_id
|
||||
:strategy (:type strategy)
|
||||
:config (dissoc strategy :type)})
|
||||
|
||||
;;; Querying DB
|
||||
|
||||
(defn granular-duration-hours
|
||||
"Returns the granular cache duration (in hours) for a card. On EE, this first checking whether there is a stored value
|
||||
for the card, dashboard, or database (in that order of decreasing preference). Returns nil on OSS."
|
||||
[card dashboard-id]
|
||||
(or (:cache_ttl card)
|
||||
(when dashboard-id
|
||||
(t2/select-one-fn :cache_ttl [:model/Dashboard :cache_ttl] :id dashboard-id))
|
||||
(t2/select-one-fn :cache_ttl [:model/Database :cache_ttl] :id (:database_id card))))
|
||||
|
||||
(defenterprise-schema granular-cache-strategy :- [:maybe CacheStrategy]
|
||||
"Returns the granular cache strategy for a card."
|
||||
:feature :cache-granular-controls
|
||||
[card dashboard-id]
|
||||
(when (public-settings/enable-query-caching)
|
||||
(let [qs (for [[i model model-id] [[1 "question" (:id card)]
|
||||
[2 "dashboard" dashboard-id]
|
||||
[3 "database" (:database_id card)]
|
||||
[4 "root" 0]]
|
||||
:when model-id]
|
||||
{:from [:cache_config]
|
||||
:select [:id
|
||||
[[:inline i] :ordering]]
|
||||
:where [:and
|
||||
[:= :model model]
|
||||
[:= :model_id model-id]]})
|
||||
q {:from [[{:union-all qs} :unused_alias]]
|
||||
:select [:id]
|
||||
:order-by :ordering
|
||||
:limit 1}
|
||||
item (t2/select-one :model/CacheConfig :id q)]
|
||||
(if item
|
||||
(-> (:strategy (row->config item))
|
||||
(m/assoc-some :invalidated-at (:invalidated_at item)))
|
||||
(when-let [ttl (granular-duration-hours card dashboard-id)]
|
||||
{:type :duration
|
||||
:duration ttl
|
||||
:unit "hours"})))))
|
||||
|
||||
;;; Strategy execution
|
||||
|
||||
(defmulti fetch-cache-stmt-ee*
|
||||
"Generate prepared statement for a db cache backend for a given strategy"
|
||||
(fn [strategy _hash _conn] (:type strategy)))
|
||||
|
||||
(defmethod fetch-cache-stmt-ee* :ttl [strategy query-hash conn]
|
||||
(backend.db/fetch-cache-stmt-ttl strategy query-hash conn))
|
||||
|
||||
(defmethod fetch-cache-stmt-ee* :duration [strategy query-hash conn]
|
||||
(if-not (and (:duration strategy) (:unit strategy))
|
||||
(log/debugf "Caching strategy %s should have :duration and :unit" (pr-str strategy))
|
||||
(let [duration (t/duration (:duration strategy) (keyword (:unit strategy)))
|
||||
timestamp (t/minus (t/offset-date-time) duration)]
|
||||
(backend.db/prepare-statement conn query-hash timestamp))))
|
||||
|
||||
(defmethod fetch-cache-stmt-ee* :schedule [{:keys [invalidated-at] :as strategy} query-hash conn]
|
||||
(if-not invalidated-at
|
||||
(log/debugf "Caching strategy %s has not run yet" (pr-str strategy))
|
||||
(backend.db/prepare-statement conn query-hash invalidated-at)))
|
||||
|
||||
(defmethod fetch-cache-stmt-ee* :query [{:keys [invalidated-at] :as strategy} query-hash conn]
|
||||
(if-not invalidated-at
|
||||
(log/debugf "Caching strategy %s has never run yet" (pr-str strategy))
|
||||
(backend.db/prepare-statement conn query-hash invalidated-at)))
|
||||
|
||||
(defmethod fetch-cache-stmt-ee* :nocache [_ _ _]
|
||||
nil)
|
||||
|
||||
(defenterprise fetch-cache-stmt
|
||||
"Returns prepared statement to query for db cache backend."
|
||||
:feature :cache-granular-controls
|
||||
[strategy query-hash conn]
|
||||
(fetch-cache-stmt-ee* strategy query-hash conn))
|
||||
@@ -4,7 +4,8 @@
|
||||
[metabase.api.common :as api]
|
||||
[metabase.models.moderation-review :as moderation-review]
|
||||
[metabase.moderation :as moderation]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(api/defendpoint POST "/"
|
||||
"Create a new `ModerationReview`."
|
||||
@@ -19,7 +20,7 @@
|
||||
:moderated_item_type moderated_item_type
|
||||
:moderator_id api/*current-user-id*
|
||||
:status status}]
|
||||
(api/check-404 (moderation/moderated-item review-data))
|
||||
(api/check-404 (t2/exists? (get moderation/moderated-item-type->model moderated_item_type) moderated_item_id))
|
||||
(moderation-review/create-review! review-data)))
|
||||
|
||||
(api/define-routes)
|
||||
|
||||
@@ -36,20 +36,32 @@
|
||||
(fn [rsp] (rename-keys rsp {:friendly_title :title
|
||||
:friendly_summary :description}))))]
|
||||
(client
|
||||
{:messages
|
||||
[{:role "system"
|
||||
:content "You are a helpful assistant that fills in the missing \"friendly_title\" and
|
||||
\"friendly_summary\" keys in a json fragment. You like to occasionally use emojis to express
|
||||
yourself but are otherwise very serious and professional."}
|
||||
{:role "assistant"
|
||||
:content (cond-> "The \"display\" key is how I intend to present the final data."
|
||||
(seq visualization_settings)
|
||||
(str " The \"visualization_settings\" key has chart settings."))}
|
||||
{:role "assistant"
|
||||
:content "The parts you replace are \"%%FILL_THIS_TITLE_IN%%\" and \"%%FILL_THIS_SUMMARY_IN%%\"."}
|
||||
{:role "assistant"
|
||||
:content "Return only a json object with the \"friendly_title\" and \"friendly_summary\" fields and nothing else."}
|
||||
{:role "assistant"
|
||||
:content "The \"friendly_title\" must be no more than 64 characters long."}
|
||||
{:role "user"
|
||||
:content json-str}]})))
|
||||
{:messages
|
||||
[{:role "system"
|
||||
:content
|
||||
"You are a helpful assistant that fills in the missing \"friendly_title\" and
|
||||
\"friendly_summary\" keys in a json fragment.
|
||||
|
||||
Your summary and title should:
|
||||
- be concise
|
||||
- be informative
|
||||
- be easy to understand by a non-technical audience from a variety of backgrounds
|
||||
- be discoverable by search engines
|
||||
- include caveats and limitations from filters (the query clauses) if applicable
|
||||
- not have caveats stated as fact but rather as suggestions
|
||||
- not explicitly talk about the visualization type
|
||||
- not provide any conclusions about the data
|
||||
|
||||
You like to occasionally use emojis to express yourself but are otherwise very serious and professional."}
|
||||
{:role "assistant"
|
||||
:content (cond-> "The \"display\" key is how I intend to present the final data."
|
||||
(seq visualization_settings)
|
||||
(str " The \"visualization_settings\" key has chart settings."))}
|
||||
{:role "assistant"
|
||||
:content "The parts you replace are \"%%FILL_THIS_TITLE_IN%%\" and \"%%FILL_THIS_SUMMARY_IN%%\"."}
|
||||
{:role "assistant"
|
||||
:content "Return only a json object with the \"friendly_title\" and \"friendly_summary\" fields and nothing else."}
|
||||
{:role "assistant"
|
||||
:content "The \"friendly_title\" must be no more than 64 characters long."}
|
||||
{:role "user"
|
||||
:content json-str}]})))
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
settings true
|
||||
data_model true}}
|
||||
:query-params}]
|
||||
{collection [:maybe [:vector {:decode/string (fn [x] (cond (vector? x) x x [x]))} ms/PositiveInt]]
|
||||
{collection [:maybe (ms/QueryVectorOf ms/PositiveInt)]
|
||||
all_collections [:maybe ms/BooleanValue]
|
||||
settings [:maybe ms/BooleanValue]
|
||||
data_model [:maybe ms/BooleanValue]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
[metabase.models.dashboard :refer [Dashboard]]
|
||||
[metabase.models.database :refer [Database]]
|
||||
[metabase.models.field :as field :refer [Field]]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.native-query-snippet :refer [NativeQuerySnippet]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.segment :refer [Segment]]
|
||||
@@ -204,8 +204,8 @@
|
||||
(t2/select Field :table_id [:in (map :id tables)] {:order-by [[:id :asc]]})
|
||||
(t2/select Field))
|
||||
metrics (if (contains? opts :only-db-ids)
|
||||
(t2/select Metric :table_id [:in (map :id tables)] {:order-by [[:id :asc]]})
|
||||
(t2/select Metric))
|
||||
(t2/select LegacyMetric :table_id [:in (map :id tables)] {:order-by [[:id :asc]]})
|
||||
(t2/select LegacyMetric))
|
||||
collections (select-collections users state)]
|
||||
(binding [serialize/*include-entity-id* (boolean include-entity-id)]
|
||||
(dump/dump! path
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[metabase.models.dimension :refer [Dimension]]
|
||||
[metabase.models.field :refer [Field]]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.segment :refer [Segment]]
|
||||
[metabase.models.setting :as setting]
|
||||
@@ -74,7 +74,7 @@
|
||||
[instance]
|
||||
(some (fn [model]
|
||||
(mi/instance-of? model instance))
|
||||
[Pulse Dashboard Metric Segment Field User]))
|
||||
[Pulse Dashboard LegacyMetric Segment Field User]))
|
||||
|
||||
(defn- spit-entity!
|
||||
[path entity]
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
[metabase.models.dimension :refer [Dimension]]
|
||||
[metabase.models.field :refer [Field]]
|
||||
[metabase.models.field-values :refer [FieldValues]]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.native-query-snippet :refer [NativeQuerySnippet]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.pulse-card :refer [PulseCard]]
|
||||
@@ -281,7 +281,7 @@
|
||||
|
||||
(defmethod load! "metrics"
|
||||
[path context]
|
||||
(maybe-upsert-many! context Metric
|
||||
(maybe-upsert-many! context LegacyMetric
|
||||
(for [metric (slurp-dir path)]
|
||||
(-> metric
|
||||
(assoc :table_id (:table context)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
[metabase.models.database :as database :refer [Database]]
|
||||
[metabase.models.field :refer [Field]]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.native-query-snippet :refer [NativeQuerySnippet]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.segment :refer [Segment]]
|
||||
@@ -70,7 +70,7 @@
|
||||
(str (->> field :table_id (fully-qualified-name Table)) "/fks/" (safe-name field))
|
||||
(str (->> field :table_id (fully-qualified-name Table)) "/fields/" (safe-name field))))
|
||||
|
||||
(defmethod fully-qualified-name* Metric
|
||||
(defmethod fully-qualified-name* LegacyMetric
|
||||
[metric]
|
||||
(str (->> metric :table_id (fully-qualified-name Table)) "/metrics/" (safe-name metric)))
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
(defmethod path->context* "metrics"
|
||||
[context _ _ metric-name]
|
||||
(assoc context :metric (t2/select-one-pk Metric
|
||||
(assoc context :metric (t2/select-one-pk LegacyMetric
|
||||
:table_id (:table context)
|
||||
:name metric-name)))
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
[metabase.models.dimension :refer [Dimension]]
|
||||
[metabase.models.field :as field :refer [Field]]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.native-query-snippet :refer [NativeQuerySnippet]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.pulse-card :refer [PulseCard]]
|
||||
@@ -64,7 +64,7 @@
|
||||
(assoc &match :source-field (fully-qualified-name Field id))
|
||||
|
||||
[:metric (id :guard integer?)]
|
||||
[:metric (fully-qualified-name Metric id)]
|
||||
[:metric (fully-qualified-name LegacyMetric id)]
|
||||
|
||||
[:segment (id :guard integer?)]
|
||||
[:segment (fully-qualified-name Segment id)])))
|
||||
@@ -109,7 +109,7 @@
|
||||
:dashboard_id :fields_hash :personal_owner_id :made_public_by_id :collection_id
|
||||
:pulse_id :result_metadata :action_id)
|
||||
(not *include-entity-id*) (dissoc :entity_id)
|
||||
(some #(instance? % entity) (map type [Metric Field Segment])) (dissoc :table_id)))
|
||||
(some #(instance? % entity) (map type [LegacyMetric Field Segment])) (dissoc :table_id)))
|
||||
|
||||
(defmulti ^:private serialize-one
|
||||
{:arglists '([instance])}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
[metabase.models.dimension :refer [Dimension]]
|
||||
[metabase.models.field :refer [Field]]
|
||||
[metabase.models.field-values :refer [FieldValues]]
|
||||
[metabase.models.metric :refer [Metric]]
|
||||
[metabase.models.metric :refer [LegacyMetric]]
|
||||
[metabase.models.native-query-snippet :refer [NativeQuerySnippet]]
|
||||
[metabase.models.pulse :refer [Pulse]]
|
||||
[metabase.models.pulse-card :refer [PulseCard]]
|
||||
@@ -34,7 +34,7 @@
|
||||
{Database [:name :engine]
|
||||
Table [:schema :name :db_id]
|
||||
Field [:name :table_id]
|
||||
Metric [:name :table_id]
|
||||
LegacyMetric [:name :table_id]
|
||||
Segment [:name :table_id]
|
||||
Collection [:name :location :namespace]
|
||||
Dashboard [:name :collection_id]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"Schema model types"
|
||||
["Database"
|
||||
"Field"
|
||||
"Metric"
|
||||
"LegacyMetric"
|
||||
"Segment"
|
||||
"Table"])
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"ApplicationPermissionsRevision"
|
||||
"AuditLog"
|
||||
"BookmarkOrdering"
|
||||
"CacheConfig"
|
||||
"CardBookmark"
|
||||
"CollectionBookmark"
|
||||
"CollectionPermissionGraphRevision"
|
||||
@@ -51,7 +52,7 @@
|
||||
"HTTPAction"
|
||||
"ImplicitAction"
|
||||
"LoginHistory"
|
||||
"MetricImportantField"
|
||||
"LegacyMetricImportantField"
|
||||
"ModelIndex"
|
||||
"ModelIndexValue"
|
||||
"ModerationReview"
|
||||
|
||||
96
enterprise/backend/src/metabase_enterprise/task/caching.clj
Normal file
96
enterprise/backend/src/metabase_enterprise/task/caching.clj
Normal file
@@ -0,0 +1,96 @@
|
||||
(ns metabase-enterprise.task.caching
|
||||
(:require
|
||||
[clojurewerkz.quartzite.jobs :as jobs]
|
||||
[clojurewerkz.quartzite.schedule.cron :as cron]
|
||||
[clojurewerkz.quartzite.triggers :as triggers]
|
||||
[java-time.api :as t]
|
||||
[metabase.public-settings.premium-features :refer [defenterprise]]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.task :as task]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(org.quartz.spi MutableTrigger)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- select-ready-to-run
|
||||
"Fetch whatever cache configs for a given `strategy` are ready to be updated."
|
||||
[strategy]
|
||||
(assert (#{:query :schedule} strategy))
|
||||
(t2/select :model/CacheConfig :strategy strategy {:where [:or
|
||||
[:= :next_run_at nil]
|
||||
[:<= :next_run_at (t/offset-date-time)]]}))
|
||||
|
||||
(defn- calc-next-run
|
||||
"Calculate when a next run should happen based on a cron schedule"
|
||||
[^String schedule now]
|
||||
(let [^MutableTrigger cron (cron/finalize (cron/cron-schedule schedule))]
|
||||
;; Synchronize cron runner to our app time, which may be mocked in tests
|
||||
(.setStartTime cron (t/java-date))
|
||||
(-> (.getFireTimeAfter cron (t/java-date now))
|
||||
(t/offset-date-time (t/zone-offset)))))
|
||||
|
||||
(defn- refresh-schedule-configs!
|
||||
"Update `invalidated_at` for every cache config with `:schedule` strategy"
|
||||
[]
|
||||
(let [now (t/offset-date-time)]
|
||||
(count
|
||||
(for [{:keys [id config]} (select-ready-to-run :schedule)]
|
||||
(t2/update! :model/CacheConfig {:id id}
|
||||
{:next_run_at (calc-next-run (:schedule config) now)
|
||||
:invalidated_at now})))))
|
||||
|
||||
(defn- refresh-query-config! [db-id table-id field-id {:keys [id config state]}]
|
||||
(let [query {:database db-id
|
||||
:type :query
|
||||
:query {:source-table table-id
|
||||
:aggregation [(:aggregation config) [:field field-id nil]]}}
|
||||
result (-> (qp/process-query query) :data :rows ffirst)
|
||||
now (t/offset-date-time)
|
||||
next-run-at (calc-next-run (:schedule config) now)
|
||||
marker (:marker state)]
|
||||
(t2/update! :model/CacheConfig {:id id}
|
||||
(cond-> {:next_run_at next-run-at}
|
||||
(not= marker result) (assoc :state {:marker result}
|
||||
:invalidated_at now)))))
|
||||
|
||||
(defn- refresh-query-configs!
|
||||
"Fetches `:query`-strategy configs wants to re-check their queries, runs those queries and updates `invalidated_at`
|
||||
where `(:marker state)` is not equal to the result of the query."
|
||||
[]
|
||||
(when-let [configs (seq (select-ready-to-run :query))]
|
||||
(let [fields (t2/select-pk->fn identity :model/Field :id [:in (map #(-> % :config :field_id) configs)])
|
||||
tables (t2/select-pk->fn identity :model/Table :id [:in (map :table_id (vals fields))])]
|
||||
(count
|
||||
(for [item configs
|
||||
:let [field (get fields (:field_id (:config item)))
|
||||
table (get tables (:table_id field))]]
|
||||
(refresh-query-config! (:db_id table) (:table_id field) (:id field) item))))))
|
||||
|
||||
(jobs/defjob ^{org.quartz.DisallowConcurrentExecution true
|
||||
:doc "Refresh 'schedule' caches"}
|
||||
Caching [_ctx]
|
||||
(refresh-schedule-configs!)
|
||||
(refresh-query-configs!))
|
||||
|
||||
(def ^:private caching-job
|
||||
(jobs/build
|
||||
(jobs/with-description "Schedule Caches refresh task")
|
||||
(jobs/of-type Caching)
|
||||
(jobs/with-identity (jobs/key "metabase-enterprise.Caching.job"))
|
||||
(jobs/store-durably)))
|
||||
|
||||
(def ^:private caching-trigger
|
||||
(triggers/build
|
||||
(triggers/with-identity (triggers/key "metabase-enterprise.Caching.trigger"))
|
||||
(triggers/start-now)
|
||||
(triggers/with-schedule
|
||||
(cron/schedule
|
||||
;; run every minute
|
||||
(cron/cron-schedule "0 * * * * ? *")))))
|
||||
|
||||
(defenterprise init-caching-task!
|
||||
"Inits periodical task checking for cache expiration"
|
||||
:feature :cache-granular-controls
|
||||
[]
|
||||
(task/schedule-task! caching-job caching-trigger))
|
||||
114
enterprise/backend/test/metabase_enterprise/caching/api_test.clj
Normal file
114
enterprise/backend/test/metabase_enterprise/caching/api_test.clj
Normal file
@@ -0,0 +1,114 @@
|
||||
(ns metabase-enterprise.caching.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.caching.api :as caching.api]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.query-processor.card :as qp.card]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment
|
||||
caching.api/keep-me)
|
||||
|
||||
(defn last-audit-event []
|
||||
(t2/select-one [:model/AuditLog :topic :user_id :model :model_id :details]
|
||||
:topic :caching-update
|
||||
{:order-by [[:id :desc]]}))
|
||||
|
||||
(deftest cache-config-test
|
||||
(mt/discard-setting-changes [enable-query-caching]
|
||||
(public-settings/enable-query-caching! true)
|
||||
(mt/with-model-cleanup [:model/CacheConfig]
|
||||
(testing "Caching requires premium token with `:caching`"
|
||||
(mt/with-premium-features #{}
|
||||
(is (= "Caching is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
|
||||
(mt/user-http-request :crowberto :get 402 "ee/caching/")))))
|
||||
(testing "Caching API"
|
||||
(mt/with-premium-features #{:cache-granular-controls :audit-app}
|
||||
(mt/with-temp [:model/Database db {}
|
||||
:model/Collection col1 {:name "col1"}
|
||||
:model/Dashboard dash1 {:name "dash1"
|
||||
:collection_id (:id col1)}
|
||||
:model/Card card1 {:name "card1"
|
||||
:database_id (:id db)
|
||||
:collection_id (:id col1)}
|
||||
:model/Card card2 {:name "card2"
|
||||
:database_id (:id db)
|
||||
:collection_id (:id col1)}
|
||||
:model/Card card3 {:name "card3"}]
|
||||
|
||||
(testing "No access from regular users"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :get 403 "ee/caching/"))))
|
||||
|
||||
(testing "Can configure root"
|
||||
(is (mt/user-http-request :crowberto :put 200 "ee/caching/"
|
||||
{:model "root"
|
||||
:model_id 0
|
||||
:strategy {:type "nocache" :name "root"}}))
|
||||
(is (=? {:data [{:model "root" :model_id 0}]}
|
||||
(mt/user-http-request :crowberto :get 200 "ee/caching/"
|
||||
:model "root")))
|
||||
(testing "Is audited"
|
||||
(is (=? {:topic :caching-update
|
||||
:user_id (:id (mt/fetch-user :crowberto))
|
||||
:model "CacheConfig"
|
||||
:model_id int?
|
||||
:details {:model "root"
|
||||
:model-id 0
|
||||
:old-value nil
|
||||
:new-value {:strategy "nocache" :config {:name "root"}}}}
|
||||
(last-audit-event)))))
|
||||
|
||||
(testing "Can configure others"
|
||||
(is (mt/user-http-request :crowberto :put 200 "ee/caching/"
|
||||
{:model "database"
|
||||
:model_id (:id db)
|
||||
:strategy {:type "nocache" :name "db"}}))
|
||||
(is (mt/user-http-request :crowberto :put 200 "ee/caching/"
|
||||
{:model "dashboard"
|
||||
:model_id (:id dash1)
|
||||
:strategy {:type "nocache" :name "dash"}}))
|
||||
(is (mt/user-http-request :crowberto :put 200 "ee/caching/"
|
||||
{:model "question"
|
||||
:model_id (:id card1)
|
||||
:strategy {:type "nocache" :name "card1"}})))
|
||||
|
||||
(testing "HTTP responds with correct listings"
|
||||
(is (=? {:data [{:model "root" :model_id 0}]}
|
||||
(mt/user-http-request :crowberto :get 200 "ee/caching/")))
|
||||
(is (=? {:data [{:model "database" :model_id (:id db)}]}
|
||||
(mt/user-http-request :crowberto :get 200 "ee/caching/" {}
|
||||
:model :database)))
|
||||
(is (=? {:data [{:model "dashboard" :model_id (:id dash1)}
|
||||
{:model "question" :model_id (:id card1)}]}
|
||||
(mt/user-http-request :crowberto :get 200 "ee/caching/" {}
|
||||
:collection (:id col1) :model :dashboard :model :question))))
|
||||
|
||||
(testing "We select correct config for something from a db"
|
||||
(testing "First card has own config"
|
||||
(is (=? {:type :nocache :name "card1"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card1 {} {} {} {}))))
|
||||
(is (=? {:type :nocache :name "card1"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card1 {} {} {} {:dashboard-id (u/the-id dash1)})))))
|
||||
(testing "Second card should hit database or dashboard cache"
|
||||
(is (=? {:type :nocache :name "db"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card2 {} {} {} {}))))
|
||||
(is (=? {:type :nocache :name "dash"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card2 {} {} {} {:dashboard-id (u/the-id dash1)})))))
|
||||
(testing "Third card targets other db and gets root config"
|
||||
(is (=? {:type :nocache :name "root"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card3 {} {} {} {}))))))
|
||||
|
||||
(testing "It's possible to delete a configuration"
|
||||
(is (nil? (mt/user-http-request :crowberto :delete 204 "ee/caching"
|
||||
{:model "database"
|
||||
:model_id (:id db)})))
|
||||
(testing "Listing for databases becomes empty"
|
||||
(is (=? {:data []}
|
||||
(mt/user-http-request :crowberto :get 200 "ee/caching/" {}
|
||||
:model :database))))
|
||||
(testing "And card2 gets root config"
|
||||
(is (=? {:type :nocache :name "root"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card2 {} {} {} {}))))))))))))
|
||||
@@ -1,45 +1,21 @@
|
||||
(ns metabase-enterprise.advanced-config.caching-test
|
||||
(ns metabase-enterprise.caching.config-test
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase.models :refer [Card Dashboard Database PersistedInfo TaskHistory]]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.query-processor.card :as qp.card]
|
||||
[metabase-enterprise.caching.config :as caching.config]
|
||||
[metabase.models :refer [Card Database PersistedInfo TaskHistory]]
|
||||
[metabase.task.persist-refresh :as task.persist-refresh]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.tools.with-temp :as t2.with-temp]))
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest query-cache-ttl-hierarchy-test
|
||||
(mt/with-premium-features #{:cache-granular-controls}
|
||||
(mt/discard-setting-changes [enable-query-caching]
|
||||
(public-settings/enable-query-caching! true)
|
||||
;; corresponding OSS tests in metabase.query-processor.card-test
|
||||
(testing "database TTL takes effect when no dashboard or card TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {}
|
||||
Card card {:database_id (u/the-id db)}]
|
||||
(is (= (* 3600 1337)
|
||||
(:cache-ttl (#'qp.card/query-for-card card {} {} {} {:dashboard-id (u/the-id dash)}))))))
|
||||
(testing "card ttl only"
|
||||
(mt/with-temp [Card card {:cache_ttl 1337}]
|
||||
(is (= (* 3600 1337) (:cache-ttl (#'qp.card/query-for-card card {} {} {}))))))
|
||||
(testing "multiple ttl, card wins if dash and database TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {:cache_ttl 1338}
|
||||
Card card {:database_id (u/the-id db) :cache_ttl 1339}]
|
||||
(is (= (* 3600 1339) (:cache-ttl (#'qp.card/query-for-card card {} {} {} {:dashboard-id (u/the-id dash)}))))))
|
||||
(testing "multiple ttl, dash wins when no card TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {:cache_ttl 1338}
|
||||
Card card {:database_id (u/the-id db)}]
|
||||
(is (= (* 3600 1338) (:cache-ttl (#'qp.card/query-for-card card {} {} {} {:dashboard-id (u/the-id dash)})))))))))
|
||||
(comment
|
||||
caching.config/keep-me)
|
||||
|
||||
(defn do-with-persist-models [f]
|
||||
(let [two-hours-ago (t/minus (t/local-date-time) (t/hours 2))]
|
||||
(t2.with-temp/with-temp
|
||||
(mt/with-temp
|
||||
[Database db {:settings {:persist-models-enabled true}}
|
||||
Card creating {:type :model, :database_id (u/the-id db)}
|
||||
Card deletable {:type :model, :database_id (u/the-id db)}
|
||||
@@ -0,0 +1,286 @@
|
||||
(ns metabase-enterprise.caching.strategies-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.caching.strategies :as caching]
|
||||
[metabase-enterprise.task.caching :as task.caching]
|
||||
[metabase.models :refer [Card Dashboard Database]]
|
||||
[metabase.models.query :as query]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.card :as qp.card]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(comment
|
||||
caching/keep-me)
|
||||
|
||||
(deftest query-cache-strategy-hierarchy-test
|
||||
(mt/with-premium-features #{:cache-granular-controls}
|
||||
(mt/discard-setting-changes [enable-query-caching]
|
||||
(public-settings/enable-query-caching! true)
|
||||
;; corresponding OSS tests in metabase.query-processor.card-test
|
||||
(testing "database TTL takes effect when no dashboard or card TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {}
|
||||
Card card {:database_id (u/the-id db)}]
|
||||
(is (= {:type :duration
|
||||
:duration 1337
|
||||
:unit "hours"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card [] {} {} {:dashboard-id (u/the-id dash)}))))))
|
||||
(testing "card ttl only"
|
||||
(mt/with-temp [Card card {:cache_ttl 1337}]
|
||||
(is (= {:type :duration
|
||||
:duration 1337
|
||||
:unit "hours"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card [] {} {}))))))
|
||||
(testing "multiple ttl, card wins if dash and database TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {:cache_ttl 1338}
|
||||
Card card {:database_id (u/the-id db) :cache_ttl 1339}]
|
||||
(is (= {:type :duration
|
||||
:duration 1339
|
||||
:unit "hours"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card [] {} {} {:dashboard-id (u/the-id dash)}))))))
|
||||
(testing "multiple ttl, dash wins when no card TTLs are set"
|
||||
(mt/with-temp [Database db {:cache_ttl 1337}
|
||||
Dashboard dash {:cache_ttl 1338}
|
||||
Card card {:database_id (u/the-id db)}]
|
||||
(is (= {:type :duration
|
||||
:duration 1338
|
||||
:unit "hours"}
|
||||
(:cache-strategy (#'qp.card/query-for-card card [] {} {} {:dashboard-id (u/the-id dash)})))))))))
|
||||
|
||||
(deftest caching-strategies
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/discard-setting-changes [enable-query-caching]
|
||||
(public-settings/enable-query-caching! true)
|
||||
(mt/with-premium-features #{:cache-granular-controls}
|
||||
|
||||
(let [query (mt/mbql-query venues {:order-by [[:asc $id]] :limit 5})
|
||||
mkres (fn [input]
|
||||
{:cache/details (if input
|
||||
{:cached true, :updated_at input, :hash some?}
|
||||
{:stored true, :hash some?})
|
||||
:row_count 5
|
||||
:status :completed})]
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(testing "strategy = ttl"
|
||||
(let [query (assoc query :cache-strategy {:type :ttl
|
||||
:multiplier 10
|
||||
:min-duration-ms 0
|
||||
:avg-execution-ms 500})]
|
||||
(testing "Results are stored and available immediately"
|
||||
(mt/with-clock #t "2024-02-13T10:00:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))
|
||||
(is (=? (mkres #t "2024-02-13T10:00:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(testing "4 seconds past that results are still there - 10 * 500 = 5 seconds"
|
||||
(mt/with-clock #t "2024-02-13T10:00:04Z"
|
||||
(is (=? (mkres #t "2024-02-13T10:00:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(testing "6 seconds later results are unavailable"
|
||||
(mt/with-clock #t "2024-02-13T10:00:06Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data)))))))))
|
||||
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(testing "strategy = duration"
|
||||
(let [query (assoc query :cache-strategy {:type :duration
|
||||
:duration 1
|
||||
:unit "minutes"
|
||||
:min-duration-ms 0})]
|
||||
(testing "Results are stored and available immediately"
|
||||
(mt/with-clock #t "2024-02-13T10:00:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))
|
||||
(is (=? (mkres #t "2024-02-13T10:00:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data)))))
|
||||
(mt/with-clock #t "2024-02-13T10:00:59Z"
|
||||
(is (=? (mkres #t "2024-02-13T10:00:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data)))))
|
||||
(mt/with-clock #t "2024-02-13T10:01:01Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data)))))))))
|
||||
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(testing "strategy = schedule"
|
||||
(let [query (assoc query :cache-strategy {:type :schedule
|
||||
:schedule "0/2 * * * *"
|
||||
:invalidated-at (t/offset-date-time #t "2024-02-13T10:00:00Z")})]
|
||||
(testing "Results are stored and available immediately"
|
||||
(mt/with-clock #t "2024-02-13T10:01:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))
|
||||
(is (=? (mkres #t "2024-02-13T10:01:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(let [query (assoc-in query [:cache-strategy :invalidated-at] (t/offset-date-time #t "2024-02-13T10:02:00Z"))]
|
||||
(testing "Cache is invalidated when schedule ran after the query"
|
||||
(mt/with-clock #t "2024-02-13T10:03:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(testing "schedule did not run - cache is still intact"
|
||||
(mt/with-clock #t "2024-02-13T10:08:00Z"
|
||||
(is (=? (mkres #t "2024-02-13T10:03:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data))))))))))
|
||||
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(testing "strategy = query"
|
||||
(let [query (assoc query :cache-strategy {:type :query
|
||||
:field_id 0
|
||||
:aggregation "max"
|
||||
:schedule "0/2 * * * *"
|
||||
:invalidated-at (t/offset-date-time #t "2024-02-13T11:00:00Z")})]
|
||||
(testing "Results are stored and available immediately"
|
||||
(mt/with-clock #t "2024-02-13T11:01:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))
|
||||
(is (=? (mkres #t "2024-02-13T11:01:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(let [query (assoc-in query [:cache-strategy :invalidated-at] (t/offset-date-time #t "2024-02-13T11:02:00Z"))]
|
||||
(testing "Cache is invalidated when schedule ran after the query"
|
||||
(mt/with-clock #t "2024-02-13T11:03:00Z"
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query query) (dissoc :data))))))
|
||||
(testing "schedule did not run - cache is still intact"
|
||||
(mt/with-clock #t "2024-02-13T11:08:00Z"
|
||||
(is (=? (mkres #t "2024-02-13T11:03:00Z")
|
||||
(-> (qp/process-query query) (dissoc :data)))))))))))))))
|
||||
|
||||
(deftest e2e-advanced-caching
|
||||
(mt/with-empty-h2-app-db
|
||||
(mt/discard-setting-changes [enable-query-caching]
|
||||
(public-settings/enable-query-caching! true)
|
||||
(mt/with-premium-features #{:cache-granular-controls}
|
||||
(mt/dataset (mt/dataset-definition "caching1"
|
||||
["table"
|
||||
[{:field-name "value" :indexed? true :base-type :type/Text}]
|
||||
[["a"] ["b"] ["c"]]])
|
||||
(mt/with-temp [:model/Card card1 {:dataset_query (mt/mbql-query table)}
|
||||
:model/Card card2 {:dataset_query (mt/mbql-query table)}
|
||||
:model/Card card3 {:dataset_query (mt/mbql-query table)}
|
||||
:model/Card card4 {:dataset_query (mt/mbql-query table)}
|
||||
:model/Card card5 {:dataset_query (mt/mbql-query table)}
|
||||
|
||||
:model/CacheConfig _c1 {:model "question"
|
||||
:model_id (:id card1)
|
||||
:strategy :ttl
|
||||
:config {:multiplier 100
|
||||
:min_duration_ms 0}}
|
||||
:model/CacheConfig _c2 {:model "question"
|
||||
:model_id (:id card2)
|
||||
:strategy :duration
|
||||
:config {:duration 1
|
||||
:unit "minutes"}}
|
||||
:model/CacheConfig _c3 {:model "question"
|
||||
:model_id (:id card3)
|
||||
:strategy :schedule
|
||||
:config {:schedule "0 0/2 * * * ? *"}}
|
||||
:model/CacheConfig c4 {:model "question"
|
||||
:model_id (:id card4)
|
||||
:strategy :query
|
||||
:config {:field_id (mt/id :table :id)
|
||||
:aggregation "max"
|
||||
:schedule "0 0/2 * * * ? *"}}]
|
||||
(let [t (fn [seconds]
|
||||
(t/plus #t "2024-02-13T10:00:00Z" (t/duration seconds :seconds)))
|
||||
mkres (fn [input]
|
||||
{:cache/details (if input
|
||||
{:cached true, :updated_at input, :hash some?}
|
||||
{:stored true, :hash some?})
|
||||
:row_count 3
|
||||
:status :completed})]
|
||||
(testing "strategy = ttl"
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(mt/with-clock (t 0)
|
||||
(let [q (with-redefs [query/average-execution-time-ms (constantly 1000)]
|
||||
(#'qp.card/query-for-card card1 [] {} {} {}))]
|
||||
(is (=? {:type :ttl}
|
||||
(:cache-strategy q)))
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))
|
||||
(testing "There is cache on second call"
|
||||
(is (=? (mkres (t 0))
|
||||
(-> (qp/process-query q) (dissoc :data)))))
|
||||
(testing "No cache after expiration"
|
||||
(mt/with-clock (t 101) ;; avg execution time 1s * multiplier 100 + 1
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))))))))
|
||||
|
||||
(testing "strategy = duration"
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(mt/with-clock (t 0)
|
||||
(let [q (#'qp.card/query-for-card card2 [] {} {} {})]
|
||||
(is (=? {:type :duration}
|
||||
(:cache-strategy q)))
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))
|
||||
(testing "There is cache on the next call"
|
||||
(is (=? (mkres (t 0))
|
||||
(-> (qp/process-query q) (dissoc :data)))))
|
||||
(testing "No cache after expiration"
|
||||
(mt/with-clock (t 61)
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))))))))
|
||||
|
||||
(testing "strategy = schedule"
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(mt/with-clock (t 0)
|
||||
(is (pos? (#'task.caching/refresh-schedule-configs!)))
|
||||
(let [q (#'qp.card/query-for-card card3 [] {} {} {})]
|
||||
(is (=? {:type :schedule}
|
||||
(:cache-strategy q)))
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))
|
||||
(testing "There is cache on second call"
|
||||
(is (=? (mkres (t 0))
|
||||
(-> (qp/process-query q) (dissoc :data)))))))
|
||||
(testing "No cache after job ran again"
|
||||
(mt/with-clock (t 121)
|
||||
(is (pos? (#'task.caching/refresh-schedule-configs!)))
|
||||
(let [q (#'qp.card/query-for-card card3 {} {} {} {})]
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data)))))))))
|
||||
|
||||
(testing "strategy = query"
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(mt/with-clock (t 0)
|
||||
(is (pos? (#'task.caching/refresh-query-configs!)))
|
||||
(let [q (#'qp.card/query-for-card card4 [] {} {} {})]
|
||||
(is (=? {:type :query}
|
||||
(:cache-strategy q)))
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data))))
|
||||
(testing "There is cache on second call"
|
||||
(is (= 1 (count (t2/select :model/QueryCache))))
|
||||
(is (=? (mkres (t 0))
|
||||
(-> (qp/process-query q) (dissoc :data)))))))
|
||||
|
||||
(mt/with-clock (t 121)
|
||||
(is (pos? (#'task.caching/refresh-query-configs!)))
|
||||
(testing "Nothing to run, because it's already scheduled for later"
|
||||
(is (nil? (#'task.caching/refresh-query-configs!))))
|
||||
(testing "Job ran again, but state has not changed, so there's still cache"
|
||||
(let [q (#'qp.card/query-for-card card4 {} {} {} {})]
|
||||
(is (=? (mkres (t 0))
|
||||
(-> (qp/process-query q) (dissoc :data)))))))
|
||||
|
||||
(mt/with-clock (t 242)
|
||||
(let [state (:state (t2/select-one :model/CacheConfig :id (:id c4)))]
|
||||
(t2/update! :model/CacheConfig {:id (:id c4)} {:config (assoc (:config c4)
|
||||
:field_id (mt/id :table :value))})
|
||||
(is (pos? (#'task.caching/refresh-query-configs!)))
|
||||
(testing "Marker has changed"
|
||||
(is (not= state
|
||||
(:state (t2/select-one :model/CacheConfig :id (:id c4)))))))
|
||||
(testing "No cache after the data has changed"
|
||||
(let [q (#'qp.card/query-for-card card4 {} {} {} {})]
|
||||
(is (=? (mkres nil)
|
||||
(-> (qp/process-query q) (dissoc :data)))))))))
|
||||
|
||||
(testing "default strategy = ttl"
|
||||
(let [q (#'qp.card/query-for-card card5 [] {} {} {})]
|
||||
(is (=? {:type :ttl}
|
||||
(:cache-strategy q))))))))))))
|
||||
@@ -39,6 +39,7 @@
|
||||
:model/ApplicationPermissionsRevision
|
||||
:model/AuditLog
|
||||
:model/BookmarkOrdering
|
||||
:model/CacheConfig
|
||||
:model/CardBookmark
|
||||
:model/CollectionBookmark
|
||||
:model/DashboardBookmark
|
||||
@@ -47,7 +48,7 @@
|
||||
:model/DashboardCardSeries
|
||||
:model/LoginHistory
|
||||
:model/FieldValues
|
||||
:model/MetricImportantField
|
||||
:model/LegacyMetricImportantField
|
||||
:model/ModelIndex
|
||||
:model/ModelIndexValue
|
||||
:model/ModerationReview
|
||||
|
||||
@@ -733,7 +733,10 @@
|
||||
:attributes {"cat" 50}}
|
||||
(letfn [(run-query []
|
||||
(qp/process-query (assoc (mt/mbql-query venues {:aggregation [[:count]]})
|
||||
:cache-ttl 100)))]
|
||||
:cache-strategy {:type :ttl
|
||||
:multiplier 60
|
||||
:avg-execution-ms 10
|
||||
:min-duration-ms 0})))]
|
||||
(testing "Run the query, should not be cached"
|
||||
(let [result (run-query)]
|
||||
(is (= nil
|
||||
@@ -1039,31 +1042,31 @@
|
||||
|
||||
(deftest caching-test
|
||||
(testing "Make sure Sandboxing works in combination with caching (#18579)"
|
||||
(met/with-gtaps! {:gtaps {:venues {:query (mt/mbql-query venues {:order-by [[:asc $id]], :limit 5})}}}
|
||||
(let [card-id (t2/select-one-fn :card_id GroupTableAccessPolicy :group_id (u/the-id &group))
|
||||
_ (is (integer? card-id))
|
||||
query (t2/select-one-fn :dataset_query Card :id card-id)
|
||||
run-query (fn []
|
||||
(let [results (qp/process-query (assoc query :cache-ttl 100))]
|
||||
{:cached? (boolean (:cached (:cache/details results)))
|
||||
:num-rows (count (mt/rows results))}))]
|
||||
(mt/with-temporary-setting-values [enable-query-caching true
|
||||
query-caching-min-ttl 0]
|
||||
(testing "Make sure the underlying card for the GTAP returns cached results without sandboxing"
|
||||
(mt/with-current-user nil
|
||||
(testing "First run -- should not be cached"
|
||||
(is (= {:cached? false, :num-rows 5}
|
||||
(run-query))))
|
||||
;; run a few more times to make sure the cache had time to populate
|
||||
(run-query)
|
||||
(run-query)
|
||||
(testing "Should be cached by now"
|
||||
(is (= {:cached? true, :num-rows 5}
|
||||
(run-query))))))
|
||||
(testing "Ok, now try to access the Table that is sandboxed by the cached Card"
|
||||
;; this should *NOT* be cached because we're generating a nested query with sandboxing in play.
|
||||
(is (= {:cached? false, :num-rows 5}
|
||||
(run-query)))))))))
|
||||
(mt/with-model-cleanup [[:model/QueryCache :updated_at]]
|
||||
(met/with-gtaps! {:gtaps {:venues {:query (mt/mbql-query venues {:order-by [[:asc $id]], :limit 5})}}}
|
||||
(let [card-id (t2/select-one-fn :card_id GroupTableAccessPolicy :group_id (u/the-id &group))
|
||||
_ (is (integer? card-id))
|
||||
query (t2/select-one-fn :dataset_query Card :id card-id)
|
||||
run-query (fn []
|
||||
(let [results (qp/process-query (assoc query :cache-strategy {:type :ttl
|
||||
:multiplier 60
|
||||
:avg-execution-ms 10
|
||||
:min-duration-ms 0}))]
|
||||
{:cached? (boolean (:cached (:cache/details results)))
|
||||
:num-rows (count (mt/rows results))}))]
|
||||
(mt/with-temporary-setting-values [enable-query-caching true]
|
||||
(testing "Make sure the underlying card for the GTAP returns cached results without sandboxing"
|
||||
(mt/with-current-user nil
|
||||
(testing "First run -- should not be cached"
|
||||
(is (= {:cached? false, :num-rows 5}
|
||||
(run-query))))
|
||||
(testing "Should be cached by now"
|
||||
(is (= {:cached? true, :num-rows 5}
|
||||
(run-query))))))
|
||||
(testing "Ok, now try to access the Table that is sandboxed by the cached Card"
|
||||
;; this should *NOT* be cached because we're generating a nested query with sandboxing in play.
|
||||
(is (= {:cached? false, :num-rows 5}
|
||||
(run-query))))))))))
|
||||
|
||||
(deftest persistence-disabled-when-sandboxed
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :persist-models)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
Dimension
|
||||
Field
|
||||
FieldValues
|
||||
Metric
|
||||
LegacyMetric
|
||||
NativeQuerySnippet
|
||||
Pulse
|
||||
PulseCard
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
(defn- world-snapshot
|
||||
[]
|
||||
(into {} (for [model [Database Table Field Metric Segment Collection Dashboard DashboardCard Pulse
|
||||
(into {} (for [model [Database Table Field LegacyMetric Segment Collection Dashboard DashboardCard Pulse
|
||||
Card DashboardCardSeries FieldValues Dimension PulseCard PulseChannel User
|
||||
NativeQuerySnippet]]
|
||||
[model (t2/select-fn-set :id model)])))
|
||||
@@ -378,7 +378,7 @@
|
||||
[Collection (t2/select-one Collection :id personal-collection-id)]
|
||||
[Collection (t2/select-one Collection :id pc-nested-id)]
|
||||
[Collection (t2/select-one Collection :id pc-deeply-nested-id)]
|
||||
[Metric (t2/select-one Metric :id metric-id)]
|
||||
[LegacyMetric (t2/select-one LegacyMetric :id metric-id)]
|
||||
[Segment (t2/select-one Segment :id segment-id)]
|
||||
[Dashboard (t2/select-one Dashboard :id dashboard-id)]
|
||||
[Dashboard (t2/select-one Dashboard :id root-dashboard-id)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.serialization.names :as names]
|
||||
[metabase-enterprise.serialization.test-util :as ts]
|
||||
[metabase.models :refer [Card Collection Dashboard Database Field Metric NativeQuerySnippet Segment Table]]
|
||||
[metabase.models :refer [Card Collection Dashboard Database Field LegacyMetric NativeQuerySnippet Segment Table]]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -30,7 +30,7 @@
|
||||
(t2/select-one Card :id card-id-nested)
|
||||
(t2/select-one Table :id table-id)
|
||||
(t2/select-one Field :id category-field-id)
|
||||
(t2/select-one Metric :id metric-id)
|
||||
(t2/select-one LegacyMetric :id metric-id)
|
||||
(t2/select-one Segment :id segment-id)
|
||||
(t2/select-one Collection :id collection-id)
|
||||
(t2/select-one Collection :id collection-id-nested)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.serialization.serialize :as serialize]
|
||||
[metabase-enterprise.serialization.test-util :as ts]
|
||||
[metabase.models :refer [Card Collection Dashboard Database Field Metric NativeQuerySnippet
|
||||
[metabase.models :refer [Card Collection Dashboard Database Field LegacyMetric NativeQuerySnippet
|
||||
Segment Table]]))
|
||||
|
||||
(defn- all-ids-are-fully-qualified-names?
|
||||
@@ -25,7 +25,7 @@
|
||||
(deftest serialization-test
|
||||
(ts/with-world
|
||||
(doseq [[model id] [[Card card-id]
|
||||
[Metric metric-id]
|
||||
[LegacyMetric metric-id]
|
||||
[Segment segment-id]
|
||||
[Collection collection-id]
|
||||
[Dashboard dashboard-id]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[metabase.db.connection :as mdb.connection]
|
||||
[metabase.db.data-source :as mdb.data-source]
|
||||
[metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database
|
||||
Field Metric NativeQuerySnippet Pulse PulseCard Segment Table User]]
|
||||
Field LegacyMetric NativeQuerySnippet Pulse PulseCard Segment Table User]]
|
||||
[metabase.models.collection :as collection]
|
||||
[metabase.shared.models.visualization-settings :as mb.viz]
|
||||
[metabase.test :as mt]
|
||||
@@ -160,10 +160,10 @@
|
||||
"Deeply Nested Personal Collection"
|
||||
:location
|
||||
(format "/%d/%d/" (crowberto-pc-id) pc-nested-id)}
|
||||
Metric {metric-id :id} {:name "My Metric"
|
||||
:table_id table-id
|
||||
:definition {:source-table table-id
|
||||
:aggregation [:sum [:field numeric-field-id nil]]}}
|
||||
LegacyMetric {metric-id :id} {:name "My Metric"
|
||||
:table_id table-id
|
||||
:definition {:source-table table-id
|
||||
:aggregation [:sum [:field numeric-field-id nil]]}}
|
||||
Segment {segment-id :id} {:name "My Segment"
|
||||
:table_id table-id
|
||||
:definition {:source-table table-id
|
||||
@@ -188,7 +188,7 @@
|
||||
[:field
|
||||
category-pk-field-id
|
||||
{:join-alias "cat"}]]}]}}}
|
||||
Card {card-arch-id :id} { ;:archived true
|
||||
Card {card-arch-id :id} {;:archived true
|
||||
:table_id table-id
|
||||
:name "My Arch Card"
|
||||
:collection_id collection-id
|
||||
@@ -238,11 +238,11 @@
|
||||
:native
|
||||
{:query "SELECT * FROM {{#1}} AS subquery"
|
||||
:template-tags
|
||||
{"#1"{:id "72461b3b-3877-4538-a5a3-7a3041924517"
|
||||
:name "#1"
|
||||
:display-name "#1"
|
||||
:type "card"
|
||||
:card-id card-id}}}}}
|
||||
{"#1" {:id "72461b3b-3877-4538-a5a3-7a3041924517"
|
||||
:name "#1"
|
||||
:display-name "#1"
|
||||
:type "card"
|
||||
:card-id card-id}}}}}
|
||||
DashboardCard {dashcard-id :id} {:dashboard_id dashboard-id
|
||||
:card_id card-id}
|
||||
DashboardCard {dashcard-top-level-click-id :id} {:dashboard_id dashboard-id
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[clojure.data :as data]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.serialization.upsert :as upsert]
|
||||
[metabase.models :refer [Card Collection Dashboard DashboardCard Database Field Metric NativeQuerySnippet
|
||||
[metabase.models :refer [Card Collection Dashboard DashboardCard Database Field LegacyMetric NativeQuerySnippet
|
||||
Pulse Segment Table User]]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.test :as mt]
|
||||
@@ -80,7 +80,7 @@
|
||||
:visualization_settings (if (= 1 instance-num) {:column_settings {}}
|
||||
{:click_behavior {}}))
|
||||
|
||||
(isa? model Metric)
|
||||
(isa? model LegacyMetric)
|
||||
(assoc entity :table_id (mt/id :checkins))
|
||||
|
||||
:else
|
||||
@@ -113,7 +113,7 @@
|
||||
Card
|
||||
Table
|
||||
Field
|
||||
Metric
|
||||
LegacyMetric
|
||||
NativeQuerySnippet
|
||||
Segment
|
||||
Dashboard
|
||||
@@ -128,4 +128,4 @@
|
||||
(is (= true
|
||||
(#'upsert/has-post-insert? User)))
|
||||
(is (= false
|
||||
(#'upsert/has-post-insert? Metric))))
|
||||
(#'upsert/has-post-insert? LegacyMetric))))
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
Dimension
|
||||
Field
|
||||
FieldValues
|
||||
Metric
|
||||
LegacyMetric
|
||||
NativeQuerySnippet
|
||||
Pulse
|
||||
PulseCard
|
||||
@@ -704,7 +704,7 @@
|
||||
Table {no-schema-id :id} {:name "Schemaless Table" :db_id db-id}
|
||||
Field {field-id :id} {:name "Some Field" :table_id no-schema-id}
|
||||
|
||||
Metric
|
||||
LegacyMetric
|
||||
{m1-id :id
|
||||
m1-eid :entity_id}
|
||||
{:name "My Metric"
|
||||
@@ -713,8 +713,8 @@
|
||||
:definition {:source-table no-schema-id
|
||||
:aggregation [[:sum [:field field-id nil]]]}}]
|
||||
(testing "metrics"
|
||||
(let [ser (serdes/extract-one "Metric" {} (t2/select-one Metric :id m1-id))]
|
||||
(is (=? {:serdes/meta [{:model "Metric" :id m1-eid :label "my_metric"}]
|
||||
(let [ser (serdes/extract-one "LegacyMetric" {} (t2/select-one LegacyMetric :id m1-id))]
|
||||
(is (=? {:serdes/meta [{:model "LegacyMetric" :id m1-eid :label "my_metric"}]
|
||||
:table_id ["My Database" nil "Schemaless Table"]
|
||||
:creator_id "ann@heart.band"
|
||||
:definition {:source-table ["My Database" nil "Schemaless Table"]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[metabase-enterprise.serialization.v2.load :as serdes.load]
|
||||
[metabase.models
|
||||
:refer [Action Card Collection Dashboard DashboardCard Database Field
|
||||
FieldValues Metric NativeQuerySnippet Segment Table Timeline
|
||||
FieldValues LegacyMetric NativeQuerySnippet Segment Table Timeline
|
||||
TimelineEvent User]]
|
||||
[metabase.models.action :as action]
|
||||
[metabase.models.serialization :as serdes]
|
||||
@@ -366,7 +366,7 @@
|
||||
(reset! table1s (ts/create! Table :name "orders" :db_id (:id @db1s)))
|
||||
(reset! field1s (ts/create! Field :name "subtotal" :table_id (:id @table1s)))
|
||||
(reset! user1s (ts/create! User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on"))
|
||||
(reset! metric1s (ts/create! Metric :table_id (:id @table1s) :name "Revenue"
|
||||
(reset! metric1s (ts/create! LegacyMetric :table_id (:id @table1s) :name "Revenue"
|
||||
:definition {:source-table (:id @table1s)
|
||||
:aggregation [[:sum [:field (:id @field1s) nil]]]}
|
||||
:creator_id (:id @user1s)))
|
||||
@@ -376,7 +376,7 @@
|
||||
(is (= {:source-table ["my-db" nil "orders"]
|
||||
:aggregation [[:sum [:field ["my-db" nil "orders" "subtotal"] nil]]]}
|
||||
(-> @serialized
|
||||
(by-model "Metric")
|
||||
(by-model "LegacyMetric")
|
||||
first
|
||||
:definition))))
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
(reset! db1d (t2/select-one Database :name "my-db"))
|
||||
(reset! table1d (t2/select-one Table :name "orders"))
|
||||
(reset! field1d (t2/select-one Field :table_id (:id @table1d) :name "subtotal"))
|
||||
(reset! metric1d (t2/select-one Metric :name "Revenue"))
|
||||
(reset! metric1d (t2/select-one LegacyMetric :name "Revenue"))
|
||||
|
||||
(testing "the main Database, Table, and Field have different IDs now"
|
||||
(is (not= (:id @db1s) (:id @db1d)))
|
||||
@@ -758,12 +758,12 @@
|
||||
(ts/with-db source-db
|
||||
(reset! user1s (ts/create! User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on"))
|
||||
(reset! user2s (ts/create! User :first_name "Neil" :last_name "Peart" :email "neil@rush.yyz"))
|
||||
(reset! metric1s (ts/create! Metric
|
||||
(reset! metric1s (ts/create! LegacyMetric
|
||||
:name "Large Users"
|
||||
:table_id (mt/id :venues)
|
||||
:creator_id (:id @user1s)
|
||||
:definition {:aggregation [[:count]]}))
|
||||
(reset! metric2s (ts/create! Metric
|
||||
(reset! metric2s (ts/create! LegacyMetric
|
||||
:name "Support Headaches"
|
||||
:table_id (mt/id :venues)
|
||||
:creator_id (:id @user2s)
|
||||
@@ -773,7 +773,7 @@
|
||||
(testing "exported form is properly converted"
|
||||
(is (= "tom@bost.on"
|
||||
(-> @serialized
|
||||
(by-model "Metric")
|
||||
(by-model "LegacyMetric")
|
||||
first
|
||||
:creator_id))))
|
||||
|
||||
@@ -782,17 +782,17 @@
|
||||
;; Create another random user to change the user IDs.
|
||||
(ts/create! User :first_name "Gideon" :last_name "Nav" :email "griddle@ninth.tomb")
|
||||
;; Likewise, create some other metrics.
|
||||
(ts/create! Metric :name "Other metric A" :table_id (mt/id :venues))
|
||||
(ts/create! Metric :name "Other metric B" :table_id (mt/id :venues))
|
||||
(ts/create! Metric :name "Other metric C" :table_id (mt/id :venues))
|
||||
(ts/create! LegacyMetric :name "Other metric A" :table_id (mt/id :venues))
|
||||
(ts/create! LegacyMetric :name "Other metric B" :table_id (mt/id :venues))
|
||||
(ts/create! LegacyMetric :name "Other metric C" :table_id (mt/id :venues))
|
||||
(reset! user1d (ts/create! User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on"))
|
||||
|
||||
;; Load the serialized content.
|
||||
(serdes.load/load-metabase! (ingestion-in-memory @serialized))
|
||||
|
||||
;; Fetch the relevant bits
|
||||
(reset! metric1d (t2/select-one Metric :name "Large Users"))
|
||||
(reset! metric2d (t2/select-one Metric :name "Support Headaches"))
|
||||
(reset! metric1d (t2/select-one LegacyMetric :name "Large Users"))
|
||||
(reset! metric2d (t2/select-one LegacyMetric :name "Support Headaches"))
|
||||
|
||||
(testing "the Metrics and Users have different IDs now"
|
||||
(is (not= (:id @metric1s) (:id @metric1d)))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Api } from "metabase/api";
|
||||
import type { AuditInfo } from "metabase-enterprise/audit_app/types/state";
|
||||
|
||||
export const auditInfoApi = Api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getAuditInfo: builder.query<AuditInfo, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/ee/audit-app/user/audit-info",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetAuditInfoQuery } = auditInfoApi;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Api } from "metabase/api";
|
||||
import type { BillingInfo } from "metabase-types/api";
|
||||
|
||||
export const billingInfoApi = Api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getBillingInfo: builder.query<BillingInfo, void>({
|
||||
query: () => ({
|
||||
method: "GET",
|
||||
url: "/api/ee/billing",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetBillingInfoQuery } = billingInfoApi;
|
||||
2
enterprise/frontend/src/metabase-enterprise/api/index.ts
Normal file
2
enterprise/frontend/src/metabase-enterprise/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./audit-info";
|
||||
export * from "./billing-info";
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useEffect } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import EntityMenuItem from "metabase/components/EntityMenuItem";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { loadInfo } from "metabase-enterprise/audit_app/reducer";
|
||||
import type { AuditInfoState } from "metabase-enterprise/audit_app/types/state";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { useGetAuditInfoQuery } from "metabase-enterprise/api";
|
||||
import type { DashboardId } from "metabase-types/api";
|
||||
|
||||
interface InstanceAnalyticsButtonProps {
|
||||
entitySelector: (state: AuditInfoState) => number;
|
||||
model: "dashboard" | "question";
|
||||
linkQueryParams: { dashboard_id: DashboardId } | { question_id: number };
|
||||
}
|
||||
|
||||
export const InstanceAnalyticsButton = ({
|
||||
entitySelector,
|
||||
model,
|
||||
linkQueryParams,
|
||||
}: InstanceAnalyticsButtonProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const entityId = useSelector(state =>
|
||||
entitySelector(state as AuditInfoState),
|
||||
);
|
||||
const { data: auditInfo, error, isLoading } = useGetAuditInfoQuery();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadInfo());
|
||||
}, [dispatch]);
|
||||
if (isLoading || error || !auditInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityId =
|
||||
model === "dashboard"
|
||||
? auditInfo.dashboard_overview
|
||||
: auditInfo.question_overview;
|
||||
|
||||
if (entityId !== undefined) {
|
||||
return (
|
||||
|
||||
@@ -3,16 +3,13 @@ import { t } from "ttag";
|
||||
import {
|
||||
PLUGIN_ADMIN_USER_MENU_ITEMS,
|
||||
PLUGIN_ADMIN_USER_MENU_ROUTES,
|
||||
PLUGIN_REDUCERS,
|
||||
PLUGIN_DASHBOARD_HEADER,
|
||||
PLUGIN_QUERY_BUILDER_HEADER,
|
||||
} from "metabase/plugins";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { InstanceAnalyticsButton } from "./components/InstanceAnalyticsButton/InstanceAnalyticsButton";
|
||||
import { auditInfo } from "./reducer";
|
||||
import { getUserMenuRotes } from "./routes";
|
||||
import { getDashboardOverviewId, getQuestionOverviewId } from "./selectors";
|
||||
|
||||
if (hasPremiumFeature("audit_app")) {
|
||||
PLUGIN_ADMIN_USER_MENU_ITEMS.push(user => [
|
||||
@@ -24,15 +21,13 @@ if (hasPremiumFeature("audit_app")) {
|
||||
|
||||
PLUGIN_ADMIN_USER_MENU_ROUTES.push(getUserMenuRotes);
|
||||
|
||||
PLUGIN_REDUCERS.auditInfo = auditInfo;
|
||||
|
||||
PLUGIN_DASHBOARD_HEADER.extraButtons = dashboard => {
|
||||
return [
|
||||
{
|
||||
key: "Usage insights",
|
||||
component: (
|
||||
<InstanceAnalyticsButton
|
||||
entitySelector={getDashboardOverviewId}
|
||||
model="dashboard"
|
||||
linkQueryParams={{ dashboard_id: dashboard.id }}
|
||||
/>
|
||||
),
|
||||
@@ -46,7 +41,7 @@ if (hasPremiumFeature("audit_app")) {
|
||||
key: "Usage insights",
|
||||
component: (
|
||||
<InstanceAnalyticsButton
|
||||
entitySelector={getQuestionOverviewId}
|
||||
model="question"
|
||||
linkQueryParams={{ question_id: question.id() }}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
|
||||
import { createAsyncThunk } from "metabase/lib/redux";
|
||||
import { AuditApi } from "metabase-enterprise/services";
|
||||
|
||||
import { isAuditInfoComplete } from "./selectors";
|
||||
import type { AuditInfoState } from "./types/state";
|
||||
|
||||
const LOAD_AUDIT_INFO = "metabase-enterprise/audit/FETCH_AUDIT_INFO";
|
||||
|
||||
export const loadInfo = createAsyncThunk(
|
||||
LOAD_AUDIT_INFO,
|
||||
async (_, { getState }) => {
|
||||
const state = getState() as AuditInfoState;
|
||||
const isComplete = isAuditInfoComplete(state);
|
||||
if (!isComplete) {
|
||||
const data = await AuditApi.getAuditInfo();
|
||||
return data;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
isLoading: false,
|
||||
isComplete: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
export const auditInfo = createReducer(initialState, builder => {
|
||||
builder.addCase(loadInfo.pending, state => {
|
||||
state.isLoading = true;
|
||||
});
|
||||
|
||||
builder.addCase(loadInfo.fulfilled, (state, { payload }) => {
|
||||
state.isLoading = false;
|
||||
state.isComplete = true;
|
||||
state.data = payload || state.data;
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { AuditInfoState } from "./types/state";
|
||||
|
||||
export const isAuditInfoLoading = (state: AuditInfoState) => {
|
||||
const {
|
||||
plugins: { auditInfo },
|
||||
} = state;
|
||||
return auditInfo.isLoading;
|
||||
};
|
||||
|
||||
export const isAuditInfoComplete = (state: AuditInfoState) => {
|
||||
const {
|
||||
plugins: { auditInfo },
|
||||
} = state;
|
||||
return auditInfo.isComplete;
|
||||
};
|
||||
|
||||
export const getDashboardOverviewId = (state: AuditInfoState) =>
|
||||
state.plugins.auditInfo.data?.dashboard_overview ?? undefined;
|
||||
export const getQuestionOverviewId = (state: AuditInfoState) =>
|
||||
state.plugins.auditInfo.data?.question_overview ?? undefined;
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { DashboardId } from "metabase-types/api";
|
||||
import type { State } from "metabase-types/store";
|
||||
import type { CardId, CollectionId, DashboardId } from "metabase-types/api";
|
||||
|
||||
export interface AuditInfoState extends State {
|
||||
plugins: {
|
||||
auditInfo: {
|
||||
isLoading: boolean;
|
||||
isComplete: boolean;
|
||||
data: {
|
||||
dashboard_overview: DashboardId;
|
||||
question_overview: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
export interface AuditInfo {
|
||||
dashboard_overview: DashboardId;
|
||||
question_overview: CardId;
|
||||
custom_reports: CollectionId;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import { ExplorePlansIllustration } from "metabase/admin/settings/components/Set
|
||||
import LoadingSpinner from "metabase/components/LoadingSpinner";
|
||||
import ExternalLink from "metabase/core/components/ExternalLink";
|
||||
import { getUpgradeUrl } from "metabase/selectors/settings";
|
||||
import { useGetBillingInfoQuery } from "metabase-enterprise/api";
|
||||
import { showLicenseAcceptedToast } from "metabase-enterprise/license/actions";
|
||||
import { useBillingInfo } from "metabase-enterprise/settings/hooks/use-billing-info";
|
||||
import type { TokenStatus } from "metabase-enterprise/settings/hooks/use-license";
|
||||
import { useLicense } from "metabase-enterprise/settings/hooks/use-license";
|
||||
import type { SettingDefinition } from "metabase-types/api";
|
||||
@@ -96,10 +96,12 @@ const LicenseAndBillingSettings = ({
|
||||
!licenseLoading && !isInvalidToken && isStoreManagedBilling;
|
||||
|
||||
const {
|
||||
loading: billingLoading,
|
||||
isLoading: billingLoading,
|
||||
error: billingError,
|
||||
billingInfo,
|
||||
} = useBillingInfo(shouldFetchBillingInfo);
|
||||
data: billingInfo,
|
||||
} = useGetBillingInfoQuery(undefined, {
|
||||
skip: !shouldFetchBillingInfo,
|
||||
});
|
||||
|
||||
const isLoading = licenseLoading || billingLoading;
|
||||
|
||||
@@ -127,7 +129,7 @@ const LicenseAndBillingSettings = ({
|
||||
isStoreManagedBilling={isStoreManagedBilling}
|
||||
hasToken={hasToken}
|
||||
billingInfo={billingInfo}
|
||||
error={billingError}
|
||||
error={!!billingError}
|
||||
/>
|
||||
|
||||
{shouldShowLicenseInput && (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DELETE, POST, GET } from "metabase/lib/api";
|
||||
|
||||
export const AuditApi = {
|
||||
unsubscribe_user: DELETE("/api/ee/audit-app/user/:id/subscriptions"),
|
||||
getAuditInfo: GET("/api/ee/audit-app/user/audit-info"),
|
||||
};
|
||||
|
||||
export const AutoDescribeApi = {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
|
||||
import { StoreApi } from "metabase/services";
|
||||
import type { BillingInfo } from "metabase-types/api";
|
||||
|
||||
type UseBillingAction =
|
||||
| { type: "SET_LOADING" }
|
||||
| { type: "SET_ERROR" }
|
||||
| { type: "SET_DATA"; payload: BillingInfo };
|
||||
|
||||
type UseBillingState =
|
||||
| { loading: false; error: false; billingInfo: undefined }
|
||||
| { loading: true; error: false; billingInfo: undefined }
|
||||
| { loading: false; error: true; billingInfo: undefined }
|
||||
| { loading: false; error: false; billingInfo: BillingInfo };
|
||||
|
||||
const defaultState: UseBillingState = {
|
||||
loading: false,
|
||||
error: false,
|
||||
billingInfo: undefined,
|
||||
};
|
||||
|
||||
function reducer(
|
||||
_state: UseBillingState,
|
||||
action: UseBillingAction,
|
||||
): UseBillingState {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { loading: true, error: false, billingInfo: undefined };
|
||||
case "SET_ERROR":
|
||||
return { loading: false, error: true, billingInfo: undefined };
|
||||
case "SET_DATA":
|
||||
return { loading: false, error: false, billingInfo: action.payload };
|
||||
default: {
|
||||
const _exhaustiveCheck: never = action;
|
||||
throw Error(
|
||||
"Unknown action dispatched in useBilling:" +
|
||||
JSON.stringify(_exhaustiveCheck),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useBillingInfo = (
|
||||
shouldFetchBillingInfo: boolean,
|
||||
): UseBillingState => {
|
||||
const [state, dispatch] = useReducer(reducer, defaultState);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchBillingInfo = async () => {
|
||||
dispatch({ type: "SET_LOADING" });
|
||||
|
||||
try {
|
||||
const billingResponse = await StoreApi.billingInfo();
|
||||
if (!cancelled) {
|
||||
dispatch({ type: "SET_DATA", payload: billingResponse });
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
dispatch({ type: "SET_ERROR" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldFetchBillingInfo) {
|
||||
fetchBillingInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shouldFetchBillingInfo]);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -97,3 +97,7 @@ export function sourceTableOrCardId(query: Query): TableId | null {
|
||||
export function canRun(query: Query): boolean {
|
||||
return ML.can_run(query);
|
||||
}
|
||||
|
||||
export function canSave(query: Query): boolean {
|
||||
return ML.can_save(query);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
Query,
|
||||
} from "metabase-lib/v1/metadata/Metadata";
|
||||
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
|
||||
import type StructuredQuery from "metabase-lib/v1/queries/StructuredQuery";
|
||||
import StructuredQuery from "metabase-lib/v1/queries/StructuredQuery";
|
||||
import type Aggregation from "metabase-lib/v1/queries/structured/Aggregation";
|
||||
import { normalize } from "metabase-lib/v1/queries/utils/normalize";
|
||||
import { DATETIME_UNITS } from "metabase-lib/v1/queries/utils/query-time";
|
||||
@@ -813,33 +813,38 @@ export class FieldDimension extends Dimension {
|
||||
}
|
||||
|
||||
field(): Field {
|
||||
// If a Field is cached on the FieldDimension instance, we can shortwire this method and
|
||||
// return the cached Field.
|
||||
const locallyCachedField = this._getTrustedFieldCachedOnInstance();
|
||||
if (locallyCachedField) {
|
||||
return locallyCachedField;
|
||||
}
|
||||
try {
|
||||
// If a Field is cached on the FieldDimension instance, we can shortwire this method and
|
||||
// return the cached Field.
|
||||
const locallyCachedField = this._getTrustedFieldCachedOnInstance();
|
||||
if (locallyCachedField) {
|
||||
return locallyCachedField;
|
||||
}
|
||||
|
||||
// Prioritize pulling a `field` from the Dimenion's associated query (if one exists)
|
||||
// because it might have locally overriding metadata on it.
|
||||
const fieldFromQuery = this._findMatchingQueryField();
|
||||
if (fieldFromQuery) {
|
||||
return fieldFromQuery;
|
||||
}
|
||||
// Prioritize pulling a `field` from the Dimenion's associated query (if one exists)
|
||||
// because it might have locally overriding metadata on it.
|
||||
const fieldFromQuery = this._findMatchingQueryField();
|
||||
if (fieldFromQuery) {
|
||||
return fieldFromQuery;
|
||||
}
|
||||
|
||||
const maybeTableId = this._query?.table()?.id;
|
||||
const fieldFromGlobalState =
|
||||
this._metadata?.field(this.fieldIdOrName(), maybeTableId) ||
|
||||
this._metadata?.field(this.fieldIdOrName());
|
||||
if (fieldFromGlobalState) {
|
||||
return fieldFromGlobalState;
|
||||
}
|
||||
const maybeTableId = this._query?.table()?.id;
|
||||
const fieldFromGlobalState =
|
||||
this._metadata?.field(this.fieldIdOrName(), maybeTableId) ||
|
||||
this._metadata?.field(this.fieldIdOrName());
|
||||
if (fieldFromGlobalState) {
|
||||
return fieldFromGlobalState;
|
||||
}
|
||||
|
||||
// Hitting this return statement means that there is a bug.
|
||||
// This primarily serves as a way to guarantee that this function returns a Field to avoid errors in dependent code.
|
||||
// Despite being unable to find a field, we _might_ still have enough data to know a few things about it.
|
||||
// For example, if we have an mbql field reference, it might contain a `base-type`
|
||||
return this._createFallbackField();
|
||||
// Hitting this return statement means that there is a bug.
|
||||
// This primarily serves as a way to guarantee that this function returns a Field to avoid errors in dependent code.
|
||||
// Despite being unable to find a field, we _might_ still have enough data to know a few things about it.
|
||||
// For example, if we have an mbql field reference, it might contain a `base-type`
|
||||
return this._createFallbackField();
|
||||
} catch (e) {
|
||||
console.warn("FieldDimension.field()", this.mbql(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getMLv1CompatibleDimension() {
|
||||
@@ -1202,95 +1207,100 @@ export class ExpressionDimension extends Dimension {
|
||||
}
|
||||
|
||||
field() {
|
||||
const query = this._query;
|
||||
const table = query ? query.table() : null;
|
||||
try {
|
||||
const query = this._query;
|
||||
const table = query ? query.table() : null;
|
||||
|
||||
// fallback
|
||||
const baseTypeOption = this.getOption("base-type");
|
||||
let type = baseTypeOption || MONOTYPE.Number;
|
||||
let semantic_type = null;
|
||||
// fallback
|
||||
const baseTypeOption = this.getOption("base-type");
|
||||
let type = baseTypeOption || MONOTYPE.Number;
|
||||
let semantic_type = null;
|
||||
|
||||
if (!baseTypeOption) {
|
||||
if (query) {
|
||||
const datasetQuery = query.legacyQuery({ useStructuredQuery: true });
|
||||
const expressions = datasetQuery?.expressions ?? {};
|
||||
const expr = expressions[this.name()];
|
||||
if (!baseTypeOption) {
|
||||
if (query instanceof StructuredQuery) {
|
||||
const datasetQuery = query.legacyQuery({ useStructuredQuery: true });
|
||||
const expressions = datasetQuery?.expressions ?? {};
|
||||
const expr = expressions[this.name()];
|
||||
|
||||
const field = mbql => {
|
||||
const dimension = Dimension.parseMBQL(
|
||||
mbql,
|
||||
this._metadata,
|
||||
this._query,
|
||||
);
|
||||
return dimension?.field();
|
||||
};
|
||||
const field = mbql => {
|
||||
const dimension = Dimension.parseMBQL(
|
||||
mbql,
|
||||
this._metadata,
|
||||
this._query,
|
||||
);
|
||||
return dimension?.field();
|
||||
};
|
||||
|
||||
type = infer(expr, mbql => field(mbql)?.base_type) ?? type;
|
||||
semantic_type =
|
||||
infer(expr, mbql => field(mbql)?.semantic_type) ?? semantic_type;
|
||||
} else {
|
||||
type = infer(this._expressionName);
|
||||
type = infer(expr, mbql => field(mbql)?.base_type) ?? type;
|
||||
semantic_type =
|
||||
infer(expr, mbql => field(mbql)?.semantic_type) ?? semantic_type;
|
||||
} else {
|
||||
type = infer(this._expressionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let base_type = type;
|
||||
if (!type.startsWith("type/")) {
|
||||
switch (type) {
|
||||
case MONOTYPE.String:
|
||||
base_type = "type/Text";
|
||||
break;
|
||||
let base_type = type;
|
||||
if (!type.startsWith("type/")) {
|
||||
switch (type) {
|
||||
case MONOTYPE.String:
|
||||
base_type = "type/Text";
|
||||
break;
|
||||
|
||||
case MONOTYPE.Boolean:
|
||||
base_type = "type/Boolean";
|
||||
break;
|
||||
case MONOTYPE.Boolean:
|
||||
base_type = "type/Boolean";
|
||||
break;
|
||||
|
||||
case MONOTYPE.DateTime:
|
||||
base_type = "type/DateTime";
|
||||
break;
|
||||
case MONOTYPE.DateTime:
|
||||
base_type = "type/DateTime";
|
||||
break;
|
||||
|
||||
// fallback
|
||||
default:
|
||||
base_type = "type/Float";
|
||||
break;
|
||||
// fallback
|
||||
default:
|
||||
base_type = "type/Float";
|
||||
break;
|
||||
}
|
||||
semantic_type = base_type;
|
||||
}
|
||||
semantic_type = base_type;
|
||||
}
|
||||
|
||||
// if a dimension has access to a question with result metadata,
|
||||
// we try to find the field using the metadata directly,
|
||||
// so that we don't have to try to infer field metadata from the expression
|
||||
const resultMetadata = query?.question()?.getResultMetadata?.();
|
||||
if (resultMetadata) {
|
||||
const fieldMetadata = _.findWhere(resultMetadata, {
|
||||
// if a dimension has access to a question with result metadata,
|
||||
// we try to find the field using the metadata directly,
|
||||
// so that we don't have to try to infer field metadata from the expression
|
||||
const resultMetadata = query?.question()?.getResultMetadata?.();
|
||||
if (resultMetadata) {
|
||||
const fieldMetadata = _.findWhere(resultMetadata, {
|
||||
name: this.name(),
|
||||
});
|
||||
if (fieldMetadata) {
|
||||
return this._createField(fieldMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
const subsOptions = getOptions(semantic_type ? semantic_type : base_type);
|
||||
const dimension_options =
|
||||
subsOptions && Array.isArray(subsOptions)
|
||||
? subsOptions.map(({ name, options }) => {
|
||||
return {
|
||||
name,
|
||||
type: base_type,
|
||||
mbql: ["expression", null, options],
|
||||
};
|
||||
})
|
||||
: null;
|
||||
|
||||
return new Field({
|
||||
id: this.mbql(),
|
||||
name: this.name(),
|
||||
display_name: this.displayName(),
|
||||
base_type,
|
||||
semantic_type,
|
||||
query,
|
||||
table,
|
||||
dimension_options,
|
||||
});
|
||||
if (fieldMetadata) {
|
||||
return this._createField(fieldMetadata);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ExpressionDimension.field()", this.mbql(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const subsOptions = getOptions(semantic_type ? semantic_type : base_type);
|
||||
const dimension_options =
|
||||
subsOptions && Array.isArray(subsOptions)
|
||||
? subsOptions.map(({ name, options }) => {
|
||||
return {
|
||||
name,
|
||||
type: base_type,
|
||||
mbql: ["expression", null, options],
|
||||
};
|
||||
})
|
||||
: null;
|
||||
|
||||
return new Field({
|
||||
id: this.mbql(),
|
||||
name: this.name(),
|
||||
display_name: this.displayName(),
|
||||
base_type,
|
||||
semantic_type,
|
||||
query,
|
||||
table,
|
||||
dimension_options,
|
||||
});
|
||||
}
|
||||
|
||||
getMLv1CompatibleDimension() {
|
||||
@@ -1449,26 +1459,31 @@ export class AggregationDimension extends Dimension {
|
||||
}
|
||||
|
||||
field() {
|
||||
const aggregation = this.aggregation();
|
||||
try {
|
||||
const aggregation = this.aggregation();
|
||||
|
||||
if (!aggregation) {
|
||||
return super.field();
|
||||
if (!aggregation) {
|
||||
return super.field();
|
||||
}
|
||||
|
||||
const dimension = aggregation.dimension();
|
||||
const field = dimension && dimension.field();
|
||||
const { semantic_type } = field || {};
|
||||
return new Field({
|
||||
name: aggregation.columnName(),
|
||||
display_name: aggregation.displayName(),
|
||||
base_type: aggregation.baseType(),
|
||||
// don't pass through `semantic_type` when aggregating these types
|
||||
...(!UNAGGREGATED_SEMANTIC_TYPES.has(semantic_type) && {
|
||||
semantic_type,
|
||||
}),
|
||||
query: this._query,
|
||||
metadata: this._metadata,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("AggregationDimension.field()", this.mbql(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const dimension = aggregation.dimension();
|
||||
const field = dimension && dimension.field();
|
||||
const { semantic_type } = field || {};
|
||||
return new Field({
|
||||
name: aggregation.columnName(),
|
||||
display_name: aggregation.displayName(),
|
||||
base_type: aggregation.baseType(),
|
||||
// don't pass through `semantic_type` when aggregating these types
|
||||
...(!UNAGGREGATED_SEMANTIC_TYPES.has(semantic_type) && {
|
||||
semantic_type,
|
||||
}),
|
||||
query: this._query,
|
||||
metadata: this._metadata,
|
||||
});
|
||||
}
|
||||
|
||||
getMLv1CompatibleDimension() {
|
||||
@@ -1613,11 +1628,15 @@ export class TemplateTagDimension extends FieldDimension {
|
||||
}
|
||||
|
||||
field() {
|
||||
if (this.isValidDimensionType()) {
|
||||
return this.dimension().field();
|
||||
try {
|
||||
if (this.isValidDimensionType()) {
|
||||
return this.dimension().field();
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.warn("TemplateTagDimension.field()", this.mbql(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
name() {
|
||||
|
||||
@@ -86,7 +86,6 @@ export function updateCardTemplateTagNames(
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export default class NativeQuery extends AtomicQuery {
|
||||
// For Flow type completion
|
||||
_nativeDatasetQuery: NativeDatasetQuery;
|
||||
|
||||
constructor(
|
||||
@@ -295,11 +294,6 @@ export default class NativeQuery extends AtomicQuery {
|
||||
if (!dimension) {
|
||||
return new ValidationError(t`Invalid template tag: ${tag.name}`);
|
||||
}
|
||||
if (tag.required && !tag.default) {
|
||||
return new ValidationError(
|
||||
t`Missing default value for a required template tag: ${tag.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return dimension.validateTemplateTag();
|
||||
})
|
||||
|
||||
@@ -115,7 +115,12 @@ export interface ErrorEmbedDataset {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the type of the `POST /api/dataset/native` response.
|
||||
* We're mostly ignoring the `params` on the FE. It's added to the type only for completeness.
|
||||
*/
|
||||
export interface NativeQueryForm {
|
||||
params: unknown;
|
||||
query: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,6 @@ export const createMockSetupState = (
|
||||
): SetupState => ({
|
||||
step: "welcome",
|
||||
isLocaleLoaded: false,
|
||||
isTrackingAllowed: false,
|
||||
isTrackingAllowed: true,
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -35,5 +35,6 @@ export const createMockState = (
|
||||
settings: createMockSettingsState(),
|
||||
setup: createMockSetupState(),
|
||||
upload: createMockUploadState(),
|
||||
modal: null,
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { SettingsState } from "./settings";
|
||||
import type { SetupState } from "./setup";
|
||||
import type { FileUploadState } from "./upload";
|
||||
|
||||
type modalName = null | "collection" | "dashboard" | "action";
|
||||
export interface State {
|
||||
admin: AdminState;
|
||||
app: AppState;
|
||||
@@ -32,6 +33,7 @@ export interface State {
|
||||
settings: SettingsState;
|
||||
setup: SetupState;
|
||||
upload: FileUploadState;
|
||||
modal: modalName;
|
||||
}
|
||||
|
||||
export type Dispatch<T = any> = (action: T) => void;
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { AppErrorDescriptor, State } from "metabase-types/store";
|
||||
|
||||
import { AppContainer, AppContent, AppContentContainer } from "./App.styled";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { NewModals } from "./new/components/NewModals/NewModals";
|
||||
|
||||
const getErrorComponent = ({ status, data, context }: AppErrorDescriptor) => {
|
||||
if (status === 403 || data?.error_code === "unauthorized") {
|
||||
@@ -111,6 +112,7 @@ function App({
|
||||
</AppContent>
|
||||
<UndoListing />
|
||||
<StatusListing />
|
||||
<NewModals />
|
||||
</AppContentContainer>
|
||||
</AppContainer>
|
||||
</ScrollToTop>
|
||||
|
||||
@@ -2,6 +2,8 @@ import cx from "classnames";
|
||||
import type { InputHTMLAttributes, Ref } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
import { FormInputRoot } from "./FormInput.styled";
|
||||
|
||||
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
@@ -18,7 +20,7 @@ const FormInput = forwardRef(function FormInput(
|
||||
{...props}
|
||||
value={props.value ?? ""}
|
||||
ref={ref}
|
||||
className={cx("input", className)}
|
||||
className={cx(CS.input, className)}
|
||||
type="text"
|
||||
touched={touched}
|
||||
error={error}
|
||||
|
||||
@@ -2,6 +2,8 @@ import cx from "classnames";
|
||||
import type { Ref, TextareaHTMLAttributes } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
import { FormTextAreaRoot } from "./FormTextArea.styled";
|
||||
|
||||
interface FormTextAreaProps
|
||||
@@ -18,7 +20,7 @@ const FormTextArea = forwardRef(function FormTextArea(
|
||||
<FormTextAreaRoot
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cx("input", className)}
|
||||
className={cx(CS.input, className)}
|
||||
touched={touched}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class ObjectRetireModal extends Component {
|
||||
<p className="text-paragraph">{t`If you're sure you want to retire this ${objectType}, please write a quick explanation of why it's being retired:`}</p>
|
||||
<textarea
|
||||
ref={this.revisionMessage}
|
||||
className="input full"
|
||||
className={cx(CS.input, CS.full)}
|
||||
placeholder={t`This will show up in the activity feed and in an email that will be sent to anyone on your team who created something that uses this ${objectType}.`}
|
||||
onChange={e => this.setState({ valid: !!e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ReactElement } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import FieldValuesWidget from "metabase/components/FieldValuesWidget";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { getCurrencySymbol } from "metabase/lib/formatting";
|
||||
import {
|
||||
getFilterArgumentFormatOptions,
|
||||
@@ -135,7 +136,7 @@ export function DefaultPicker({
|
||||
return (
|
||||
<FieldValuesWidget
|
||||
key={index}
|
||||
className="input"
|
||||
className={CS.input}
|
||||
value={values}
|
||||
onChange={onValuesChange}
|
||||
multi={operator.multi}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import moment from "moment-timezone"; // eslint-disable-line no-restricted-imports -- deprecated usage
|
||||
|
||||
import NumericInput from "metabase/components/NumericInput";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { has24HourModeSetting } from "metabase/lib/time";
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
@@ -37,7 +38,7 @@ const HoursMinutesInput = ({
|
||||
<span className="px1">:</span>
|
||||
<NumericInput
|
||||
data-testid="minutes-input"
|
||||
className="input"
|
||||
className={CS.input}
|
||||
style={{ height: 36 }}
|
||||
size={2}
|
||||
maxLength={2}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import cx from "classnames";
|
||||
import PropTypes from "prop-types";
|
||||
import { Component } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
import { TextPickerArea, TextPickerInput } from "./TextPicker.styled";
|
||||
|
||||
export default class TextPicker extends Component {
|
||||
@@ -81,7 +84,7 @@ export default class TextPicker extends Component {
|
||||
)}
|
||||
{!isSingleLine && (
|
||||
<TextPickerArea
|
||||
className="input block full"
|
||||
className={cx(CS.block, CS.full, CS.input)}
|
||||
type="text"
|
||||
value={this.state.fieldString}
|
||||
onChange={e => this.setValue(e.target.value)}
|
||||
@@ -96,7 +99,7 @@ export default class TextPicker extends Component {
|
||||
|
||||
{isSingleLine && (
|
||||
<TextPickerInput
|
||||
className="input block full"
|
||||
className={cx(CS.block, CS.full, CS.input)}
|
||||
style={{
|
||||
paddingLeft: this.props.prefix
|
||||
? `${this.props.prefix.length}.2rem`
|
||||
|
||||
@@ -26,7 +26,7 @@ export const AddRow = forwardRef(function AddRow(
|
||||
>
|
||||
{children}
|
||||
<input
|
||||
className="input--borderless h3 ml1 flex-full"
|
||||
className={cx(CS.inputBorderless, CS.h3, CS.ml1, CS.flexFull)}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Fragment, useEffect, useState } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { t, ngettext, msgid } from "ttag";
|
||||
|
||||
import AdminPaneLayout from "metabase/components/AdminPaneLayout";
|
||||
import { AdminPaneLayout } from "metabase/components/AdminPaneLayout";
|
||||
import Alert from "metabase/components/Alert";
|
||||
import { useConfirmation } from "metabase/hooks/use-confirmation";
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import _ from "underscore";
|
||||
|
||||
import { useListApiKeyQuery } from "metabase/api";
|
||||
import AdminContentTable from "metabase/components/AdminContentTable";
|
||||
import AdminPaneLayout from "metabase/components/AdminPaneLayout";
|
||||
import { AdminPaneLayout } from "metabase/components/AdminPaneLayout";
|
||||
import Alert from "metabase/components/Alert";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import ModalContent from "metabase/components/ModalContent";
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
||||
import { connect } from "react-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import AdminPaneLayout from "metabase/components/AdminPaneLayout";
|
||||
import { AdminPaneLayout } from "metabase/components/AdminPaneLayout";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Group, Radio } from "metabase/ui";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { push } from "react-router-redux";
|
||||
|
||||
import SettingsSetting from "metabase/admin/settings/components/SettingsSetting";
|
||||
import { SettingsSetting } from "metabase/admin/settings/components/SettingsSetting";
|
||||
import type { SettingElement } from "metabase/admin/settings/types";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { Tabs } from "metabase/ui";
|
||||
|
||||
@@ -13,7 +13,7 @@ import Button from "metabase/core/components/Button";
|
||||
import { isEmail, isEmpty } from "metabase/lib/utils";
|
||||
|
||||
import { CollapsibleSectionContent } from "./SettingsBatchForm.styled";
|
||||
import SettingsSetting from "./SettingsSetting";
|
||||
import { SettingsSetting } from "./SettingsSetting";
|
||||
|
||||
const VALIDATIONS = {
|
||||
email: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import PropTypes from "prop-types";
|
||||
import { Component } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-use";
|
||||
import { jt } from "ttag";
|
||||
|
||||
import ExternalLink from "metabase/core/components/ExternalLink";
|
||||
import { alpha, color } from "metabase/lib/colors";
|
||||
|
||||
import { settingToFormFieldId, getEnvVarDocsUrl } from "../utils";
|
||||
|
||||
import { getEnvVarDocsUrl, settingToFormFieldId } from "./../../settings/utils";
|
||||
import SettingHeader from "./SettingHeader";
|
||||
import {
|
||||
SettingContent,
|
||||
@@ -33,62 +35,80 @@ const SETTING_WIDGET_MAP = {
|
||||
hidden: () => null,
|
||||
};
|
||||
|
||||
export default class SettingsSetting extends Component {
|
||||
static propTypes = {
|
||||
setting: PropTypes.object.isRequired,
|
||||
settingValues: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onChangeSetting: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
export const SettingsSetting = props => {
|
||||
const { hash } = useLocation();
|
||||
const [fancyStyle, setFancyStyle] = useState({});
|
||||
const thisRef = useRef();
|
||||
|
||||
const { setting, settingValues, errorMessage } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (hash === `#${setting.key}` && thisRef.current) {
|
||||
thisRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
thisRef.current.focus();
|
||||
|
||||
setFancyStyle({
|
||||
background: alpha("brand", 0.1),
|
||||
boxShadow: `0 0 0 1px ${color("brand")}`,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setFancyStyle({});
|
||||
}, 1500);
|
||||
}
|
||||
}, [hash, setting.key]);
|
||||
|
||||
const settingId = settingToFormFieldId(setting);
|
||||
|
||||
let Widget = setting.widget || SETTING_WIDGET_MAP[setting.type];
|
||||
if (!Widget) {
|
||||
console.warn(
|
||||
"No render method for setting type " +
|
||||
setting.type +
|
||||
", defaulting to string input.",
|
||||
);
|
||||
Widget = SettingInput;
|
||||
}
|
||||
|
||||
const widgetProps = {
|
||||
...setting.getProps?.(setting, settingValues),
|
||||
...setting.props,
|
||||
...props,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { setting, settingValues, errorMessage } = this.props;
|
||||
const settingId = settingToFormFieldId(setting);
|
||||
|
||||
let Widget = setting.widget || SETTING_WIDGET_MAP[setting.type];
|
||||
if (!Widget) {
|
||||
console.warn(
|
||||
"No render method for setting type " +
|
||||
setting.type +
|
||||
", defaulting to string input.",
|
||||
);
|
||||
Widget = SettingInput;
|
||||
}
|
||||
|
||||
const widgetProps = {
|
||||
...setting.getProps?.(setting, settingValues),
|
||||
...setting.props,
|
||||
...this.props,
|
||||
};
|
||||
|
||||
return (
|
||||
// TODO - this formatting needs to be moved outside this component
|
||||
<SettingRoot data-testid={`${setting.key}-setting`}>
|
||||
{!setting.noHeader && (
|
||||
<SettingHeader id={settingId} setting={setting} />
|
||||
return (
|
||||
// TODO - this formatting needs to be moved outside this component
|
||||
<SettingRoot
|
||||
data-testid={`${setting.key}-setting`}
|
||||
ref={thisRef}
|
||||
style={{
|
||||
transition: "500ms ease all",
|
||||
...fancyStyle,
|
||||
}}
|
||||
>
|
||||
{!setting.noHeader && <SettingHeader id={settingId} setting={setting} />}
|
||||
<SettingContent>
|
||||
{setting.is_env_setting && !setting.forceRenderWidget ? (
|
||||
<SettingEnvVarMessage>
|
||||
{jt`This has been set by the ${(
|
||||
<ExternalLink href={getEnvVarDocsUrl(setting.env_name)}>
|
||||
{setting.env_name}
|
||||
</ExternalLink>
|
||||
)} environment variable.`}
|
||||
</SettingEnvVarMessage>
|
||||
) : (
|
||||
<Widget id={settingId} {...widgetProps} />
|
||||
)}
|
||||
<SettingContent>
|
||||
{setting.is_env_setting && !setting.forceRenderWidget ? (
|
||||
<SettingEnvVarMessage>
|
||||
{jt`This has been set by the ${(
|
||||
<ExternalLink href={getEnvVarDocsUrl(setting.env_name)}>
|
||||
{setting.env_name}
|
||||
</ExternalLink>
|
||||
)} environment variable.`}
|
||||
</SettingEnvVarMessage>
|
||||
) : (
|
||||
<Widget id={settingId} {...widgetProps} />
|
||||
)}
|
||||
</SettingContent>
|
||||
{errorMessage && (
|
||||
<SettingErrorMessage>{errorMessage}</SettingErrorMessage>
|
||||
)}
|
||||
{setting.warning && (
|
||||
<SettingWarningMessage>{setting.warning}</SettingWarningMessage>
|
||||
)}
|
||||
</SettingRoot>
|
||||
);
|
||||
}
|
||||
}
|
||||
</SettingContent>
|
||||
{errorMessage && (
|
||||
<SettingErrorMessage>{errorMessage}</SettingErrorMessage>
|
||||
)}
|
||||
{setting.warning && (
|
||||
<SettingWarningMessage>{setting.warning}</SettingWarningMessage>
|
||||
)}
|
||||
</SettingRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,8 @@ import styled from "@emotion/styled";
|
||||
import { color } from "metabase/lib/colors";
|
||||
|
||||
export const SettingRoot = styled.li`
|
||||
margin: 1rem 1rem 2rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
padding: 0.5rem 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
export const SettingContent = styled.div`
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from "__support__/ui";
|
||||
|
||||
import { SettingsSetting } from "./SettingsSetting";
|
||||
|
||||
const SETTING = {
|
||||
key: "site-name",
|
||||
display_name: "Site Name",
|
||||
type: "string",
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
render(<SettingsSetting setting={SETTING} />);
|
||||
};
|
||||
|
||||
describe("SettingsSetting", () => {
|
||||
it("renders a setting", () => {
|
||||
setup();
|
||||
expect(screen.getByText("Site Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights itself if it's key is in location.hash", () => {
|
||||
const ogScrollIntoView = window.HTMLElement.prototype.scrollIntoView;
|
||||
window.location.hash = "#site-name";
|
||||
const mockScroll = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = mockScroll;
|
||||
|
||||
setup();
|
||||
|
||||
expect(screen.getByTestId("site-name-setting")).toHaveStyle(
|
||||
"box-shadow: 0 0 0 1px #509EE3",
|
||||
);
|
||||
|
||||
expect(mockScroll).toHaveBeenCalled();
|
||||
|
||||
window.location.hash = "";
|
||||
window.HTMLElement.prototype.scrollIntoView = ogScrollIntoView;
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import PropTypes from "prop-types";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import MetabaseSettings from "metabase/lib/settings";
|
||||
|
||||
import SettingsSetting from "../SettingsSetting";
|
||||
import { SettingsSetting } from "../SettingsSetting";
|
||||
|
||||
import VersionUpdateNotice from "./VersionUpdateNotice/VersionUpdateNotice";
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import cx from "classnames";
|
||||
import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { MappingsType } from "metabase/admin/types";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
type AddMappingRowProps = {
|
||||
mappings: MappingsType;
|
||||
@@ -46,7 +48,7 @@ function AddMappingRow({
|
||||
<div className="m2 p1 bordered border-brand justify-between rounded relative flex align-center">
|
||||
<input
|
||||
aria-label="new-group-mapping-name-input"
|
||||
className="input--borderless h3 ml1 flex-full"
|
||||
className={cx(CS.inputBorderless, CS.h3, CS.ml1, CS.flexFull)}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
|
||||
16
frontend/src/metabase/api/dataset.ts
Normal file
16
frontend/src/metabase/api/dataset.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Api } from "metabase/api";
|
||||
import type { NativeQueryForm, DatasetQuery } from "metabase-types/api";
|
||||
|
||||
export const datasetApi = Api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
createNativeDataset: builder.mutation<NativeQueryForm, DatasetQuery>({
|
||||
query: input => ({
|
||||
method: "POST",
|
||||
url: "/api/dataset/native",
|
||||
body: input,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useCreateNativeDatasetMutation } = datasetApi;
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./api-keys";
|
||||
export * from "./dataset";
|
||||
|
||||
@@ -52,6 +52,7 @@ function CreateCollectionModal({
|
||||
size="lg"
|
||||
data-testid="new-collection-modal"
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content p="md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t`New collection`}</Modal.Title>
|
||||
|
||||
@@ -400,10 +400,10 @@ describe("AggregationPicker", () => {
|
||||
});
|
||||
|
||||
describe("custom expressions", () => {
|
||||
it("should allow to enter a custom expression", async () => {
|
||||
it("should allow to enter a custom expression containing an aggregation", async () => {
|
||||
const { getRecentClauseInfo } = setup();
|
||||
|
||||
const expression = "1 + 1";
|
||||
const expression = "count + 1";
|
||||
const expressionName = "My expression";
|
||||
|
||||
userEvent.click(screen.getByText("Custom Expression"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
import { useState, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
@@ -83,12 +84,24 @@ export function EntityPickerModal<TItem extends TypeWithModel>({
|
||||
const hasTabs = tabs.length > 1 || searchQuery;
|
||||
const tabModels = useMemo(() => tabs.map(t => t.model), [tabs]);
|
||||
|
||||
useWindowEvent(
|
||||
"keydown",
|
||||
event => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
data-testid="entity-picker-modal"
|
||||
trapFocus={trapFocus}
|
||||
closeOnEscape={false} // we're doing this manually in useWindowEvent
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<ModalContent h="100%">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AdminPaneTitle } from "./AdminPaneTitle";
|
||||
import type { AdminPaneProps } from "./types";
|
||||
|
||||
type AdminPaneLayoutProps = AdminPaneProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const AdminPaneLayout = ({
|
||||
title,
|
||||
description,
|
||||
buttonText,
|
||||
buttonAction,
|
||||
buttonDisabled,
|
||||
children,
|
||||
buttonLink,
|
||||
headingContent,
|
||||
}: AdminPaneLayoutProps) => (
|
||||
<div data-testid="admin-panel">
|
||||
<AdminPaneTitle
|
||||
title={title}
|
||||
description={description}
|
||||
buttonText={buttonText}
|
||||
buttonAction={buttonAction}
|
||||
buttonDisabled={buttonDisabled}
|
||||
buttonLink={buttonLink}
|
||||
headingContent={headingContent}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user