From b2a481db9b559a27fbe24fa2973ab038f5e33d96 Mon Sep 17 00:00:00 2001 From: Mike Alfare Date: Fri, 14 Jul 2023 14:31:39 -0400 Subject: [PATCH] applied new integration tests to existing framework --- core/dbt/tests/util.py | 31 +- .../materialized_view_tests/conftest.py | 45 +++ .../materialized_view_tests/files.py | 31 ++ .../materialized_view_tests/fixtures.py | 67 ---- .../test_materialized_view.py | 376 +++++++++++------- .../materialized_view_tests/utils.py | 85 ++++ 6 files changed, 415 insertions(+), 220 deletions(-) create mode 100644 tests/functional/materializations/materialized_view_tests/conftest.py create mode 100644 tests/functional/materializations/materialized_view_tests/files.py delete mode 100644 tests/functional/materializations/materialized_view_tests/fixtures.py create mode 100644 tests/functional/materializations/materialized_view_tests/utils.py diff --git a/core/dbt/tests/util.py b/core/dbt/tests/util.py index 5179ceb2f04..1bd41d4bfb9 100644 --- a/core/dbt/tests/util.py +++ b/core/dbt/tests/util.py @@ -20,7 +20,7 @@ ) from dbt.events.base_types import EventLevel from dbt.events.types import Note - +from dbt.adapters.base.relation import BaseRelation # ============================================================================= # Test utilities @@ -588,3 +588,32 @@ def __eq__(self, other): def __repr__(self): return "AnyStringWith<{!r}>".format(self.contains) + + +def assert_message_in_logs(message: str, logs: str, expected_pass: bool = True): + # if the logs are json strings, then 'jsonify' the message because of things like escape quotes + if os.environ.get("DBT_LOG_FORMAT", "") == "json": + message = message.replace(r'"', r"\"") + + if expected_pass: + assert message in logs + else: + assert message not in logs + + +def get_project_config(project): + file_yaml = read_file(project.project_root, "dbt_project.yml") + return yaml.safe_load(file_yaml) + + +def set_project_config(project, config): + config_yaml = yaml.safe_dump(config) + write_file(config_yaml, project.project_root, "dbt_project.yml") + + +def get_model_file(project, relation: BaseRelation) -> str: + return read_file(project.project_root, "models", f"{relation.name}.sql") + + +def set_model_file(project, relation: BaseRelation, model_sql: str): + write_file(model_sql, project.project_root, "models", f"{relation.name}.sql") diff --git a/tests/functional/materializations/materialized_view_tests/conftest.py b/tests/functional/materializations/materialized_view_tests/conftest.py new file mode 100644 index 00000000000..c02afd09223 --- /dev/null +++ b/tests/functional/materializations/materialized_view_tests/conftest.py @@ -0,0 +1,45 @@ +import pytest + +from dbt.contracts.relation import RelationType + +from dbt.adapters.postgres.relation import PostgresRelation + + +@pytest.fixture(scope="class") +def my_materialized_view(project) -> PostgresRelation: + return PostgresRelation.create( + identifier="my_materialized_view", + schema=project.test_schema, + database=project.database, + type=RelationType.MaterializedView, + ) + + +@pytest.fixture(scope="class") +def my_view(project) -> PostgresRelation: + return PostgresRelation.create( + identifier="my_view", + schema=project.test_schema, + database=project.database, + type=RelationType.View, + ) + + +@pytest.fixture(scope="class") +def my_table(project) -> PostgresRelation: + return PostgresRelation.create( + identifier="my_table", + schema=project.test_schema, + database=project.database, + type=RelationType.Table, + ) + + +@pytest.fixture(scope="class") +def my_seed(project) -> PostgresRelation: + return PostgresRelation.create( + identifier="my_seed", + schema=project.test_schema, + database=project.database, + type=RelationType.Table, + ) diff --git a/tests/functional/materializations/materialized_view_tests/files.py b/tests/functional/materializations/materialized_view_tests/files.py new file mode 100644 index 00000000000..9bf881ef970 --- /dev/null +++ b/tests/functional/materializations/materialized_view_tests/files.py @@ -0,0 +1,31 @@ +MY_SEED = """ +id,value +1,100 +2,200 +3,300 +""".strip() + + +MY_TABLE = """ +{{ config( + materialized='table', +) }} +select * from {{ ref('my_seed') }} +""" + + +MY_VIEW = """ +{{ config( + materialized='view', +) }} +select * from {{ ref('my_seed') }} +""" + + +MY_MATERIALIZED_VIEW = """ +{{ config( + materialized='materialized_view', + indexes=[{'columns': ['id']}], +) }} +select * from {{ ref('my_seed') }} +""" diff --git a/tests/functional/materializations/materialized_view_tests/fixtures.py b/tests/functional/materializations/materialized_view_tests/fixtures.py deleted file mode 100644 index 0250152376f..00000000000 --- a/tests/functional/materializations/materialized_view_tests/fixtures.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest - -from dbt.tests.util import relation_from_name -from tests.adapter.dbt.tests.adapter.materialized_view.base import Base -from tests.adapter.dbt.tests.adapter.materialized_view.on_configuration_change import ( - OnConfigurationChangeBase, - get_model_file, - set_model_file, -) - - -class PostgresBasicBase(Base): - @pytest.fixture(scope="class") - def models(self): - base_table = """ - {{ config(materialized='table') }} - select 1 as base_column - """ - base_materialized_view = """ - {{ config(materialized='materialized_view') }} - select * from {{ ref('base_table') }} - """ - return {"base_table.sql": base_table, "base_materialized_view.sql": base_materialized_view} - - -class PostgresOnConfigurationChangeBase(OnConfigurationChangeBase): - @pytest.fixture(scope="class") - def models(self): - base_table = """ - {{ config( - materialized='table', - indexes=[{'columns': ['id', 'value']}] - ) }} - select - 1 as id, - 100 as value, - 42 as new_id, - 4242 as new_value - """ - base_materialized_view = """ - {{ config( - materialized='materialized_view', - indexes=[{'columns': ['id', 'value']}] - ) }} - select * from {{ ref('base_table') }} - """ - return {"base_table.sql": base_table, "base_materialized_view.sql": base_materialized_view} - - @pytest.fixture(scope="function") - def configuration_changes(self, project): - initial_model = get_model_file(project, "base_materialized_view") - - # change the index from [`id`, `value`] to [`new_id`, `new_value`] - new_model = initial_model.replace( - "indexes=[{'columns': ['id', 'value']}]", - "indexes=[{'columns': ['new_id', 'new_value']}]", - ) - set_model_file(project, "base_materialized_view", new_model) - - yield - - # set this back for the next test - set_model_file(project, "base_materialized_view", initial_model) - - @pytest.fixture(scope="function") - def update_index_message(self, project): - return f"Applying UPDATE INDEXES to: {relation_from_name(project.adapter, 'base_materialized_view')}" diff --git a/tests/functional/materializations/materialized_view_tests/test_materialized_view.py b/tests/functional/materializations/materialized_view_tests/test_materialized_view.py index 733329b42ff..32c2ef72d92 100644 --- a/tests/functional/materializations/materialized_view_tests/test_materialized_view.py +++ b/tests/functional/materializations/materialized_view_tests/test_materialized_view.py @@ -1,197 +1,269 @@ import pytest + from dbt.contracts.graph.model_config import OnConfigurationChangeOption -from dbt.contracts.results import RunStatus -from dbt.contracts.relation import RelationType -from tests.adapter.dbt.tests.adapter.materialized_view.base import ( - run_model, - assert_model_exists_and_is_correct_type, - insert_record, - get_row_count, +from dbt.tests.util import ( + assert_message_in_logs, + get_model_file, + run_dbt, + run_dbt_and_capture, + set_model_file, +) +from tests.functional.materializations.materialized_view_tests.files import ( + MY_MATERIALIZED_VIEW, + MY_SEED, + MY_TABLE, + MY_VIEW, ) -from tests.adapter.dbt.tests.adapter.materialized_view.on_configuration_change import ( - assert_proper_scenario, +from tests.functional.materializations.materialized_view_tests.utils import ( + swap_indexes, + query_indexes, + query_relation_type, + query_row_count, ) -from tests.functional.materializations.materialized_view_tests.fixtures import ( - PostgresOnConfigurationChangeBase, - PostgresBasicBase, + +@pytest.fixture(scope="class", autouse=True) +def seeds(): + return {"my_seed.csv": MY_SEED} + + +@pytest.fixture(scope="class", autouse=True) +def models(): + yield { + "my_table.sql": MY_TABLE, + "my_view.sql": MY_VIEW, + "my_materialized_view.sql": MY_MATERIALIZED_VIEW, + } + + +@pytest.fixture(scope="class", autouse=True) +def setup(project): + run_dbt(["seed"]) + yield + + +def test_materialized_view_create(project, my_materialized_view): + assert query_relation_type(project, my_materialized_view) is None + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" + + +def test_materialized_view_create_idempotent(project, my_materialized_view): + assert query_relation_type(project, my_materialized_view) is None + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" + + +def test_materialized_view_full_refresh(project, my_materialized_view): + run_dbt(["run", "--models", my_materialized_view.identifier]) + _, logs = run_dbt_and_capture( + ["--debug", "run", "--models", my_materialized_view.identifier, "--full-refresh"] + ) + assert query_relation_type(project, my_materialized_view) == "materialized_view" + assert_message_in_logs(f"Applying REPLACE to: {my_materialized_view}", logs) + + +@pytest.mark.skip( + "The current implementation does not support overwriting tables with materialized views." ) +def test_materialized_view_replaces_table(project, my_materialized_view, my_table): + run_dbt(["run", "--models", my_table.identifier]) + sql = f""" + alter table {my_table} + rename to {my_materialized_view.identifier} + """ + project.run_sql(sql) + assert query_relation_type(project, my_materialized_view) == "table" + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" -class TestBasic(PostgresBasicBase): - def test_relation_is_materialized_view_on_initial_creation(self, project): - assert_model_exists_and_is_correct_type( - project, "base_materialized_view", RelationType.MaterializedView - ) - assert_model_exists_and_is_correct_type(project, "base_table", RelationType.Table) +@pytest.mark.skip( + "The current implementation does not support overwriting views with materialized views." +) +def test_materialized_view_replaces_view(project, my_materialized_view, my_view): + run_dbt(["run", "--models", my_view.identifier]) + sql = f""" + alter table {my_view} + rename to {my_materialized_view.identifier} + """ + project.run_sql(sql) + assert query_relation_type(project, my_materialized_view) == "view" + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" - def test_relation_is_materialized_view_when_rerun(self, project): - run_model("base_materialized_view") - assert_model_exists_and_is_correct_type( - project, "base_materialized_view", RelationType.MaterializedView - ) - def test_relation_is_materialized_view_on_full_refresh(self, project): - run_model("base_materialized_view", full_refresh=True) - assert_model_exists_and_is_correct_type( - project, "base_materialized_view", RelationType.MaterializedView - ) +@pytest.mark.skip( + "The current implementation does not support overwriting materialized views with tables." +) +def test_table_replaces_materialized_view(project, my_materialized_view, my_table): + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_table) == "materialized_view" + sql = f""" + alter materialized view {my_materialized_view} + rename to {my_table.identifier} + """ + project.run_sql(sql) + run_dbt(["run", "--models", my_table.identifier]) + assert query_relation_type(project, my_table) == "table" - def test_relation_is_materialized_view_on_update(self, project): - run_model("base_materialized_view", run_args=["--vars", "quoting: {identifier: True}"]) - assert_model_exists_and_is_correct_type( - project, "base_materialized_view", RelationType.MaterializedView - ) - def test_updated_base_table_data_only_shows_in_materialized_view_after_rerun(self, project): - # poll database - table_start = get_row_count(project, "base_table") - view_start = get_row_count(project, "base_materialized_view") +@pytest.mark.skip( + "The current implementation does not support overwriting materialized views with views." +) +def test_view_replaces_materialized_view(project, my_materialized_view, my_view): + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_view) == "materialized_view" + sql = f""" + alter materialized view {my_materialized_view} + rename to {my_view.identifier} + """ + project.run_sql(sql) + run_dbt(["run", "--models", my_materialized_view.identifier]) + assert query_relation_type(project, my_view) == "view" - # insert new record in table - new_record = (2,) - insert_record(project, new_record, "base_table", ["base_column"]) - # poll database - table_mid = get_row_count(project, "base_table") - view_mid = get_row_count(project, "base_materialized_view") +def test_materialized_view_only_updates_after_refresh(project, my_materialized_view, my_seed): + run_dbt(["run", "--models", my_materialized_view.identifier]) - # refresh the materialized view - run_model("base_materialized_view") + # poll database + table_start = query_row_count(project, my_seed) + view_start = query_row_count(project, my_materialized_view) - # poll database - table_end = get_row_count(project, "base_table") - view_end = get_row_count(project, "base_materialized_view") + # insert new record in table + project.run_sql(f"insert into {my_seed} (id, value) values (4, 400);") - # new records were inserted in the table but didn't show up in the view until it was refreshed - assert table_start < table_mid == table_end - assert view_start == view_mid < view_end + # poll database + table_mid = query_row_count(project, my_seed) + view_mid = query_row_count(project, my_materialized_view) + # refresh the materialized view + project.run_sql(f"refresh materialized view {my_materialized_view};") -class TestOnConfigurationChangeApply(PostgresOnConfigurationChangeBase): - # we don't need to specify OnConfigurationChangeOption.Apply because it's the default - # this is part of the test + # poll database + table_end = query_row_count(project, my_seed) + view_end = query_row_count(project, my_materialized_view) - def test_full_refresh_takes_precedence_over_any_configuration_changes( - self, configuration_changes, replace_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view", full_refresh=True) - assert_proper_scenario( - OnConfigurationChangeOption.Apply, - results, - logs, - RunStatus.Success, - messages_in_logs=[replace_message], - messages_not_in_logs=[configuration_change_message], - ) + # new records were inserted in the table but didn't show up in the view until it was refreshed + assert table_start < table_mid == table_end + assert view_start == view_mid < view_end - def test_model_is_refreshed_with_no_configuration_changes( - self, refresh_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view") - assert_proper_scenario( - OnConfigurationChangeOption.Apply, - results, - logs, - RunStatus.Success, - messages_in_logs=[refresh_message, configuration_change_message], - ) - def test_model_applies_changes_with_configuration_changes( - self, configuration_changes, alter_message, update_index_message - ): - results, logs = run_model("base_materialized_view") - assert_proper_scenario( - OnConfigurationChangeOption.Apply, - results, - logs, - RunStatus.Success, - messages_in_logs=[alter_message, update_index_message], - ) +class OnConfigurationChangeBase: + @pytest.fixture(scope="class", autouse=True) + def models(self): + yield {"my_materialized_view.sql": MY_MATERIALIZED_VIEW} + + @pytest.fixture(scope="function", autouse=True) + def setup(self, project, my_materialized_view): + run_dbt(["seed"]) + + # make sure the model in the data reflects the files each time + run_dbt(["run", "--models", my_materialized_view.identifier, "--full-refresh"]) + + # the tests touch these files, store their contents in memory + initial_model = get_model_file(project, my_materialized_view) + + yield + + # and then reset them after the test runs + set_model_file(project, my_materialized_view, initial_model) + + +class TestOnConfigurationChangeApply(OnConfigurationChangeBase): + @pytest.fixture(scope="class") + def project_config_update(self): + return {"models": {"on_configuration_change": OnConfigurationChangeOption.Apply.value}} + + @pytest.mark.skip( + "The current implementation does not properly drop the old index. The new one still gets created." + ) + def test_index_change_is_applied_with_alter(self, project, my_materialized_view): + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "id" + + swap_indexes(project, my_materialized_view) + _, logs = run_dbt_and_capture(["--debug", "run", "--models", my_materialized_view.name]) + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "value" # this changed -class TestOnConfigurationChangeContinue(PostgresOnConfigurationChangeBase): + assert_message_in_logs(f"Applying ALTER to: {my_materialized_view}", logs) + assert_message_in_logs(f"Applying UPDATE INDEXES to: {my_materialized_view}", logs) + assert_message_in_logs(f"Applying REPLACE to: {my_materialized_view}", logs, False) + + +class TestOnConfigurationChangeContinue(OnConfigurationChangeBase): @pytest.fixture(scope="class") def project_config_update(self): return {"models": {"on_configuration_change": OnConfigurationChangeOption.Continue.value}} - def test_full_refresh_takes_precedence_over_any_configuration_changes( - self, configuration_changes, replace_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view", full_refresh=True) - assert_proper_scenario( - OnConfigurationChangeOption.Continue, - results, - logs, - RunStatus.Success, - messages_in_logs=[replace_message], - messages_not_in_logs=[configuration_change_message], - ) + def test_index_change_is_not_applied(self, project, my_materialized_view): + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "id" - def test_model_is_refreshed_with_no_configuration_changes( - self, refresh_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view") - assert_proper_scenario( - OnConfigurationChangeOption.Continue, - results, + swap_indexes(project, my_materialized_view) + _, logs = run_dbt_and_capture(["--debug", "run", "--models", my_materialized_view.name]) + + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "id" # this did not change + + assert_message_in_logs( + f"Configuration changes were identified and `on_configuration_change` was set" + f" to `continue` for `{my_materialized_view}`", logs, - RunStatus.Success, - messages_in_logs=[refresh_message, configuration_change_message], ) - - def test_model_is_not_refreshed_with_configuration_changes( - self, configuration_changes, configuration_change_continue_message, refresh_message - ): - results, logs = run_model("base_materialized_view") - assert_proper_scenario( - OnConfigurationChangeOption.Continue, - results, + assert_message_in_logs(f"Applying ALTER to: {my_materialized_view}", logs, False) + assert_message_in_logs( + f"Applying UPDATE AUTOREFRESH to: {my_materialized_view}", logs, - RunStatus.Success, - messages_in_logs=[configuration_change_continue_message], - messages_not_in_logs=[refresh_message], + False, ) + assert_message_in_logs(f"Applying REPLACE to: {my_materialized_view}", logs, False) + def test_full_refresh_still_occurs_with_changes(self, project, my_materialized_view): + run_dbt(["run", "--models", my_materialized_view.identifier, "--full-refresh"]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" -class TestOnConfigurationChangeFail(PostgresOnConfigurationChangeBase): + +class TestOnConfigurationChangeFail(OnConfigurationChangeBase): @pytest.fixture(scope="class") def project_config_update(self): return {"models": {"on_configuration_change": OnConfigurationChangeOption.Fail.value}} - def test_full_refresh_takes_precedence_over_any_configuration_changes( - self, configuration_changes, replace_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view", full_refresh=True) - assert_proper_scenario( - OnConfigurationChangeOption.Fail, - results, - logs, - RunStatus.Success, - messages_in_logs=[replace_message], - messages_not_in_logs=[configuration_change_message], + def test_index_change_is_not_applied(self, project, my_materialized_view): + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "id" + + swap_indexes(project, my_materialized_view) + _, logs = run_dbt_and_capture( + ["--debug", "run", "--models", my_materialized_view.name], expect_pass=False ) - def test_model_is_refreshed_with_no_configuration_changes( - self, refresh_message, configuration_change_message - ): - results, logs = run_model("base_materialized_view") - assert_proper_scenario( - OnConfigurationChangeOption.Fail, - results, + indexes = query_indexes(project, my_materialized_view) + assert len(indexes) == 1 + assert indexes[0]["column_names"] == "id" + + assert_message_in_logs( + f"Configuration changes were identified and `on_configuration_change` was set" + f" to `fail` for `{my_materialized_view}`", logs, - RunStatus.Success, - messages_in_logs=[refresh_message, configuration_change_message], ) - - def test_run_fails_with_configuration_changes( - self, configuration_changes, configuration_change_fail_message - ): - results, logs = run_model("base_materialized_view", expect_pass=False) - assert_proper_scenario( - OnConfigurationChangeOption.Fail, - results, + assert_message_in_logs(f"Applying ALTER to: {my_materialized_view}", logs, False) + assert_message_in_logs( + f"Applying UPDATE AUTOREFRESH to: {my_materialized_view}", logs, - RunStatus.Error, - messages_in_logs=[configuration_change_fail_message], + False, ) + assert_message_in_logs(f"Applying REPLACE to: {my_materialized_view}", logs, False) + + def test_full_refresh_still_occurs_with_changes(self, project, my_materialized_view): + run_dbt(["run", "--models", my_materialized_view.identifier, "--full-refresh"]) + assert query_relation_type(project, my_materialized_view) == "materialized_view" diff --git a/tests/functional/materializations/materialized_view_tests/utils.py b/tests/functional/materializations/materialized_view_tests/utils.py new file mode 100644 index 00000000000..00f6fefa554 --- /dev/null +++ b/tests/functional/materializations/materialized_view_tests/utils.py @@ -0,0 +1,85 @@ +from typing import Dict, List, Optional + +from dbt.tests.util import get_model_file, set_model_file + +from dbt.adapters.postgres.relation import PostgresRelation + + +def swap_indexes(project, my_materialized_view): + initial_model = get_model_file(project, my_materialized_view) + new_model = initial_model.replace( + "indexes=[{'columns': ['id']}]", + "indexes=[{'columns': ['value']}]", + ) + set_model_file(project, my_materialized_view, new_model) + + +def query_relation_type(project, relation: PostgresRelation) -> Optional[str]: + sql = f""" + select + 'table' as relation_type + from pg_tables + where schemaname = '{relation.schema}' + and tablename = '{relation.identifier}' + union all + select + 'view' as relation_type + from pg_views + where schemaname = '{relation.schema}' + and viewname = '{relation.identifier}' + union all + select + 'materialized_view' as relation_type + from pg_matviews + where schemaname = '{relation.schema}' + and matviewname = '{relation.identifier}' + """ + results = project.run_sql(sql, fetch="all") + if len(results) == 0: + return None + elif len(results) > 1: + raise ValueError(f"More than one instance of {relation.name} found!") + else: + return results[0][0] + + +def query_row_count(project, relation: PostgresRelation) -> int: + sql = f"select count(*) from {relation}" + return project.run_sql(sql, fetch="one")[0] + + +def query_indexes(project, relation: PostgresRelation) -> List[Dict[str, str]]: + # pulled directly from `postgres__describe_indexes_template` and manually verified + sql = f""" + select + i.relname as name, + m.amname as method, + ix.indisunique as "unique", + array_to_string(array_agg(a.attname), ',') as column_names + from pg_index ix + join pg_class i + on i.oid = ix.indexrelid + join pg_am m + on m.oid=i.relam + join pg_class t + on t.oid = ix.indrelid + join pg_namespace n + on n.oid = t.relnamespace + join pg_attribute a + on a.attrelid = t.oid + and a.attnum = ANY(ix.indkey) + where t.relname ilike '{ relation.identifier }' + and n.nspname ilike '{ relation.schema }' + and t.relkind in ('r', 'm') + group by 1, 2, 3 + order by 1, 2, 3 + """ + raw_indexes = project.run_sql(sql, fetch="all") + indexes = [ + { + header: value + for header, value in zip(["name", "method", "unique", "column_names"], index) + } + for index in raw_indexes + ] + return indexes