diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj index 8a9b45f12209b9bd5198b4a4b3b53f42e774980e..daf70db389ff8ddcc40e78b9e690b37aa390313e 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj @@ -422,9 +422,9 @@ Collection coll1s {:name "My Collection"} Table table1s {:name "CUSTOMERS" :db_id (:id db1s)} - Field field1s {:name "NAME" + Field field1s {:name (mt/random-name) :table_id (:id table1s)} - Card card1s {:name "Source card"} + Card card1s {:name (mt/random-name)} Card card2s {:name "Card with parameter" :database_id (:id db1s) :table_id (:id table1s) @@ -436,7 +436,7 @@ ;; card_id is in a different collection with dashboard's collection :values_source_config {:card_id (:id card1s) :value_field [:field (:id field1s) nil]}}]} - Dashboard dash1s {:name "A dashboard" + Dashboard dash1s {:name (mt/random-name) :collection_id (:id coll1s) :parameters [{:id "abc" :type "category" @@ -457,7 +457,7 @@ :type :category, :values_source_config {:card_id (:entity_id card1s), :value_field [:field - ["my-db" nil "CUSTOMERS" "NAME"] + ["my-db" nil "CUSTOMERS" (:name field1s)] nil]}, :values_source_type "card"}] (:parameters (first (by-model extraction "Dashboard"))))) @@ -469,7 +469,7 @@ :type :category, :values_source_config {:card_id (:entity_id card1s), :value_field [:field - ["my-db" nil "CUSTOMERS" "NAME"] + ["my-db" nil "CUSTOMERS" (:name field1s)] nil]}, :values_source_type "card"}]} (set (map :parameters (by-model extraction "Card"))))) diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml index fd17c9a93fe651fed1f08a15ac33a3d7c0e3929e..828294b6cbf79b008579908ac359d34c639a8891 100644 --- a/resources/migrations/001_update_migrations.yaml +++ b/resources/migrations/001_update_migrations.yaml @@ -5747,17 +5747,6 @@ databaseChangeLog: defaultValue: true remarks: Whether or not to apply formatting to the rows of the export - - changeSet: - id: v49.2024-03-26T20:27:58 - author: noahmoss - comment: Added 0.46.0 - Delete the truncate audit log task (renamed to truncate audit tables) - # Apparently this migration can hang when connection pool is small (#44528). It's also not needed - # as of v50 due to #42383 so let's skip it. - ignore: true - changes: - - customChange: - class: "metabase.db.custom_migrations.DeleteTruncateAuditLogTask" - - changeSet: id: v49.2024-04-09T10:00:00 author: qnkhuat @@ -6494,6 +6483,211 @@ databaseChangeLog: - dbms: type: mysql,mariadb + # The changesets from v49.2024-06-27T00:00:00 to v49.2024-06-27T00:00:08 prevent duplicate fields from being + # created on MySQL and H2. See #44866 for details. Below is an index of the changesets: + # - v49.2024-06-27T00:00:00: Make metabase_field.name use case-sensitive collation for MySQL + # - v49.2024-06-27T00:00:01: Add metabase_field.is_defective_duplicate + # - v49.2024-06-27T00:00:02: Populate metabase_field.is_defective_duplicate + # - v49.2024-06-27T00:00:03: Drop fk_field_parent_ref_field_id constraint with ON DELETE CASCADE + # - v49.2024-06-27T00:00:04: Add fk_field_parent_ref_field_id constraint with ON DELETE RESTRICT + # - v49.2024-06-27T00:00:05: Remove idx_uniq_field_table_id_parent_id_name because it is redundant with idx_unique_field + # - v49.2024-06-27T00:00:06: Remove the idx_uniq_field_table_id_parent_id_name_2col unique index because it blocks load-from-h2 + # - v49.2024-06-27T00:00:07: Add unique_field_helper column to metabase_field + # - v49.2024-06-27T00:00:08: Add unique constraint on metabase_field's (name, table_id, unique_field_helper) + + # This is necessary to make unique indexes using metabase_field.name case-sensitive for MySQL. + - changeSet: + id: v49.2024-06-27T00:00:00 + author: calherries + comment: Make metabase_field.name use case-sensitive collation for MySQL + changes: + - sql: + dbms: mysql,mariadb + sql: >- + ALTER TABLE metabase_field MODIFY + name VARCHAR(254) + CHARACTER SET utf8mb4 + COLLATE utf8mb4_bin; + rollback: + - sql: + dbms: mysql,mariadb + sql: >- + ALTER TABLE metabase_field MODIFY + name VARCHAR(254) + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + + # metabase_field.is_defective_duplicate is a new column that indicates whether a field is a defective duplicate field + # that should never have been created under Postgres' `idx_uniq_field_table_id_parent_id_name_2col` constraint, but + # was allowed to be created in MySQL or H2. + - changeSet: + id: v49.2024-06-27T00:00:01 + author: calherries + comment: Add metabase_field.is_defective_duplicate + changes: + - addColumn: + tableName: metabase_field + columns: + - column: + name: is_defective_duplicate + type: ${boolean.type} + remarks: "Indicates whether column is a defective duplicate field that should never have been created." + constraints: + nullable: false + defaultValue: false + + - changeSet: + id: v49.2024-06-27T00:00:02 + author: calherries + comment: Populate metabase_field.is_defective_duplicate + changes: + - sql: + sql: >- + UPDATE metabase_field mf + SET is_defective_duplicate = TRUE + WHERE mf.id IN ( + SELECT id + FROM ( + SELECT + mf.id, + ROW_NUMBER() OVER ( + PARTITION BY mf.table_id, mf.name, mf.parent_id + ORDER BY + CASE WHEN mf.active THEN 0 ELSE 1 END, + CASE WHEN mf.nfc_path IS NULL THEN 0 ELSE 1 END, + mf.created_at + ) AS rn + FROM metabase_field mf + ) x + WHERE x.rn > 1 + ); + rollback: # No rollback needed since is_defective_duplicate is introduced in 49 + + # The next two changesets replace ON DELETE CASCADE with ON DELETE RESTRICT on fk_field_parent_ref_field_id. + # We need to do this for MySQL because it doesn't support ON DELETE CASCADE in a stored generated column, and we + # want to use parent_id in the unique_field_helper generated column. Cascading deletes are handled in the + # application instead. + - changeSet: + id: v49.2024-06-27T00:00:03 + author: calherries + comment: Drop fk_field_parent_ref_field_id constraint with ON DELETE CASCADE + changes: + - dropForeignKeyConstraint: + baseTableName: metabase_field + constraintName: fk_field_parent_ref_field_id + rollback: + - addForeignKeyConstraint: + baseTableName: metabase_field + baseColumnNames: parent_id + referencedTableName: metabase_field + referencedColumnNames: id + constraintName: fk_field_parent_ref_field_id + onDelete: CASCADE + + - changeSet: + id: v49.2024-06-27T00:00:04 + author: calherries + comment: Add fk_field_parent_ref_field_id constraint with ON DELETE RESTRICT + changes: + - addForeignKeyConstraint: + baseTableName: metabase_field + baseColumnNames: parent_id + referencedTableName: metabase_field + referencedColumnNames: id + constraintName: fk_field_parent_ref_field_id + onDelete: RESTRICT + rollback: + - dropForeignKeyConstraint: + baseTableName: metabase_field + constraintName: fk_field_parent_ref_field_id + + - changeSet: + id: v49.2024-06-27T00:00:05 + author: calherries + comment: Remove idx_uniq_field_table_id_parent_id_name because it is redundant with idx_unique_field + changes: + - dropUniqueConstraint: + tableName: metabase_field + constraintName: idx_uniq_field_table_id_parent_id_name + rollback: + - addUniqueConstraint: + tableName: metabase_field + columnNames: table_id, parent_id, name + constraintName: idx_uniq_field_table_id_parent_id_name + + - changeSet: + id: v49.2024-06-27T00:00:06 + author: calherries + comment: Remove the idx_uniq_field_table_id_parent_id_name_2col unique index because it blocks load-from-h2 + changes: + - sql: + dbms: postgresql + sql: DROP INDEX IF EXISTS idx_uniq_field_table_id_parent_id_name_2col; + rollback: # We deliberately don't restore this constraint because otherwise it can block downgrading to 48 after load-from-h2 + + # Previously we had two unique constraints on metabase_field's name, table_id, and parent_id columns. + # + # 1. `idx_uniq_field_table_id_parent_id_name` is a unique constraint on (name, table_id, parent_id) + # Since NULL <> NULL, it only applies to fields where parent_id IS NOT NULL. + # Worse, the constraint was not case-sensitive for MySQL. + # + # 2. `idx_uniq_field_table_id_parent_id_name_2col` is a Postgres-only unique constraint on + # `(name, table_id) WHERE parent_id IS NULL`. There was no equivalent constraint for H2 or MySQL because only + # Postgres supports partial indexes. The following changesets introduce an equivalent constraint for H2 and + # MySQL by adding a constraint that uses a generated column to handle NULL parent_id values. + # + # At the same time, we exclude fields where is_defective_duplicate=TRUE from the above constraints. + # + # The following two changesets add a new column `unique_field_helper` to `metabase_field` and a unique constraint + # `idx_unique_field`. + # The combination of `unique_field_helper` and `idx_unique_field` achieves two things: + # 1. It enforces the conditional constraints: + # - if parent_id IS NULL, then (table_id, name) is unique. (like idx_uniq_field_table_id_parent_id_name_col2) + # - if parent_id IS NOT NULL, then (table_id, name, parent_id) is unique. (like idx_uniq_field_table_id_parent_id_name) + # 2. It only enforces the constraints when is_defective_duplicate = FALSE + + - changeSet: + id: v49.2024-06-27T00:00:07 + author: calherries + comment: Add unique_field_helper column to metabase_field + changes: + - sql: + dbms: postgresql + sql: >- + ALTER TABLE metabase_field ADD COLUMN unique_field_helper INTEGER GENERATED ALWAYS AS ( + CASE WHEN is_defective_duplicate = TRUE THEN NULL ELSE (CASE WHEN parent_id IS NULL THEN 0 ELSE parent_id END) END + ) STORED; + - sql: + dbms: h2 + sql: >- + ALTER TABLE metabase_field ADD COLUMN unique_field_helper INTEGER GENERATED ALWAYS AS ( + CASE WHEN is_defective_duplicate = TRUE THEN NULL ELSE (CASE WHEN parent_id IS NULL THEN 0 ELSE parent_id END) END + ); + - sql: + dbms: mysql,mariadb + sql: >- + ALTER TABLE metabase_field ADD COLUMN unique_field_helper INTEGER GENERATED ALWAYS AS ( + CASE WHEN is_defective_duplicate = TRUE THEN NULL ELSE (CASE WHEN parent_id IS NULL THEN 0 ELSE parent_id END) END + ) STORED; + rollback: + - sql: + dbms: postgresql,h2,mysql,mariadb + sql: ALTER TABLE metabase_field DROP COLUMN unique_field_helper; + + - changeSet: + id: v49.2024-06-27T00:00:08 + author: calherries + comment: Add unique constraint on metabase_field's (name, table_id, unique_field_helper) + changes: + - addUniqueConstraint: + tableName: metabase_field + constraintName: idx_unique_field + columnNames: name, table_id, unique_field_helper + rollback: + - dropUniqueConstraint: + tableName: metabase_field + constraintName: idx_unique_field + - changeSet: id: v50.2024-01-04T13:52:51 author: noahmoss diff --git a/src/metabase/cmd/copy.clj b/src/metabase/cmd/copy.clj index 6ffc0010dce948aec811d1c3a276860e9b56d7c1..663fe5f19b91edc99633c1bc677f021b545e4b3a 100644 --- a/src/metabase/cmd/copy.clj +++ b/src/metabase/cmd/copy.clj @@ -166,6 +166,9 @@ ;; Never create dumps with read-only-mode turned on. ;; It will be confusing to restore from and prevent key rotation. (remove (fn [{k :key}] (= k "read-only-mode"))) + :model/Field + ;; unique_field_helper is a computed/generated column + (map #(dissoc % :unique_field_helper)) ;; else identity)) diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj index b91e4f051419ad2c2f4f3d781bea099fff68f760..e3f77547bd38a0ab1ed961fcac089d116acc8c15 100644 --- a/src/metabase/models/database.clj +++ b/src/metabase/models/database.clj @@ -208,6 +208,10 @@ [{id :id, driver :engine, :as database}] (unschedule-tasks! database) (delete-orphaned-secrets! database) + ;; We need to use toucan to delete the fields instead of cascading deletes because MySQL doesn't support columns with cascade delete + ;; foreign key constraints in generated columns. #44866 + (when-some [table-ids (not-empty (t2/select-pks-vec :model/Table :db_id id))] + (t2/delete! :model/Field :table_id [:in table-ids])) (try (driver/notify-database-updated driver database) (catch Throwable e diff --git a/src/metabase/models/field.clj b/src/metabase/models/field.clj index d970ce804129bbc84b77cff76424766b34c726e1..a866f6d8890b7ea4eb47818fa9e3dd14286eb099 100644 --- a/src/metabase/models/field.clj +++ b/src/metabase/models/field.clj @@ -127,6 +127,10 @@ (derive :metabase/model) (derive :hook/timestamped?)) +(t2/define-after-select :model/Field + [field] + (dissoc field :is_defective_duplicate :unique_field_helper)) + (t2/define-before-insert :model/Field [field] (let [defaults {:display_name (humanization/name->human-readable-name (:name field))}] @@ -139,6 +143,13 @@ (t2/update! :model/Field {:fk_target_field_id (:id field)} {:semantic_type nil :fk_target_field_id nil})))) +(t2/define-before-delete :model/Field + [field] + ; Cascading deletes through parent_id cannot be done with foreign key constraints in the database + ; because parent_id constributes to a generated column, and MySQL doesn't support columns with cascade delete + ;; foreign key constraints in generated columns. #44866 + (t2/delete! :model/Field :parent_id (:id field))) + (defn- field->db-id [{table-id :table_id, {db-id :db_id} :table}] (or db-id (database/table-id->database-id table-id))) diff --git a/src/metabase/models/table.clj b/src/metabase/models/table.clj index 8bc0429365d23fd959d93b246686c74be0be3de8..288a853a19aa77157d52950242f864778de2d240 100644 --- a/src/metabase/models/table.clj +++ b/src/metabase/models/table.clj @@ -61,6 +61,12 @@ :field_order (driver/default-field-order (t2/select-one-fn :engine :model/Database :id (:db_id table)))}] (merge defaults table))) +(t2/define-before-delete :model/Table + [table] + ;; We need to use toucan to delete the fields instead of cascading deletes because MySQL doesn't support columns with cascade delete + ;; foreign key constraints in generated columns. #44866 + (t2/delete! :model/Field :table_id (:id table))) + (defn- set-new-table-permissions! [table] (t2/with-transaction [_conn] diff --git a/test/metabase/cmd/load_from_h2_test.clj b/test/metabase/cmd/load_from_h2_test.clj index 34419da61fa7a698df7ef3b7cadea1914ba23004..562bee61b183669ae5ef704412b23a435be5f544 100644 --- a/test/metabase/cmd/load_from_h2_test.clj +++ b/test/metabase/cmd/load_from_h2_test.clj @@ -58,12 +58,12 @@ (is (=? {:db string?} (t2/select-one-fn :details :model/Database :engine :h2)))))))) -(defn- get-data-source [db-type db-def] +(defn get-data-source [db-type db-def] (let [connection-details (tx/dbdef->connection-details db-type :db db-def) db-spec (sql-jdbc.conn/connection-details->spec db-type connection-details)] (mdb.test-util/->ClojureJDBCSpecDataSource db-spec))) -(defn- create-current-database +(defn create-current-database [db-type db-def data-source] (tx/create-db! db-type db-def) (mdb.setup/setup-db! db-type data-source true false)) diff --git a/test/metabase/db/schema_migrations_test.clj b/test/metabase/db/schema_migrations_test.clj index 61382f1b7b819c99869e78e00f7602dfa7cfb31c..138cc43022b297807e7ef939f6b6299d7fc65550 100644 --- a/test/metabase/db/schema_migrations_test.clj +++ b/test/metabase/db/schema_migrations_test.clj @@ -11,15 +11,21 @@ (:require [cheshire.core :as json] [clojure.java.jdbc :as jdbc] + [clojure.set :as set] [clojure.test :refer :all] [java-time.api :as t] [medley.core :as m] + [metabase.cmd.dump-to-h2 :as dump-to-h2] + [metabase.cmd.load-from-h2 :as load-from-h2] + [metabase.cmd.load-from-h2-test :as load-from-h2-test] [metabase.config :as config] [metabase.db :as mdb] + [metabase.db.connection :as mdb.connection] [metabase.db.custom-migrations-test :as custom-migrations-test] [metabase.db.liquibase :as liquibase] [metabase.db.query :as mdb.query] [metabase.db.schema-migrations-test.impl :as impl] + [metabase.driver :as driver] [metabase.models :refer [Action Card @@ -33,6 +39,7 @@ User]] [metabase.models.collection :as collection] [metabase.test :as mt] + [metabase.test.data.env :as tx.env] [metabase.test.fixtures :as fixtures] [metabase.util.encryption :as encryption] [metabase.util.encryption-test :as encryption-test] @@ -2205,3 +2212,220 @@ #{i} ;; => I is the last archived subtree. It's a grandchild of G, but H isn't archived. #{b h}} ;; => not archived at all, `archive_operation_id` is nil (set (vals archive-operation-id->collection-ids)))))))))) + +(deftest populate-is-defective-duplicate-test + (testing "Migration v49.2024-06-27T00:00:02 populates is_defective_duplicate correctly" + (mt/test-drivers #{:postgres :h2 :mysql} + (impl/test-migrations ["v49.2024-06-27T00:00:00" "v49.2024-06-27T00:00:08"] [migrate!] + (when (= driver/*driver* :postgres) + ;; This is to test what happens when Postgres is rolled back to 48 from 49, and + ;; then rolled back to 49 again. The rollback to 48 will cause the + ;; idx_uniq_field_table_id_parent_id_name_2col index to be dropped + (t2/query "DROP INDEX IF EXISTS idx_uniq_field_table_id_parent_id_name_2col;")) + (let [db-id (t2/insert-returning-pk! (t2/table-name Database) + {:details "{}" + :created_at :%now + :updated_at :%now + :engine "h2" + :is_sample false + :name "populate-is-defective-duplicate-test-db"}) + table! (fn [] + (t2/insert-returning-instance! (t2/table-name Table) + {:db_id db-id + :name (mt/random-name) + :created_at :%now + :updated_at :%now + :active true})) + field! (fn [table values] + (t2/insert-returning-instance! (t2/table-name Field) + (merge {:table_id (:id table) + :parent_id nil + :base_type "type/Text" + :database_type "TEXT" + :created_at :%now + :updated_at :%now} + values))) + earlier #t "2023-01-01T00:00:00" + later #t "2024-01-01T00:00:00" + ; 1. + table-1 (table!) + cases-1 {; field ; is_defective_duplicate + (field! table-1 {:name "F1", :active true, :nfc_path "NOT NULL", :created_at later}) false + (field! table-1 {:name "F1", :active false, :nfc_path nil, :created_at earlier}) true} + ; 2. + table-2 (table!) + cases-2 {(field! table-2 {:name "F2", :active true, :nfc_path nil, :created_at later}) false + (field! table-2 {:name "F2", :active true, :nfc_path "NOT NULL", :created_at earlier}) true} + ; 3. + table-3 (table!) + cases-3 {(field! table-3 {:name "F3", :active true, :nfc_path nil, :created_at earlier}) false + (field! table-3 {:name "F3", :active true, :nfc_path nil, :created_at later}) true} + ; 4. + table-4 (table!) + cases-4 {(field! table-4 {:name "F4", :active true, :nfc_path nil, :created_at earlier}) false + (field! table-4 {:name "F4", :active false, :nfc_path nil, :created_at later}) true + (field! table-4 {:name "F4", :active false, :nfc_path "NOT NULL", :created_at earlier}) true + (field! table-4 {:name "F4", :active false, :nfc_path "NOT NULL", :created_at later}) true} + ; 5. + table-5 (table!) + field-no-parent-1 (field! table-5 {:name "F5", :active true, :parent_id nil}) + field-no-parent-2 (field! table-5 {:name "F5", :active false, :parent_id nil}) + field-with-parent-1 (field! table-5 {:name "F5", :active true, :parent_id (:id field-no-parent-1)}) + field-with-parent-2 (field! table-5 {:name "F5", :active true, :parent_id (:id field-no-parent-2)}) + cases-5 {field-no-parent-1 false + field-no-parent-2 true + field-with-parent-1 false + field-with-parent-2 false} + assert-defective-cases (fn [field->defective?] + (doseq [[field-before defective?] field->defective?] + (let [field-after (t2/select-one (t2/table-name Field) :id (:id field-before))] + (is (= defective? (:is_defective_duplicate field-after))))))] + (migrate!) + (testing "1. Active is 1st preference" + (assert-defective-cases cases-1)) + (testing "2. NULL nfc_path is 2nd preference" + (assert-defective-cases cases-2)) + (testing "3. Earlier created_at is 3rd preference" + (assert-defective-cases cases-3)) + (testing "4. More than two fields can be defective" + (assert-defective-cases cases-4)) + (testing "5. Fields with different parent_id's are not defective duplicates" + (assert-defective-cases cases-5)) + (when (not= driver/*driver* :mysql) ; skipping MySQL because of rollback flakes (metabase#37434) + (testing "Migrate down succeeds" + (migrate! :down 48)))))))) + +(deftest is-defective-duplicate-constraint-test + (testing "Migrations for H2 and MySQL to prevent duplicate fields" + (impl/test-migrations ["v49.2024-06-27T00:00:00" "v49.2024-06-27T00:00:08"] [migrate!] + (let [db-id (t2/insert-returning-pk! (t2/table-name Database) + {:details "{}" + :created_at :%now + :updated_at :%now + :engine "h2" + :is_sample false + :name "populate-is-defective-duplicate-test-db"}) + table (t2/insert-returning-instance! (t2/table-name Table) + {:db_id db-id + :name (mt/random-name) + :created_at :%now + :updated_at :%now + :active true}) + field! (fn [values] + (t2/insert-returning-instance! (t2/table-name Field) + (merge {:table_id (:id table) + :parent_id nil + :base_type "type/Text" + :database_type "TEXT" + :created_at :%now + :updated_at :%now} + values))) + field-no-parent-1 (field! {:name "F1", :active true, :parent_id nil}) + field-no-parent-2 (field! {:name "F2", :active true, :parent_id nil}) + defective+field-thunk [; A field is defective if they have non-unique (table, name) but parent_id is NULL + [true #(field! {:name "F1", :active true, :parent_id nil, :nfc_path "NOT NULL"})] + ; A field is not defective if they have non-unique (table, name) but different parent_id + [false #(field! {:name "F1", :active true, :parent_id (:id field-no-parent-1)})] + [false #(field! {:name "F1", :active true, :parent_id (:id field-no-parent-2)})]] + fields-to-clean-up (atom []) + clean-up-fields! (fn [] + (t2/delete! (t2/table-name Field) :id [:in (map :id @fields-to-clean-up)]) + (reset! fields-to-clean-up []))] + (if (= driver/*driver* :postgres) + (testing "Before the migrations, Postgres does not allow fields to have the same table, name, but different parent_id" + (doseq [[defective? field-thunk] defective+field-thunk] + (if defective? + (is (thrown? Exception (field-thunk))) + (let [field (field-thunk)] + (is (some? field)) + (swap! fields-to-clean-up conj field))))) + (testing "Before the migrations, all fields are allowed" + (doseq [[_ field-thunk] defective+field-thunk] + (let [field (field-thunk)] + (is (some? field)) + (swap! fields-to-clean-up conj field))))) + (migrate!) + (clean-up-fields!) + (testing "After the migrations, only allow fields that have the same table, name, but different parent_id" + (doseq [[defective? field-thunk] defective+field-thunk] + (if defective? + (is (thrown? Exception (field-thunk))) + (let [field (field-thunk)] + (is (some? field)) + (swap! fields-to-clean-up conj field))))) + (when (not= driver/*driver* :mysql) ; skipping MySQL because of rollback flakes (metabase#37434) + (testing "Migrate down succeeds" + (migrate! :down 48)) + (clean-up-fields!) + (testing "After rolling back the migrations, all fields are allowed" + ;; Postgres' unique index is removed on rollback, so we can add defective fields + ;; This is needed to allow load-from-h2 to Postgres and then downgrading to work + (testing "After migrating down, all fields are allowed" + (doseq [[_ field-thunk] defective+field-thunk] + (is (some? (field-thunk)))))) + (testing "Migrate up again succeeds" + (migrate!))))))) + +(deftest is-defective-duplicate-constraint-load-from-h2 + (testing "Test that you can use load-from-h2 with fields that meet the conditions for is_defective_duplicate=TRUE" + ;; In this test: + ;; 1. starting from an H2 app DB, create a field that meets the conditions for is_defective_duplicate=TRUE + ;; 2. migrate, adding constraints around is_defective_duplicate to prevent duplicates + ;; 3. test load-from-h2 works successfully by migrating to MySQL or Postgres + ;; 4. test you can downgrade and upgrade again after that + (when-let [test-drivers (set/intersection (tx.env/test-drivers) #{:mysql :postgres})] + (mt/with-driver :h2 + (impl/test-migrations-for-driver! + :h2 + ["v49.2024-06-27T00:00:00" "v49.2024-06-27T00:00:08"] + (fn [migrate!] + (let [db-id (t2/insert-returning-pk! (t2/table-name Database) + {:details "{}" + :created_at :%now + :updated_at :%now + :engine "h2" + :is_sample false + :name ""}) + table (t2/insert-returning-instance! (t2/table-name Table) + {:db_id db-id + :name (mt/random-name) + :created_at :%now + :updated_at :%now + :active true}) + field! (fn [values] + (t2/insert-returning-instance! (t2/table-name Field) + (merge {:table_id (:id table) + :active true + :parent_id nil + :base_type "type/Text" + :database_type "TEXT" + :created_at :%now + :updated_at :%now} + values))) + _normal-field (field! {:name "F1", :parent_id nil}) + create-defective-field! #(field! {:name "F1", :parent_id nil}) + defective-field-id (:id (create-defective-field!))] + (testing "Before the migration, creating a defective duplicate field is allowed" + (is (some? defective-field-id))) + (migrate!) + (testing "After the migration, defective duplicate fields are not allowed" + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Unique index" (field! {:name "F1", :parent_id nil})))) + (mt/with-temp-dir [dir nil] + (let [h2-filename (str dir "/dump")] + (dump-to-h2/dump-to-h2! h2-filename) ; this migrates the DB back to the newest and creates a dump + (mt/test-drivers test-drivers + (let [db-def {:database-name "field-test-db"} + data-source (load-from-h2-test/get-data-source driver/*driver* db-def)] + (load-from-h2-test/create-current-database driver/*driver* db-def data-source) + (binding [mdb.connection/*application-db* (mdb.connection/application-db driver/*driver* data-source)] + (load-from-h2/load-from-h2! h2-filename) + (testing "The defective field should still exist after loading from H2" + (is (= #{defective-field-id} + (t2/select-pks-set (t2/table-name :model/Field) :is_defective_duplicate true))))) + (when (not= driver/*driver* :mysql) ; skipping MySQL because of rollback flakes (metabase#37434) + (testing "Migrating down to 48 should still work" + (migrate! :down 48)) + (testing "The defective field should still exist after loading from H2 and downgrading" + (is (t2/exists? (t2/table-name :model/Field) :id defective-field-id))) + (testing "Migrating up again should still work" + (migrate!)))))))))))))) diff --git a/test/metabase/db/schema_migrations_test/impl.clj b/test/metabase/db/schema_migrations_test/impl.clj index b6855a3d83390b3aeae657a38af9b625c9890051..0270b3853bdedf573430d7da0eb73d82d9a0e3a1 100644 --- a/test/metabase/db/schema_migrations_test/impl.clj +++ b/test/metabase/db/schema_migrations_test/impl.clj @@ -212,7 +212,7 @@ (.generateDeploymentId change-log-service) (liquibase/update-with-change-log liquibase {:change-set-filters change-set-filters}))))) -(defn- test-migrations-for-driver! [driver [start-id end-id] f] +(defn test-migrations-for-driver! [driver [start-id end-id] f] (log/debug (u/format-color 'yellow "Testing migrations for driver %s..." driver)) (with-temp-empty-app-db [conn driver] ;; sanity check: make sure the DB is actually empty diff --git a/test/metabase/native_query_analyzer_test.clj b/test/metabase/native_query_analyzer_test.clj index 620f8f7dbecfc1878be5ca929ece69d3a8435e67..945cba410345cddcf72d1504e5c5211af4f4e356 100644 --- a/test/metabase/native_query_analyzer_test.clj +++ b/test/metabase/native_query_analyzer_test.clj @@ -1,7 +1,6 @@ (ns metabase.native-query-analyzer-test (:require [clojure.test :refer :all] - [metabase.db.connection :as mdb.connection] [metabase.driver :as driver] [metabase.native-query-analyzer :as query-analyzer] [metabase.public-settings :as public-settings] @@ -39,13 +38,8 @@ (is (= {:explicit #{(mt/id :venues :id)} :implicit nil} (q "select id from venues")))) (testing "quotes stop case matching" - ;; MySQL does case-insensitive string comparisons by default; quoting does not make it consider case - ;; in field names either, so it's consistent behavior - (if (= (mdb.connection/db-type) :mysql) - (is (= {:explicit #{(mt/id :venues :id)} :implicit nil} - (q "select \"id\" from venues"))) - (is (= {:explicit nil :implicit nil} - (q "select \"id\" from venues")))) + (is (= {:explicit nil :implicit nil} + (q "select \"id\" from venues"))) (is (= {:explicit #{(mt/id :venues :id)} :implicit nil} (q "select \"ID\" from venues")))) (testing "you can mix quoted and unquoted names"