Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Polyankin
55563125d4 Rename apply-table-sql-remapping-middleware -> apply-table-sql-remapping
The registered execution middleware keeps the clean name; the
defenterprise hook it wraps becomes apply-table-sql-remapping*.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:59:03 -04:00
Alexander Polyankin
682bf2ace7 Add EE table-remapping module with binding-scoped QP middleware
Revives the QP-middleware portion of the table remapping removed in
#78617 as a standalone EE module. Remappings are supplied exactly via
(table-remapping/with-table-remapping [{:from-schema ... :from-table ...
:to-schema ... :to-table ...}] <body>) instead of an app-DB table.

Phase 1 (preprocess) overrides :schema/:name on matching table metadata
via a transforming metadata provider; Phase 2 (execute) authoritatively
rewrites compiled/native SQL via sql-tools/replace-names and fails
closed on unparseable SQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:54:37 -04:00
8 changed files with 394 additions and 2 deletions

View File

@@ -3673,6 +3673,16 @@
util}
:model-imports #{:model/AuthIdentity :model/Session :model/User}}
enterprise/table-remapping
{:team "Graphy"
:api #{}
:uses #{driver
lib
premium-features
query-processor
sql-tools
util}}
enterprise/tenants
{:team "Admin Webapp"
:api #{metabase-enterprise.tenants.api metabase-enterprise.tenants.init}

View File

@@ -9,10 +9,10 @@
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:def-fn 1
:deprecated-namespace 95
:deprecated-namespace 97
:deprecated-var 81
:discouraged-java-method 3
:discouraged-namespace 80
:discouraged-namespace 81
:discouraged-var 100
:equals-true 1
:main-without-gen-class 1

View File

@@ -0,0 +1,35 @@
(ns metabase-enterprise.table-remapping.core
"API namespace for the `table-remapping` module.
Table remapping lets a caller run a query with certain table references redirected to
other tables, without touching the query itself. Wrap the QP call in
[[with-table-remapping]]:
(table-remapping/with-table-remapping [{:from-schema \"public\" :from-table \"orders\"
:to-schema \"ws_123\" :to-table \"orders_copy\"}]
(qp/process-query query))
The remappings are applied by QP middleware (see
[[metabase-enterprise.table-remapping.middleware]]): MBQL queries get their table
metadata overridden before compilation, and compiled/native SQL is rewritten via SQL
parsing as the authoritative final step. Remapping fails closed — if the SQL cannot be
parsed, the query throws instead of running un-remapped."
(:require
[metabase-enterprise.table-remapping.middleware :as table-remapping.middleware]
[metabase.util.malli :as mu]))
(mu/defn do-with-table-remapping
"Impl for [[with-table-remapping]]."
[remappings :- ::table-remapping.middleware/remappings
thunk :- fn?]
(binding [table-remapping.middleware/*remappings* remappings]
(thunk)))
(defmacro with-table-remapping
"Run `body` with QP table remapping enabled. `remappings` is a sequence of maps, each with
`:from-schema`, `:from-table`, `:to-schema`, and `:to-table` (schemas may be nil/absent
for schema-less tables). Every query executed within `body` (on this thread) has
references to each from-side table redirected to the corresponding to-side table."
{:style/indent 1}
[remappings & body]
`(do-with-table-remapping ~remappings (fn [] ~@body)))

View File

@@ -0,0 +1,213 @@
(ns metabase-enterprise.table-remapping.middleware
"QP middleware for table remapping.
The job: when a query references a table (e.g. `public.orders`), redirect it to another
table (e.g. `ws_alice.orders_copy`). Remappings are supplied programmatically by wrapping
the QP call in [[metabase-enterprise.table-remapping.core/with-table-remapping]], which
binds [[*remappings*]] for the duration of the body. When nothing is bound, both
middleware phases are pass-throughs.
The work is split across **two phases** because there is no single point in the QP
pipeline where both structured query data AND fully-resolved SQL are available
simultaneously:
## Phase 1 — [[apply-table-remapping]] (preprocess; MBQL metadata mutation)
Walks the cached metadata provider, finds each `:metadata/table` whose `(:schema, :name)`
matches a remapping's from-side, and overrides `:schema`/`:name` with the to-side.
Downstream HoneySQL compilation reads the overridden values and emits remapped
identifiers directly. Native SQL is intentionally untouched here — at this stage it may
still contain unresolved template tags that make it un-parseable.
## Phase 2 — [[apply-table-sql-remapping*]] (execute; authoritative SQL rewrite)
Runs after every preprocess step — snippets expanded, card references resolved,
parameters substituted, MBQL compiled to SQL — so the query is reduced to one canonical
SQL string. Parses it via `sql-tools/replace-names` (SQLGlot), rewrites every matching
table reference, re-emits. This covers both MBQL-origin and native-origin queries and is
the only place native SQL is rewritten.
## Failure contract: fail closed
On parse failure Phase 2 throws `ex-info` with `:type qp.error-type/qp`. The query never
reaches the warehouse. There is no fallback to the original SQL — the caller asked for
remapping, so silently running the un-remapped query would be a correctness (and
potentially isolation) bug."
(:require
[metabase.driver :as driver]
[metabase.lib.metadata :as lib.metadata]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.query-processor.error-type :as qp.error-type]
^{:clj-kondo/ignore [:discouraged-namespace :deprecated-namespace]}
[metabase.query-processor.store :as qp.store]
[metabase.sql-tools.core :as sql-tools]
[metabase.util.malli.registry :as mr]))
(set! *warn-on-reflection* true)
(mr/def ::remapping
"A single table remapping. `:from-schema` / `:to-schema` are nil (or absent) for tables
that live outside any schema (e.g. on schema-less drivers like MySQL)."
[:map
[:from-schema {:optional true} [:maybe :string]]
[:from-table :string]
[:to-schema {:optional true} [:maybe :string]]
[:to-table :string]])
(mr/def ::remappings
[:sequential ::remapping])
(def ^:dynamic *remappings*
"The active remappings for the current QP call, a sequence of [[::remapping]] maps, or nil
when remapping is disabled. Bind via
[[metabase-enterprise.table-remapping.core/with-table-remapping]]."
nil)
;;; --------------------------------------- Phase 1: Preprocessing (MBQL only) ------------------------------------
(defn- table-remapper
"Build a function that remaps table metadata according to `remappings`. The returned fn
overrides `:schema` and `:name` on any table whose `(:schema, :name)` matches a
remapping's from-side."
[remappings]
(let [index (into {}
(map (fn [{:keys [from-schema from-table to-schema to-table]}]
[[from-schema from-table] {:schema to-schema, :name to-table}]))
remappings)]
(fn [table-metadata]
(merge table-metadata (get index [(:schema table-metadata) (:name table-metadata)])))))
(defn- table-transform
"Wrap a per-table function `f` into a transform suitable for
[[lib.metadata/transforming-metadata-provider]]. Only applies `f` when the metadata
spec's `:lib/type` is `:metadata/table`; all other types pass through."
[f]
(fn [{metadata-type :lib/type} results]
(if (= metadata-type :metadata/table)
(into [] (map f) results)
results)))
(defn- install-remapped-metadata-provider!
"Replace the QP store's metadata provider with one that overrides `:schema` and `:name` on
table metadata for each remapping. Downstream HoneySQL compilation will read the
overridden values."
[remappings]
(let [remapping-mp (lib.metadata/transforming-metadata-provider
(table-transform (table-remapper remappings))
(qp.store/metadata-provider))]
(binding [qp.store/*DANGER-allow-replacing-metadata-provider* true]
(qp.store/with-metadata-provider remapping-mp))))
(defenterprise apply-table-remapping
"**Phase 1 — preprocess.** Override cached table metadata so every QP middleware between
here and execute sees remapped identifiers, not the original ones.
Phase 1 is not the boundary that guarantees remapping — Phase 2 is. Phase 1 exists for
*pipeline coherence*: middleware like sandboxing, permission checks, and cache-key
generation may read `:schema`/`:name` off table metadata and make decisions on them.
With Phase 1, the whole pipeline sees the same identifiers Phase 2 will emit.
`:feature :none` — remapping is opt-in via [[*remappings*]]; there is nothing to gate on
a token. The binding itself is the gate."
:feature :none
[query]
(if (empty? *remappings*)
query
(do
(install-remapped-metadata-provider! *remappings*)
query)))
;;; ----------------------------- Phase 2: Post-Compilation SQL Rewrite (authoritative) ----------------------------
(defn- remapping->table-replacement
"Project a [[::remapping]] into a `[from to]` entry for `sql-tools/replace-names`'
`:tables` map. Nil schemas are omitted so SQLGlot treats the slot as a wildcard rather
than matching a literal empty value."
[{:keys [from-schema from-table to-schema to-table]}]
[(cond-> {:table from-table}
from-schema (assoc :schema from-schema))
(cond-> {:table to-table}
to-schema (assoc :schema to-schema))])
(defn- rewrite-sql
"Parse `sql` and rewrite every table reference matching a remapping's from-side to its
to-side. Returns the rewritten SQL. Fail-closed: throws `ex-info` with
`:type qp.error-type/qp` on parse failure."
[driver sql remappings]
(try
(let [replacements {:tables (into {} (map remapping->table-replacement) remappings)}]
(sql-tools/replace-names driver sql replacements {:allow-unused? true}))
(catch Exception e
(throw (ex-info "Table remapping failed: cannot parse SQL"
{:type qp.error-type/qp
:sql sql
:driver driver}
e)))))
(defn- rewrite-stages
"Recursively walk an MBQL 5 query's `:stages` and rewrite the `:native` SQL on every
native stage, descending into each join's own `:stages` as well.
The stage's `:native` is the source of truth for native-origin SQL:
[[metabase.query-processor.execute/run]] calls `lib/->legacy-MBQL` immediately before
driver dispatch, and that conversion rebuilds the legacy top-level `:native` from
`(get-in query [:stages -1 :native])`. So patching legacy `:native` directly is futile —
it gets overwritten. Patch the stage and the rewrite propagates to the legacy form
naturally."
[driver stages remappings]
(mapv (fn [stage]
(cond-> stage
(and (= :mbql.stage/native (:lib/type stage))
(string? (:native stage)))
(update :native #(rewrite-sql driver % remappings))
(seq (:joins stage))
(update :joins
(fn [joins]
(mapv (fn [join]
(cond-> join
(seq (:stages join))
(update :stages #(rewrite-stages driver % remappings))))
joins)))))
stages))
(defenterprise apply-table-sql-remapping*
"**Phase 2 — execute (post-compilation).** The authoritative SQL rewriter.
Runs in the execution middleware chain after every preprocess step, when the query is
reduced to one canonical SQL string with no unresolved template syntax. Parses that SQL
via `sql-tools/replace-names` (SQLGlot), rewrites every from-side table reference to its
to-side counterpart, re-emits.
Patches, in order:
- stage `:native` — the source of truth; flows through `lib/->legacy-MBQL` to the
legacy `:native` that JDBC reads.
- `:qp/compiled` — a compile-time snapshot, not re-derived at execute time; read by
`add-native-form-to-result-metadata` for the user-facing `:native_form` and by the
rename branch in `qp.execute/run` for MBQL-origin queries.
- `:qp/compiled-inline` — same, for the inlined-parameters form.
**Failure contract: fail closed.** On parse failure throws `ex-info` with
`:type qp.error-type/qp`; the query does not execute.
`:feature :none` — see [[apply-table-remapping]]."
:feature :none
[qp]
(fn [query rff]
(let [remappings *remappings*]
(if (empty? remappings)
(qp query rff)
(let [driver driver/*driver*
rewrite (fn [compiled-map]
(update compiled-map :query #(rewrite-sql driver % remappings)))
query (cond-> query
(:lib/type query)
(update :stages #(rewrite-stages driver % remappings))
(:qp/compiled query)
(update :qp/compiled rewrite)
(:qp/compiled-inline query)
(update :qp/compiled-inline rewrite))]
(qp query rff))))))

View File

@@ -0,0 +1,103 @@
(ns metabase-enterprise.table-remapping.core-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.table-remapping.core :as table-remapping]
[metabase-enterprise.table-remapping.middleware :as table-remapping.middleware]
[metabase.driver :as driver]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.query-processor :as qp]
^{:clj-kondo/ignore [:deprecated-namespace]}
[metabase.query-processor.store :as qp.store]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]))
(use-fixtures :once (fixtures/initialize :db))
;;; ------------------------------------------------ End-to-end -----------------------------------------------------
(deftest with-table-remapping-mbql-query-test
(testing "an MBQL query against a remapped table reads from the remapping target"
(let [mp (mt/metadata-provider)
query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues)))
(lib/aggregate (lib/count)))]
(testing "sanity check: without remapping the query hits venues"
(is (= [[100]]
(mt/rows (qp/process-query query)))))
(testing "with remapping the same query hits checkins"
(table-remapping/with-table-remapping [{:from-schema "PUBLIC" :from-table "VENUES"
:to-schema "PUBLIC" :to-table "CHECKINS"}]
(is (= [[1000]]
(mt/rows (qp/process-query query)))))))))
(deftest with-table-remapping-native-query-test
(testing "a native query referencing a table that doesn't exist runs against the remapping target"
(let [mp (mt/metadata-provider)
query (lib/native-query mp "SELECT count(*) FROM FAKE_VENUES")]
(table-remapping/with-table-remapping [{:from-table "FAKE_VENUES"
:to-schema "PUBLIC" :to-table "VENUES"}]
(is (= [[100]]
(mt/rows (qp/process-query query))))))))
;;; --------------------------------------- Phase 1: metadata provider swap -----------------------------------------
(deftest phase-1-metadata-swap-test
(testing "Phase 1 overrides :schema/:name on matching table metadata in the QP store"
(qp.store/with-metadata-provider (mt/id)
(let [venues (lib.metadata/table (qp.store/metadata-provider) (mt/id :venues))
query (-> (lib/query (qp.store/metadata-provider) venues)
(assoc :database (mt/id)))]
(table-remapping/with-table-remapping [{:from-schema (:schema venues) :from-table (:name venues)
:to-schema "WS" :to-table "VENUES_COPY"}]
(table-remapping.middleware/apply-table-remapping query)
(let [table-after (lib.metadata/table (qp.store/metadata-provider) (mt/id :venues))]
(is (= "WS" (:schema table-after)))
(is (= "VENUES_COPY" (:name table-after)))))))))
(deftest phase-1-no-remapping-passthrough-test
(testing "Phase 1 leaves the metadata provider alone when no remappings are bound"
(qp.store/with-metadata-provider (mt/id)
(let [venues (lib.metadata/table (qp.store/metadata-provider) (mt/id :venues))
query (-> (lib/query (qp.store/metadata-provider) venues)
(assoc :database (mt/id)))]
(is (= query (table-remapping.middleware/apply-table-remapping query)))
(is (= venues (lib.metadata/table (qp.store/metadata-provider) (mt/id :venues))))))))
;;; --------------------------------------- Phase 2: compiled SQL rewrite -------------------------------------------
(deftest phase-2-rewrites-compiled-sql-test
(testing "Phase 2 rewrites table references in compiled SQL and preserves params"
(binding [driver/*driver* :h2]
(table-remapping/with-table-remapping [{:from-schema "PUBLIC" :from-table "VENUES"
:to-schema "WS" :to-table "VENUES_COPY"}]
(let [captured (atom nil)
mock-qp (fn [query _rff] (reset! captured query) :ok)
wrapped (table-remapping.middleware/apply-table-sql-remapping* mock-qp)]
(wrapped {:database 1
:qp/compiled {:query "SELECT * FROM PUBLIC.VENUES WHERE id = ?"
:params [42]}}
identity)
(is (re-find #"(?i)ws\W+venues_copy" (get-in @captured [:qp/compiled :query])))
(is (= [42] (get-in @captured [:qp/compiled :params]))))))))
(deftest phase-2-no-remapping-passthrough-test
(testing "Phase 2 passes the query through untouched when no remappings are bound"
(binding [driver/*driver* :h2]
(let [original-sql "SELECT * FROM PUBLIC.VENUES"
captured (atom nil)
mock-qp (fn [query _rff] (reset! captured query) :ok)
wrapped (table-remapping.middleware/apply-table-sql-remapping* mock-qp)]
(wrapped {:database 1, :qp/compiled {:query original-sql}} identity)
(is (= original-sql (get-in @captured [:qp/compiled :query])))))))
(deftest phase-2-fails-closed-on-unparseable-sql-test
(testing "Phase 2 throws instead of running the query un-remapped when the SQL cannot be parsed"
(binding [driver/*driver* :h2]
(table-remapping/with-table-remapping [{:from-schema "PUBLIC" :from-table "VENUES"
:to-schema "WS" :to-table "VENUES_COPY"}]
(let [mock-qp (fn [_query _rff] :ok)
wrapped (table-remapping.middleware/apply-table-sql-remapping* mock-qp)]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Table remapping failed: cannot parse SQL"
(wrapped {:database 1, :qp/compiled {:query "SELECT ((("}} identity))))))))

View File

@@ -40,6 +40,7 @@
;; to execute INSIDE the `binding` blocks established by the EE postprocessing middlewares below. See
;; [[metabase.query-processor.middleware.process-userland-query/capture-execution-context-middleware]] for why.
[#'qp.process-userland-query/capture-execution-context-middleware
#'qp.middleware.enterprise/apply-table-sql-remapping
#'qp.middleware.enterprise/swap-destination-db-middleware
#'qp.middleware.enterprise/apply-impersonation-postprocessing-middleware
#'update-used-cards/update-used-cards!

View File

@@ -77,8 +77,37 @@
[query]
query)
(defenterprise apply-table-remapping
"**Table remapping, Phase 1 — preprocess.** Overrides cached MBQL table metadata so downstream middleware and
HoneySQL compilation see remapped identifiers. No-op unless remappings are bound via
`metabase-enterprise.table-remapping.core/with-table-remapping`. Native SQL is intentionally untouched here — Phase 2
([[apply-table-sql-remapping]]) is the authoritative rewriter. See the EE impl namespace docstring for the design
rationale."
metabase-enterprise.table-remapping.middleware
[query]
query)
;;;; Execution middleware
(defenterprise apply-table-sql-remapping*
"**Table remapping, Phase 2 — execute (post-compilation).** The authoritative SQL rewriter. Runs after all
preprocess work — snippets, card refs, params, and MBQL compilation are complete — so the query is reduced to one
canonical SQL string. Parses it, rewrites every from-side table ref to its to-side counterpart, re-emits. No-op
unless remappings are bound via `metabase-enterprise.table-remapping.core/with-table-remapping`.
**Fails closed:** on parse failure throws `ex-info` with `:type qp.error-type/qp`. There is no fallback to running
the original SQL un-remapped."
metabase-enterprise.table-remapping.middleware
[qp]
qp)
(defn apply-table-sql-remapping
"Helper middleware wrapper for [[apply-table-sql-remapping*]] to make sure we do [[defenterprise]] dispatch correctly
on each QP run rather than just once when we combine all of the QP middleware."
[qp]
(fn [query rff]
((apply-table-sql-remapping* qp) query rff)))
;;; (f qp) => qp
(defenterprise swap-destination-db

View File

@@ -108,6 +108,7 @@
#'optimize-temporal-clauses/optimize-temporal-clauses
#'limit/add-default-limit
#'qp.middleware.enterprise/apply-download-limit
#'qp.middleware.enterprise/apply-table-remapping
#'check-features/check-features])
(def ^:private ^Long slow-middleware-warning-threshold-ms