Compare commits

...

1 Commits

Author SHA1 Message Date
dragonsahead
9a9e2e6529 Add refingerprint endpoints for tables and fields
Add REST API endpoints `POST /api/field/:id/refingerprint` and
`POST /api/table/:id/refingerprint` that manually trigger an update of
the fingerprints for a Field or all Fields in a Table. The work is
submitted via the quick-task executor and runs with admin perms, matching
the behavior of normal sync.

Backend:
- `metabase.sync.analyze.fingerprint/refingerprint-table!` delegates to
  `fingerprint-table!` with `*refingerprint?*` bound, reusing the existing
  OOM-safe, traced, error-handling logic.
- `metabase.sync.sync/refingerprint-table!` wraps it with a connection
  check (mirroring `refingerprint-field!`).
- New defendpoint routes with response schema `[:map {:closed true}
  [:status [:= :success]]]`.
- API tests verify the endpoints return success, actually invoke the sync
  functions (via with-redefs + atom), and 404 on missing ids.

Frontend:
- RTK Query mutations `refingerprintField`/`refingerprintTable`.
- `RefingerprintFieldButton`/`RefingerprintTableButton` (modeled on the
  existing Rescan buttons), wired into FieldValuesModal and
  SyncOptionsModal, with unit tests.

Fixes #64110 and #18074.
2026-06-25 17:40:38 -03:00
20 changed files with 438 additions and 0 deletions

View File

@@ -161,6 +161,14 @@ export const fieldApi = Api.injectEndpoints({
tag("parameter-values"),
]),
}),
refingerprintField: builder.mutation<void, FieldId>({
query: (id) => ({
method: "POST",
url: `/api/field/${id}/refingerprint`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("field", id)]),
}),
}),
});
@@ -176,4 +184,5 @@ export const {
useDeleteFieldDimensionMutation,
useRescanFieldValuesMutation,
useDiscardFieldValuesMutation,
useRefingerprintFieldMutation,
} = fieldApi;

View File

@@ -165,6 +165,14 @@ export const tableApi = Api.injectEndpoints({
invalidatesTags: (_, error) =>
invalidateTags(error, [tag("field-values"), tag("parameter-values")]),
}),
refingerprintTable: builder.mutation<void, TableId>({
query: (id) => ({
method: "POST",
url: `/api/table/${id}/refingerprint`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("table", id), listTag("field")]),
}),
appendTableCsv: builder.mutation<
void,
{ tableId: TableId; formData: FormData }
@@ -275,6 +283,7 @@ export const {
useRescanTableFieldValuesMutation,
useSyncTableSchemaMutation,
useDiscardTableFieldValuesMutation,
useRefingerprintTableMutation,
useAppendTableCsvMutation,
useReplaceTableCsvMutation,

View File

@@ -6,6 +6,7 @@ import { Modal, Stack, Text, rem } from "metabase/ui";
import type { FieldId } from "metabase-types/api";
import { DiscardFieldValuesButton } from "../DiscardFieldValuesButton";
import { RefingerprintFieldButton } from "../RefingerprintFieldButton";
import { RescanFieldButton } from "../RescanFieldButton";
type FieldValuesModalProps = {
@@ -36,6 +37,8 @@ export const FieldValuesModal = ({
<RescanFieldButton fieldId={fieldId} />
<RefingerprintFieldButton fieldId={fieldId} />
<DiscardFieldValuesButton fieldId={fieldId} />
</Stack>
</Modal>

View File

@@ -0,0 +1,33 @@
import { t } from "ttag";
import { useRefingerprintFieldMutation } from "metabase/api";
import { useTemporaryState } from "metabase/common/hooks";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Button } from "metabase/ui";
import type { FieldId } from "metabase-types/api";
interface Props {
fieldId: FieldId;
}
export const RefingerprintFieldButton = ({ fieldId }: Props) => {
const [refingerprintField] = useRefingerprintFieldMutation();
const [started, setStarted] = useTemporaryState(false, 2000);
const { sendErrorToast } = useMetadataToasts();
const handleClick = async () => {
const { error } = await refingerprintField(fieldId);
if (error) {
sendErrorToast(t`Failed to start fingerprinting`);
} else {
setStarted(true);
}
};
return (
<Button variant="default" onClick={handleClick}>
{started ? t`Fingerprinting triggered!` : t`Refingerprint field`}
</Button>
);
};

View File

@@ -0,0 +1,128 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupFieldEndpoints } from "__support__/server-mocks";
import {
act,
renderWithProviders,
screen,
waitFor,
within,
} from "__support__/ui";
import { UndoListing } from "metabase/common/components/UndoListing";
import { getRawTableFieldId } from "metabase/metadata/utils/field";
import { createMockField } from "metabase-types/api/mocks";
import { RefingerprintFieldButton } from "./RefingerprintFieldButton";
function setup() {
const field = createMockField();
setupFieldEndpoints(field);
renderWithProviders(
<>
<RefingerprintFieldButton fieldId={getRawTableFieldId(field)} />
<UndoListing />
</>,
);
return { field };
}
describe("RefingerprintFieldButton", () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
it("should show success message for 2 seconds", async () => {
const { field } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint field");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/field/${field.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Fingerprinting triggered!");
});
await act(() => {
jest.advanceTimersByTime(2000);
});
expect(button).toHaveTextContent("Refingerprint field");
});
it("resets success message timer after next click", async () => {
const { field } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint field");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/field/${field.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Fingerprinting triggered!");
});
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Fingerprinting triggered!");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/field/${field.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(2);
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Fingerprinting triggered!");
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Refingerprint field");
});
it("should show error message toast", async () => {
const { field } = setup();
fetchMock.modifyRoute(`field-${field.id}-refingerprint`, {
response: { status: 500 },
});
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint field");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/field/${field.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Refingerprint field");
});
await waitFor(() => {
const undo = screen.getByTestId("undo-list");
expect(
within(undo).getByText("Failed to start fingerprinting"),
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1 @@
export * from "./RefingerprintFieldButton";

View File

@@ -0,0 +1,33 @@
import { t } from "ttag";
import { useRefingerprintTableMutation } from "metabase/api";
import { useTemporaryState } from "metabase/common/hooks";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Button } from "metabase/ui";
import type { TableId } from "metabase-types/api";
interface Props {
tableId: TableId;
}
export const RefingerprintTableButton = ({ tableId }: Props) => {
const [refingerprintTable] = useRefingerprintTableMutation();
const [started, setStarted] = useTemporaryState(false, 2000);
const { sendErrorToast } = useMetadataToasts();
const handleClick = async () => {
const { error } = await refingerprintTable(tableId);
if (error) {
sendErrorToast(t`Failed to start fingerprinting`);
} else {
setStarted(true);
}
};
return (
<Button variant="default" onClick={handleClick}>
{started ? t`Fingerprinting triggered!` : t`Refingerprint table`}
</Button>
);
};

View File

@@ -0,0 +1,127 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupTableEndpoints } from "__support__/server-mocks";
import {
act,
renderWithProviders,
screen,
waitFor,
within,
} from "__support__/ui";
import { UndoListing } from "metabase/common/components/UndoListing";
import { createMockTable } from "metabase-types/api/mocks";
import { RefingerprintTableButton } from "./RefingerprintTableButton";
function setup() {
const table = createMockTable();
setupTableEndpoints(table);
renderWithProviders(
<>
<RefingerprintTableButton tableId={table.id} />
<UndoListing />
</>,
);
return { table };
}
describe("RefingerprintTableButton", () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
it("should show success message for 2 seconds", async () => {
const { table } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint table");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/table/${table.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Fingerprinting triggered!");
});
await act(() => {
jest.advanceTimersByTime(2000);
});
expect(button).toHaveTextContent("Refingerprint table");
});
it("resets success message timer after next click", async () => {
const { table } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint table");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/table/${table.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Fingerprinting triggered!");
});
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Fingerprinting triggered!");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/table/${table.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(2);
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Fingerprinting triggered!");
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Refingerprint table");
});
it("should show error message toast", async () => {
const { table } = setup();
fetchMock.modifyRoute(`table-${table.id}-refingerprint`, {
response: { status: 500 },
});
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Refingerprint table");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(`path:/api/table/${table.id}/refingerprint`, {
method: "POST",
}),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Refingerprint table");
});
await waitFor(() => {
const undo = screen.getByTestId("undo-list");
expect(
within(undo).getByText("Failed to start fingerprinting"),
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1 @@
export * from "./RefingerprintTableButton";

View File

@@ -4,6 +4,7 @@ import { Modal, Stack, Text, rem } from "metabase/ui";
import type { TableId } from "metabase-types/api";
import { DiscardTableFieldValuesButton } from "../DiscardTableFieldValuesButton";
import { RefingerprintTableButton } from "../RefingerprintTableButton";
import { RescanTableFieldsButton } from "../RescanTableFieldsButton";
import { SyncTableSchemaButton } from "../SyncTableSchemaButton";
@@ -52,6 +53,8 @@ export const SyncOptionsModal = ({
<RescanTableFieldsButton tableId={tableId} />
<RefingerprintTableButton tableId={tableId} />
<DiscardTableFieldValuesButton tableId={tableId} />
</Stack>
</Stack>

View File

@@ -17,8 +17,10 @@ export * from "./NameDescriptionInput";
export * from "./NoDatabasesEmptyState";
export * from "./PreviewSection";
export * from "./RescanFieldButton";
export * from "./RefingerprintFieldButton";
export * from "./ResponsiveButton";
export * from "./RescanTableFieldsButton";
export * from "./RefingerprintTableButton";
export * from "./SemanticTypeAndTargetPicker";
export * from "./SemanticTypePicker";
export * from "./SortableFieldItem";

View File

@@ -22,6 +22,11 @@ export function setupFieldEndpoints(field: Field) {
{},
{ name: `field-${field.id}-discard-values` },
);
fetchMock.post(
`path:/api/field/${field.id}/refingerprint`,
{},
{ name: `field-${field.id}-refingerprint` },
);
}
export function setupFieldValuesEndpoint(fieldValues: GetFieldValuesResponse) {

View File

@@ -30,6 +30,11 @@ export function setupTableEndpoints(
{},
{ name: `table-${table.id}-sync-schema` },
);
fetchMock.post(
`path:/api/table/${table.id}/refingerprint`,
{},
{ name: `table-${table.id}-refingerprint` },
);
setupTableQueryMetadataEndpoint(table);
table.fields?.forEach((field) => setupFieldEndpoints({ ...field, table }));
}

View File

@@ -304,3 +304,11 @@
[field :- i/FieldInstance]
(let [table (field/table field)]
(fingerprint-fields! table [field])))
(mu/defn refingerprint-table!
"Refingerprint all the Fields in `table` that are eligible for fingerprinting. Like [[refingerprint-field!]], but
for every Field in the Table rather than just one. See [[fingerprint-table!]] for details on how the eligible
Fields are selected and how many are processed at once."
[table :- i/TableInstance]
(binding [*refingerprint?* true]
(fingerprint-table! table)))

View File

@@ -27,6 +27,7 @@
update-field-values-for-table!]
[metabase.sync.sync
refingerprint-field!
refingerprint-table!
sync-database!
sync-table!]
[metabase.sync.sync-metadata

View File

@@ -102,3 +102,18 @@
(sync-util/name-for-logging field))
(sync.fingerprint/refingerprint-field! field))
:sync/no-connection)))
(mu/defn refingerprint-table!
"Refingerprint a table, usually after its visibility changes or manual request. Checks if can connect to database, returning
`:sync/no-connection` if not."
[table :- i/TableInstance]
(let [database (table/database table)]
;; it's okay to allow testing H2 connections during sync. We only want to disallow you from testing them for the
;; purposes of creating a new H2 database.
(if (binding [driver.settings/*allow-testing-h2-connections* true
driver.settings/*allow-testing-sqlite-connections* true]
(driver.u/can-connect-with-details? (:engine database) (:details database)))
(sync-util/with-error-handling (format "Error refingerprinting table %s"
(sync-util/name-for-logging table))
(sync.fingerprint/refingerprint-table! table))
:sync/no-connection)))

View File

@@ -345,6 +345,19 @@
(field-values/create-or-update-full-field-values! field)))
{:status :success})
(api.macros/defendpoint :post "/:id/refingerprint"
:- [:map {:closed true}
[:status [:= :success]]]
"Manually trigger an update of the fingerprints for this `Field`."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(let [field (api/write-check (t2/select-one :model/Field :id id))]
;; Run with admin perms to match behavior during normal sync.
(quick-task/submit-task!
(fn []
(request/as-admin (sync/refingerprint-field! field))))
{:status :success}))
;; TODO (Cam 10/28/25) -- fix this endpoint route to use kebab-case for consistency with the rest of our REST API
;;
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to

View File

@@ -396,6 +396,19 @@
(sync/update-field-values-for-table! table))))
{:status :success}))
(api.macros/defendpoint :post "/:id/refingerprint"
:- [:map {:closed true}
[:status [:= :success]]]
"Manually trigger an update of the fingerprints for the Fields belonging to this Table."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(let [table (api/write-check (t2/select-one :model/Table :id id))]
;; Run with admin perms to match behavior during normal sync.
(quick-task/submit-task!
(fn []
(request/as-admin (sync/refingerprint-table! table))))
{:status :success}))
;; TODO (Cam 10/28/25) -- fix this endpoint route to use kebab-case for consistency with the rest of our REST API
;;
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to

View File

@@ -938,6 +938,20 @@
(is (isa? (:effective_type field) :type/DateTime))
(is (= "minutes" (-> field :settings :time_enabled)))))))
(deftest refingerprint-field-test
(testing "POST /api/field/:id/refingerprint"
(testing "It should return success and actually call refingerprint-field!"
(mt/with-temp [:model/Field {field-id :id} {:name "Field Test"}]
(let [called? (atom false)]
(with-redefs [quick-task/submit-task! (fn [task] (task))
sync/refingerprint-field! (fn [_field] (reset! called? true))]
(is (= {:status "success"}
(mt/user-http-request :crowberto :post 200 (format "field/%d/refingerprint" field-id))))
(is @called? "refingerprint-field! should have been called")))))
(testing "It should return 404 for non-existent field"
(is (= "Not found."
(mt/user-http-request :crowberto :post 404 (format "field/%d/refingerprint" Integer/MAX_VALUE)))))))
(deftest field-values-requires-query-permission-test
(testing "GET /api/field/:id/values requires query permission (view-data + create-queries)"
(mt/with-temp-copy-of-db

View File

@@ -20,6 +20,7 @@
[metabase.test.http-client :as client]
[metabase.upload.impl-test :as upload-test]
[metabase.util :as u]
[metabase.util.quick-task :as quick-task]
[metabase.warehouse-schema-rest.api.table :as api.table]
[toucan2.core :as t2]))
@@ -1383,6 +1384,20 @@
(sync/sync-database! db {:scan :schema})
(is (= () (mt/user-http-request :rasta :get 200 (format "table/%d/fks" (mt/id :continent)))))))))
(deftest refingerprint-table-test
(testing "POST /api/table/:id/refingerprint"
(testing "It should return success and actually call refingerprint-table!"
(mt/with-temp [:model/Table {table-id :id} {}]
(let [called? (atom false)]
(with-redefs [quick-task/submit-task! (fn [task] (task))
sync/refingerprint-table! (fn [_table] (reset! called? true))]
(is (= {:status "success"}
(mt/user-http-request :crowberto :post 200 (format "table/%d/refingerprint" table-id))))
(is @called? "refingerprint-table! should have been called")))))
(testing "It should return 404 for non-existent table"
(is (= "Not found."
(mt/user-http-request :crowberto :post 404 (format "table/%d/refingerprint" Integer/MAX_VALUE)))))))
;;; ---------------------------------------- can-query and can-write filter tests ----------------------------------------
(deftest list-tables-can-query-filter-returns-only-queryable-tables-test