Apply FieldUserSettings on read instead of writing them into metabase_field (#82246)

This commit is contained in:
Alexander Polyankin
2026-09-14 13:36:47 -04:00
committed by GitHub
parent 14faa1e777
commit 82cc16d2b5
490 changed files with 3967 additions and 1731 deletions

View File

@@ -73,6 +73,7 @@
:metabase/no-jsqlparser-imports {:level :warning}
:metabase/prefer-with-dynamic-fn-redefs {:level :warning}
:metabase/t2-query-namespace {:level :warning}
:metabase/table-or-field-query {:level :warning}
:metabase/toucan-model-ns {:level :warning}
:metabase/require-shape-checker {:level :warning}
:metabase/tests-must-live-in-known-modules {:level :warning}
@@ -900,7 +901,12 @@
:name app-db-namespaces}]
:config-in-ns
{test-namespaces
{metabase.app-db.custom-migrations
{:linters
;; a migration reads the rows as they were when it ran; applying today's user settings would make it drift
{:metabase/table-or-field-query {:level :off}}}
test-namespaces
{:linters
{:inline-def {:level :off}
:missing-docstring {:level :off}

View File

@@ -114,6 +114,7 @@
settings
users
util
warehouse-schema-overlay
warehouses}
:model-exports #{:model/Action}
:model-imports #{:model/Card
@@ -156,7 +157,8 @@
models
permissions
util
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/Dashboard
@@ -199,7 +201,8 @@
system
task
util
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/CacheConfig
:model/Card
:model/Collection
@@ -209,7 +212,6 @@
:model/Database
:model/DatabaseRouter
:model/Document
:model/Field
:model/ModerationReview
:model/PermissionsGroup
:model/Pulse
@@ -378,7 +380,8 @@
task
task.history
tracing
util}
util
warehouse-schema-overlay}
:model-exports #{:model/AuditLog}
:model-imports #{:model/ApiKey
:model/Card
@@ -599,7 +602,8 @@
transforms
upload
util
util.config}
util.config
warehouse-schema-overlay}
:model-exports #{:model/Collection}
:model-imports #{:model/Card
:model/CollectionBookmark
@@ -613,6 +617,7 @@
:model/Permissions
:model/Pulse
:model/Table
:model/TableUserSettings
:model/Timeline
:model/Transform
:model/User}}
@@ -632,7 +637,8 @@
premium-features
queries
request
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/Collection}}
comments
@@ -820,6 +826,7 @@
request
sync
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Database
:model/Dimension
@@ -857,7 +864,8 @@
query-processor
revisions
search
util}
util
warehouse-schema-overlay}
:model-exports #{:model/Document}
:model-imports #{:model/Card
:model/Collection
@@ -910,6 +918,7 @@
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
warehouses.schema}
:model-imports #{:model/Database :model/Field :model/Table}}
@@ -941,7 +950,8 @@
metabot
permissions
premium-features
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/ConnectionImpersonation
@@ -1170,7 +1180,8 @@
lib.schema
models
settings
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Database
:model/Dimension
@@ -1219,7 +1230,8 @@
lib.schema
lib.types
settings
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/Field :model/Table}}
lib.schema
@@ -1335,6 +1347,7 @@
users
util
util.config
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Card
:model/Collection
@@ -1387,7 +1400,8 @@
permissions
remote-sync
search
util}
util
warehouse-schema-overlay}
:model-exports #{:model/Measure}
:model-imports #{:model/Collection :model/Table :model/User}}
@@ -1458,6 +1472,7 @@
transforms.base
util
util.config
warehouse-schema-overlay
warehouses
warehouses.schema}
:model-exports #{:model/AiUsageLog
@@ -1548,6 +1563,7 @@
sync
util
util.config
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Card
:model/Database
@@ -1602,7 +1618,8 @@
query-processor
request
server
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/Field :model/Measure}}
mfa
@@ -1641,7 +1658,6 @@
models
{:team "DevEx"
:api #{metabase.models.db
metabase.models.humanization
metabase.models.init
metabase.models.interface
metabase.models.resolution
@@ -1656,9 +1672,9 @@
lib.legacy-mbql
lib.metadata
lib.schema
settings
util
util.classloader}
util.classloader
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/Dashboard
@@ -1788,6 +1804,7 @@
query-processor
util
util.classloader
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Card
:model/Dashboard
@@ -1816,6 +1833,7 @@
users
util
util.config
warehouse-schema-overlay
enterprise/permissions}
:model-exports #{:model/ApplicationPermissionsRevision
:model/CollectionPermissionGraphRevision
@@ -1842,7 +1860,8 @@
premium-features
request
settings
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Dashboard
:model/DataPermissions
@@ -2022,6 +2041,7 @@
util
util.config
util.graph
warehouse-schema-overlay
warehouses.schema}
:model-exports #{:model/Card
:model/ParameterCard
@@ -2066,7 +2086,8 @@
request
revisions
search
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/CardBookmark
:model/Collection
@@ -2089,7 +2110,8 @@
permissions
query-processor
request
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/Field :model/Table}}
query-processor
@@ -2165,7 +2187,8 @@
tracing
users
util
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/DashboardCardSeries
:model/Database
@@ -2341,7 +2364,8 @@
tracing
transforms
util
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/Dashboard
@@ -2381,6 +2405,7 @@
remote-sync
search
util
warehouse-schema-overlay
xrays}
:model-exports #{:model/Segment}
:model-imports #{:model/Collection :model/Table :model/User}}
@@ -2526,7 +2551,8 @@
lib.schema
settings
sql-tools.parsing
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Table}}
sql-tools.parsing
@@ -2622,6 +2648,7 @@
tracing
util
util.config
warehouse-schema-overlay
warehouses
warehouses.schema}
:model-imports #{:model/Database
@@ -2789,7 +2816,8 @@
tracing
transforms.base
transforms.indexes
util}
util
warehouse-schema-overlay}
:model-exports #{:model/Transform
:model/TransformDagRun
:model/TransformJob
@@ -2832,7 +2860,8 @@
query-processor
sync
transforms.indexes
util}}
util
warehouse-schema-overlay}}
transforms.indexes
{:team "Gadget"
@@ -2896,7 +2925,8 @@
models
permissions
system
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Action
:model/Card
:model/Collection
@@ -2933,6 +2963,7 @@
settings
sync
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Card
:model/Database
@@ -2955,7 +2986,8 @@
models
settings
task
util}}
util
warehouse-schema-overlay}}
user-key-value
{:team "UX West"
@@ -3061,6 +3093,15 @@
:api #{metabase.graph.core}
:uses #{util}}
warehouse-schema-overlay
{:team "UX West"
:api #{metabase.warehouse-schema-overlay.core}
:uses #{util}
:model-imports #{:model/Field
:model/FieldUserSettings
:model/Table
:model/TableUserSettings}}
warehouses
{:team "UX West"
:module-exports #{warehouses.schema
@@ -3082,6 +3123,7 @@
settings
sync
util
warehouse-schema-overlay
warehouses.secrets}
:model-exports #{:model/Database}
:model-imports #{:model/Card
@@ -3116,6 +3158,7 @@
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
warehouses.schema
warehouses.secrets
@@ -3129,14 +3172,18 @@
warehouses.schema
{:team "UX West"
:ns-prefix "metabase.warehouse-schema"
:api #{metabase.warehouse-schema.field
:api #{metabase.warehouse-schema.core
metabase.warehouse-schema.field
metabase.warehouse-schema.field-values.distinct-batch
metabase.warehouse-schema.humanization
metabase.warehouse-schema.init
metabase.warehouse-schema.metadata-from-qp
metabase.warehouse-schema.metadata-queries
metabase.warehouse-schema.models.field
metabase.warehouse-schema.models.field-user-settings
metabase.warehouse-schema.models.field-values
metabase.warehouse-schema.models.table
metabase.warehouse-schema.models.table-user-settings
metabase.warehouse-schema.schema
metabase.warehouse-schema.table}
:uses #{api
@@ -3155,17 +3202,20 @@
query-processor
remote-sync
search
settings
sync.analyze
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
enterprise/permissions}
:model-exports #{:model/Dimension
:model/Field
:model/FieldUserSettings
:model/FieldValues
:model/Table}
:model/Table
:model/TableUserSettings}
:model-imports #{:model/Card
:model/Collection
:model/Database
@@ -3198,6 +3248,7 @@
sync
upload
util
warehouse-schema-overlay
warehouses.schema
xrays}
:model-imports #{:model/Collection
@@ -3243,6 +3294,7 @@
settings
sync.analyze
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Card
:model/Collection
@@ -3272,7 +3324,8 @@
models
query-processor
sync
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Database
:model/Field
:model/FieldValues
@@ -3358,6 +3411,7 @@
util
util.classloader
util.config
warehouse-schema-overlay
warehouses
warehouses.schema
enterprise/serialization}
@@ -3529,7 +3583,8 @@
premium-features
request
settings
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/DataPermissions
@@ -3549,11 +3604,12 @@
models
permissions
premium-features
util}
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/AuditLog
:model/Collection
:model/Dimension
:model/Field
:model/Table
:model/User}}
@@ -3607,12 +3663,13 @@
transforms
transforms.base
util
util.graph}
util.graph
warehouse-schema-overlay}
:model-imports #{:model/Card
:model/Collection
:model/Dashboard
:model/Document
:model/Field
:model/FieldUserSettings
:model/Measure
:model/NativeQuerySnippet
:model/Sandbox
@@ -3642,6 +3699,7 @@
task
transforms
util
warehouse-schema-overlay
warehouses.schema
enterprise/dependencies}
:model-imports #{:model/Card
@@ -3672,6 +3730,7 @@
:uses #{lib.schema
premium-features
util
warehouse-schema-overlay
enterprise/sso}
:model-imports #{:model/Card
:model/Collection
@@ -3690,7 +3749,8 @@
collections
lib.schema
premium-features
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/Collection :model/Table}}
enterprise/mcp
@@ -3797,6 +3857,7 @@
task
util
util.config
warehouse-schema-overlay
enterprise/search.embeddings
enterprise/search.semantic}
:model-imports #{:model/Card
@@ -3854,6 +3915,7 @@
query-processor
remote-sync
util
warehouse-schema-overlay
warehouses
enterprise/permissions.impersonation}
:model-imports #{:model/ApplicationPermissionsRevision
@@ -3874,7 +3936,8 @@
models
permissions
query-processor
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Card :model/User}}
enterprise/permissions.impersonation
@@ -3918,6 +3981,7 @@
users
users.tenants
util
warehouse-schema-overlay
warehouses
warehouses.schema
enterprise/api}
@@ -3960,6 +4024,7 @@
task
task.history
util
warehouse-schema-overlay
enterprise/data-apps
enterprise/serialization
enterprise/transforms.python}
@@ -3975,6 +4040,7 @@
:model/PythonLibrary
:model/Segment
:model/Table
:model/TableUserSettings
:model/Timeline
:model/Transform
:model/TransformTag
@@ -4015,6 +4081,7 @@
search.entity-retrieval
task
util
warehouse-schema-overlay
enterprise/search.semantic}
:model-imports #{:model/Card
:model/Measure
@@ -4055,7 +4122,8 @@
task
tracing
util
util.config}
util.config
warehouse-schema-overlay}
:model-imports #{:model/Collection :model/Dashboard :model/Table}}
enterprise/security-center
@@ -4239,7 +4307,8 @@
tracing
transforms
transforms.base
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Database
:model/Field
:model/Table
@@ -4268,7 +4337,8 @@
transforms
transforms.base
util
util.config}
util.config
warehouse-schema-overlay}
:model-exports #{:model/PythonLibrary}
:model-imports #{:model/Database
:model/Field
@@ -4285,7 +4355,8 @@
permissions
premium-features
upload
util}
util
warehouse-schema-overlay}
:model-imports #{:model/Database :model/Table}}
enterprise/warehouses
@@ -4297,6 +4368,7 @@
models
permissions
util
warehouse-schema-overlay
warehouses.schema}
:model-imports #{:model/Database :model/Field :model/Table}}}

View File

@@ -62,6 +62,7 @@
:metabase/defsetting-namespace 1
:metabase/prefer-with-dynamic-fn-redefs 3
:metabase/t2-query-namespace 2
:metabase/table-or-field-query 1
:missing-docstring 1
:private-call 1
:redundant-ignore 1

View File

@@ -42,4 +42,5 @@
:message (format "Application database query calls like `%s` must live in metabase[-enterprise].<module>.db (or metabase.driver.<driver>.db) namespaces"
(hooks/sexpr fn-node))
:type :metabase/t2-query-namespace))))
((requiring-resolve 'hooks.metabase.warehouse-schema-overlay.table-or-field-query/lint-read) input)
input)

View File

@@ -0,0 +1,52 @@
(ns hooks.metabase.warehouse-schema-overlay.table-or-field-query
"Lint that a query over `:model/Field` or `:model/Table` names its source with `field-query`/`table-query` from
`metabase.warehouse-schema-overlay.core`, or reads sync's values with `{:user-settings? false}`."
(:require
[clj-kondo.hooks-api :as hooks]))
(def ^:private read-fn?
"The Toucan 2 query functions that read rows. Writes are not affected: they go to `metabase_field` itself."
#{"select" "select-one" "select-fn->fn" "select-fn->pk" "select-fn-reducible" "select-fn-set" "select-fn-vec"
"select-one-fn" "select-one-pk" "select-pk->fn" "select-pks-set" "select-pks-vec" "select-reducible"
"reducible-select" "reducible-query" "query" "query-one" "count" "exists?"})
(def ^:private model->helper
{":model/Field" "field-query"
":model/Table" "table-query"})
(defn- test-file? [filename]
(boolean (and filename (re-find #"(?:^|/)test/" filename))))
(defn- tokens
"Every token in `node`'s tree, as strings."
[node]
(tree-seq :children :children node))
(defn- mentions?
[node pred]
(boolean (some (fn [n] (when-let [s (some-> (hooks/sexpr n) pr-str)] (pred s)))
(filter (complement :children) (tokens node)))))
(defn- writes?
"Whether a `t2/query` form is really a write spelled as a query."
[node]
(mentions? node #{":update" ":delete-from" ":insert-into"}))
(defn lint-read
"Register a `:metabase/table-or-field-query` finding when a read of `:model/Field` or `:model/Table` does not name
its source with the matching helper."
[{:keys [node filename]}]
(let [fn-node (first (:children node))
fn-name (some-> (hooks/sexpr fn-node) name)]
(when (and fn-name
(read-fn? fn-name)
(not (test-file? filename))
(not (writes? node)))
(doseq [[model helper] model->helper
:when (mentions? node #(= % model))
:when (not (mentions? node #(re-find (re-pattern (str "\\b" helper "$")) %)))]
(hooks/reg-finding!
(assoc (meta fn-node)
:message (format "A read of %s must name its source: add {:from [(%s)]} (or {:user-settings? false} for sync's own values)"
model helper)
:type :metabase/table-or-field-query))))))

View File

@@ -0,0 +1,188 @@
(ns metabase.core.table-or-field-raw-usage-test
"Guards against hand-written Honey SQL that names `metabase_table`/`metabase_field` directly.
`metabase.warehouse-schema-overlay.core/table-query` and `/field-query` are the source a query over
`:model/Table`/`:model/Field` must read from, so that a user's Field values (`metabase_field_user_settings`) and a
workspace's table remapping (`workspace_table_remapping`) are applied. The `:metabase/table-or-field-query` clj-kondo
linter (`.clj-kondo/src/hooks/metabase/warehouse_schema_overlay/table_or_field_query.clj`) already catches this for
`t2/select`-style calls naming `:model/Table`/`:model/Field`, but it only fires on those Toucan 2 query functions.
Hand-written Honey SQL that instead names the raw table with the literal keyword `:metabase_table`/`:metabase_field`
-- in a `:from` or a join -- sails right past it; clj-kondo has no `:discouraged-keyword` linter to catch a bare
keyword literal irrespective of the function it's passed to (verified against the pinned 2026.07.24 build: only
`:discouraged-var` and `:discouraged-namespace` exist, both keyed on a resolved var/namespace symbol, not a keyword
value), and a hook can't fire on a keyword sitting in arbitrary data with no distinguishing enclosing call. Hence
this repo-level test instead, in the style of [[metabase.core.modules-test]]."
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]))
(set! *warn-on-reflection* true)
(def ^:private scanned-roots
"Where hand-written application-database Honey SQL lives. Driver modules are globbed since each has its own
`src` directory. `.clj-kondo/` (which spells out both keywords in the hook this test backs up) is simply never
one of these roots, so it needs no explicit exclusion."
(into ["src" "enterprise/backend/src"]
(comp (filter #(.isDirectory ^java.io.File %))
(map #(str "modules/drivers/" (.getName ^java.io.File %) "/src"))
(filter #(.isDirectory (io/file %))))
(some-> (io/file "modules/drivers") .listFiles)))
(def ^:private excluded-paths
"Whole files/directories the scan never reports on, because a hit there isn't the linter's business:
- `metabase.warehouse-schema-overlay.core` itself builds `table-query`/`field-query` by naming the raw tables --
that's the one place allowed to.
- `metabase.sync.*` is sync's own read/write path: sync is the thing that writes `metabase_table`/`metabase_field`,
so it always wants the raw columns, and a separate change owns this namespace right now.
- Liquibase/custom migrations run against whatever shape the app DB had at that migration's version, before (or
long after) `field-query`/`table-query` existed; they are not reads a *current* user sees rendered anywhere."
#{"src/metabase/warehouse_schema_overlay/core.clj"})
(defn- excluded-dir?
[path]
(or (str/starts-with? path "src/metabase/sync/")
(str/includes? path "custom_migrations")))
(def ^:private allowed-raw-usages
"Files where a `:metabase_table`/`:metabase_field` literal survives review: every hit in the file was read back and
confirmed to select only columns `table-query`/`field-query` don't touch (an id, a count, a foreign key used purely
to join or filter), or the file has its own structural reason to want the raw row. Note that a Table's
`display_name`, `description`, `is_published` and `collection_id` are user values like a Field's -- a rationale
resting on those being sync-owned no longer holds. Add an entry here only after
checking every hit it covers -- see the check's failure message for the two helpers this is standing in for.
Keep this in sync with what the scan actually finds: [[stale-allowed-raw-usage-test]] fails on an entry that no
longer matches anything, same as clj-kondo's ignore ratchets flag a stale exemption."
{"src/metabase/usage_metadata/db.clj"
"raw-field-fingerprint selects only :fingerprint, a sync-owned column with no user override."
"src/metabase/collections/children.clj"
"the :model case joins metabase_table only for :is_upload, which is sync-owned."
"src/metabase/queries/db.clj"
"field-database-info-for-ids reads the Field's own raw :name (sync-owned, not user-settable); its Table join is
already routed through table-query."
"src/metabase/models/db.clj"
"field-in-path-query is a nested id-only lookup used as a :where filter, not a value read."
"src/metabase/warehouses_rest/db.clj"
"delete-field-values-for-database! selects only :f.id inside a DELETE's subquery; database-usage-counts only
counts rows. autocomplete-fields' Table join now goes through table-query."
"src/metabase/parameters/db.clj"
"both joins bring in the Table only to filter (.active, .db_id) or project its :id; neither selects
:schema/:name."
"enterprise/backend/src/metabase_enterprise/dependencies/db.clj"
"the remaining hits select only :id, to build permission and dependency filters. The two that read a Table's
display_name -- a user value -- now go through table-query."
"enterprise/backend/src/metabase_enterprise/remote_sync/db.clj"
"remote sync matches and tracks content by its actual physical (db, schema, table) location across instances --
overlaying the workspace-remapped name would work against the one thing this module does."
"enterprise/backend/src/metabase_enterprise/data_complexity_score/db.clj"
"active-field-counts-by-table only counts active Fields per table; no Field/Table content column is read."})
(defn- source-file?
[^java.io.File file]
(and (.isFile file)
(let [name (.getName file)]
(or (str/ends-with? name ".clj") (str/ends-with? name ".cljc")))))
(defn- source-files
[]
(for [root scanned-roots
^java.io.File file (file-seq (io/file root))
:when (source-file? file)]
(.getPath file)))
(defn- strip-strings-and-comments
"Blank out string-literal and line-comment content in `text`, keeping newlines so line numbers still line up.
A naive keyword scan over raw source text would otherwise flag docstrings and comments that merely *mention*
`:metabase_table`/`:metabase_field` (see e.g. `metabase.warehouse-schema.schema`'s column-list docstrings)."
[^String text]
(let [n (.length text)
sb (StringBuilder. n)]
(loop [i 0, in-str? false]
(if (>= i n)
(str sb)
(let [c (.charAt text i)]
(cond
(and in-str? (= c \\) (< (inc i) n))
(do (.append sb (if (= (.charAt text (inc i)) \newline) "\n " " "))
(recur (+ i 2) true))
(= c \")
(do (.append sb \space)
(recur (inc i) (not in-str?)))
in-str?
(do (.append sb (if (= c \newline) \newline \space))
(recur (inc i) true))
(= c \;)
(let [nl (long (or (str/index-of text "\n" i) n))]
(.append sb (apply str (repeat (- nl i) \space)))
(recur nl false))
:else
(do (.append sb c)
(recur (inc i) false))))))))
(def ^:private raw-usage-pattern
"A `:metabase_table`/`:metabase_field` keyword literal naming a table -- as opposed to `:metabase_field.column` or
`:metabase_field/column`, which qualify a column of a source named elsewhere (often `table-query`/`field-query`'s
own default alias, which is the raw table name)."
#":metabase_(?:table|field)(?![./\w])")
(defn- raw-usage-hits
"Every `{:file :line :text}` where `path`'s code (not its strings or comments) uses the literal keyword to name a
table, skipping `t2/table-name` definitions -- the canonical place the keyword has to be spelled out."
[path]
(let [text (slurp path)
code-only (strip-strings-and-comments text)
lines (str/split-lines text)
code-lines (str/split-lines code-only)]
(into []
(keep (fn [[line-no orig code]]
(when (and (re-find raw-usage-pattern code)
(not (str/includes? orig "t2/table-name")))
{:file path, :line line-no, :text (str/trim orig)})))
(map vector (rest (range)) lines code-lines))))
(defn- relative-path
[path]
(str/replace path #"^\./" ""))
(defn- all-hits
[]
(into []
(mapcat raw-usage-hits)
(remove excluded-dir? (map relative-path (source-files)))))
(defn- excused?
[file]
(boolean (or (contains? excluded-paths file)
(contains? allowed-raw-usages file))))
(deftest no-raw-metabase-table-or-field-honey-sql-test
(testing (str "Hand-written Honey SQL must not name `:metabase_table`/`:metabase_field` directly.\n"
"Read from `(metabase.warehouse-schema-overlay.core/table-query)` / `(field-query)` instead"
" (or add `{:alias ...}` for a join), or pass `{:user-settings? false}` for a deliberate raw read.\n"
"If the hit really is fine as-is (only an id, a count, or a foreign key used to join/filter), review"
" it and add it to `allowed-raw-usages` in this test with a one-line reason.")
(doseq [{:keys [file line text]} (all-hits)]
(testing (format "\n%s:%d\n %s" file line text)
(is (excused? file) "unexpected raw metabase_table/metabase_field usage (see message above)")))))
(deftest stale-allowed-raw-usage-test
(testing "every allowed-raw-usages entry still matches at least one hit, so the allowlist doesn't accumulate cruft"
(let [files-with-hits (into #{} (map :file) (all-hits))]
(doseq [file (keys allowed-raw-usages)]
(testing (format "\n%s" file)
(is (contains? files-with-hits file)
"no raw metabase_table/metabase_field usage found here anymore -- remove this allowlist entry"))))))

View File

@@ -7,17 +7,18 @@
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn table
"The Table with `table-id`, or nil."
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Table table-id))
(t2/select-one :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn active-table
"The active Table with `table-id`, or nil."
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Table :id table-id :active true))
(t2/select-one :model/Table :id table-id :active true {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn database
"The Database with `database-id`, or nil."
@@ -27,28 +28,29 @@
(mu/defn fields
"The Fields with `field-ids`."
[field-ids :- [:sequential ::lib.schema.id/field]]
(t2/select :model/Field :id [:in field-ids]))
(t2/select :model/Field :id [:in field-ids] {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn pk-fields-for-table
"The active primary key Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select :model/Field :table_id table-id :semantic_type :type/PK :active true))
(t2/select :model/Field :table_id table-id :semantic_type :type/PK :active true {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn fields-by-name
"The Fields of the Table with `table-id` named one of `field-names`."
[table-id :- ::lib.schema.id/table
field-names :- [:sequential :string]]
(t2/select :model/Field :table_id table-id :name [:in field-names]))
(t2/select :model/Field :table_id table-id :name [:in field-names] {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn active-fields-in-position-order
"The active Fields of the Table with `table-id`, in position order."
[table-id :- ::lib.schema.id/table]
(t2/select :model/Field :table_id table-id :active true {:order-by [[:position]]}))
(t2/select :model/Field :table_id table-id :active true {:from [(warehouse-schema-overlay/field-query)]
:order-by [[:position]]}))
(mu/defn field-requirements-by-name
"A map of name to the name, required flag, and base type of the Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select-fn->fn :name identity [:model/Field :name :database_required :base_type] :table_id table-id))
(t2/select-fn->fn :name identity [:model/Field :name :database_required :base_type] :table_id table-id {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn category-list-field-ids-by-name
"The `:id` and `:lower_name` rows of the category list Fields of the Table with `table-id` whose name matches one
@@ -56,7 +58,7 @@
[table-id :- ::lib.schema.id/table
names :- [:sequential :string]]
(t2/query {:select [:id [[:lower :name] :lower_name]]
:from [(t2/table-name :model/Field)]
:from [(warehouse-schema-overlay/field-query)]
:where [:and
[:= :table_id table-id]
[:in [:lower :name] (map u/lower-case-en names)]

View File

@@ -6,12 +6,13 @@
[metabase.util.honey-sql-2 :as h2x]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn table
"The Table with `table-id`, or nil."
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Table :id table-id))
(t2/select-one :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn group-manager?
"Whether the User with `user-id` manages the PermissionsGroup with `group-id`."
@@ -42,7 +43,8 @@
database-ids :- [:sequential ::lib.schema.id/database]]
(t2/query {:select [[:t.db_id :db_id]]
:from [[(t2/table-name :model/Sandbox) :s]]
:join [[(t2/table-name :model/Table) :t] [:= :s.table_id :t.id]]
:join [(warehouse-schema-overlay/table-query {:alias :t, :user-settings? false})
[:= :s.table_id :t.id]]
:where [:and
[:= :s.group_id group-id]
[:in :t.db_id database-ids]]}))
@@ -83,7 +85,8 @@
group-ids :- [:sequential ms/PositiveInt]]
(t2/query {:select [[:s.group_id :group_id]]
:from [[(t2/table-name :model/Sandbox) :s]]
:join [[(t2/table-name :model/Table) :t] [:= :t.id :s.table_id]]
:join [(warehouse-schema-overlay/table-query {:alias :t, :user-settings? false})
[:= :t.id :s.table_id]]
:where [:and
[:in :s.group_id group-ids]
[:= :t.db_id database-id]]}))

View File

@@ -6,6 +6,7 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.queries.schema :as queries.schema]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[metabase.warehouse-schema.schema :as warehouse-schema.schema]
[metabase.warehouses.schema :as warehouses.schema]
[toucan2.core :as t2]))
@@ -56,17 +57,18 @@
"The Tables of the Database with `database-id` named one of `table-names`."
[database-id :- ::lib.schema.id/database
table-names :- [:or [:set :string] [:sequential :string]]]
(t2/select :model/Table :db_id database-id :name [:in table-names]))
(t2/select :model/Table :db_id database-id :name [:in table-names] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn tables-of-database-in-id-order
"The `:id`, `:name`, `:schema`, and `:active` of the Tables of the Database with `database-id`, in ID order."
[database-id :- ::lib.schema.id/database]
(t2/select [:model/Table :id :name :schema :active] :db_id database-id {:order-by [[:id :asc]]}))
(t2/select [:model/Table :id :name :schema :active] :db_id database-id {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]
:order-by [[:id :asc]]}))
(mu/defn active-public-table-exists?
"Whether the Database with `database-id` has an active Table in the `public` schema."
[database-id :- ::lib.schema.id/database]
(t2/exists? :model/Table :db_id database-id :schema "public" :active true))
(t2/exists? :model/Table :db_id database-id :schema "public" :active true {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn table-ids-to-downcase
"The IDs of the Metabase-managed Tables of the Database with `database-id` that have no lower-cased counterpart
@@ -74,14 +76,14 @@
[database-id :- ::lib.schema.id/database]
(mapv :id
(t2/query {:select [:table.id]
:from [[(t2/table-name :model/Table) :table]]
:from [(warehouse-schema-overlay/table-query {:alias :table, :user-settings? false})]
:where [:and [:= :table.db_id database-id]
;; Exclude DATABASECHANGELOG, DATABASECHANGELOGLOCK, and QRTZ_* tables, they are not metabase managed
[:not= :table.name "DATABASECHANGELOG"]
[:not= :table.name "DATABASECHANGELOGLOCK"] ;; new instances do not get this file, but existing instances may have it
[:not [:like :table.name "QRTZ_%"]]
[:not [:exists ^:allow-subquery {:select [1]
:from [[(t2/table-name :model/Table) :self_table]]
:from [(warehouse-schema-overlay/table-query {:alias :self_table, :user-settings? false})]
:where [:and
[:= :self_table.db_id :table.db_id]
[:or
@@ -102,15 +104,15 @@
[database-id :- ::lib.schema.id/database]
(mapv :id
(t2/query {:select [:field.id]
:from [[(t2/table-name :model/Field) :field]]
:inner-join [[(t2/table-name :model/Table) :table]
:from [(warehouse-schema-overlay/field-query {:alias :field})]
:inner-join [(warehouse-schema-overlay/table-query {:alias :table})
[:= :table.id :field.table_id]]
:where [:and [:= :table.db_id database-id]
[:not= :table.name "DATABASECHANGELOG"]
[:not [:like :table.name "QRTZ_%"]]
[:not [:exists ^:allow-subquery {:select [1]
:from [[(t2/table-name :model/Field) :self_field]]
:inner-join [[(t2/table-name :model/Table) :self_table]
:from [(warehouse-schema-overlay/field-query {:alias :self_field})]
:inner-join [(warehouse-schema-overlay/table-query {:alias :self_table})
[:= :self_table.id :self_field.table_id]]
:where [:and
[:= :self_table.db_id :table.db_id]
@@ -161,7 +163,7 @@
(mu/defn field-names-of-table
"The `:id` and `:name` of the Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select [:model/Field :id :name] :table_id table-id))
(t2/select [:model/Field :id :name] :table_id table-id {:from [(warehouse-schema-overlay/field-query {:user-settings? false})]}))
(mu/defn card-result-metadata-reducible
"Reducible raw `:id` and `:result_metadata` rows of the Cards of the Database with `database-id`."

View File

@@ -5,7 +5,8 @@
[metabase-enterprise.audit-app.pages.common :as common]
[metabase-enterprise.audit-app.pages.common.cards :as cards]
[metabase.app-db.core :as mdb]
[metabase.audit-app.core :as audit]))
[metabase.audit-app.core :as audit]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]))
;; List of all failing questions
(defmethod audit.i/internal-query ::bad-table
@@ -75,7 +76,7 @@
:from [[:report_card :card]]
:left-join [[:collection :coll] [:= :card.collection_id :coll.id]
[:metabase_database :db] [:= :card.database_id :db.id]
[:metabase_table :t] [:= :card.table_id :t.id]
(warehouse-schema-overlay/table-query {:alias :t}) [:= :card.table_id :t.id]
[:core_user :u] [:= :card.creator_id :u.id]
:latest_qe [:= :card.id :latest_qe.card_id]
:query_runs [:= :card.id :query_runs.card_id]

View File

@@ -6,6 +6,7 @@
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(def ^:private non-blob-columns
@@ -52,13 +53,15 @@
(defn table-database-id
"The database ID for the Table with `table-id`, or nil."
[table-id]
(t2/select-one-fn :db_id :model/Table :id table-id))
(t2/select-one-fn :db_id :model/Table :id table-id
{:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(defn existing-table-ids
"The IDs from `table-ids` that belong to existing Tables."
[table-ids]
(if (seq table-ids)
(t2/select-pks-set :model/Table :id [:in table-ids])
(t2/select-pks-set :model/Table :id [:in table-ids]
{:from [(warehouse-schema-overlay/table-query {:user-settings? false})]})
#{}))
(defn users-for-permission-warnings
@@ -196,7 +199,7 @@
:t.schema
[:t.db_id :database_id]
[:d.name :database_name]]
:from [[:metabase_table :t]]
:from [(warehouse-schema-overlay/table-query {:alias :t})]
:join [[:metabase_database :d] [:= :d.id :t.db_id]]
:where [:in :t.id table-ids]
:order-by [[:d.name :asc] [:t.schema :asc] [:t.display_name :asc]]}))
@@ -222,11 +225,12 @@
:from [[:permissions_group_membership :pgm]]
:join [[:data_permissions :dp] [:= :dp.group_id :pgm.group_id]
[:permissions_group :pg] [:= :pg.id :pgm.group_id]
[:metabase_table :t] [:and
[:= :t.db_id :dp.db_id]
[:or
[:= :dp.table_id nil]
[:= :dp.table_id :t.id]]]]
(warehouse-schema-overlay/table-query {:alias :t, :user-settings? false})
[:and
[:= :t.db_id :dp.db_id]
[:or
[:= :dp.table_id nil]
[:= :dp.table_id :t.id]]]]
:where [:and
[:in :pgm.user_id user-ids]
[:in :t.id table-ids]

View File

@@ -10,6 +10,7 @@
[metabase.util.honey-sql-2 :as h2x]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn active-field-counts-by-table
@@ -64,7 +65,7 @@
[audit-database-id :- ::lib.schema.id/database]
(t2/select [:model/Table :id :name :collection_id :is_published :visibility_type :db_id :data_layer :data_authority]
:active true
:db_id [:not= audit-database-id]))
:db_id [:not= audit-database-id] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn metabot-by-entity-id
"The Metabot with `entity-id`, or nil."

View File

@@ -7,6 +7,8 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[metabase.warehouse-schema.models.table-user-settings :as schema.table-user-settings]
[toucan2.core :as t2]))
(def ^:private TableSelectors
@@ -50,9 +52,9 @@
[:not [:in output-table-id tables]])]
(t2/reducible-query {:select [[output-table-id :table_id]]
:from [[(t2/table-name :model/Dimension) :dim]]
:join [[(t2/table-name :model/Field) :source_field]
:join [(warehouse-schema-overlay/field-query {:alias :source_field, :user-settings? false})
[:= :dim.field_id :source_field.id]
[(t2/table-name :model/Field) :target_field]
(warehouse-schema-overlay/field-query {:alias :target_field, :user-settings? false})
[:= :dim.human_readable_field_id :target_field.id]]
:where [:and
[:= :dim.type "external"]
@@ -66,7 +68,8 @@
extra-table-ids :- [:maybe [:set ::lib.schema.id/table]]
extra-mode :- [:enum :unpublished :any]]
(t2/select-pks-set :model/Table
{:where (let [selector-expr (table-selectors-expr selectors)]
{:from [(warehouse-schema-overlay/table-query)]
:where (let [selector-expr (table-selectors-expr selectors)]
(if (seq extra-table-ids)
[:or selector-expr (case extra-mode
:unpublished [:and [:in :id extra-table-ids] [:= :is_published false]]
@@ -76,12 +79,12 @@
(mu/defn published-table-ids
"The IDs of the published Tables among `table-ids`."
[table-ids :- [:set ::lib.schema.id/table]]
(t2/select-pks-set :model/Table :id [:in table-ids] :is_published true))
(t2/select-pks-set :model/Table :id [:in table-ids] :is_published true {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn tables
"The Tables with `table-ids`."
[table-ids :- [:set ::lib.schema.id/table]]
(t2/select :model/Table :id [:in table-ids]))
(t2/select :model/Table :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn collection
"The Collection with `collection-id`, or nil."
@@ -106,12 +109,14 @@
"Publish the Tables with `table-ids` into the Collection with `collection-id`, returning the number updated."
[table-ids :- [:set ::lib.schema.id/table]
collection-id :- ::lib.schema.id/collection]
(t2/update! :model/Table :id [:in table-ids] {:collection_id collection-id, :is_published true}))
(schema.table-user-settings/upsert-user-settings-for-tables!
table-ids {:collection_id collection-id, :is_published true}))
(mu/defn unpublish-tables!
"Unpublish the Tables with `table-ids` and detach them from their Collection, returning the number updated."
[table-ids :- [:set ::lib.schema.id/table]]
(t2/update! :model/Table :id [:in table-ids] {:collection_id nil, :is_published false}))
(schema.table-user-settings/upsert-user-settings-for-tables!
table-ids {:collection_id nil, :is_published false}))
(mu/defn published-table-visible-to-user?
"Whether the Table with `table-id` is published in a Collection the User with `user-id` can read."
@@ -119,7 +124,8 @@
user-id :- ::lib.schema.id/user
superuser? :- :boolean]
(t2/exists? :model/Table
{:where [:and
{:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :id table-id]
[:= :is_published true]
(collection/visible-collection-filter-clause
@@ -130,7 +136,8 @@
"Whether the current user can read the Collection of any published Table."
[]
(t2/exists? :model/Table
{:where [:and
{:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :is_published true]
(collection/visible-collection-filter-clause :collection_id)]}))
@@ -138,7 +145,8 @@
"Whether the current user can read the Collection of any published Table in the Database with `database-id`."
[database-id :- ::lib.schema.id/database]
(t2/exists? :model/Table
{:where [:and
{:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :db_id database-id]
[:= :is_published true]
(collection/visible-collection-filter-clause :collection_id)]}))

View File

@@ -7,7 +7,8 @@
[metabase.models.interface :as mi]
[metabase.permissions.core :as perms]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.util.honey-sql-2 :as h2x]))
[metabase.util.honey-sql-2 :as h2x]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]))
(defenterprise user-published-table-permission
"Returns :query-builder permission if table is published and user has collection access.
@@ -44,7 +45,7 @@
[:in table-id-column
^:allow-subquery
{:select [:id]
:from [:metabase_table]
:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :is_published true]
(collection/visible-collection-filter-clause
@@ -67,7 +68,7 @@
{:select [[:mt.id :id]
[(h2x/literal :perms/create-queries) :perm_type]
[(h2x/literal :query-builder) :perm_value]]
:from [[:metabase_table :mt]]
:from [(warehouse-schema-overlay/table-query {:alias :mt})]
:where (cond-> [:and
[:= :mt.is_published true]
(collection/visible-collection-filter-clause

View File

@@ -16,6 +16,7 @@
[metabase.util.honey-sql-2 :as h2x]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(def ^:private VisibleOpts
@@ -277,7 +278,7 @@
nil))]
{:table-name (case entity-type
:card :report_card
:table :metabase_table
:table (first (warehouse-schema-overlay/table-query))
:transform :transform
:snippet :native_query_snippet
:dashboard :report_dashboard
@@ -427,7 +428,7 @@
(:collection joins) (conj :collection [:= :entity.collection_id :collection.id])
(:dashboard joins) (conj [:report_dashboard :dashboard] [:= :entity.dashboard_id :dashboard.id])
(:document joins) (conj :document [:= :entity.document_id :document.id])
(:table joins) (conj [:metabase_table :table] [:= :entity.table_id :table.id])))
(:table joins) (conj (warehouse-schema-overlay/table-query {:alias :table}) [:= :entity.table_id :table.id])))
(defn- dependency-item-select
"The per-entity-type SELECT that [[dependency-item-ids]] and [[dependency-item-count]] union together, matching
@@ -549,19 +550,28 @@
ids :- [:set ::deps.dependency-types/entity-id]]
(t2/select (deps.dependency-types/dependency-type->model entity-type) :id [:in ids]))
(defn- entity-source
"What a read of `entity-type` selects from: the overlay for a Table, else the model's own table."
[entity-type]
(if (= entity-type :table)
{:from [(warehouse-schema-overlay/table-query)]}
{}))
(mu/defn instances-with-columns
"The `columns` of the instances of the entity type `entity-type` with `ids`."
[entity-type :- ::deps.dependency-types/dependency-types
columns :- [:sequential :keyword]
ids :- [:sequential ::deps.dependency-types/entity-id]]
(t2/select (into [(deps.dependency-types/dependency-type->model entity-type)] columns) :id [:in ids]))
(t2/select (into [(deps.dependency-types/dependency-type->model entity-type)] columns)
:id [:in ids] (entity-source entity-type)))
(mu/defn instance-with-columns
"The `columns` of the instance of the entity type `entity-type` with `id`, or nil."
[entity-type :- ::deps.dependency-types/dependency-types
columns :- [:sequential :keyword]
id :- ::deps.dependency-types/entity-id]
(t2/select-one (into [(deps.dependency-types/dependency-type->model entity-type)] columns) :id id))
(t2/select-one (into [(deps.dependency-types/dependency-type->model entity-type)] columns)
:id id (entity-source entity-type)))
(mu/defn card
"The Card with `card-id`, or nil."
@@ -587,19 +597,19 @@
(mu/defn tables
"The Tables with `table-ids`."
[table-ids :- [:set ::lib.schema.id/table]]
(t2/select :model/Table :id [:in table-ids]))
(t2/select :model/Table :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn table-database-ids
"The `:id` and `:db_id` of the Tables with `table-ids`."
[table-ids :- [:sequential ::lib.schema.id/table]]
(t2/select [:model/Table :id :db_id] :id [:in table-ids]))
(t2/select [:model/Table :id :db_id] :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(mu/defn table-id-by-name
"The ID of the Table named `table-name` in `schema` of the Database with `db-id`, or nil."
[db-id :- ::lib.schema.id/database
schema :- [:maybe :string]
table-name :- :string]
(t2/select-one-fn :id :model/Table :db_id db-id :schema schema :name table-name))
(t2/select-one-fn :id :model/Table :db_id db-id :schema schema :name table-name {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(mu/defn transform-sources
"The `:id` and `:source` of the Transforms with `transform-ids`."
@@ -857,17 +867,24 @@
:limit batch-size})))
(mu/defn table-ids-with-outdated-findings
"The IDs of the Tables of the Database with `db-id` whose dependents were analyzed before the Table or one of
its Fields last changed."
"The IDs of the Tables of the Database with `db-id` whose dependents were analyzed before the Table, one of its
Fields, or one of its Fields' user settings last changed."
[db-id :- ::lib.schema.id/database]
(t2/select-fn-set :table_id :model/AnalysisFinding
{:select [:field_updates/table_id]
:from [[^:allow-subquery {:select [[:table/id :table_id]
[:table/updated_at :last_table_update]
[[:max :field/updated_at] :last_field_update]]
:from [[(t2/table-name :model/Table) :table]]
:left-join [[(t2/table-name :model/Field) :field]
[:= :field/table_id :table/id]]
[[:max [:case
[:>= :field/updated_at
[:coalesce :field_settings/updated_at :field/updated_at]]
:field/updated_at
:else :field_settings/updated_at]]
:last_field_update]]
:from [(warehouse-schema-overlay/table-query {:alias :table})]
:left-join [(warehouse-schema-overlay/field-query {:alias :field})
[:= :field/table_id :table/id]
[(t2/table-name :model/FieldUserSettings) :field_settings]
[:= :field_settings/field_id :field/id]]
:where [:= :table/db_id db-id]
:group-by [:table/id
:table/updated_at]}

View File

@@ -4,6 +4,7 @@
(:require
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn user-database-exists?
@@ -21,7 +22,8 @@
(mu/defn upload-table-exists-in-database?
"Whether the Database with `database-id` holds an active uploaded Table."
[database-id :- ::lib.schema.id/database]
(t2/exists? :model/Table {:where [:and
(t2/exists? :model/Table {:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :active true]
[:= :is_upload true]
[:= :db_id database-id]]}))

View File

@@ -5,6 +5,7 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn library-cards-in-collections
@@ -25,7 +26,8 @@
[collection-ids :- [:sequential ::lib.schema.id/collection]
id :- [:maybe ::lib.schema.id/table]]
(t2/select [:model/Table :id :name :display_name :description]
{:where [:and
{:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:in :collection_id collection-ids]
[:= :is_published true]
[:= :active true]

View File

@@ -4,6 +4,7 @@
(:require
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[metabase.warehouse-schema.models.table :as schema.table]
[toucan2.core :as t2]))
@@ -22,7 +23,8 @@
opts :- [:maybe ActiveTablesOpts]]
(let [{:keys [table-ids schema]} opts]
(t2/select :model/Table
{:select columns
{:from [(warehouse-schema-overlay/table-query)]
:select columns
:where (cond-> [:and [:= :db_id database-id] [:= :active true]]
(contains? opts :table-ids) (conj [:in :id table-ids])
(contains? opts :schema) (conj (if (= schema "")
@@ -32,7 +34,8 @@
(mu/defn active-fields-for-tables
"The active Fields of the Tables with `table-ids`, in field order."
[table-ids :- [:set ::lib.schema.id/table]]
(t2/select :model/Field {:where [:and
(t2/select :model/Field {:from [(warehouse-schema-overlay/field-query)]
:where [:and
[:in :table_id table-ids]
[:= :active true]]
:order-by schema.table/field-order-rule}))
@@ -40,6 +43,7 @@
(mu/defn active-fields
"The active Fields with `field-ids`."
[field-ids :- [:or [:set ::lib.schema.id/field] [:sequential ::lib.schema.id/field]]]
(t2/select :model/Field {:where [:and
(t2/select :model/Field {:from [(warehouse-schema-overlay/field-query)]
:where [:and
[:in :id field-ids]
[:= :active true]]}))

View File

@@ -5,6 +5,7 @@
[metabase.collections.models.collection :as collection]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn card-types-in-collections
@@ -15,7 +16,7 @@
(mu/defn published-table-in-collections?
"Whether a published Table exists in the Collections with `collection-ids`."
[collection-ids :- [:sequential ::lib.schema.id/collection]]
(t2/exists? :model/Table :is_published true :collection_id [:in collection-ids]))
(t2/exists? :model/Table :is_published true :collection_id [:in collection-ids] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn library-collections
"The readable Library, Library-data, and Library-metrics Collections, ordered by name."

View File

@@ -5,6 +5,7 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.permissions.core :as perms]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn user-superuser?
@@ -20,7 +21,7 @@
(defn- blocked-tables-select
[user-id tables-expr permissions-blocking permissions-granting]
{:select [[:db.name :db_name] :blocked.schema [:blocked.name :table_name] [:pg.name :group_name]]
:from [[:metabase_table :blocked]]
:from [(warehouse-schema-overlay/table-query {:alias :blocked, :user-settings? false})]
:join [[(perms/select-tables-and-groups-granting-perm
{:user-id user-id :is-superuser? false}
permissions-blocking) :perm_grant] [:= :blocked.id :perm_grant.id]

View File

@@ -8,6 +8,7 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(def ^:private Conditions
@@ -134,11 +135,15 @@
(t2/select-one model :id id))
(mu/defn instance-with-columns
"The `columns` of the instance of `model` with `id`, or nil."
"The `columns` of the instance of `model` with `id`, or nil; a Table is read through the overlay."
[model :- :keyword
columns :- [:sequential :keyword]
id :- ms/PositiveInt]
(t2/select-one (into [model] columns) :id id))
(t2/select-one (into [model] columns)
:id id
(if (= model :model/Table)
{:from [(warehouse-schema-overlay/table-query)]}
{})))
(mu/defn instance-names
"The `:id` and `:name` of the instances of `model` with `ids`."
@@ -178,15 +183,15 @@
:model/Field {:alias "f"
:select [:f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:metabase_field :f]]
:join [[:metabase_table :t] [:= :f.table_id :t.id]]}
:join [(warehouse-schema-overlay/table-query {:alias :t}) [:= :f.table_id :t.id]]}
:model/Segment {:alias "s"
:select [:s.name :s.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:segment :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]}
:join [(warehouse-schema-overlay/table-query {:alias :t}) [:= :s.table_id :t.id]]}
:model/Measure {:alias "s"
:select [:s.name :s.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:measure :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]}))
:join [(warehouse-schema-overlay/table-query {:alias :t}) [:= :s.table_id :t.id]]}))
(mu/defn tracking-details-by-id
"The name, table id, collection id, and table name of the `model-key` (Field, Segment, or Measure) instance with
@@ -269,10 +274,15 @@
[card-ids :- [:sequential ::lib.schema.id/card]]
(t2/select [:model/Card :id :type :card_schema] :id [:in card-ids]))
(mu/defn field-user-settings-exist?
"Whether the Field with `field-id` has FieldUserSettings."
[field-id :- ::lib.schema.id/field]
(t2/exists? :model/FieldUserSettings :field_id field-id))
(mu/defn user-settings-exist-for-table?
"Whether the Table with `table-id`, or any of its Fields, has a user-settings row."
[table-id :- ::lib.schema.id/table]
(or (t2/exists? :model/TableUserSettings :table_id table-id)
(t2/exists? :model/FieldUserSettings
{:from [[(t2/table-name :model/FieldUserSettings) :u]]
:join [(warehouse-schema-overlay/field-query {:alias :f :user-settings? false})
[:= :f.id :u.field_id]]
:where [:= :f.table_id table-id]})))
(mu/defn snippets
"The `:id`, `:name`, and `:collection_id` of every NativeQuerySnippet."

View File

@@ -11,8 +11,8 @@
Tracked model types:
- Card, Dashboard, Document, NativeQuerySnippet, Timeline, Collection
- Table (when published in a remote-synced collection)
- Field, Segment (when belonging to a published table in a remote-synced collection)
- Table and TableUserSettings, which also carries a Field's own edits (when published in a remote-synced collection)
- Segment, Measure (when belonging to a published table in a remote-synced collection)
- Transform, TransformTag, transforms-namespace Collections (when remote-sync-transforms setting is enabled)
- NativeQuerySnippet, snippets-namespace Collections (when Library is remote-synced)"
(:require
@@ -244,7 +244,7 @@
;;; --------------------------------- Spec-based Event Registration (Non-Collection) -----------------------------------
(doseq [[_model-key model-spec] (dissoc spec/remote-sync-specs :model/Collection :model/Field)]
(doseq [[_model-key model-spec] (dissoc spec/remote-sync-specs :model/Collection :model/Field :model/Table)]
(register-events-for-spec! model-spec))
;;; ----------------------------------------- Collection Event Handler -------------------------------------------------
@@ -300,27 +300,51 @@
(log/infof "Collection %s no longer needs syncing, marking as removed" (:id object))
(create-or-update-remote-sync-object-entry! "Collection" (:id object) "removed" hydrate-collection-details)))))
;;; ----------------------------------------- FieldUserSettings Tracking -----------------------------------------------
;; When a field is updated in a published table, also track any FieldUserSettings row for that field.
;; FieldUserSettings has no separate event; it piggybacks on :event/field-update.
;;; ----------------------------------------- TableUserSettings Tracking -----------------------------------------------
(def ^:private table-spec (get spec/remote-sync-specs :model/Table))
(def ^:private field-spec (get spec/remote-sync-specs :model/Field))
(defn- hydrate-table-user-settings-details
"The RemoteSyncObject details of the Table with `table-id`, as its own `:table_id`/`:table_name`."
[table-id]
(let [details (spec/hydrate-model-details table-spec table-id)]
(assoc details :table_id (:id details) :table_name (:name details))))
(defn- sync-table-user-settings!
"Track the Table's TableUserSettings when eligible and any of its user settings exist, else mark it removed."
[table-id eligible?]
(cond
(and eligible? (remote-sync.db/user-settings-exist-for-table? table-id))
(create-or-update-remote-sync-object-entry!
"TableUserSettings" table-id "update" hydrate-table-user-settings-details)
(and (not eligible?)
(remote-sync.db/rso-exists? "TableUserSettings" table-id))
(create-or-update-remote-sync-object-entry!
"TableUserSettings" table-id "removed" hydrate-table-user-settings-details)))
(events/derive! :event/field-update ::field-update-event)
(events/derive! ::field-update-event :metabase/event)
(methodical/defmethod events/publish-event! ::field-update-event
[_topic {:keys [object]}]
(let [field-id (:id object)
eligible? (spec/check-eligibility field-spec object)]
(cond
(and eligible? (remote-sync.db/field-user-settings-exist? field-id))
(create-or-update-remote-sync-object-entry!
"FieldUserSettings" field-id "update"
(fn [id] (spec/hydrate-model-details field-spec id)))
(when-let [table-id (:table_id object)]
(sync-table-user-settings! table-id (spec/check-eligibility field-spec object))))
(and (not eligible?)
(remote-sync.db/rso-exists? "FieldUserSettings" field-id))
(create-or-update-remote-sync-object-entry!
"FieldUserSettings" field-id "removed"
(fn [id] (spec/hydrate-model-details field-spec id))))))
(defn- handle-table-event!
"The generic Table handler plus the Table's TableUserSettings; one handler, since a second primary method on the
same events would displace the generic one."
[topic {:keys [object] :as event}]
(handle-model-event-from-spec table-spec topic event)
(sync-table-user-settings! (:id object) (spec/check-eligibility table-spec object)))
(let [event-kws (spec/event-keywords table-spec)
parent-kw (:parent event-kws)]
(events/derive! parent-kw :metabase/event)
(doseq [[_event-type event-kw] (dissoc event-kws :parent)]
(events/derive! event-kw parent-kw))
(methodical/add-primary-method!
#'events/publish-event!
parent-kw
(fn [topic event] (handle-table-event! topic event))))

View File

@@ -250,7 +250,7 @@
Returns:
- [{:model_type :model_id :path :content_hash}] (entities that fail to serialize are omitted)"
[rows repo-paths]
(let [storage-opts (serdes/storage-base-context)
(let [storage-opts (source/storage-context)
repo-by-eid (into {} (map (fn [{:keys [model_type entity_id path]}] [[model_type entity_id] path])) repo-paths)
serialize (fn [model-type opts id->eid instance]
(try
@@ -271,9 +271,9 @@
(mapcat (fn [[model-type model-rows]]
(let [spec (spec/spec-for-model-type model-type)
model-key (:model-key spec)
opts {:filter-column :id
:filter-ids (mapv :model_id model-rows)
:skip-archived true}
opts (merge spec/git-sync-extract-opts
{:filter-column :id
:filter-ids (mapv :model_id model-rows)})
;; entity-id models: map local id -> entity_id so we can look up the repo path
id->eid (when (and model-key (= :entity-id (:identity spec)))
(remote-sync.db/entity-ids-by-id model-key (mapv :model_id model-rows)))]
@@ -839,11 +839,11 @@
Return:
- [[row entity]] (if no entity, then omit)"
[{:keys [model_type rows]}]
(let [pk-col (spec/pk-col model_type)
(let [pk-col (serdes/primary-key model_type)
id->row (u/index-by :model_id rows)
opts {:filter-column pk-col
:filter-ids (mapv :model_id rows)
:skip-archived true}]
opts (merge spec/git-sync-extract-opts
{:filter-column pk-col
:filter-ids (mapv :model_id rows)})]
;; extract-one must run inside the extract-query reduction, while its ResultSet is open
(into [] (keep (fn [instance]
(when-let [row (id->row (get instance pk-col))]
@@ -989,7 +989,7 @@
- {:writes [{:id :model_type :model_id :file_path}] :delete-paths [path] :removed-ids [id]}, or
- :remote-sync/incremental-not-possible when any row can't go incrementally"
[snapshot rows]
(let [opts (serdes/storage-base-context)
(let [opts (source/storage-context)
;; create/update rows on entity-id models need an entity (extracted per chunk); everything else
;; (removed/delete, non-entity-id, bad status) is decided with no entity
{cu-rows true other-rows false} (group-by #(boolean (and (#{"create" "update"} (:status %))
@@ -1104,7 +1104,7 @@
total (count export-rows)
span (- export-progress-serialize export-progress-plan-done)]
(report export-progress-plan-done {:force? true})
(let [opts (serdes/storage-base-context)
(let [opts (source/storage-context)
[synced version] (commit-staged! snapshot message
(fn [commit]
(source.p/replace-all! commit) ; replace the managed dirs wholesale
@@ -1136,7 +1136,7 @@
total (max 1 (count writes))
span (- export-progress-serialize export-progress-plan-done)]
(report export-progress-plan-done {:force? true})
(let [opts (serdes/storage-base-context)
(let [opts (source/storage-context)
[synced version] (commit-staged! snapshot message
(fn [commit]
(let [synced (stage-writes commit opts writes

View File

@@ -68,6 +68,11 @@
(atom nil) (atom []))
(seq root-dependencies) (ingestable/wrap-root-dep-ingestable root-dependencies)))
(defn storage-context
"The serdes storage context for git sync: a Table's user settings file stands in for the Table's own."
[]
(assoc (serdes/storage-base-context) :inline-user-settings true))
(defn entity->path
"The repo-relative path an extracted `entity` serializes to, using storage context `opts`."
[opts entity]
@@ -84,7 +89,7 @@
(defn entity->file-spec
"Serializes a single extracted entity into a `{:path :content}` file spec, using storage context
`opts` (from `serdes/storage-base-context`)."
`opts` (from [[storage-context]])."
[opts entity]
{:path (entity->path opts entity)
:content (entity->content entity)})
@@ -99,7 +104,7 @@
can't be extracted. Hashes the live DB serialization (never on-disk bytes), so it's stable across sync points."
[row]
(when-let [entity (first (spec/extract-entities-for-rows [row]))]
(content-hash (:content (entity->file-spec (serdes/storage-base-context) entity)))))
(content-hash (:content (entity->file-spec (storage-context) entity)))))
(defn serialize-specs
"Serializes a stream of entities into an eager vector of `{:path :content}` file specs. Reports progress
@@ -108,7 +113,7 @@
Throws Exception if any entity in the stream is an Exception instance."
[stream task-id]
(let [opts (serdes/storage-base-context)
(let [opts (storage-context)
stream-count (bounded-count 10000 stream)]
(into []
(map-indexed (fn [idx entity]

View File

@@ -819,15 +819,14 @@
;;; ---------------------------------------------------- Hydration -----------------------------------------------------
(defn hydrate-model-details
"Hydrates model details for RemoteSyncObject based on spec.
Returns a map with the fields needed to populate the sync object."
"Returns a map with the fields (always including :id) needed to populate a RemoteSyncObject for `spec`."
[{:keys [model-key tracking]} model-id]
(if (:hydrate-query? tracking)
;; Use the tracking-join query for models needing a Table join (Field, Segment, Measure)
(remote-sync.db/tracking-details-by-id model-key model-id)
;; Simple select using select-fields
(when-let [fields (:select-fields tracking)]
(remote-sync.db/instance-with-columns model-key fields model-id))))
(remote-sync.db/instance-with-columns model-key (into [:id] fields) model-id))))
(defn build-sync-object-fields
"Builds the fields map for RemoteSyncObject from hydrated model details.
@@ -1136,14 +1135,17 @@
(defmethod query-export-roots :default [_] nil)
(def ^:private git-sync-extract-opts
"Serdes extraction opts for git sync. :user-edits-only restricts field-level export to
FieldUserSettings (user-curated metadata only), omitting the full Field YAML for every column."
(def git-sync-extract-opts
"Serdes extraction opts for git sync."
{:include-field-values false
:include-database-secrets false
:continue-on-error false
:skip-archived true
:user-edits-only true})
:inline-user-settings true})
(def ^:private models-traversed-but-not-stored
"Models git sync walks through but never writes."
#{"Table" "Field"})
(defn exportable-entities
"What a full export would serialize: a map of {model-name [id ...]} — the export roots plus their transitive
@@ -1155,12 +1157,7 @@
targets (-> #{}
(into (keys (u/traverse root-targets #(serdes/descendants (first %) (second %) git-sync-extract-opts))))
(into (keys (u/traverse root-targets #(serdes/required (first %) (second %))))))]
(u/group-by first second targets)))
(defn pk-col
"Returns the PK column keyword for `model`. FieldUserSettings uses :field_id; all others use :id."
[model]
(if (= model "FieldUserSettings") :field_id :id))
(apply dissoc (u/group-by first second targets) models-traversed-but-not-stored)))
(defn extract-entities-for-export
"Extracts all entities for remote-sync export based on enabled specs.
@@ -1173,9 +1170,9 @@
3. Are in one of the provided collections (or descendants)"
[]
(eduction (map (fn [[model ids]]
(serdes/extract-all model {:filter-column (pk-col model)
:filter-ids (vec ids)
:skip-archived true})))
(serdes/extract-all model (merge git-sync-extract-opts
{:filter-column (serdes/primary-key model)
:filter-ids (vec ids)}))))
cat
(exportable-entities)))
@@ -1186,8 +1183,8 @@
[rows]
(let [by-model (u/group-by :model_type :model_id conj #{} rows)]
(eduction (map (fn [[model ids]]
(serdes/extract-all model {:filter-column (pk-col model)
:filter-ids (vec ids)
:skip-archived true})))
(serdes/extract-all model (merge git-sync-extract-opts
{:filter-column (serdes/primary-key model)
:filter-ids (vec ids)}))))
cat
by-model)))

View File

@@ -13,7 +13,7 @@
[metabase.util.honey-sql-2 :as h2x]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema.schema :as warehouse-schema.schema]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn run
@@ -75,7 +75,7 @@
(mu/defn tables-with-ids
"The Tables with `ids`."
[ids :- [:sequential ms/PositiveInt]]
(t2/select :model/Table :id [:in ids]))
(t2/select :model/Table :id [:in ids] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn dashboards-with-ids
"The Dashboards with `ids`."
@@ -182,28 +182,22 @@
(mu/defn table-database-id
"The Database ID of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select-one-fn :db_id :model/Table :id table-id))
(t2/select-one-fn :db_id :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(mu/defn active-fields-of-table
"The active Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select :model/Field :table_id table-id :active true))
(t2/select :model/Field :table_id table-id :active true {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn active-field-ids-of-table
"The IDs of the active Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select-pks-set :model/Field :table_id table-id :active true))
(t2/select-pks-set :model/Field :table_id table-id :active true {:from [(warehouse-schema-overlay/field-query {:user-settings? false})]}))
(mu/defn active-fk-to-fields-exists?
"Whether an active Field points at one of the Fields with `field-ids`."
[field-ids :- [:set ::lib.schema.id/field]]
(t2/exists? :model/Field :fk_target_field_id [:in field-ids] :active true))
(mu/defn update-field!
"Apply `changes` to the Field with `field-id`, returning the number updated."
[field-id :- ::lib.schema.id/field
changes :- ::warehouse-schema.schema/field.update]
(t2/update! :model/Field field-id changes))
(t2/exists? :model/Field :fk_target_field_id [:in field-ids] :active true {:from [(warehouse-schema-overlay/field-query)]}))
(mu/defn sandbox-exists-for-table?
"Whether a Sandbox is defined on the Table with `table-id`."

View File

@@ -169,8 +169,8 @@
[:description :display_name :semantic_type :fk_target_field_id :settings :visibility_type])
(defn- copy-model-metadata-overrides!
"Copy user-edited metadata from a model's result_metadata onto the Fields of the
output table. Writes to both Field and FieldUserSettings so overrides survive sync."
"Copy user-edited metadata from a model's result_metadata onto the FieldUserSettings of the
output table's Fields, so the overrides keep showing on the new Fields after the swap."
[card-id table-id]
(let [card (replacement.db/card card-id)
result-metadata (:result_metadata card)
@@ -180,7 +180,6 @@
:let [field (field-by-name (source-swap.util/column-match-key col-meta))
overrides (u/select-keys-when col-meta :non-nil metadata-override-keys)]
:when (and field (seq overrides))]
(replacement.db/update-field! (:id field) overrides)
(field-user-settings/upsert-user-settings field overrides))))
(defn run-swap-model-with-transform!

View File

@@ -10,6 +10,7 @@
[metabase.users.schema :as users.schema]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn sandbox
@@ -60,7 +61,8 @@
(t2/select :model/Sandbox
{:select [:s.group_id :s.table_id :t.db_id :t.schema]
:from [[:sandboxes :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
:join [(warehouse-schema-overlay/table-query {:alias :t, :user-settings? false})
[:= :s.table_id :t.id]]
:where [:and
(when group-id [:= :s.group_id group-id])
(when group-ids [:in :s.group_id group-ids])
@@ -79,7 +81,7 @@
[:table.db_id :db_id]
[:table.schema :schema]]
:from [[:sandboxes]]
:left-join [[:metabase_table :table]
:left-join [(warehouse-schema-overlay/table-query {:alias :table, :user-settings? false})
[:= :sandboxes.table_id :table.id]]
:where [:and
[:in :sandboxes.group_id group-ids]
@@ -149,7 +151,7 @@
(mu/defn table
"The Table with `table-id`, or nil."
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Table :id table-id))
(t2/select-one :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn tables-of-database
"The `:id`, `:db_id`, and `:schema` of the Tables of the Database with `db-id`, restricted to `schema` when
@@ -158,7 +160,8 @@
schema-only? :- :boolean
schema :- [:maybe :string]]
(t2/select [:model/Table :id :db_id :schema]
{:where [:and
{:from [(warehouse-schema-overlay/table-query {:user-settings? false})]
:where [:and
[:= :db_id db-id]
(when schema-only?
[:= :schema schema])]}))
@@ -168,14 +171,14 @@
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Database
:id ^:allow-subquery {:select [:t.db_id]
:from [[(t2/table-name :model/Table) :t]]
:from [(warehouse-schema-overlay/table-query {:alias :t, :user-settings? false})]
:where [:= :t.id table-id]}))
(mu/defn fields-of-table-named
"The `:id` and `:name` of the Fields of the Table with `table-id` named one of `field-names`."
[table-id :- ::lib.schema.id/table
field-names :- [:set :string]]
(t2/select [:model/Field :id :name] :table_id table-id :name [:in field-names]))
(t2/select [:model/Field :id :name] :table_id table-id :name [:in field-names] {:from [(warehouse-schema-overlay/field-query {:user-settings? false})]}))
(mu/defn cards-by-id
"A map of Card ID to the query, result metadata, and schema of the Cards with `card-ids`."

View File

@@ -10,6 +10,7 @@
[metabase.search.scoring :as search.scoring]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn library-root-collections
@@ -27,7 +28,8 @@
authoritative."
[]
(t2/reducible-select [:model/Table :id :is_published :data_layer :data_authority]
{:where [:and
{:from [(warehouse-schema-overlay/table-query)]
:where [:and
[:= :active true]
[:or [:= :is_published true]
[:= :data_authority ^:allow-raw-sql [:inline "authoritative"]]]]}))

View File

@@ -166,11 +166,9 @@
;; so cards that reference them can still be exported and imported correctly
(and analytics-cards (contains? by-model "Card"))
(update "Card" (fn [ids] (vec (remove analytics-cards ids)))))
;; FieldUserSettings has a non-standard PK (field_id, not id) — use the right column.
pk-col (fn [model] (if (= model "FieldUserSettings") :field_id :id))
extract-by-ids (fn [[model ids]]
(serdes/extract-all model (merge opts {:collection-set coll-set
:filter-column (pk-col model)
:filter-column (serdes/primary-key model)
:filter-ids (vec ids)})))
extract-all (fn [model]
(serdes/extract-all model (assoc opts :collection-set coll-set)))]

View File

@@ -5,19 +5,18 @@
"Schema model types"
["Database"
"Field"
"FieldUserSettings"
"Measure"
"Segment"
"Table"
"TableUserSettings"
"Channel"])
(def data-model-in-collection
"Data model types that can be found in collections (via published tables).
These are extracted by ID when discovered via descendants, even if no-data-model is set.
Includes both Field (full serdes) and FieldUserSettings (user-edits-only / git sync)."
"Data model types that can be found in collections (via published tables), extracted by ID when discovered via
descendants even if no-data-model is set."
["Table"
"TableUserSettings"
"Field"
"FieldUserSettings"
"Segment"])
(def content
@@ -53,6 +52,7 @@
["DashboardCard"
"DashboardTab"
"Dimension"
"FieldUserSettings"
"ParameterCard"
"DashboardCardSeries"
"MetabotPrompt"

View File

@@ -4,12 +4,13 @@
(:require
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn table-source-rows
"The ID, name, schema, and Database ID of the Tables with `table-ids`, as source info."
[table-ids :- [:or [:set ::lib.schema.id/table] [:sequential ::lib.schema.id/table]]]
(t2/select [:model/Table [:id :table-id] [:name :table-name] :schema [:db_id :db-id]] :id [:in table-ids]))
(t2/select [:model/Table [:id :table-id] [:name :table-name] :schema [:db_id :db-id]] :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn database-engine
"The engine of the Database with `database-id`."
@@ -19,4 +20,4 @@
(mu/defn active-fields-for-table
"The active Fields of the Table with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select :model/Field :table_id table-id :active true))
(t2/select :model/Field :table_id table-id :active true {:from [(warehouse-schema-overlay/field-query)]}))

View File

@@ -6,17 +6,18 @@
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn table-database-ids
"The set of Database IDs of the Tables with `table-ids`."
[table-ids :- [:sequential ::lib.schema.id/table]]
(t2/select-fn-set :db_id [:model/Table :db_id] :id [:in table-ids]))
(t2/select-fn-set :db_id [:model/Table :db_id] :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(mu/defn table-database-id
"The Database ID of the raw table row with `table-id`."
[table-id :- ::lib.schema.id/table]
(t2/select-one-fn :db_id (t2/table-name :model/Table) :id table-id))
(t2/select-one-fn :db_id (t2/table-name :model/Table) :id table-id {:from [(warehouse-schema-overlay/table-query {:user-settings? false})]}))
(mu/defn database
"The Database with `database-id`, or nil."
@@ -66,4 +67,5 @@
;; we are only interested in top-level objects, so filter out nested fields (parent or path)
:parent_id nil
:nfc_path nil
{:order-by [[:database_position :asc]]}))
{:from [(warehouse-schema-overlay/field-query)]
:order-by [[:database_position :asc]]}))

View File

@@ -4,6 +4,7 @@
(:require
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(mu/defn attached-dwh-database-id
@@ -14,14 +15,15 @@
(mu/defn non-upload-tables-for-database
"The active Tables of the Database with `database-id` that were not uploaded."
[database-id :- ::lib.schema.id/database]
(t2/select :model/Table :db_id database-id :active true :is_upload false))
(t2/select :model/Table :db_id database-id :active true :is_upload false {:from [(warehouse-schema-overlay/table-query)]}))
(mu/defn upload-tables
"The active uploaded Tables, ordered by name."
[]
(t2/select :model/Table :active true :is_upload true {:order-by [[:name :asc]]}))
(t2/select :model/Table :active true :is_upload true {:from [(warehouse-schema-overlay/table-query)]
:order-by [[:name :asc]]}))
(mu/defn table
"The Table with `table-id`, or nil."
[table-id :- ::lib.schema.id/table]
(t2/select-one :model/Table table-id))
(t2/select-one :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query)]}))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.action-v2.db-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.action-v2.db :as action-v2.db]
[metabase.test :as mt]))
(deftest pk-fields-for-table-honors-user-set-semantic-type-test
(testing "pk-fields-for-table honors a user-set semantic_type that differs from the sync value"
(mt/with-temp [:model/Table {table-id :id} {}
:model/Field {field-id :id} {:table_id table-id, :semantic_type nil}]
(testing "sanity check: not a PK by sync"
(is (= [] (map :id (action-v2.db/pk-fields-for-table table-id)))))
(mt/with-temp [:model/FieldUserSettings _ {:field_id field-id
:semantic_type :type/PK
:semantic_type_set true}]
(is (= [field-id] (map :id (action-v2.db/pk-fields-for-table table-id))))))))
(deftest category-list-field-ids-by-name-honors-user-settings-test
(testing "category-list-field-ids-by-name honors user-set has_field_values/semantic_type"
(mt/with-temp [:model/Table {table-id :id} {}
:model/Field {field-id :id} {:table_id table-id, :name "STATUS"
:has_field_values nil, :semantic_type nil}]
(testing "sanity check: not a category-list field by sync"
(is (= [] (action-v2.db/category-list-field-ids-by-name table-id ["status"]))))
(mt/with-temp [:model/FieldUserSettings _ {:field_id field-id
:has_field_values :list
:semantic_type :type/Category
:semantic_type_set true}]
(is (= [{:id field-id, :lower_name "status"}]
(action-v2.db/category-list-field-ids-by-name table-id ["status"])))))))

View File

@@ -11,12 +11,29 @@
[metabase.permissions.models.permissions-group :as perms-group]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
(defn- user-table
"The Table with `table-id` as users see it. A bare `t2/select :model/Table` shows what sync wrote."
[table-id]
(t2/select-one :model/Table :id table-id {:from [(warehouse-schema-overlay/table-query)]}))
(defn- user-tables
"The Tables with `table-ids` as users see it, ordered by `order-by`."
[table-ids order-by]
(t2/select :model/Table :id [:in table-ids] {:from [(warehouse-schema-overlay/table-query)]
:order-by [order-by]}))
(defn- user-table-fn
"The value of `column` on the Table with `table-id` as users see it."
[column table-id]
(get (user-table table-id) column))
(deftest table-metadata-survives-deleted-transform-test
(testing "a table whose creating transform has since been deleted (metabase#69904)"
(mt/with-premium-features #{:transforms-basic :hosting}
@@ -24,7 +41,7 @@
:model/Table {table-id :id} {:transform_id (:id transform)}]
(t2/delete! :model/Transform (:id transform))
(testing "metabase_table.transform_id -> transform.id is ON DELETE SET NULL, not left dangling"
(is (nil? (t2/select-one-fn :transform_id :model/Table :id table-id))))
(is (nil? (user-table-fn :transform_id table-id))))
(testing "GET /api/table/:id does not crash"
(is (=? {:id table-id} (mt/user-http-request :crowberto :get 200 (str "table/" table-id)))))
(testing "GET /api/table (list, which hydrates :transform when transforms are enabled) does not crash"
@@ -52,7 +69,7 @@
{:display_name "Venues"
:collection_id collection-id
:is_published true}]
(t2/select :model/Table :id [:in [(mt/id :users) (mt/id :venues)]] {:order-by [:display_name]}))))
(user-tables [(mt/id :users) (mt/id :venues)] :display_name))))
(testing "audit log entries are created for publish"
(is (=? {:topic :table-publish, :model "Table", :model_id (mt/id :users)}
(mt/latest-audit-log-entry "table-publish" (mt/id :users))))
@@ -67,7 +84,7 @@
(is (=? {:display_name "Venues"
:collection_id nil
:is_published false}
(t2/select-one :model/Table (mt/id :venues))))
(user-table (mt/id :venues))))
(testing "audit log entry is created for unpublish"
(is (=? {:topic :table-unpublish, :model "Table", :model_id (mt/id :venues)}
(mt/latest-audit-log-entry "table-unpublish" (mt/id :venues)))))))))
@@ -75,7 +92,7 @@
(is (=? {:display_name "Users"
:collection_id nil
:is_published false}
(t2/select-one :model/Table (mt/id :users))))))
(user-table (mt/id :users))))))
(testing "returns 404 when no library-data collection exists"
(is (= "Not found."
(mt/user-http-request :crowberto :post 404 "ee/data-studio/table/publish-tables"
@@ -99,7 +116,7 @@
:collection_id subcollection-id})
(is (=? {:collection_id subcollection-id
:is_published true}
(t2/select-one :model/Table (mt/id :users)))))))))
(user-table (mt/id :users)))))))))
(deftest publishing-info-test
(mt/with-premium-features #{:library :audit-app}
@@ -151,13 +168,17 @@
(without-library
(mt/with-temp [:model/Collection {collection-id :id} {:type collection/library-data-collection-type}]
;; a bumped updated_at proves the write went through the Toucan pipeline (:hook/timestamped?),
;; the same pipeline that fires :hook/search-index; a raw UPDATE would leave it unchanged
(let [baseline (t/offset-date-time 2020)
;; raw update, precisely to keep the timestamped hook from overwriting the backdate
backdate! #(t2/query {:update (t2/table-name :model/Table)
:set {:updated_at baseline}
:where [:= :id (mt/id :venues)]})
updated-at #(t2/select-one-fn :updated_at :model/Table (mt/id :venues))]
backdate! #(do (t2/query {:update (t2/table-name :model/TableUserSettings)
:set {:updated_at baseline}
:where [:= :table_id (mt/id :venues)]})
(t2/query {:update (t2/table-name :model/Table)
:set {:updated_at baseline}
:where [:= :id (mt/id :venues)]}))
updated-at #(or (t2/select-one-fn :updated_at :model/TableUserSettings
:table_id (mt/id :venues))
(user-table-fn :updated_at (mt/id :venues)))]
(backdate!)
(mt/user-http-request :crowberto :post 200 "ee/data-studio/table/publish-tables"
{:table_ids [(mt/id :venues)]
@@ -245,7 +266,7 @@
(mt/user-http-request :crowberto :post 200 "ee/data-studio/table/publish-tables"
{:table_ids [orders-id]
:collection_id collection-id})
(are [table-id] (true? (t2/select-one-fn :is_published :model/Table table-id))
(are [table-id] (true? (user-table-fn :is_published table-id))
orders-id products-id))))))
(deftest publish-tables-does-not-move-already-published-upstream-test
@@ -275,10 +296,10 @@
:collection_id coll-y})
(testing "the selected table is published into the target collection"
(is (=? {:is_published true :collection_id coll-y}
(t2/select-one [:model/Table :is_published :collection_id] orders-id))))
(user-table orders-id))))
(testing "the already-published upstream table stays in its original collection"
(is (=? {:is_published true :collection_id coll-x}
(t2/select-one [:model/Table :is_published :collection_id] products-id))))))))
(user-table products-id))))))))
(deftest publish-tables-recursive-upstream-test
(mt/with-premium-features #{:library}
@@ -316,7 +337,7 @@
(mt/user-http-request :crowberto :post 200 "ee/data-studio/table/publish-tables"
{:table_ids [items-id]
:collection_id collection-id})
(are [table-id] (true? (t2/select-one-fn :is_published :model/Table table-id))
(are [table-id] (true? (user-table-fn :is_published table-id))
items-id orders-id purchasers-id))))))
(deftest unpublish-tables-with-downstream-dependents-test
@@ -345,7 +366,7 @@
(testing "unpublishing products also unpublishes orders (downstream dependent)"
(mt/user-http-request :crowberto :post 204 "ee/data-studio/table/unpublish-tables"
{:table_ids [products-id]})
(are [table-id] (false? (t2/select-one-fn :is_published :model/Table table-id))
(are [table-id] (false? (user-table-fn :is_published table-id))
products-id orders-id))))))
(deftest unpublish-tables-recursive-downstream-test
@@ -386,7 +407,7 @@
(testing "unpublishing customers also unpublishes orders and order_items (recursive downstream)"
(mt/user-http-request :crowberto :post 204 "ee/data-studio/table/unpublish-tables"
{:table_ids [purchasers-id]})
(are [table-id] (false? (t2/select-one-fn :is_published :model/Table table-id))
(are [table-id] (false? (user-table-fn :is_published table-id))
purchasers-id orders-id items-id))))))
(defn- with-fk-linked-published-tables
@@ -418,9 +439,9 @@
(fn [{:keys [coll-a x-id y-id]}]
(t2/delete! :model/Collection :id coll-a)
(testing "Table X (in the deleted collection) is unpublished"
(is (=? {:is_published false :collection_id nil} (t2/select-one :model/Table x-id))))
(is (=? {:is_published false :collection_id nil} (user-table x-id))))
(testing "Table Y (FK-linked, in another collection) is also unpublished"
(is (=? {:is_published false :collection_id nil} (t2/select-one :model/Table y-id)))))))))
(is (=? {:is_published false :collection_id nil} (user-table y-id)))))))))
(deftest unpublish-fk-linked-tables-on-collection-archive-test
(mt/with-premium-features #{:library}
@@ -431,9 +452,9 @@
(collection/archive-or-unarchive-collection!
(t2/select-one :model/Collection :id coll-a) {:archived true}))
(testing "Table X (in the archived collection) is unpublished"
(is (=? {:is_published false :collection_id nil} (t2/select-one :model/Table x-id))))
(is (=? {:is_published false :collection_id nil} (user-table x-id))))
(testing "Table Y (FK-linked, in another collection) is also unpublished"
(is (=? {:is_published false :collection_id nil} (t2/select-one :model/Table y-id)))))))))
(is (=? {:is_published false :collection_id nil} (user-table y-id)))))))))
;;; ------------------------------------------ Publish/Unpublish requires write and query perms ------------------------------------------

View File

@@ -11,6 +11,7 @@
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[metabase.warehouse-schema.models.table-user-settings :as schema.table-user-settings]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
@@ -39,7 +40,9 @@
(mt/with-temp [:model/User {user-id :id} {}
:model/Collection {collection-id :id} {:type "library-data"}]
(try
(t2/update! :model/Table (mt/id :venues) {:is_published true :collection_id collection-id})
(schema.table-user-settings/upsert-user-settings (t2/select-one :model/Table (mt/id :venues))
{:is_published true
:collection_id collection-id})
(let [all-users (perms/all-users-group)]
(perms/set-database-permission! all-users (mt/id) :perms/view-data db-view-data)
(perms/set-database-permission! all-users (mt/id) :perms/create-queries :no)
@@ -50,7 +53,8 @@
(perms/set-table-permission! all-users (mt/id :venues) :perms/create-queries :no))
(f user-id collection-id)
(finally
(t2/update! :model/Table (mt/id :venues) {:is_published false :collection_id nil})))))))
(schema.table-user-settings/unset-user-settings! (t2/select-one :model/Table (mt/id :venues))
[:is_published :collection_id])))))))
(deftest published-table-test
(testing "Published tables grant query access via collection permissions only with library enabled\n"

View File

@@ -199,7 +199,7 @@
(deftest content-metadata-matches-row-hash-test
(testing "a full export writes a content_hash matching per-row row->content-hash for every identity flavor —
including the extract-query overrides (Collection, NativeQuerySnippet), hybrids (Measure), and path
models (Field) that have no entity_id (GHY-3933)"
models (TableUserSettings, keyed by table_id) that have no entity_id (GHY-3933)"
(with-library-synced
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Database db {:name "DB"}
@@ -209,14 +209,14 @@
:model/Card card {:name "Card" :dataset_query (mt/mbql-query venues) :collection_id (:id rs)}
:model/NativeQuerySnippet snip {:name "Snip" :content "SELECT 1" :collection_id (:id snips)}
:model/Table table {:name "T" :schema "PUBLIC" :db_id (:id db) :is_published true :collection_id (:id data)}
:model/Field field {:name "F" :table_id (:id table) :base_type :type/Integer}
:model/FieldUserSettings _ {:field_id (:id field) :description "curated"}
:model/Field _field {:name "F" :table_id (:id table) :base_type :type/Integer}
:model/TableUserSettings _ {:table_id (:id table) :display_name "curated"}
:model/Measure measure {:name "M" :table_id (:id table)}]
(mt/with-model-cleanup [:model/RemoteSyncTask]
(let [task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "export" :initiated_by (mt/user->id :rasta)})
rows (mapv (fn [[mt id]] {:model_type mt :model_id id})
[["Card" (:id card)] ["Collection" (:id rs)] ["NativeQuerySnippet" (:id snip)]
["FieldUserSettings" (:id field)] ["Measure" (:id measure)]])]
["TableUserSettings" (:id table)] ["Measure" (:id measure)]])]
(t2/delete! :model/RemoteSyncObject)
(doseq [row rows]
(t2/insert! :model/RemoteSyncObject (merge row {:model_name "x" :status "synced"

View File

@@ -1117,10 +1117,6 @@
;;; Field Event Tests
;; Field RSO tracking has been replaced by FieldUserSettings RSO tracking.
;; field-update events now track FieldUserSettings (user-curated metadata) rather than raw Field rows.
;; See the FieldUserSettings RSO Tracking section below for the current behavior tests.
(deftest field-update-event-no-entry-for-unpublished-table-test
(testing "field-update event creates no RSOs for an unpublished table"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
@@ -1149,12 +1145,11 @@
{:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
;;; ----------------------------------------- FieldUserSettings RSO Tracking ------------------------------------------
;; In git-sync mode, field updates should track FieldUserSettings RSOs only — not Field RSOs.
;; Field YAMLs are replaced by FieldUserSettings YAMLs (user-curated metadata only).
;;; ----------------------------------------- TableUserSettings RSO Tracking from Field Events --------------------------
(deftest field-update-creates-only-fus-rso-when-fus-exists-test
(testing "field-update on an eligible field with a FUS row creates ONLY a FieldUserSettings RSO — no Field RSO"
(deftest field-update-creates-table-user-settings-rso-when-fus-exists-test
(testing "field-update on an eligible field with a FUS row creates a TableUserSettings RSO for its Table — no
Field or FieldUserSettings RSO"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
@@ -1162,9 +1157,9 @@
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)) "exactly one RSO — no Field RSO alongside the FUS RSO")
(is (=? {:model_type "FieldUserSettings"
:model_id (:id field)
(is (= 1 (count entries)) "exactly one RSO — the Table's TableUserSettings, no Field/FieldUserSettings RSO")
(is (=? {:model_type "TableUserSettings"
:model_id (:id table)
:status "update"
:model_table_id (:id table)}
(first entries)))))))
@@ -1272,14 +1267,15 @@
(assert-removal-survived coll-id card)))))
(deftest field-update-no-rso-when-no-fus-row-test
(testing "field-update on an eligible field with NO FUS row creates no RSOs at all"
(testing "field-update on an eligible field with NO FUS row, and no TableUserSettings row either, creates no
RSOs at all"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))
"no RSOs created — field has no user-curated metadata to track"))))
"no RSOs created — neither the Field nor the Table has user-curated metadata to track"))))
(deftest field-update-no-rso-for-unpublished-table-test
(testing "field-update creates no RSOs when the table is not published"
@@ -1301,23 +1297,24 @@
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
(deftest field-update-fus-rso-marks-removed-when-table-leaves-sync-scope-test
(testing "existing FieldUserSettings RSO is marked removed when table leaves sync scope — no Field RSO created"
(deftest field-update-table-user-settings-rso-marks-removed-when-table-leaves-sync-scope-test
(testing "existing TableUserSettings RSO is marked removed when the field's table leaves sync scope — no
Field or FieldUserSettings RSO created"
(mt/with-temp [:model/Collection synced {:is_remote_synced true :name "Synced" :type "library-data"}
:model/Collection normal {:name "Normal" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id synced)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject {:model_type "FieldUserSettings"
:model_id (:id field)
:model_name "f"
(t2/insert! :model/RemoteSyncObject {:model_type "TableUserSettings"
:model_id (:id table)
:model_name "T"
:model_table_id (:id table)
:status "synced"
:status_changed_at (t/offset-date-time)})
(t2/update! :model/Table (:id table) {:collection_id (:id normal)})
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)) "still only one RSO — no Field RSO added")
(is (= 1 (count entries)) "still only one RSO — no Field/FieldUserSettings RSO added")
(is (= "removed"
(:status (t2/select-one :model/RemoteSyncObject :model_type "FieldUserSettings" :model_id (:id field)))))))))
(:status (t2/select-one :model/RemoteSyncObject :model_type "TableUserSettings" :model_id (:id table)))))))))

View File

@@ -2168,3 +2168,47 @@ serdes/meta:
:base-snapshot nil)]
(is (= :conflict (:status result)))
(is (str/includes? (:message result) "rewritten"))))))
;;; --------------------------------- Table/Field user-settings inline round trip ---------------------------------
(deftest table-and-field-user-settings-round-trip-test
(testing "a full export writes one file per published Table with user edits -- the TableUserSettings entity at
the Table's own path, inlining its edited Fields' settings under :fields -- and a later import
restores it, pruning field edits the file no longer lists"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Database {db-id :id} {:name "test-db"}
:model/Collection {coll-id :id} {:name "RS" :is_remote_synced true :location "/"}
:model/Table {table-id :id} {:name "Test Table" :db_id db-id
:is_published true :collection_id coll-id}
:model/Field {f1-id :id} {:name "F1" :table_id table-id :base_type :type/Text}
:model/Field {f2-id :id} {:name "F2" :table_id table-id :base_type :type/Text}]
(t2/insert! :model/TableUserSettings {:table_id table-id :display_name "Renamed"})
(t2/insert! :model/FieldUserSettings {:field_id f1-id :description "curated"})
(let [export-task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "export" :initiated_by (mt/user->id :rasta)})
mock-source (test-helpers/create-mock-source)
export-result (impl/export! (source.p/snapshot mock-source) export-task-id "Test export" :force? true)]
(is (= :success (:status export-result)))
(remote-sync.task/complete-sync-task! export-task-id)
(let [files (get @(:files-atom mock-source) "main")
table-files (into {} (filter (fn [[p _]] (str/includes? p "test_table"))) files)]
(is (= 1 (count table-files))
(str "expected exactly one Table-related file, got " (keys table-files)))
(let [[path content] (first table-files)]
(is (str/ends-with? path "test_table.yaml")
"the file lives at the Table's own path, not a ___tableusersettings/___fieldusersettings file")
(is (not (str/includes? path "___tableusersettings")))
(is (not (str/includes? path "___fieldusersettings")))
(is (str/includes? content "Renamed"))
(is (str/includes? content "fields:"))
(is (str/includes? content "curated"))))
(t2/insert! :model/FieldUserSettings {:field_id f2-id :description "stale"})
(t2/update! :model/TableUserSettings :table_id table-id {:display_name "Local Edit"})
(let [import-task-id (t2/insert-returning-pk! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
import-result (impl/import! (source.p/snapshot mock-source) import-task-id :force? true)]
(is (= :success (:status import-result)))
(is (= "Renamed" (:display_name (t2/select-one :model/TableUserSettings :table_id table-id)))
"T's user display_name is restored from the import")
(is (= "curated" (:description (t2/select-one :model/FieldUserSettings :field_id f1-id)))
"F1's edited description is present")
(is (not (t2/exists? :model/FieldUserSettings :field_id f2-id))
"F2's stale local row, absent from the imported file's :fields, is gone")))))))

View File

@@ -623,3 +623,40 @@
"the warning covers only the unsynced subset (the potential data loss)")
(is (set/subset? flagged would-delete)
"everything the warning flags would indeed be removed")))))
(deftest git-sync-exports-only-user-settings-test
(testing "git sync stores what users changed about a Table and its Fields, never the Table or Fields themselves --
those belong to sync, which runs against each instance's own warehouse -- as one TableUserSettings
entity per Table inlining its Fields' edits, never separate FieldUserSettings entities"
(mt/with-premium-features #{:library}
(mt/with-temp [:model/Collection {coll-id :id} {:is_remote_synced true :name "RS" :type "library-data"}
:model/Database {db-id :id} {:name "DB"}
:model/Table {table-id :id} {:name "T" :db_id db-id
:is_published true :collection_id coll-id}
:model/Field {f1 :id} {:name "F1" :table_id table-id}
:model/Field {f2 :id} {:name "F2" :table_id table-id}]
(t2/insert! :model/FieldUserSettings {:field_id f2 :description "curated" :description_set true})
(t2/insert! :model/TableUserSettings {:table_id table-id :display_name "Renamed"})
(let [exportable (spec/exportable-entities)]
(is (contains? (set (get exportable "TableUserSettings")) table-id)
"the Table's own edit, plus its edited Field's, are carried by one TableUserSettings entity")
(is (nil? (get exportable "FieldUserSettings"))
"FieldUserSettings is never exported as its own entity")
(is (not (contains? (set (get exportable "Table")) table-id)))
(is (empty? (filter #{f1 f2} (get exportable "Field")))))))))
(deftest git-sync-exports-table-user-settings-for-field-only-edit-test
(testing "a Table with no TableUserSettings row of its own, but an edited Field, is still exportable -- the
TableUserSettings entity is synthesized to carry the Field's edit"
(mt/with-premium-features #{:library}
(mt/with-temp [:model/Collection {coll-id :id} {:is_remote_synced true :name "RS" :type "library-data"}
:model/Database {db-id :id} {:name "DB"}
:model/Table {table-id :id} {:name "T" :db_id db-id
:is_published true :collection_id coll-id}
:model/Field {f1 :id} {:name "F1" :table_id table-id}]
(t2/insert! :model/FieldUserSettings {:field_id f1 :description "curated" :description_set true})
(let [exportable (spec/exportable-entities)]
(is (not (t2/exists? :model/TableUserSettings :table_id table-id))
"the Table has no settings row of its own")
(is (contains? (set (get exportable "TableUserSettings")) table-id)
"the Table is still exportable, synthesized from its Field's edit"))))))

View File

@@ -266,7 +266,7 @@
[:stages 0 :source-card]))))))))))))))
(deftest copy-model-metadata-overrides!-test
(testing "copies user-edited metadata from model result_metadata to Field and FieldUserSettings"
(testing "copies user-edited metadata from model result_metadata to FieldUserSettings only"
(mt/with-temp [:model/Table {table-id :id} {:name "transform_output"
:db_id (mt/id)
:active true}
@@ -299,14 +299,14 @@
:base_type "type/DateTimeWithLocalTZ"}])}
:where [:= :id card-id]})
(#'replacement.runner/copy-model-metadata-overrides! card-id table-id)
(testing "Field records are updated with overrides from model metadata"
(testing "Field records are untouched; they stay sync-owned"
(let [field-1 (t2/select-one :model/Field :id field-1-id)
field-2 (t2/select-one :model/Field :id field-2-id)]
(is (= "Order Total" (:display_name field-1)))
(is (= "The total amount" (:description field-1)))
(is (= :type/Currency (:semantic_type field-1)))
(is (= "Order Date" (:display_name field-2)))
(is (= :type/CreationTimestamp (:semantic_type field-2)))))
(is (= "Total" (:display_name field-1)))
(is (nil? (:description field-1)))
(is (nil? (:semantic_type field-1)))
(is (= "Created At" (:display_name field-2)))
(is (nil? (:semantic_type field-2)))))
(testing "FieldUserSettings are created so overrides survive sync"
(let [fus-1 (t2/select-one :model/FieldUserSettings :field_id field-1-id)
fus-2 (t2/select-one :model/FieldUserSettings :field_id field-2-id)]
@@ -344,8 +344,8 @@
:where [:= :id card-id]})
(#'replacement.runner/copy-model-metadata-overrides! card-id table-id)
(let [field (t2/select-one :model/Field :id field-id)]
(is (= "Product ID" (:display_name field)))
(is (= "The product identifier" (:description field))))
(is (= "Products ID" (:display_name field)))
(is (nil? (:description field))))
(let [fus (t2/select-one :model/FieldUserSettings :field_id field-id)]
(is (some? fus))
(is (= "Product ID" (:display_name fus)))

View File

@@ -24,6 +24,7 @@
:model/Field
:model/FieldValues
:model/FieldUserSettings
:model/TableUserSettings
;; OsiAiContext is identified by the entity it describes (entity_type + the entity's portable ref); its
;; serdes path nests under that entity, so it has no generated entity_id.
:model/OsiAiContext
@@ -164,7 +165,7 @@
(testing "All exported models should get entity id except those with other unique property (like name)"
(is (= (set (concat serdes.models/exported-models
;; those are inline models which still have entity_id
["DashboardCard" "DashboardTab" "Dimension" "MetabotPrompt"]))
["DashboardCard" "DashboardTab" "Dimension" "MetabotPrompt" "FieldUserSettings"]))
(set (->> (concat entity-id-models
entities-external-name)
(map name))))))

View File

@@ -1153,63 +1153,79 @@
:data_sensitivity :PII}
ser))
(is (not (contains? ser :field_id))
":field_id is dropped; its implied by the path")
(testing "depend only on the Database; the parent Field is synthesized on import if missing"
(is (= #{[{:model "Database" :id "My Database"}]}
(set (serdes/deserialization-dependencies ser)))))))
":field_id is dropped; its implied by the path")))
(testing "data_sensitivity on the Field itself"
(is (= :PII (:data_sensitivity (ts/extract-one "Field" field-id)))
"a labeled field exports the keyword as-is")
(is (not (contains? (ts/extract-one "Field" plain-id) :data_sensitivity))
"an unlabeled field exports no key, so nil never reaches the YAML"))
(testing "extract-metabase behavior"
(let [models (->> {} (extract/extract) (map (comp :model last :serdes/meta)))]
(is (= 1
(t2/count :model/FieldUserSettings)
(count (filter #{"FieldUserSettings"} models)))))))))
(let [entities (into [] (extract/extract {}))
models (map (comp :model last :serdes/meta) entities)]
(is (empty? (filter #{"FieldUserSettings"} models))
"written inside its Table's settings, never on its own")
(is (= 1 (count (filter #{"TableUserSettings"} models))))
(is (= 1 (count (:fields (first (filter #(= "TableUserSettings" (-> % :serdes/meta last :model)) entities)))))))))))
(deftest table-descendants-user-edits-only-test
(deftest table-descendants-user-settings-test
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Database {db-id :id} {:name "DB"}
:model/Table {table-id :id} {:name "T" :db_id db-id}
:model/Field {f1-id :id} {:name "F1" :table_id table-id}
:model/Field {f2-id :id} {:name "F2" :table_id table-id}
:model/Field {f3-id :id} {:name "F3" :table_id table-id}]
(testing "without user-edits-only: all fields returned as Field descendants"
(let [desc (serdes/descendants "Table" table-id {})]
(is (= #{["Field" f1-id] ["Field" f2-id] ["Field" f3-id]}
(set (keys desc))))))
(testing "with user-edits-only and no FieldUserSettings rows: no field descendants"
(let [desc (serdes/descendants "Table" table-id {:user-edits-only true})]
(is (empty? (filter (fn [[model _]] (#{"Field" "FieldUserSettings"} model)) (keys desc))))))
(testing "with user-edits-only and one FieldUserSettings row: only that field appears as FieldUserSettings"
(testing "every Field is a descendant, and none has user settings to speak of yet"
(is (= #{["Field" f1-id] ["Field" f2-id] ["Field" f3-id]}
(set (keys (serdes/descendants "Table" table-id {}))))))
(testing "a Field with user settings makes the Table's settings a descendant, since they carry the Field's"
(t2/insert! :model/FieldUserSettings {:field_id f2-id :description "edited"})
(let [desc (serdes/descendants "Table" table-id {:user-edits-only true})]
(is (= #{["FieldUserSettings" f2-id]}
(set (filter (fn [[model _]] (#{"Field" "FieldUserSettings"} model)) (keys desc))))))
(is (= #{["Field" f1-id] ["Field" f2-id] ["Field" f3-id] ["TableUserSettings" table-id]}
(set (keys (serdes/descendants "Table" table-id {})))))
(t2/delete! :model/FieldUserSettings :field_id f2-id))
(testing "with user-edits-only and a FieldUserSettings row holding only data_sensitivity: that field appears as FieldUserSettings"
(t2/insert! :model/FieldUserSettings {:field_id f1-id :data_sensitivity :PII})
(let [desc (serdes/descendants "Table" table-id {:user-edits-only true})]
(is (= #{["FieldUserSettings" f1-id]}
(set (filter (fn [[model _]] (#{"Field" "FieldUserSettings"} model)) (keys desc))))))
(t2/delete! :model/FieldUserSettings :field_id f1-id))
(testing "with user-edits-only and all fields edited: all appear as FieldUserSettings, not Field"
(t2/insert! :model/FieldUserSettings {:field_id f1-id})
(t2/insert! :model/FieldUserSettings {:field_id f2-id})
(t2/insert! :model/FieldUserSettings {:field_id f3-id})
(let [desc (serdes/descendants "Table" table-id {:user-edits-only true})]
(is (= #{["FieldUserSettings" f1-id] ["FieldUserSettings" f2-id] ["FieldUserSettings" f3-id]}
(set (filter (fn [[model _]] (#{"Field" "FieldUserSettings"} model)) (keys desc)))))))
(testing "Field and FieldUserSettings are leaf nodes in the descendants graph"
;; Table's descendants method is the only source of field-level entries; if Field ever
;; grows its own descendants (e.g. Field -> FieldUserSettings), traversal would visit
;; every field and full exports would change shape. Cement the leaf-ness here.
(doseq [opts [{} {:user-edits-only true}]]
(is (empty? (serdes/descendants "Field" f1-id opts)))
(is (empty? (serdes/descendants "FieldUserSettings" f1-id opts))))))))
(testing "a Table with user settings appears as TableUserSettings"
(t2/insert! :model/TableUserSettings {:table_id table-id :display_name "Renamed"})
(is (contains? (set (keys (serdes/descendants "Table" table-id {})))
["TableUserSettings" table-id])))
(testing "Field is a leaf node in the descendants graph"
(is (empty? (serdes/descendants "Field" f1-id {})))))))
(deftest user-edits-only-extract-test
(deftest inline-user-settings-extract-test
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Database {db-id :id} {:name "DB"}
:model/Table {table-id :id} {:name "T" :db_id db-id}
:model/Table {other-id :id} {:name "Unedited T" :db_id db-id}
:model/Field {f1-id :id} {:name "F1" :table_id table-id}
:model/Field {_f2-id :id} {:name "F2" :table_id table-id}
:model/FieldUserSettings _ {:field_id f1-id :description "edited"}]
(testing "a Table with only a Field edit synthesizes a TableUserSettings entity with that Field inlined"
(let [entities (into [] (serdes/extract-all "TableUserSettings"
{:inline-user-settings true
:filter-column :table_id
:filter-ids [table-id]}))]
(is (= 1 (count entities)))
(let [entity (first entities)]
(is (=? [{:model "Database" :id "DB"}
{:model "Table" :id "T"}
{:model "TableUserSettings" :id "1"}]
(:serdes/meta entity))
"synthesized for a Table with no settings row of its own")
(is (nil? (:display_name entity)))
(is (= 1 (count (:fields entity))))
(is (=? {:description "edited"
:serdes/meta [{:model "Database" :id "DB"}
{:model "Table" :id "T"}
{:model "Field" :id "F1"}
{:model "FieldUserSettings" :id "1"}]}
(first (:fields entity)))
"the edited Field is inlined, with its own serdes/meta")
(is (not (contains? (first (:fields entity)) :field_id))))))
(testing "a Table with no edits at all yields nothing"
(is (empty? (into [] (serdes/extract-all "TableUserSettings"
{:inline-user-settings true
:filter-column :table_id
:filter-ids [other-id]}))))))))
(deftest collection-export-includes-user-settings-test
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Database {db-id :id} {:name "DB"}
:model/Collection {coll-id :id} {:name "Library" :type "library-data"}
@@ -1218,24 +1234,19 @@
:collection_id coll-id}
:model/Field _ {:name "F1" :table_id table-id}
:model/Field {f2-id :id} {:name "F2" :table_id table-id}
:model/FieldUserSettings _ {:field_id f2-id :description "curated"}]
(testing "targeting the collection with user-edits-only: produces FieldUserSettings, not Field"
(let [entities (into [] (extract/extract {:targets [["Collection" coll-id]]
:user-edits-only true
:no-data-model true}))
by-model (group-by (comp :model last :serdes/meta) entities)]
(is (contains? by-model "FieldUserSettings") "should include FieldUserSettings")
(is (not (contains? by-model "Field")) "should not include Field")
(is (= #{"F2"}
(set (map #(-> % :serdes/meta (nth 2) :id) (by-model "FieldUserSettings"))))
"only the edited field's FieldUserSettings (identified by field name in path)")
(is (some #(= "T" (:name %)) (by-model "Table")) "the table itself is included")))
(testing "without user-edits-only: produces Field, not FieldUserSettings"
:model/FieldUserSettings _ {:field_id f2-id :description "curated"}
:model/TableUserSettings _ {:table_id table-id :display_name "Renamed"}]
(testing "targeting the collection exports the Table and its Fields, each beside its user settings"
(let [entities (into [] (extract/extract {:targets [["Collection" coll-id]]
:no-data-model true}))
by-model (group-by (comp :model last :serdes/meta) entities)]
(is (contains? by-model "Field") "should include Field")
(is (not (contains? by-model "FieldUserSettings")) "should not include FieldUserSettings"))))))
(is (some #(= "T" (:name %)) (by-model "Table")))
(is (= #{"F1" "F2"}
(set (map :name (by-model "Field")))))
(is (= 1 (count (by-model "TableUserSettings"))))
(is (= #{"F2"}
(set (map #(-> % :serdes/meta (nth 2) :id) (:fields (first (by-model "TableUserSettings"))))))
"only the field the user edited has settings to export, inside the Table's"))))))
(deftest cards-test
(mt/with-empty-h2-app-db!

View File

@@ -142,6 +142,47 @@
(is (= "Basic Collection" (:name (first colls))))
(is (= eid1 (:entity_id (first colls)))))))))))
(deftest inline-user-settings-round-trip-test
(testing "git sync's inline-user-settings mode replaces a Table's FieldUserSettings wholesale on import"
(let [serialized (atom nil)]
(ts/with-dbs [source-db dest-db]
(testing "extracting inline from the source"
(ts/with-db source-db
(let [db (ts/create! :model/Database :name "my-db")
table (ts/create! :model/Table :name "customers" :db_id (:id db))
f1 (ts/create! :model/Field :name "age" :table_id (:id table))
f2 (ts/create! :model/Field :name "email" :table_id (:id table))]
(t2/insert! :model/TableUserSettings {:table_id (:id table) :display_name "Renamed"})
(t2/insert! :model/FieldUserSettings {:field_id (:id f1) :description "edited"})
(reset! serialized
(into [(ts/extract-one "Database" (:id db))
(ts/extract-one "Table" (:id table))
(ts/extract-one "Field" (:id f1))
(ts/extract-one "Field" (:id f2))]
(serdes/extract-all "TableUserSettings"
{:inline-user-settings true
:filter-column :table_id
:filter-ids [(:id table)]}))))))
(testing "the extracted TableUserSettings inlines F1's edit and no others"
(let [tus (first (by-model @serialized "TableUserSettings"))]
(is (= "Renamed" (:display_name tus)))
(is (= 1 (count (:fields tus))))
(is (= "edited" (:description (first (:fields tus)))))))
(testing "loading into a destination where the same Database/Table/Fields exist, and F2 has a stale row"
(ts/with-db dest-db
(let [db (ts/create! :model/Database :name "my-db")
table (ts/create! :model/Table :name "customers" :db_id (:id db))
f1 (ts/create! :model/Field :name "age" :table_id (:id table))
f2 (ts/create! :model/Field :name "email" :table_id (:id table))]
(t2/insert! :model/FieldUserSettings {:field_id (:id f2) :description "stale"})
(serdes.load/load-metabase! (ingestion-in-memory @serialized))
(testing "the Table's settings arrive"
(is (= "Renamed" (t2/select-one-fn :display_name :model/TableUserSettings :table_id (:id table)))))
(testing "F1's settings arrive"
(is (= "edited" (t2/select-one-fn :description :model/FieldUserSettings :field_id (:id f1)))))
(testing "F2's stale row is deleted -- replace semantics"
(is (nil? (t2/select-one :model/FieldUserSettings :field_id (:id f2))))))))))))
(deftest escape-continue-on-error-roundtrip-test
(testing "archive exported past escape analysis imports under continue-on-error without crashing (#74622)"
(let [serialized (atom nil)
@@ -2132,9 +2173,9 @@
(reset! serialized (into [] (serdes.extract/extract {})))
(testing "the labeled Field and its FieldUserSettings both export the label"
(is (= :PII (:data_sensitivity (field-ser @serialized "CONTACT_EMAIL"))))
(is (= :PII (:data_sensitivity (u/seek #(and (in-db? %)
(-> % :serdes/meta last :model (= "FieldUserSettings")))
@serialized)))))
(is (= :PII (-> (u/seek #(and (in-db? %) (-> % :serdes/meta last :model (= "TableUserSettings")))
@serialized)
:fields first :data_sensitivity))))
(testing "the unlabeled Field exports no data_sensitivity key"
(is (not (contains? (field-ser @serialized "CONTACT_ROW") :data_sensitivity))))))
(ts/with-db dest-db

View File

@@ -90,7 +90,8 @@
(testing "Nested models should declare `parent-ref`\n"
(doseq [[_nested transform] (filter #(::serdes/nested (second %)) spec')
:let [{:keys [model backward-fk]} transform
inner-spec (serdes/make-spec (name model) nil)]]
inner-spec (serdes/make-spec (name model) nil)]
:when (not (:delete-children! (:opts transform)))]
(testing (format "%s has %s declared as `parent-ref`" model backward-fk)
(is (= (serdes/parent-ref)
(get-in inner-spec [:transform backward-fk]))))))

View File

@@ -158,12 +158,25 @@
(storage/store! (into [] (extract/extract {})) (storage.files/file-writer dump-dir))
(let [fields-dir (io/file dump-dir "databases" "my_company_data" "tables" "customers" "fields")
read-yaml (fn [file-name] (yaml/from-file (io/file fields-dir file-name)))]
(testing "the label is written as the bare enum string on both files"
(testing "the label is written as the bare enum string on the Field and inside the Table's settings"
(is (= "PII" (:data_sensitivity (read-yaml "email.yaml"))))
(is (= "PII" (:data_sensitivity (read-yaml "email___fieldusersettings.yaml")))))
(is (= "PII" (-> (yaml/from-file (io/file fields-dir ".." "customers___tableusersettings.yaml"))
:fields first :data_sensitivity))))
(testing "an unlabeled field's file has no data_sensitivity line"
(is (not (contains? (read-yaml "id.yaml") :data_sensitivity)))))))))
(deftest inline-user-settings-storage-path-test
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Database db {:name "My Company Data"}
:model/Table table {:name "Customers" :db_id (:id db)}
:model/Field email {:name "Email" :table_id (:id table)}
:model/FieldUserSettings _ {:field_id (:id email) :description "edited"}]
(let [[tus] (into [] (serdes/extract-all "TableUserSettings" {:filter-column :table_id :filter-ids [(:id table)]}))]
(is (= 1 (count (:fields tus))))
(testing "git sync writes the entity at the Table's own path"
(is (= "Customers" (-> (serdes/storage-path tus {:inline-user-settings true}) peek :label))))
(testing "the CLI export writes it beside the Table's file"
(is (= "Customers___tableusersettings" (-> (serdes/storage-path tus {}) peek :label))))))))
(deftest entity-counts-report-test
(ts/with-random-dump-dir [dump-dir "serdesv2-"]
(mt/with-empty-h2-app-db!

View File

@@ -67,8 +67,7 @@ const updateDataWrapper = (
try {
const editedFields = filterUntouchedFields(fields, props.entity);
if (!isEmptyObject(editedFields)) {
const newEntity = { ...props.entity, ...editedFields };
await fn(newEntity);
await fn({ id: props.entity.id, ...editedFields });
}
} finally {
resetForm(props);
@@ -127,7 +126,7 @@ export const rUpdateFields = (
),
}))
.filter(({ formField }) => !isEmptyObject(formField))
.map(({ field, formField }) => ({ ...field, ...formField }));
.map(({ field, formField }) => ({ id: field.id, ...formField }));
await Promise.all(updatedFields.map(props.updateField));
} finally {

View File

@@ -1,5 +1,3 @@
import _ from "underscore";
import { databaseApi, fieldApi, segmentApi, tableApi } from "metabase/api";
import { runRtkEndpoint } from "metabase/api/utils/run-rtk-endpoint";
import {
@@ -8,19 +6,21 @@ import {
tableFetched,
} from "metabase/metadata-store";
import type { Dispatch } from "metabase/redux/store";
import type { Database, Field, Segment, Table } from "metabase-types/api";
/**
* The edit forms in this module submit the entity they rendered, which came
* from `state.entities` and carries the nested records the mirror stitched onto
* it. The API rejects those, so each one is dropped before the request.
*/
import type {
Database,
Field,
Table,
UpdateDatabaseRequest,
UpdateFieldRequest,
UpdateSegmentRequest,
UpdateTableRequest,
} from "metabase-types/api";
export const updateDatabase =
(database: Database) =>
(request: UpdateDatabaseRequest) =>
async (dispatch: Dispatch): Promise<unknown> => {
const updated: Database = await runRtkEndpoint(
_.omit(database, "tables", "tables_lookup"),
request,
dispatch,
databaseApi.endpoints.updateDatabase,
);
@@ -29,16 +29,10 @@ export const updateDatabase =
};
export const updateTable =
(table: Table) =>
(request: UpdateTableRequest) =>
async (dispatch: Dispatch): Promise<unknown> => {
const updated: Table = await runRtkEndpoint(
_.omit(
table,
"fields",
"fields_lookup",
"aggregation_operators",
"segments",
),
request,
dispatch,
tableApi.endpoints.updateTable,
);
@@ -47,10 +41,10 @@ export const updateTable =
};
export const updateField =
(field: Field) =>
(request: UpdateFieldRequest) =>
async (dispatch: Dispatch): Promise<unknown> => {
const updated: Field = await runRtkEndpoint(
_.omit(field, "filter_operators_lookup"),
request,
dispatch,
fieldApi.endpoints.updateField,
);
@@ -59,6 +53,6 @@ export const updateField =
};
export const updateSegment =
(segment: Segment) =>
(request: UpdateSegmentRequest) =>
(dispatch: Dispatch): Promise<unknown> =>
runRtkEndpoint(segment, dispatch, segmentApi.endpoints.updateSegment);
runRtkEndpoint(request, dispatch, segmentApi.endpoints.updateSegment);

View File

@@ -204,6 +204,7 @@
core.version
audit-app.view-log
warehouses.schema
warehouse-schema-overlay
xrays})
(defn- affected-modules

View File

@@ -11,6 +11,9 @@
'[dev.modules-config-test
metabase.core.modules-test])
(def ^:private honey-sql-check-namespaces
'[metabase.core.table-or-field-raw-usage-test])
(def ^:private ratchet-test-namespaces
;; These test the tooling itself. `./bin/mage kondo-ratchets` checks the source tree.
'[metabase.core.kondo-ratchet-test
@@ -20,7 +23,10 @@
'[metabase.core.namespace-uniqueness-test])
(def ^:private backend-check-namespaces
(vec (concat module-check-namespaces ratchet-test-namespaces source-layout-check-namespaces)))
(vec (concat module-check-namespaces
ratchet-test-namespaces
source-layout-check-namespaces
honey-sql-check-namespaces)))
(def ^:private default-suites
"Suites the bare `project-tests` command runs, in order."

View File

@@ -47,7 +47,8 @@
metabase.core.modules-test
metabase.core.kondo-ratchet-test
metabase.core.kondo-ratchet-check-test
metabase.core.namespace-uniqueness-test])]]
metabase.core.namespace-uniqueness-test
metabase.core.table-or-field-raw-usage-test])]]
calls)))
(testing "the failed suite is reported"
(is (= ["migrations"] failed))

View File

@@ -4,6 +4,7 @@
(:require
[metabase.lib.schema.id :as lib.schema.id]
[metabase.util.malli :as mu]
[metabase.warehouse-schema-overlay.core :as warehouse-schema-overlay]
[metabase.warehouses.schema :as warehouses.schema]
;; the driver persists dataset-filter and project-id migrations of its Database details back to the app DB
^{:clj-kondo/ignore [:discouraged-namespace]}
@@ -37,4 +38,5 @@
"Whether the Table with `table-id` has an active database-partitioned Field named `field-name`."
[table-id :- ::lib.schema.id/table
field-name :- :string]
(t2/exists? :model/Field :table_id table-id :name field-name :database_partitioned true :active true))
(t2/exists? :model/Field :table_id table-id :name field-name :database_partitioned true :active true
{:from [(warehouse-schema-overlay/field-query {:user-settings? false})]}))

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/Quantity
dimensions: []
position: 17
custom_position: 0
database_position: 17
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/Quantity
dimensions: []
position: 18
custom_position: 0
database_position: 18
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/Quantity
dimensions: []
position: 7
custom_position: 0
database_position: 7
has_field_values: auto-list
serdes/meta:

View File

@@ -15,7 +15,6 @@ fk_target_field_id:
- conversation_id
dimensions: []
position: 9
custom_position: 0
database_position: 9
has_field_values: auto-list
serdes/meta:

View File

@@ -13,7 +13,6 @@ effective_type: type/DateTimeWithLocalTZ
semantic_type: type/CreationTimestamp
dimensions: []
position: 1
custom_position: 0
database_position: 1
serdes/meta:
- id: Internal Metabase Database

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 17
custom_position: 0
database_position: 17
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ base_type: type/Text
effective_type: type/Text
dimensions: []
position: 18
custom_position: 0
database_position: 18
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 13
custom_position: 0
database_position: 13
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ base_type: type/Text
effective_type: type/Text
dimensions: []
position: 14
custom_position: 0
database_position: 14
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 4
custom_position: 0
database_position: 4
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 5
custom_position: 0
database_position: 5
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/Quantity
dimensions: []
position: 6
custom_position: 0
database_position: 6
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 16
custom_position: 0
database_position: 16
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 20
custom_position: 0
database_position: 20
has_field_values: auto-list
serdes/meta:

View File

@@ -13,7 +13,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 2
custom_position: 0
database_position: 2
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 3
custom_position: 0
database_position: 3
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/FK
dimensions: []
position: 15
custom_position: 0
database_position: 15
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Integer
semantic_type: type/Quantity
dimensions: []
position: 8
custom_position: 0
database_position: 8
has_field_values: auto-list
serdes/meta:

View File

@@ -13,7 +13,6 @@ effective_type: type/BigInteger
semantic_type: type/PK
dimensions: []
position: 0
custom_position: 0
database_position: 0
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ base_type: type/Text
effective_type: type/Text
dimensions: []
position: 19
custom_position: 0
database_position: 19
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Name
dimensions: []
position: 12
custom_position: 0
database_position: 12
has_field_values: auto-list
serdes/meta:

View File

@@ -15,7 +15,6 @@ fk_target_field_id:
- user_id
dimensions: []
position: 10
custom_position: 0
database_position: 10
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 11
custom_position: 0
database_position: 11
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 7
custom_position: 0
database_position: 7
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Boolean
semantic_type: type/Category
dimensions: []
position: 11
custom_position: 0
database_position: 11
has_field_values: auto-list
serdes/meta:

View File

@@ -8,7 +8,6 @@ base_type: type/Integer
effective_type: type/Integer
dimensions: []
position: 5
custom_position: 0
database_position: 5
has_field_values: auto-list
serdes/meta:

View File

@@ -17,7 +17,6 @@ dimensions:
serdes/meta:
- {id: nzL7XHp1yPu74r7BjcoJX, label: card_qualified_id, model: Dimension}
position: 6
custom_position: 0
database_position: 6
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/DateTimeWithLocalTZ
semantic_type: type/CreationTimestamp
dimensions: []
position: 2
custom_position: 0
database_position: 2
serdes/meta:
- {id: Internal Metabase Database, model: Database}

View File

@@ -17,7 +17,6 @@ dimensions:
serdes/meta:
- {id: AVykiWpDBL1s7oVrW86ru, label: creator_id, model: Dimension}
position: 4
custom_position: 0
database_position: 4
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ base_type: type/Text
effective_type: type/Text
dimensions: []
position: 12
custom_position: 0
database_position: 12
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Integer
semantic_type: type/PK
dimensions: []
position: 0
custom_position: 0
database_position: 0
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/PK
dimensions: []
position: 1
custom_position: 0
database_position: 1
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 14
custom_position: 0
database_position: 14
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 12
custom_position: 0
database_position: 12
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 13
custom_position: 0
database_position: 13
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 9
custom_position: 0
database_position: 9
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Integer
semantic_type: type/Category
dimensions: []
position: 10
custom_position: 0
database_position: 10
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 8
custom_position: 0
database_position: 8
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/DateTimeWithLocalTZ
semantic_type: type/UpdatedTimestamp
dimensions: []
position: 3
custom_position: 0
database_position: 3
serdes/meta:
- {id: Internal Metabase Database, model: Database}

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 8
custom_position: 0
database_position: 8
has_field_values: auto-list
serdes/meta:

View File

@@ -8,7 +8,6 @@ base_type: type/Text
effective_type: type/Text
dimensions: []
position: 3
custom_position: 0
database_position: 3
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Integer
semantic_type: type/Category
dimensions: []
position: 6
custom_position: 0
database_position: 6
has_field_values: auto-list
serdes/meta:

View File

@@ -17,7 +17,6 @@ dimensions:
serdes/meta:
- {id: 3AntG1-Ylk_c9KthNvyxV, label: entity_qualified_id, model: Dimension}
position: 7
custom_position: 0
database_position: 7
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 5
custom_position: 0
database_position: 5
has_field_values: auto-list
serdes/meta:

View File

@@ -9,7 +9,6 @@ effective_type: type/Integer
semantic_type: type/PK
dimensions: []
position: 0
custom_position: 0
database_position: 0
has_field_values: auto-list
serdes/meta:

View File

@@ -10,7 +10,6 @@ effective_type: type/Text
semantic_type: type/Category
dimensions: []
position: 5
custom_position: 0
database_position: 5
has_field_values: auto-list
serdes/meta:

View File

@@ -11,7 +11,6 @@ semantic_type: type/FK
fk_target_field_id: [Internal Metabase Database, public, v_content, id]
dimensions: []
position: 6
custom_position: 0
database_position: 6
serdes/meta:
- {id: Internal Metabase Database, model: Database}

Some files were not shown because too many files have changed in this diff Show More