Repository: dbt-labs/dbt-core

Issue:
[CT-2068] [Feature] Let's add Materialized View as a materialization, finally
## `materialized: materialized_view`

### Context : The time has come, let's do this.

I'll elaborate on why we're doing this now and not before in a public post in the near future. The short version is that a new generation of Materialized Views (MVs) is emerging, powered by streaming technologies, and we want to support them. This also allows dbt to retroactively support some of the current implementations in a cleaner way. Win, win, win.

This specific issue is about building the foundation. It is about re-purposing the work already done in the [experimental package](https://github.com/dbt-labs/dbt-labs-experimental-features/tree/main/materialized-views) for a **default** materialization, **Postgres**, **Redshift** and **BigQuery** implementations, and make it official. 

Please note that **Snowflake** is not in the list above. For `dbt-snowflake`, we'll skip their current MVs and support directly [Dynamic Tables](https://www.snowflake.com/blog/dynamic-tables-delivering-declarative-streaming-data-pipelines/) when they're ready ([we already have an issue for it!](https://github.com/dbt-labs/dbt-snowflake/issues/400)).

### A new materialization

So yes, we need a new materialization with a default implementation in dbt core, and 3 dispatch implementations for Postgres, Redshift and BigQuery.

I like the ergonomics designed in the [experimental package](https://github.com/dbt-labs/dbt-labs-experimental-features/tree/main/materialized-views)...
- [**edit 1**]~~I see no reason for us to change them for now (well... see the alternative option below).~~ 
- [**edit 2**]~~I re-used them but added `on_definition_change` (now split into `on_configuration_change` and `on_schema_change` for easier onboarding from incremental models) to offer a similar behavior to what can be found with `on_schema_change` for [incremental models](https://docs.getdbt.com/docs/build/incremental-models#what-if-the-columns-of-my-incremental-model-change). Here it's not only the schema that we may need to change, but most importantly the configuration options like auto_refresh mode, or the refresh_interval_minutes. These options can be modified via an ALTER statement that doesn't require the full state of the MV to be recomputed (not that it's limited to that, but it allows those too).~~ 
- ... I re-used them but added `on_configuration_change` to mimic the behavior of `on_schema_change` from incremental models, focused on configs. A normal run of dbt on MVs should only be about "moving data", and not deploying code changes. So either refresh, or do nothing. We can still try to pass along configuration changes (indexes, labels, warehouses...) because there's a chance we can do that without triggering a full refresh of the view (which is what we are trying to avoid). 

We also want to provide that capability for the logic of the view (SQL statement). But at this point, we can't do it cleanly. We can't compare the logic that is deployed to the one we have in the model (I dread comparing SQL statements as strings), and no platform in scope supports `CREATE OR REPLACE`. So we can't be clever, and we can't be brutal expecting the platform to be clever.

We also can't limit the scope to `on_schema_change` because in MVs there is only room for 1 logic to be applied at a point in time, contrary to incremental models where the previous logic can live with the new one. We will follow up on that later.

___
#### Default implementation

Note how the current expectation is that base MVs require a refresh.

- Configurations : none
- Lifecycle (`dbt run`) : 
  - **If** necessary backup (exists with different materialization ~~OR exists and full-refresh~~)
  - **If** necessary create (doesn't exist) **Else** 
    - **If** the options of the object in the database are different from the current model, depending on `on_configuration_change`
      - ~~`ignore`~~ : we can't use ignore here, as on_schema_change=ignore for incremental models has set weird expectations on the word. Let's use skip instead for now.
      - `skip` : Do nothing, aka stage changes until the next full refresh. (Documentation follow-up: this can create uncertainty where there is a delta between the model definition and what's in the database - same as on_schema_change=ignore for incremental models)
      - `fail` : Fail the run
      - `apply` : [default] Apply the changes (implementation detail: let's try to favor commands that do not trigger full recomputes (ALTER, CREATE OR REPLACE))
    - Refresh when necessary (the object was not re-created in a step above)
 - Lifecycle (`dbt run --full-refresh`) :
   - **If** necessary backup (exists with different materialization ~~OR exists and full-refresh~~)
   - **If** necessary drop
   - Create

Described more simply, for a MV already created:
- `dbt run --full-refresh` : we drop and create
  - Here, in order we should try to:
    1. CREATE OR REPLACE - if CREATE OR REPLACE is supported
    2. CREATE, DROP, ALTER NAME - if rename is supported (Postgres, Redshift)
    3. DROP, CREATE - if none of the above (BQ)
- `dbt run` : we refresh (or do nothing if auto_refresh)
  - Except if you set `on_configuration_change` to something other that `skip`, which is the default, then we check the config in database to the one in the model, and if there are diffs, we `apply` or `fail`
  - For `apply`, in order we should try to - this choice need to be made for each configuration (some will support ALTER, some not)
    1. ALTER in place 
    2. else apply the full refresh order

___
#### Postgres

[Postgres : Materialized Views](https://www.postgresql.org/docs/current/rules-materializedviews.html)

⚠️ [Schema management requires some attention](https://github.com/dbt-labs/dbt-labs-experimental-features/tree/main/materialized-views#extra-installation-steps-for-postgres-and-redshift)

- Configurations:
  - From models : [indexes](https://docs.getdbt.com/reference/resource-configs/postgres-configs#indexes)
- Lifecycle : _default_
  - `on_configuration_change`.`apply` : indexes via [DROP/CREATE INDEXES](https://www.postgresql.org/docs/current/rules-materializedviews.html)

___
#### Redshift

[Redshift Materialized Views](https://docs.aws.amazon.com/redshift/latest/dg/materialized-view-overview.html)

⚠️ [Schema management requires some attention](https://github.com/dbt-labs/dbt-labs-experimental-features/tree/main/materialized-views#extra-installation-steps-for-postgres-and-redshift)

- Configurations:
  - auto_refresh : YES/NO, optional (database level default no) (see lifecycle for effect)
  - From models : [sort / sort_type / dist](https://docs.getdbt.com/reference/resource-configs/redshift-configs#using-sortkey-and-distkey)
- Lifecycle : _default_ except Refresh is bypassed if auto_refresh
  - `on_configuration_change`.`apply` : auto_refresh via [ALTER](https://docs.aws.amazon.com/redshift/latest/dg/r_ALTER_MATERIALIZED_VIEW.html), rest via DROP/[CREATE](https://docs.aws.amazon.com/redshift/latest/dg/materialized-view-create-sql-command.html)

___
#### BigQuery

[BigQuery Materialized Views](https://cloud.google.com/bigquery/docs/materialized-views-intro)

- Configurations:
  - enable_refresh : BOOLEAN, optional (database level [default](https://cloud.google.com/bigquery/docs/materialized-views-manage#automatic_refresh) true) (see lifecycle for effect)
  - refresh_interval_minutes : integer, optional (database level [default](https://cloud.google.com/bigquery/docs/materialized-views-manage#automatic_refresh) at 30), only valid for enable_refresh true or default
  - max_staleness : time before bypassing the MV and running the statement instead (in preview but I like it!)
  - From models: [Partition / Cluster](https://docs.getdbt.com/reference/resource-configs/bigquery-configs#using-table-partitioning-and-clustering), [TTL](https://docs.getdbt.com/reference/resource-configs/bigquery-configs#controlling-table-expiration), [Labels](https://docs.getdbt.com/reference/resource-configs/bigquery-configs#labels-and-tags)  
- Lifecycle : _default_ except Refresh is bypassed if auto_refresh
  - `on_configuration_change`.`apply` : enable_refresh/refresh_interval_minutes/max_staleness via [ALTER](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#alter_materialized_view_set_options_statement), rest via DROP/[CREATE](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_materialized_view_statement)

___

### Backward compatibility

People using `materialized: materialized_view` from the [experimental package](https://github.com/dbt-labs/dbt-labs-experimental-features/tree/main/materialized-views) in dbt core 1.4 and before, should not be broken by the addition of a default and adapter level `materialized_view` materializations. The chain of precedence in macros will preserve existing behavior. The package will need to be uninstalled to benefit from the new implementation.

___
___
___

## Additional details, not required for implementation

### Risks

1. Most users interested by this topic are already using the experimental package. We need to make sure that this implementation won't break them when we roll it out. I'm guessing the [chain of inheritance](https://docs.getdbt.com/reference/dbt-jinja-functions/dispatch#adapter-inheritance) should work in our favor here, but we need to be sure. 

2. Same thing for community/vendor supported adapters already implementing a version of MVs ([Materialize](https://docs.getdbt.com/reference/resource-configs/materialize-configs#incremental-models-materialized-views) for example).

3. Information collection will be tricky across the board. MVs are usually not accessible via the default information schemas / catalogues

4. Cascading drops in full-refresh are tricky. If an upstream table used in a MV is dropped/altered, the MV may end up in a funky state. See comments in the experimental packages. We may need to revisit some of the behaviors or re-calibrate expectations.



### Alternative/Options

#### default auto_refresh = true

~~I'm seriously wondering if the default ergonomics should not be more aggressively geared towards auto refreshing:~~
_[Edit following comments below] Not a good idea_

- **Default**
  - Configurations
    - auto_refresh : optional, default `true` 
  - Lifecycle : 
    - DDL : 
      - **If** necessary backup (exists with different materialization OR exists and full-refresh)
      - **If** necessary create (doesn't exist)
- **Postgres**
  - Configurations:
    - auto_refresh : mandatory no
  - Lifecycle : _default_ + **else** refresh
- **Redshift**
  - Configurations: 
    - auto_refresh : ⚠️ default option is reversed (still true, contrary to the engine default)
- **BigQuery**
  - Configurations:
    - auto_refresh/enable_refresh : default
    - refresh_interval_minutes
    - max_staleness

___

### Who will this benefit?

A lot of people:
- Core / Postgres : https://github.com/dbt-labs/dbt-core/issues/1162
- BigQuery : https://github.com/dbt-labs/dbt-bigquery/issues/18
- Snowflake : https://github.com/dbt-labs/dbt-snowflake/pull/181
- Databricks : https://github.com/databricks/dbt-databricks/pull/242
- [Issues](https://github.com/dbt-labs/dbt-labs-experimental-features/issues?q=is%3Aissue+%22materialized+view%22) in the experimental package

### Anything else?

Historical perspective on the topic : https://www.youtube.com/watch?v=awj-5aYXZnc


Discussion:
@jtcohen6, @Kayrnt and everyone else, I welcome your feedback on the alternative ergonomics ;)
I agree with this approach of not making auto-refresh the default. It will help users be more mindful of managing to compute costs (i.e making sure their development environments are running with intent). While I don't find using MVs for analytics compelling without auto-refresh in prod, a user interacting with an MV in dbt will be primarily doing it during development where having clear levers will be more helpful. 

Materialized views in Materialize work under different assumptions to other databases (_i.e._ native incremental updates, no need for scheduled refreshes), so at first glance it doesn't look like we could inherit from `dbt-postgres` once the new materialization lands. As a maintainer, I'd like to make sure that we're consistent with the materialization name you land on, though! As is, we're using `materializedview` in our [custom materialization](https://github.com/MaterializeInc/materialize/blob/9f9cac8e3ff6cf5ae020426d800a45611eed1211/misc/dbt-materialize/dbt/include/materialize/macros/materializations/materialized_view.sql#L17), so deviating from that would mean a breaking change for existing users.

On our end, the materialization looks like:

**Configurations**
   * (optional) `cluster`: the cluster that maintains the materialized view (_i.e._ compute)
   * (optional) `indexes`: any indexes defined on top of the materialized view
   
**Lifecycle**
The default behaviour in `dbt-materialize` is to drop and recreate the materialized view, which is less problematic than it might be in other adapters since you're not expected to hit `dbt run` on a cadence (but rather _once_ to bootstrap your setup, and then to add new models or evolve existing ones (using partial runs when possible)), and the engine is purpose-built to, well, be very efficient at computing and incrementally maintaining materialized views! At the risk of going too deep into Materialize specifics, one thing we're eyeing next is allowing an operation that waits until a new view with the new SQL definition is caught up before dropping an existing one, and then swaps them.
We should talk ;)

> At the risk of going too deep into Materialize specifics, one thing we're eyeing next is allowing an operation that waits until a new view with the new SQL definition is caught up before dropping an existing one, and then swaps them.

I like that.

Updated with a new `on_definition_change` parameter for `dbt run`.
Updated following internal review:
- `on_definition_change` is split between `on_schema_change` (tracking column list) and `on_configuration_change` (tracking model configurations), to ease onboarding from incremental models
- reduced the option for these configs to `ignore`/`fail`/`apply` - we try a little bit of cleverness, favoring ALTER / CREATE OR REPLACE statements where applicable, but mostly rely on the databases to optimize and avoid full recomputes

Resolving patch:
diff --git a/core/dbt/contracts/graph/model_config.py b/core/dbt/contracts/graph/model_config.py
--- a/core/dbt/contracts/graph/model_config.py
+++ b/core/dbt/contracts/graph/model_config.py
@@ -42,6 +42,24 @@ def _set_meta_value(obj: M, key: str, existing: Optional[Dict[str, Any]] = None)
     return result
 
 
+class OnConfigurationChangeOption(str, Enum):
+    apply = "apply"
+    skip = "skip"
+    fail = "fail"
+
+    @classmethod
+    def get_default(cls):
+        return cls.apply
+
+    @classmethod
+    def is_valid(cls, item):
+        try:
+            cls(item)
+        except ValueError:
+            return False
+        return True
+
+
 class Metadata(Enum):
     @classmethod
     def from_field(cls: Type[M], fld: Field) -> M:
@@ -445,6 +463,9 @@ class NodeConfig(NodeAndTestConfig):
     # sometimes getting the Union order wrong, causing serialization failures.
     unique_key: Union[str, List[str], None] = None
     on_schema_change: Optional[str] = "ignore"
+    on_configuration_change: Optional[OnConfigurationChangeOption] = field(
+        default_factory=OnConfigurationChangeOption.get_default
+    )
     grants: Dict[str, Any] = field(
         default_factory=dict, metadata=MergeBehavior.DictKeyAppend.meta()
     )


Test patch:
diff --git a/test/unit/test_contracts_graph_compiled.py b/test/unit/test_contracts_graph_compiled.py
--- a/test/unit/test_contracts_graph_compiled.py
+++ b/test/unit/test_contracts_graph_compiled.py
@@ -147,6 +147,7 @@ def basic_uncompiled_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "packages": [],
@@ -199,6 +200,7 @@ def basic_compiled_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "packages": [],
diff --git a/test/unit/test_contracts_graph_parsed.py b/test/unit/test_contracts_graph_parsed.py
--- a/test/unit/test_contracts_graph_parsed.py
+++ b/test/unit/test_contracts_graph_parsed.py
@@ -82,6 +82,7 @@ def populated_node_config_dict():
         "tags": [],
         "extra": "even more",
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "grants": {},
         "packages": [],
@@ -166,6 +167,7 @@ def base_parsed_model_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -274,6 +276,7 @@ def complex_parsed_model_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -353,6 +356,7 @@ def complex_parsed_model_object():
     "quoting": {},
     "column_types": {"a": "text"},
     "on_schema_change": "ignore",
+    "on_configuration_change": "apply",
     "grants": {},
     "packages": [],
     "docs": {"show": True},
@@ -371,6 +375,7 @@ def complex_parsed_model_object():
     "quoting": {},
     "tags": [],
     "on_schema_change": "ignore",
+    "on_configuration_change": "apply",
     "meta": {},
     "grants": {},
     "docs": {"show": True},
@@ -521,6 +526,7 @@ def basic_parsed_seed_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -612,6 +618,7 @@ def complex_parsed_seed_dict():
             "tags": [],
             "quote_columns": True,
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -905,6 +912,7 @@ def base_parsed_hook_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -987,6 +995,7 @@ def complex_parsed_hook_dict():
             "quoting": {},
             "tags": [],
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -1343,6 +1352,7 @@ def basic_timestamp_snapshot_config_dict():
         "target_database": "some_snapshot_db",
         "target_schema": "some_snapshot_schema",
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "grants": {},
         "packages": [],
@@ -1380,6 +1390,7 @@ def complex_timestamp_snapshot_config_dict():
         "strategy": "timestamp",
         "updated_at": "last_update",
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "grants": {},
         "packages": [],
@@ -1445,6 +1456,7 @@ def basic_check_snapshot_config_dict():
         "strategy": "check",
         "check_cols": "all",
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "grants": {},
         "packages": [],
@@ -1482,6 +1494,7 @@ def complex_set_snapshot_config_dict():
         "strategy": "check",
         "check_cols": ["a", "b"],
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "grants": {},
         "packages": [],
@@ -1598,6 +1611,7 @@ def basic_timestamp_snapshot_dict():
             "strategy": "timestamp",
             "updated_at": "last_update",
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
@@ -1745,6 +1759,7 @@ def basic_check_snapshot_dict():
             "strategy": "check",
             "check_cols": "all",
             "on_schema_change": "ignore",
+            "on_configuration_change": "apply",
             "meta": {},
             "grants": {},
             "docs": {"show": True},
diff --git a/tests/adapter/dbt/tests/adapter/materialized_views/test_materialized_views.py b/tests/adapter/dbt/tests/adapter/materialized_views/base.py
similarity index 53%
rename from tests/adapter/dbt/tests/adapter/materialized_views/test_materialized_views.py
rename to tests/adapter/dbt/tests/adapter/materialized_views/base.py
--- a/tests/adapter/dbt/tests/adapter/materialized_views/test_materialized_views.py
+++ b/tests/adapter/dbt/tests/adapter/materialized_views/base.py
@@ -1,4 +1,5 @@
 from typing import List
+import os
 
 import pytest
 
@@ -6,7 +7,7 @@
 from dbt.contracts.relation import RelationType
 
 
-class MaterializedViewTestsBase:
+class Base:
 
     materialized_view = "mat_view"
     base_table = "base_table"
@@ -27,50 +28,52 @@ def models(self):
 
     @pytest.fixture(scope="function", autouse=True)
     def setup(self, project):
-        run_dbt()
-        for record in self.inserted_records:
-            self.insert_record(project, self.base_table, self.base_table_columns, record)
+        run_dbt(["run", "--models", self.base_table])
+        self.insert_records(
+            project, self.starting_records, self.base_table, self.base_table_columns
+        )
+        run_dbt(["run", "--models", self.materialized_view])
 
     @staticmethod
     def get_records(project, relation_identifier: str) -> List[tuple]:
         sql = f"select * from {project.database}.{project.test_schema}.{relation_identifier};"
         return [tuple(row) for row in project.run_sql(sql, fetch="all")]
 
-    @staticmethod
-    def insert_record(project, relation_identifier: str, columns: List[str], record: tuple):
+    def insert_record(
+        self, project, record: tuple, relation_identifier: str = None, columns: List[str] = None
+    ):
+        relation_identifier = relation_identifier or self.base_table
+        columns = columns or self.base_table_columns
+
         sql = f"""
         insert into {project.database}.{project.test_schema}.{relation_identifier} ({', '.join(columns)})
         values ({','.join(str(value) for value in record)})
         ;"""
         project.run_sql(sql)
 
-    def insert_test_records(self, project):
-        for record in self.inserted_records:
-            self.insert_record(project, self.base_table, self.base_table_columns, record)
+    def insert_records(
+        self,
+        project,
+        records: List[tuple],
+        relation_identifier: str = None,
+        columns: List[str] = None,
+    ):
+        for record in records:
+            self.insert_record(project, record, relation_identifier, columns)
 
     def assert_relation_is_materialized_view(self, project, relation_identifier):
         manifest = get_manifest(project.project_root)
         model = manifest.nodes[f"model.test.{relation_identifier}"]
         assert model.config.materialized == RelationType.MaterializedView
-        assert len(self.get_records(project, relation_identifier)) > 0
-
-    def test_relation_is_materialized_view_on_initial_creation(self, project):
-        self.assert_relation_is_materialized_view(project, self.materialized_view)
-
-    def test_relation_is_materialized_view_when_rerun(self, project, adapter):
-        run_dbt(["run", "--models", self.materialized_view])
-        self.assert_relation_is_materialized_view(project, self.materialized_view)
-
-    def test_relation_is_materialized_view_on_full_refresh(self, project):
-        run_dbt(["run", "--models", self.materialized_view, "--full-refresh"])
-        self.assert_relation_is_materialized_view(project, self.materialized_view)
-
-    def test_updated_base_table_data_only_shows_in_materialized_view_after_rerun(self, project):
-        self.insert_test_records(project)
-        assert self.get_records(project, self.materialized_view) == self.starting_records
+        assert len(self.get_records(project, relation_identifier)) >= 0
 
-        run_dbt(["run", "--models", self.materialized_view])
-        assert (
-            self.get_records(project, self.materialized_view)
-            == self.starting_records + self.inserted_records
-        )
+    @staticmethod
+    def assert_message_in_logs(logs: str, message: str, expected_fail: bool = False):
+        # 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_fail:
+            assert message not in logs
+        else:
+            assert message in logs
diff --git a/tests/adapter/dbt/tests/adapter/materialized_views/test_basic.py b/tests/adapter/dbt/tests/adapter/materialized_views/test_basic.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/materialized_views/test_basic.py
@@ -0,0 +1,30 @@
+from dbt.tests.util import run_dbt
+
+from dbt.tests.adapter.materialized_views.base import Base
+
+
+class BasicTestsBase(Base):
+    def test_relation_is_materialized_view_on_initial_creation(self, project):
+        self.assert_relation_is_materialized_view(project, self.materialized_view)
+
+    def test_relation_is_materialized_view_when_rerun(self, project):
+        run_dbt(["run", "--models", self.materialized_view])
+        self.assert_relation_is_materialized_view(project, self.materialized_view)
+
+    def test_relation_is_materialized_view_on_full_refresh(self, project):
+        run_dbt(["run", "--models", self.materialized_view, "--full-refresh"])
+        self.assert_relation_is_materialized_view(project, self.materialized_view)
+
+    def test_relation_is_materialized_view_on_update(self, project):
+        run_dbt(
+            ["run", "--models", self.materialized_view, "--vars", "quoting: {identifier: True}"]
+        )
+        self.assert_relation_is_materialized_view(project, self.materialized_view)
+
+    def test_updated_base_table_data_only_shows_in_materialized_view_after_rerun(self, project):
+        self.insert_records(project, self.inserted_records)
+        assert self.get_records(project, self.materialized_view) == self.starting_records
+
+        run_dbt(["run", "--models", self.materialized_view])
+        final_records = self.starting_records + self.inserted_records
+        assert self.get_records(project, self.materialized_view) == final_records
diff --git a/tests/adapter/dbt/tests/adapter/materialized_views/test_on_configuration_change.py b/tests/adapter/dbt/tests/adapter/materialized_views/test_on_configuration_change.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/materialized_views/test_on_configuration_change.py
@@ -0,0 +1,173 @@
+from typing import List
+
+import pytest
+
+from dbt.contracts.graph.model_config import OnConfigurationChangeOption
+from dbt.contracts.results import RunStatus
+from dbt.tests.util import run_dbt_and_capture, relation_from_name
+
+from dbt.tests.adapter.materialized_views.base import Base
+
+
+class OnConfigurationChangeBase(Base):
+
+    on_configuration_change: OnConfigurationChangeOption
+
+    @pytest.fixture(scope="class")
+    def project_config_update(self):
+        return {"models": {"on_configuration_change": str(self.on_configuration_change)}}
+
+    def assert_proper_scenario(
+        self,
+        results,
+        logs,
+        status: RunStatus,
+        messages_in_logs: List[str],
+        messages_not_in_logs: List[str],
+        rows_affected: int,
+    ):
+        assert len(results.results) == 1
+        result = results.results[0]
+
+        assert result.node.config.on_configuration_change == self.on_configuration_change
+        assert result.status == status
+        assert result.adapter_response["rows_affected"] == rows_affected
+        for message in messages_in_logs:
+            self.assert_message_in_logs(logs, message)
+        for message in messages_not_in_logs:
+            self.assert_message_in_logs(logs, message, expected_fail=True)
+
+    def apply_configuration_change_triggering_apply(self, project):
+        raise NotImplementedError(
+            (
+                "Overwrite this to apply a configuration change that should trigger an `apply` scenario "
+                "that's specific to your adapter. Do this by altering the project files.",
+            )
+        )
+
+    def apply_configuration_change_triggering_full_refresh(self, project):
+        raise NotImplementedError(
+            (
+                "Overwrite this to apply a configuration change that should trigger a `full refresh` scenario "
+                "specific to your adapter. Do this by altering the project files.",
+            )
+        )
+
+    def test_full_refresh_takes_precedence_over_any_configuration_changes(self, project):
+        self.apply_configuration_change_triggering_apply(project)
+        self.apply_configuration_change_triggering_full_refresh(project)
+        results, logs = run_dbt_and_capture(
+            ["--debug", "run", "--models", self.materialized_view, "--full-refresh"]
+        )
+
+        messages_in_logs = [
+            f"Applying REPLACE to: {relation_from_name(project.adapter, self.materialized_view)}",
+        ]
+        messages_not_in_logs = [
+            f"Determining configuration changes on: {project.adapter, self.materialized_view}",
+        ]
+        self.assert_proper_scenario(
+            results,
+            logs,
+            RunStatus.Success,
+            messages_in_logs,
+            messages_not_in_logs,
+            -1,
+        )
+
+    def test_model_is_refreshed_with_no_configuration_changes(self, project):
+        results, logs = run_dbt_and_capture(["--debug", "run", "--models", self.materialized_view])
+
+        messages_in_logs = [
+            f"Determining configuration changes on: {relation_from_name(project.adapter, self.materialized_view)}",
+            f"Applying REFRESH to: {relation_from_name(project.adapter, self.materialized_view)}",
+        ]
+        messages_not_in_logs = []
+        self.assert_proper_scenario(
+            results,
+            logs,
+            RunStatus.Success,
+            messages_in_logs,
+            messages_not_in_logs,
+            -1,
+        )
+
+
+class OnConfigurationChangeApplyTestsBase(OnConfigurationChangeBase):
+
+    on_configuration_change = "apply"
+
+    def test_full_refresh_configuration_changes_will_not_attempt_apply_configuration_changes(
+        self, project
+    ):
+        self.apply_configuration_change_triggering_apply(project)
+        self.apply_configuration_change_triggering_full_refresh(project)
+        results, logs = run_dbt_and_capture(["--debug", "run", "--models", self.materialized_view])
+
+        messages_in_logs = [
+            f"Applying REPLACE to: {relation_from_name(project.adapter, self.materialized_view)}",
+        ]
+        messages_not_in_logs = [
+            f"Applying ALTER to: {relation_from_name(project.adapter, self.materialized_view)}",
+        ]
+        self.assert_proper_scenario(
+            results,
+            logs,
+            RunStatus.Success,
+            messages_in_logs,
+            messages_not_in_logs,
+            -1,
+        )
+
+    def test_model_applies_changes_with_configuration_changes(self, project):
+        self.apply_configuration_change_triggering_apply(project)
+        results, logs = run_dbt_and_capture(["--debug", "run", "--models", self.materialized_view])
+
+        messages_in_logs = [
+            f"Applying ALTER to: {relation_from_name(project.adapter, self.materialized_view)}"
+        ]
+        messages_not_in_logs = []
+        self.assert_proper_scenario(
+            results,
+            logs,
+            RunStatus.Success,
+            messages_in_logs,
+            messages_not_in_logs,
+            -1,
+        )
+
+
+class OnConfigurationChangeSkipTestsBase(OnConfigurationChangeBase):
+
+    on_configuration_change = "skip"
+
+    def test_model_is_skipped_with_configuration_changes(self, project):
+        self.apply_configuration_change_triggering_apply(project)
+        results, logs = run_dbt_and_capture(["--debug", "run", "--models", self.materialized_view])
+
+        messages_in_logs = [
+            f"Configuration changes were identified and `on_configuration_change` "
+            f"was set to `skip` for `{self.materialized_view}`",
+        ]
+        messages_not_in_logs = []
+        self.assert_proper_scenario(
+            results, logs, RunStatus.Success, messages_in_logs, messages_not_in_logs, -1
+        )
+
+
+class OnConfigurationChangeFailTestsBase(OnConfigurationChangeBase):
+
+    on_configuration_change = "fail"
+
+    def test_run_fails_with_configuration_changes(self, project):
+        self.apply_configuration_change_triggering_apply(project)
+        results, logs = run_dbt_and_capture(["--debug", "run", "--models", self.materialized_view])
+
+        messages_in_logs = [
+            f"Configuration changes were identified and `on_configuration_change` "
+            f"was set to `fail` for `{self.materialized_view}`",
+        ]
+        messages_not_in_logs = []
+        self.assert_proper_scenario(
+            results, logs, RunStatus.Error, messages_in_logs, messages_not_in_logs, -1
+        )
diff --git a/tests/functional/artifacts/expected_manifest.py b/tests/functional/artifacts/expected_manifest.py
--- a/tests/functional/artifacts/expected_manifest.py
+++ b/tests/functional/artifacts/expected_manifest.py
@@ -29,6 +29,7 @@ def get_rendered_model_config(**updates):
         "persist_docs": {},
         "full_refresh": None,
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "meta": {},
         "unique_key": None,
         "grants": {},
@@ -59,6 +60,7 @@ def get_rendered_seed_config(**updates):
         "quote_columns": True,
         "full_refresh": None,
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "database": None,
         "schema": None,
         "alias": None,
@@ -96,6 +98,7 @@ def get_rendered_snapshot_config(**updates):
         "persist_docs": {},
         "full_refresh": None,
         "on_schema_change": "ignore",
+        "on_configuration_change": "apply",
         "strategy": "check",
         "check_cols": "all",
         "unique_key": "id",
diff --git a/tests/functional/list/test_list.py b/tests/functional/list/test_list.py
--- a/tests/functional/list/test_list.py
+++ b/tests/functional/list/test_list.py
@@ -90,6 +90,7 @@ def expect_snapshot_output(self, project):
                     "alias": None,
                     "check_cols": None,
                     "on_schema_change": "ignore",
+                    "on_configuration_change": "apply",
                     "meta": {},
                     "grants": {},
                     "packages": [],
@@ -127,6 +128,7 @@ def expect_analyses_output(self):
                     "persist_docs": {},
                     "full_refresh": None,
                     "on_schema_change": "ignore",
+                    "on_configuration_change": "apply",
                     "database": None,
                     "schema": None,
                     "alias": None,
@@ -170,6 +172,7 @@ def expect_model_output(self):
                         "full_refresh": None,
                         "unique_key": None,
                         "on_schema_change": "ignore",
+                        "on_configuration_change": "apply",
                         "database": None,
                         "schema": None,
                         "alias": None,
@@ -206,6 +209,7 @@ def expect_model_output(self):
                         "full_refresh": None,
                         "unique_key": None,
                         "on_schema_change": "ignore",
+                        "on_configuration_change": "apply",
                         "database": None,
                         "schema": None,
                         "alias": None,
@@ -239,6 +243,7 @@ def expect_model_output(self):
                         "full_refresh": None,
                         "unique_key": None,
                         "on_schema_change": "ignore",
+                        "on_configuration_change": "apply",
                         "database": None,
                         "schema": None,
                         "alias": None,
@@ -272,6 +277,7 @@ def expect_model_output(self):
                         "full_refresh": None,
                         "unique_key": None,
                         "on_schema_change": "ignore",
+                        "on_configuration_change": "apply",
                         "database": None,
                         "schema": None,
                         "alias": None,
@@ -319,6 +325,7 @@ def expect_model_ephemeral_output(self):
                         "persist_docs": {},
                         "full_refresh": None,
                         "on_schema_change": "ignore",
+                        "on_configuration_change": "apply",
                         "database": None,
                         "schema": None,
                         "alias": None,
@@ -384,6 +391,7 @@ def expect_seed_output(self):
                     "full_refresh": None,
                     "unique_key": None,
                     "on_schema_change": "ignore",
+                    "on_configuration_change": "apply",
                     "database": None,
                     "schema": None,
                     "alias": None,
diff --git a/tests/functional/materializations/test_materialized_view.py b/tests/functional/materializations/test_materialized_view.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/materializations/test_materialized_view.py
@@ -0,0 +1,62 @@
+import pytest
+
+from dbt.tests.adapter.materialized_views import (
+    test_basic,
+    test_on_configuration_change,
+)
+
+
+class TestBasic(test_basic.BasicTestsBase):
+    @pytest.mark.skip("This fails because we are mocking with a traditional view")
+    def test_updated_base_table_data_only_shows_in_materialized_view_after_rerun(self, project):
+        super().test_updated_base_table_data_only_shows_in_materialized_view_after_rerun(project)
+
+
+class TestOnConfigurationChangeApply(
+    test_on_configuration_change.OnConfigurationChangeApplyTestsBase
+):
+    def apply_configuration_change_triggering_apply(self, project):
+        pass
+
+    def apply_configuration_change_triggering_full_refresh(self, project):
+        pass
+
+    @pytest.mark.skip("This fails because we are mocking with a traditional view")
+    def test_full_refresh_configuration_changes_will_not_attempt_apply_configuration_changes(
+        self, project
+    ):
+        super().test_full_refresh_configuration_changes_will_not_attempt_apply_configuration_changes(
+            project
+        )
+
+    @pytest.mark.skip("This fails because we are mocking with a traditional view")
+    def test_model_applies_changes_with_configuration_changes(self, project):
+        super().test_model_applies_changes_with_configuration_changes(project)
+
+
+class TestOnConfigurationChangeSkip(
+    test_on_configuration_change.OnConfigurationChangeSkipTestsBase
+):
+    def apply_configuration_change_triggering_apply(self, project):
+        pass
+
+    def apply_configuration_change_triggering_full_refresh(self, project):
+        pass
+
+    @pytest.mark.skip("This fails because we are mocking with a traditional view")
+    def test_model_is_skipped_with_configuration_changes(self, project):
+        super().test_model_is_skipped_with_configuration_changes(project)
+
+
+class TestOnConfigurationChangeFail(
+    test_on_configuration_change.OnConfigurationChangeFailTestsBase
+):
+    def apply_configuration_change_triggering_apply(self, project):
+        pass
+
+    def apply_configuration_change_triggering_full_refresh(self, project):
+        pass
+
+    @pytest.mark.skip("This fails because we are mocking with a traditional view")
+    def test_run_fails_with_configuration_changes(self, project):
+        super().test_run_fails_with_configuration_changes(project)