Compare commits

...

48 Commits

Author SHA1 Message Date
Callum Herries
b0a5c0540f Re-enable uploads for redshift 2024-03-21 10:16:35 +02:00
Callum Herries
a3b31c9a86 Revert "Use correct function"
This reverts commit ec86080727.
2024-03-21 10:16:35 +02:00
Alexander Solovyov
e90bab1b52 OpenAPI docs (#40162) 2024-03-21 10:16:35 +02:00
adam-james
679a784fa0 Autodescribe Question Prompt adjustments (#40319)
In a working meeting, some prompt adjustments were tried. This change felt like the best incremental improvement,
considering feedback from users on stats.

We attempted to guide the LLM to return descriptions that:

 - don't state the chart type as if its *very* important (we can see what it is already, so its not that important)
 - do call out caveats and important specifics as implied by the question's filters, aggregations, and breakouts
 - are descriptive but not verbose
 - try not to state caveats too strongly
2024-03-21 10:16:35 +02:00
Luis Paolini
1a8e1a83db Add formatted values (#40389) 2024-03-21 10:16:35 +02:00
Alexander Solovyov
c4840169e2 Fix caching tests flakes (#40379) 2024-03-21 10:16:35 +02:00
Chris Truter
ff8978f4f2 Rename load-from-csv to create-from-csv (#40367) 2024-03-21 10:16:35 +02:00
metamben
f29487d8de Rename metric to legacy-metric (#39232)
* Rename :model/Metric to :model/LegacyMetric
* Rename ::lib.schema.metadata/metric tp ::lib.schema.metadata/legacy-metric
* Rename models.metric/Metric to models.metric/LegacyMetric
* Move /api/metric endpoints to /api/legacy-metric
2024-03-21 10:16:35 +02:00
Nick Fitzpatrick
ce902a565b SettingsSetting to Functional Component (#40318)
* SettingsSetting FC, change to named export

* adding unit test
2024-03-21 10:16:34 +02:00
Nemanja Glumac
f6689e7141 Expand and explain the NativeQueryForm type (#40358) 2024-03-21 10:16:34 +02:00
Nemanja Glumac
bc0e0d71d4 Update dataset RTKQuery API (#40356)
* Rename api to just `dataset`

We're going to use this api for both structured and native endpoints.

* Fix the input type
2024-03-21 10:16:34 +02:00
Alexander Solovyov
0c7853380e Caching: new strategies and configuration API (#38400) 2024-03-21 10:16:34 +02:00
Nick Fitzpatrick
e1b60d8ae9 Moving new item modals to be global (#40315)
* moving new item modals to be global

* adjusting unit tests
2024-03-21 10:16:34 +02:00
Nemanja Glumac
30ffc3c78d Prevent QB header from jumping between modes (#40335) 2024-03-21 10:16:34 +02:00
Alexander Polyankin
d32b3344f2 Fix UX for picking multiple values in filters (#40278) 2024-03-21 10:16:34 +02:00
Oisin Coveney
4f18ce8cc0 Convert inputs.module.css to CSS Modules (#40163) 2024-03-21 10:16:34 +02:00
Oleg Gromov
48c210ee4b Fix: allow running native queries with required parameters and no defaults (#40288) 2024-03-21 10:16:34 +02:00
Nemanja Glumac
d6292febc7 Rename QueryViewNotebook to NotebookContainer (#40316)
* Use Mantine Flex for the NotebookContainer

* Rename the component to `NotebookContainer`

* Convert to TS

* Explain the delay

* Use regular div

* Destructure the import type

* Simplify styling

* Shorten the prop name to `isOpen`
2024-03-21 10:16:34 +02:00
Callum Herries
b46156bced oops 2024-03-21 10:16:34 +02:00
Callum Herries
818eb4b885 Use correct function 2024-03-21 10:16:34 +02:00
Callum Herries
1011b38d6c Fix timezones_test 2024-03-21 10:16:34 +02:00
Callum Herries
0e8d73a14c Add docstring 2024-03-21 10:16:34 +02:00
Callum Herries
dfacd7024d Invert dynamic var 2024-03-21 10:16:34 +02:00
Callum Herries
f47bed4fe7 Rename dataset again 2024-03-21 10:16:34 +02:00
Callum Herries
47c6da750e Allow extsales to be synced 2024-03-21 10:16:34 +02:00
Callum Herries
8efb278158 Use qualified table names in redshift tests 2024-03-21 10:16:22 +02:00
Nicolò Pretto
21757f1cab v2 of minimal embed homepage (#40168)
* v2 of minimal embed homepage

* useState instead of useMemo, fix unit tests

* address pr feedback
2024-03-21 10:16:22 +02:00
Ngoc Khuat
fec5f7e2da Convert simple hydration to batched hydration (#40217) 2024-03-21 10:16:21 +02:00
Ngoc Khuat
1d0d1b554c Redshift and Oracle test sync: do not use hack (#40052) 2024-03-21 10:16:21 +02:00
Ryan Laurie
7eca5c28f6 RTKQuery Implementation: AuditInfo (#39266)
* use rtk for auditInfo

* destructured hook export

* fix rebase

* remove unused services definition
2024-03-21 10:15:10 +02:00
Sloan Sparger
168cf78a00 Split setup process into atomic stages (#39287) 2024-03-21 10:15:10 +02:00
Ryan Laurie
e68a02c096 Handle double escape in entityPicker (#40151)
* handle double escape in entityPicker

* escape key test

* use fancy hook
2024-03-21 10:15:10 +02:00
Jeff Bruemmer
fc0cfd2c32 add 48.9 to list of releases (#40324) 2024-03-21 10:15:10 +02:00
Jesse Devaney
be522a846d Fix null values being applied to filters with click behavior custom URL links (#40023)
* do not pass null values to link url

* update null check to include query builder nulls replaced with "(empty")

* add test to verify behavior

* replace magic string with constant

* fix breakout in cypress spec
2024-03-21 10:15:10 +02:00
Nemanja Glumac
582ef8db00 createNativeDataset API using RTK query (#40286) 2024-03-21 10:15:10 +02:00
Chris Truter
9441f1fdc0 Preserve sign on parenthesized zero floats when parsing (#40309) 2024-03-21 10:15:10 +02:00
Braden Shepherdson
71c47a7f63 [MLv2] Allow any arithmetic sub-clauses in an aggregation (#40259)
This issue was previously punted, because the schema for aggregation
clauses allowed the tag to be `:any`. Now it's a short list of permitted
arithmetic expressions, but that list is too short and excludes things
like `:round`.

This change searches for an aggregation clause transitively in the
expression, and so allows any arithmetic expression.
2024-03-21 10:15:10 +02:00
Chris Truter
2f6eae6de5 Fix float-to-int type coercion for CSV appends (#40275) 2024-03-21 10:15:10 +02:00
Cal Herries
c65c8ec236 Revert "Revert updating FKs during metadata sync (#39679) (#39924)" (#40233) 2024-03-21 10:15:09 +02:00
Cal Herries
4e65155237 Fix flaking tests using mt/with-temp-copy-of-db (#40291) 2024-03-21 10:15:09 +02:00
Alexander Polyankin
4137aa3f42 Dimension field error handling (#40300)
* Add try-catch in Dimension.field()

* Fix the bug

* Fix the bug

* Fix the bug
2024-03-21 10:15:09 +02:00
Kamil Mielnik
124417eb78 Fix e2e search tests running out of memory (#40294)
* Move search-related snowplow tests to a separate file

* Update test names

* Move filters-related search tests to a separate file

* Extract search helpers into a separate file

* Revert test rename
2024-03-21 10:15:09 +02:00
Oisin Coveney
1e5a29bb10 Convert AdminPaneLayout and AdminPaneTitle to TS (#40285) 2024-03-21 10:15:09 +02:00
Ryan Laurie
7ca0281893 RTK Implementation: store/billing info API (#39270)
* use rtk query for billing info
2024-03-21 10:15:09 +02:00
Callum Herries
a1824f1ff8 make functions private 2024-03-20 10:53:05 +02:00
Callum Herries
3f38a8f727 Assert length of qualified name is short enough, instead of chopping off the prefix and hoping for the best 2024-03-20 10:37:00 +02:00
Callum Herries
b7f1d9888b shorten test database names 2024-03-20 10:30:11 +02:00
Callum Herries
23029186be Only sync redshift tables that are in the test dataset 2024-03-19 18:44:21 +02:00
304 changed files with 10634 additions and 4732 deletions

View File

@@ -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"}

View File

@@ -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))])}))

View File

@@ -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))))

View File

@@ -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$"

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View 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);
}
},
);
}
}

View File

@@ -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 => {

View File

@@ -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,

View File

@@ -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";

View File

@@ -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");
});

View File

@@ -284,7 +284,7 @@ describe("scenarios > question > custom column", () => {
aggregation: [
[
"aggregation-options",
["*", 1, 1],
["*", ["count"], 1],
{ name: CE_NAME, "display-name": CE_NAME },
],
],

View File

@@ -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");
});
});
});
});
/**

View File

@@ -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,
});

View File

@@ -146,7 +146,7 @@ describe("scenarios > question > filter", () => {
aggregation: [
[
"aggregation-options",
["+", 1, 1],
["+", ["count"], 1],
{ name: CE_NAME, "display-name": CE_NAME },
],
],

View File

@@ -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");
});
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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],

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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");
});
});

View File

@@ -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");
});

View File

@@ -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 => {

View File

@@ -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))))

View File

@@ -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

View 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)

View File

@@ -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"})

View File

@@ -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))

View File

@@ -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)

View File

@@ -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}]})))

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)))

View File

@@ -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])}

View File

@@ -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]

View File

@@ -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"

View 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))

View 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 {} {} {} {}))))))))))))

View File

@@ -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)}

View File

@@ -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))))))))))))

View File

@@ -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

View File

@@ -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)

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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))))

View File

@@ -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"]

View File

@@ -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)))

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export * from "./audit-info";
export * from "./billing-info";

View File

@@ -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 (

View File

@@ -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() }}
/>
),

View File

@@ -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;
});
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 && (

View File

@@ -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 = {

View File

@@ -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;
};

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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();
})

View File

@@ -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;
}

View File

@@ -43,6 +43,6 @@ export const createMockSetupState = (
): SetupState => ({
step: "welcome",
isLocaleLoaded: false,
isTrackingAllowed: false,
isTrackingAllowed: true,
...opts,
});

View File

@@ -35,5 +35,6 @@ export const createMockState = (
settings: createMockSettingsState(),
setup: createMockSetupState(),
upload: createMockUploadState(),
modal: null,
...opts,
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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 })}
/>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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`

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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>
);
};

View File

@@ -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`

View File

@@ -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;
});
});

View File

@@ -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";

View File

@@ -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}

View 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;

View File

@@ -1,2 +1,3 @@
export * from "./api";
export * from "./api-keys";
export * from "./dataset";

View File

@@ -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>

View File

@@ -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"));

View File

@@ -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%">

View File

@@ -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