Repository: dbt-labs/dbt-core

Issue:
Port adapter_macro implementation into Python
### Describe the feature
Right now, the `adapter_macro` implementation is written in jinja. It doesn't really do any string templating, so it's a better candidate for Python code. Rewriting this macro in Python would let us add some more comprehensive unit tests, and be more flexible with the implementation.

In addition to moving this macro to Python, I think we should rename it to `dispatch`, and it should live on the adapter object. Example usage:

```
{% macro debug(args...) %}
  {% set macro = adapter.dispatch(macro_name='debug', packages=[...]) %}
  {% do return macro(args...) %}
{% endmacro %}
```

Here, `dispatch` takes two arguments:
1. The macro name to dispatch
2. A list of of packages/namespaces to search for the macro in

The macro resolution order should largely be the same as the current implementation with one notable difference. Instead of namespacing a macro in the `macro_name` argument (eg. `dbt_utils.debug`), we'd be able to supply a list of namespaces to search in. Package maintainers could use this functionality to allow 3rd-party packages to extend the adapter-specific implementations provided in the base package.

A quick example:
```
-- dbt-utils: macros/debug.sql

{% macro get_macro_namespaces() %}
  {% do return(var('namespaces') + ['dbt_utils'] if var('namespaces') is not none else ['dbt_utils'] %}
{% endmacro %}

{% macro debug() %}
  {% set macro = adapter.dispatch(macro_name='debug', packages=get_macro_namespaces()) %}
  {% do return(macro()) %}
{% endmacro %}

{% macro default__debug() %}
  {% do log("default impl", info=true) %}
{% endmacro %}
```

```
-- spark-utils: macros/debug.sql

{% macro spark__debug() %}
  {% do log("spark impl", info=true) %}
{% endmacro %}
```

1. There's a ton of opportunity for syntactic sugar here, so take this as a thing we could support, not a final spec!
2. We can probably do a better job than what's described in `get_macro_namespaces` above


Discussion:

Resolving patch:
diff --git a/core/dbt/context/providers.py b/core/dbt/context/providers.py
--- a/core/dbt/context/providers.py
+++ b/core/dbt/context/providers.py
@@ -6,16 +6,16 @@
 )
 from typing_extensions import Protocol
 
-
+from dbt import deprecations
 from dbt.adapters.base.column import Column
 from dbt.adapters.factory import get_adapter, get_adapter_package_names
 from dbt.clients import agate_helper
-from dbt.clients.jinja import get_rendered
+from dbt.clients.jinja import get_rendered, MacroGenerator
 from dbt.config import RuntimeConfig, Project
 from .base import contextmember, contextproperty, Var
 from .configured import FQNLookup
 from .context_config import ContextConfigType
-from .macros import MacroNamespaceBuilder
+from .macros import MacroNamespaceBuilder, MacroNamespace
 from .manifest import ManifestContext
 from dbt.contracts.graph.manifest import Manifest, Disabled
 from dbt.contracts.graph.compiled import (
@@ -82,9 +82,10 @@ class BaseDatabaseWrapper:
     Wrapper for runtime database interaction. Applies the runtime quote policy
     via a relation proxy.
     """
-    def __init__(self, adapter):
+    def __init__(self, adapter, namespace: MacroNamespace):
         self.adapter = adapter
         self.Relation = RelationProxy(adapter)
+        self.namespace = namespace
 
     def __getattr__(self, name):
         raise NotImplementedError('subclasses need to implement this')
@@ -99,6 +100,67 @@ def type(self):
     def commit(self):
         return self.adapter.commit_if_has_connection()
 
+    def _get_adapter_macro_prefixes(self) -> List[str]:
+        # a future version of this could have plugins automatically call fall
+        # back to their dependencies' dependencies by using
+        # `get_adapter_type_names` instead of `[self.config.credentials.type]`
+        search_prefixes = [self.adapter.type(), 'default']
+        return search_prefixes
+
+    def dispatch(
+        self, macro_name: str, packages: Optional[List[str]] = None
+    ) -> MacroGenerator:
+        search_packages: List[Optional[str]]
+
+        if '.' in macro_name:
+            suggest_package, suggest_macro_name = macro_name.split('.', 1)
+            msg = (
+                f'In adapter.dispatch, got a macro name of "{macro_name}", '
+                f'but "." is not a valid macro name component. Did you mean '
+                f'`adapter.dispatch("{suggest_macro_name}", '
+                f'packages=["{suggest_package}"])`?'
+            )
+            raise CompilationException(msg)
+
+        if packages is None:
+            search_packages = [None]
+        elif isinstance(packages, str):
+            raise CompilationException(
+                f'In adapter.dispatch, got a string packages argument '
+                f'("{packages}"), but packages should be None or a list.'
+            )
+        else:
+            search_packages = packages
+
+        attempts = []
+
+        for package_name in search_packages:
+            for prefix in self._get_adapter_macro_prefixes():
+                search_name = f'{prefix}__{macro_name}'
+                try:
+                    macro = self.namespace.get_from_package(
+                        package_name, search_name
+                    )
+                except CompilationException as exc:
+                    raise CompilationException(
+                        f'In dispatch: {exc.msg}',
+                    ) from exc
+
+                if package_name is None:
+                    attempts.append(search_name)
+                else:
+                    attempts.append(f'{package_name}.{search_name}')
+
+                if macro is not None:
+                    return macro
+
+        searched = ', '.join(repr(a) for a in attempts)
+        msg = (
+            f"In dispatch: No macro named '{macro_name}' found\n"
+            f"    Searched for: {searched}"
+        )
+        raise CompilationException(msg)
+
 
 class BaseResolver(metaclass=abc.ABCMeta):
     def __init__(self, db_wrapper, model, config, manifest):
@@ -574,7 +636,9 @@ def __init__(
         self.context_config: Optional[ContextConfigType] = context_config
         self.provider: Provider = provider
         self.adapter = get_adapter(self.config)
-        self.db_wrapper = self.provider.DatabaseWrapper(self.adapter)
+        self.db_wrapper = self.provider.DatabaseWrapper(
+            self.adapter, self.namespace
+        )
 
     def _get_namespace_builder(self):
         internal_packages = get_adapter_package_names(
@@ -1045,13 +1109,6 @@ def sql(self) -> Optional[str]:
     def sql_now(self) -> str:
         return self.adapter.date_function()
 
-    def _get_adapter_macro_prefixes(self) -> List[str]:
-        # a future version of this could have plugins automatically call fall
-        # back to their dependencies' dependencies by using
-        # `get_adapter_type_names` instead of `[self.config.credentials.type]`
-        search_prefixes = [self.config.credentials.type, 'default']
-        return search_prefixes
-
     @contextmember
     def adapter_macro(self, name: str, *args, **kwargs):
         """Find the most appropriate macro for the name, considering the
@@ -1096,38 +1153,24 @@ def adapter_macro(self, name: str, *args, **kwargs):
                ...
             {%- endmacro %}
         """
-        original_name: str = name
-        package_name: Optional[str] = None
+        deprecations.warn('adapter-macro', macro_name=name)
+        original_name = name
+        package_names: Optional[List[str]] = None
         if '.' in name:
             package_name, name = name.split('.', 1)
+            package_names = [package_name]
 
-        attempts = []
-
-        for prefix in self._get_adapter_macro_prefixes():
-            search_name = f'{prefix}__{name}'
-            try:
-                macro = self.namespace.get_from_package(
-                    package_name, search_name
-                )
-            except CompilationException as exc:
-                raise CompilationException(
-                    f'In adapter_macro: {exc.msg}, original name '
-                    f"'{original_name}'",
-                    node=self.model,
-                ) from exc
-            if package_name is None:
-                attempts.append(search_name)
-            else:
-                attempts.append(f'{package_name}.{search_name}')
-            if macro is not None:
-                return macro(*args, **kwargs)
-
-        searched = ', '.join(repr(a) for a in attempts)
-        raise_compiler_error(
-            f"In adapter_macro: No macro named '{name}' found\n"
-            f"    Original name: '{original_name}'\n"
-            f"    Searched for: {searched}"
-        )
+        try:
+            macro = self.db_wrapper.dispatch(
+                macro_name=name, packages=package_names
+            )
+        except CompilationException as exc:
+            raise CompilationException(
+                f'In adapter_macro: {exc.msg}\n'
+                f"    Original name: '{original_name}'",
+                node=self.model
+            ) from exc
+        return macro(*args, **kwargs)
 
 
 class MacroContext(ProviderContext):
diff --git a/core/dbt/deprecations.py b/core/dbt/deprecations.py
--- a/core/dbt/deprecations.py
+++ b/core/dbt/deprecations.py
@@ -116,6 +116,15 @@ class ExecuteMacrosReleaseDeprecation(DBTDeprecation):
     '''
 
 
+class AdapterMacroDeprecation(DBTDeprecation):
+    _name = 'adapter-macro'
+    _description = '''\
+    The "adapter_macro" macro has been deprecated. Instead, use the
+    `adapter.dispatch` method to find a macro and call the result.
+    adapter_macro was called for: {macro_name}
+    '''
+
+
 _adapter_renamed_description = """\
 The adapter function `adapter.{old_name}` is deprecated and will be removed in
 a future release of dbt. Please use `adapter.{new_name}` instead.
@@ -160,6 +169,7 @@ def warn(name, *args, **kwargs):
     ModelsKeyNonModelDeprecation(),
     DbtProjectYamlDeprecation(),
     ExecuteMacrosReleaseDeprecation(),
+    AdapterMacroDeprecation(),
 ]
 
 deprecations: Dict[str, DBTDeprecation] = {


Test patch:
diff --git a/test/integration/004_simple_snapshot_test/macros/test_no_overlaps.sql b/test/integration/004_simple_snapshot_test/macros/test_no_overlaps.sql
--- a/test/integration/004_simple_snapshot_test/macros/test_no_overlaps.sql
+++ b/test/integration/004_simple_snapshot_test/macros/test_no_overlaps.sql
@@ -1,5 +1,5 @@
 {% macro get_snapshot_unique_id() -%}
-    {{ return(adapter_macro('get_snapshot_unique_id', )) }}
+    {{ return(adapter.dispatch('get_snapshot_unique_id')()) }}
 {%- endmacro %}
 
 {% macro default__get_snapshot_unique_id() -%}
diff --git a/test/integration/012_deprecation_tests/adapter-macro-macros/macros.sql b/test/integration/012_deprecation_tests/adapter-macro-macros/macros.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/012_deprecation_tests/adapter-macro-macros/macros.sql
@@ -0,0 +1,17 @@
+{% macro some_macro(arg1, arg2) -%}
+    {{ adapter_macro('some_macro', arg1, arg2) }}
+{%- endmacro %}
+
+
+{% macro default__some_macro(arg1, arg2) %}
+    {% do exceptions.raise_compiler_error('not allowed') %}
+{% endmacro %}
+
+{% macro postgres__some_macro(arg1, arg2) -%}
+    {{ arg1 }}{{ arg2 }}
+{%- endmacro %}
+
+
+{% macro some_other_macro(arg1, arg2) -%}
+	{{ adapter_macro('test.some_macro', arg1, arg2) }}
+{%- endmacro %}
diff --git a/test/integration/012_deprecation_tests/adapter-macro-models-package/model.sql b/test/integration/012_deprecation_tests/adapter-macro-models-package/model.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/012_deprecation_tests/adapter-macro-models-package/model.sql
@@ -0,0 +1,4 @@
+{% if some_other_macro('foo', 'bar') != 'foobar' %}
+  {% do exceptions.raise_compiler_error('invalid foobar') %}
+{% endif %}
+select 1 as id
diff --git a/test/integration/012_deprecation_tests/adapter-macro-models/model.sql b/test/integration/012_deprecation_tests/adapter-macro-models/model.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/012_deprecation_tests/adapter-macro-models/model.sql
@@ -0,0 +1,4 @@
+{% if some_macro('foo', 'bar') != 'foobar' %}
+  {% do exceptions.raise_compiler_error('invalid foobar') %}
+{% endif %}
+select 1 as id
diff --git a/test/integration/012_deprecation_tests/test_deprecations.py b/test/integration/012_deprecation_tests/test_deprecations.py
--- a/test/integration/012_deprecation_tests/test_deprecations.py
+++ b/test/integration/012_deprecation_tests/test_deprecations.py
@@ -104,3 +104,77 @@ def test_postgres_project_deprecations(self):
         self.run_dbt(strict=False)
         expected = {'dbt-project-yaml-v1'}
         self.assertEqual(expected, deprecations.active_deprecations)
+
+
+class TestAdapterMacroDeprecation(BaseTestDeprecations):
+    @property
+    def models(self):
+        return self.dir('adapter-macro-models')
+
+    @property
+    def project_config(self):
+        return {
+            'config-version': 2,
+            'macro-paths': [self.dir('adapter-macro-macros')]
+        }
+
+    @use_profile('postgres')
+    def test_postgres_adapter_macro(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        self.run_dbt(strict=False)
+        expected = {'adapter-macro'}
+        self.assertEqual(expected, deprecations.active_deprecations)
+
+    @use_profile('postgres')
+    def test_postgres_adapter_macro_fail(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        with self.assertRaises(dbt.exceptions.CompilationException) as exc:
+            self.run_dbt(strict=True)
+        exc_str = ' '.join(str(exc.exception).split())  # flatten all whitespace
+        assert 'The "adapter_macro" macro has been deprecated' in exc_str
+
+    @use_profile('redshift')
+    def test_redshift_adapter_macro(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        # picked up the default -> error
+        with self.assertRaises(dbt.exceptions.CompilationException) as exc:
+            self.run_dbt(strict=False, expect_pass=False)
+        exc_str = ' '.join(str(exc.exception).split())  # flatten all whitespace
+        assert 'not allowed' in exc_str  # we saw the default macro
+
+
+class TestAdapterMacroDeprecationPackages(BaseTestDeprecations):
+    @property
+    def models(self):
+        return self.dir('adapter-macro-models-package')
+
+    @property
+    def project_config(self):
+        return {
+            'config-version': 2,
+            'macro-paths': [self.dir('adapter-macro-macros')]
+        }
+
+    @use_profile('postgres')
+    def test_postgres_adapter_macro_pkg(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        self.run_dbt(strict=False)
+        expected = {'adapter-macro'}
+        self.assertEqual(expected, deprecations.active_deprecations)
+
+    @use_profile('postgres')
+    def test_postgres_adapter_macro_pkg_fail(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        with self.assertRaises(dbt.exceptions.CompilationException) as exc:
+            self.run_dbt(strict=True)
+        exc_str = ' '.join(str(exc.exception).split())  # flatten all whitespace
+        assert 'The "adapter_macro" macro has been deprecated' in exc_str
+
+    @use_profile('redshift')
+    def test_redshift_adapter_macro_pkg(self):
+        self.assertEqual(deprecations.active_deprecations, set())
+        # picked up the default -> error
+        with self.assertRaises(dbt.exceptions.CompilationException) as exc:
+            self.run_dbt(strict=False, expect_pass=False)
+        exc_str = ' '.join(str(exc.exception).split())  # flatten all whitespace
+        assert 'not allowed' in exc_str  # we saw the default macro
diff --git a/test/integration/016_macro_tests/fail-missing-macro-models/model.sql b/test/integration/016_macro_tests/fail-missing-macro-models/model.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/016_macro_tests/fail-missing-macro-models/model.sql
@@ -0,0 +1,2 @@
+{{ dispatch_to_nowhere() }}
+select 1 as id
diff --git a/test/integration/016_macro_tests/macros/my_macros.sql b/test/integration/016_macro_tests/macros/my_macros.sql
--- a/test/integration/016_macro_tests/macros/my_macros.sql
+++ b/test/integration/016_macro_tests/macros/my_macros.sql
@@ -13,3 +13,10 @@
     {{ ref('table_model') }}
 
 {% endmacro %}
+
+
+{# there is no no default__dispatch_to_nowhere! #}
+{% macro dispatch_to_nowhere() %}
+	{% set macro = adapter.dispatch('dispatch_to_nowhere') %}
+	{{ macro() }}
+{% endmacro %}
diff --git a/test/integration/016_macro_tests/test_macros.py b/test/integration/016_macro_tests/test_macros.py
--- a/test/integration/016_macro_tests/test_macros.py
+++ b/test/integration/016_macro_tests/test_macros.py
@@ -1,5 +1,8 @@
 from test.integration.base import DBTIntegrationTest, use_profile
 
+import dbt.exceptions
+import pytest
+
 
 class TestMacros(DBTIntegrationTest):
 
@@ -70,20 +73,11 @@ def project_config(self):
 
     @use_profile('postgres')
     def test_postgres_invalid_macro(self):
-
-        try:
-            self.run_dbt(["run"], expect_pass=False)
-            self.assertTrue(False,
-                            'compiling bad macro should raise a runtime error')
-
-        except RuntimeError:
-            pass
+        with pytest.raises(RuntimeError):
+            self.run_dbt(["run"])
 
 
-class TestMisusedMacros(DBTIntegrationTest):
-
-    def setUp(self):
-        DBTIntegrationTest.setUp(self)
+class TestAdapterMacroNoDestination(DBTIntegrationTest):
 
     @property
     def schema(self):
@@ -91,36 +85,11 @@ def schema(self):
 
     @property
     def models(self):
-        return "bad-models"
-
-    @property
-    def packages_config(self):
-        return {
-            'packages': [
-                {
-                    'git': 'https://github.com/fishtown-analytics/dbt-integration-project',
-                    'revision': 'dbt/0.17.0',
-                }
-            ]
-        }
+        return "fail-missing-macro-models"
 
-    @property
-    def project_config(self):
-        return {
-            'config-version': 2,
-            "macro-paths": ["macros"],
-        }
+    @use_profile('postgres')
+    def test_postgres_invalid_macro(self):
+        with pytest.raises(dbt.exceptions.CompilationException) as exc:
+            self.run_dbt(['run'])
 
-    # TODO: compilation no longer exists, so while the model calling this macro
-    # fails, it does not raise a runtime exception. change this test to verify
-    # that the model finished with ERROR state.
-    #
-    # @use_profile('postgres')
-    # def test_working_macros(self):
-    #     self.run_dbt(["deps"])
-
-    #     try:
-    #         self.run_dbt(["run"])
-    #         self.assertTrue(False, 'invoked a package macro from global scope')
-    #     except RuntimeError:
-    #         pass
+        assert "In dispatch: No macro named 'dispatch_to_nowhere' found" in str(exc.value)
diff --git a/test/integration/026_aliases_test/macros/cast.sql b/test/integration/026_aliases_test/macros/cast.sql
--- a/test/integration/026_aliases_test/macros/cast.sql
+++ b/test/integration/026_aliases_test/macros/cast.sql
@@ -1,7 +1,7 @@
 
 
 {% macro string_literal(s) -%}
-  {{ adapter_macro('test.string_literal', s) }}
+  {{ adapter.dispatch('string_literal', packages=['test'])(s) }}
 {%- endmacro %}
 
 {% macro default__string_literal(s) %}
diff --git a/test/integration/043_custom_aliases_test/macros-configs/macros.sql b/test/integration/043_custom_aliases_test/macros-configs/macros.sql
--- a/test/integration/043_custom_aliases_test/macros-configs/macros.sql
+++ b/test/integration/043_custom_aliases_test/macros-configs/macros.sql
@@ -9,7 +9,7 @@
 {%- endmacro %}
 
 {% macro string_literal(s) -%}
-  {{ adapter_macro('test.string_literal', s) }}
+  {{ adapter.dispatch('string_literal', packages=['test'])(s) }}
 {%- endmacro %}
 
 {% macro default__string_literal(s) %}
diff --git a/test/integration/043_custom_aliases_test/macros/macros.sql b/test/integration/043_custom_aliases_test/macros/macros.sql
--- a/test/integration/043_custom_aliases_test/macros/macros.sql
+++ b/test/integration/043_custom_aliases_test/macros/macros.sql
@@ -9,7 +9,7 @@
 
 
 {% macro string_literal(s) -%}
-  {{ adapter_macro('test.string_literal', s) }}
+  {{ adapter.dispatch('string_literal', packages=['test'])(s) }}
 {%- endmacro %}
 
 {% macro default__string_literal(s) %}
diff --git a/test/unit/test_context.py b/test/unit/test_context.py
--- a/test/unit/test_context.py
+++ b/test/unit/test_context.py
@@ -6,8 +6,8 @@
 
 import pytest
 
-# make sure 'postgres' is available
-from dbt.adapters import postgres  # noqa
+# make sure 'redshift' is available
+from dbt.adapters import postgres, redshift
 from dbt.adapters import factory
 from dbt.adapters.base import AdapterConfig
 from dbt.clients.jinja import MacroStack
@@ -18,7 +18,7 @@
 from dbt.context import base, target, configured, providers, docs, manifest, macros
 from dbt.node_types import NodeType
 import dbt.exceptions
-from .utils import profile_from_dict, config_from_parts_or_dicts
+from .utils import profile_from_dict, config_from_parts_or_dicts, inject_adapter, clear_plugin
 from .mock_adapter import adapter_factory
 
 
@@ -112,7 +112,8 @@ def setUp(self):
         self.mock_config = mock.MagicMock()
         adapter_class = adapter_factory()
         self.mock_adapter = adapter_class(self.mock_config)
-        self.wrapper = providers.ParseDatabaseWrapper(self.mock_adapter)
+        self.namespace = mock.MagicMock()
+        self.wrapper = providers.ParseDatabaseWrapper(self.mock_adapter, self.namespace)
         self.responder = self.mock_adapter.responder
 
     def test_unwrapped_method(self):
@@ -131,7 +132,8 @@ def setUp(self):
         self.mock_config.quoting = {'database': True, 'schema': True, 'identifier': True}
         adapter_class = adapter_factory()
         self.mock_adapter = adapter_class(self.mock_config)
-        self.wrapper = providers.RuntimeDatabaseWrapper(self.mock_adapter)
+        self.namespace = mock.MagicMock()
+        self.wrapper = providers.RuntimeDatabaseWrapper(self.mock_adapter, self.namespace)
         self.responder = self.mock_adapter.responder
 
     def test_unwrapped_method(self):
@@ -369,6 +371,39 @@ def manifest_fx(config):
     return mock_manifest(config)
 
 
+@pytest.fixture
+def manifest_extended(manifest_fx):
+    dbt_macro = mock_macro('default__some_macro', 'dbt')
+    # same namespace, same name, different pkg!
+    rs_macro = mock_macro('redshift__some_macro', 'dbt_redshift')
+    # same name, different package
+    package_default_macro = mock_macro('default__some_macro', 'root')
+    package_rs_macro = mock_macro('redshift__some_macro', 'root')
+    manifest_fx.macros[dbt_macro.unique_id] = dbt_macro
+    manifest_fx.macros[rs_macro.unique_id] = rs_macro
+    manifest_fx.macros[package_default_macro.unique_id] = package_default_macro
+    manifest_fx.macros[package_rs_macro.unique_id] = package_rs_macro
+    return manifest_fx
+
+
+@pytest.fixture
+def redshift_adapter(config, get_adapter):
+    adapter = redshift.RedshiftAdapter(config)
+    inject_adapter(adapter, redshift.Plugin)
+    get_adapter.return_value = adapter
+    yield adapter
+    clear_plugin(redshift.Plugin)
+
+
+@pytest.fixture
+def postgres_adapter(config, get_adapter):
+    adapter = postgres.PostgresAdapter(config)
+    inject_adapter(adapter, postgres.Plugin)
+    get_adapter.return_value = adapter
+    yield adapter
+    clear_plugin(postgres.Plugin)
+
+
 def test_query_header_context(config, manifest_fx):
     ctx = manifest.generate_query_header_context(
         config=config,
@@ -463,3 +498,62 @@ def test_macro_namespace(config, manifest_fx):
         assert result['root']['some_macro'].macro is package_macro
         assert result['some_macro'].macro is package_macro
 
+
+def test_resolve_specific(config, manifest_extended, redshift_adapter, get_include_paths):
+    rs_macro = manifest_extended.macros['macro.dbt_redshift.redshift__some_macro']
+    package_rs_macro = manifest_extended.macros['macro.root.redshift__some_macro']
+
+    ctx = providers.generate_runtime_model(
+        model=mock_model(),
+        config=config,
+        manifest=manifest_extended,
+    )
+
+    # macro_a exists, but default__macro_a and redshift__macro_a do not
+    with pytest.raises(dbt.exceptions.CompilationException):
+        ctx['adapter'].dispatch('macro_a').macro
+
+    assert ctx['adapter'].dispatch('some_macro').macro is package_rs_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['dbt']).macro is rs_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['root']).macro is package_rs_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['root', 'dbt']).macro is package_rs_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['dbt', 'root']).macro is rs_macro
+
+
+def test_resolve_default(config, manifest_extended, postgres_adapter, get_include_paths):
+    dbt_macro = manifest_extended.macros['macro.dbt.default__some_macro']
+    package_macro = manifest_extended.macros['macro.root.default__some_macro']
+
+    ctx = providers.generate_runtime_model(
+        model=mock_model(),
+        config=config,
+        manifest=manifest_extended,
+    )
+
+    # macro_a exists, but default__macro_a and redshift__macro_a do not
+    with pytest.raises(dbt.exceptions.CompilationException):
+        ctx['adapter'].dispatch('macro_a').macro
+
+    assert ctx['adapter'].dispatch('some_macro').macro is package_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['dbt']).macro is dbt_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['root']).macro is package_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['root', 'dbt']).macro is package_macro
+    assert ctx['adapter'].dispatch('some_macro', packages=['dbt', 'root']).macro is dbt_macro
+
+
+def test_resolve_errors(config, manifest_extended, redshift_adapter, get_include_paths):
+    ctx = providers.generate_runtime_model(
+        model=mock_model(),
+        config=config,
+        manifest=manifest_extended,
+    )
+    with pytest.raises(dbt.exceptions.CompilationException) as exc:
+        ctx['adapter'].dispatch('no_default_exists')
+    assert 'default__no_default_exists' in str(exc.value)
+    assert 'redshift__no_default_exists' in str(exc.value)
+
+    with pytest.raises(dbt.exceptions.CompilationException) as exc:
+        ctx['adapter'].dispatch('namespace.no_default_exists')
+    assert '"." is not a valid macro name component' in str(exc.value)
+    assert 'adapter.dispatch' in str(exc.value)
+    assert 'packages=["namespace"]' in str(exc.value)
diff --git a/test/unit/utils.py b/test/unit/utils.py
--- a/test/unit/utils.py
+++ b/test/unit/utils.py
@@ -121,6 +121,13 @@ def inject_adapter(value, plugin):
     FACTORY.adapters[key] = value
 
 
+def clear_plugin(plugin):
+    from dbt.adapters.factory import FACTORY
+    key = plugin.adapter.type()
+    FACTORY.plugins.pop(key, None)
+    FACTORY.adapters.pop(key, None)
+
+
 class ContractTestCase(TestCase):
     ContractType = None