Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment with functional testing #577

Merged
merged 34 commits into from
May 12, 2022

Conversation

dbeatty10
Copy link
Contributor

@dbeatty10 dbeatty10 commented May 9, 2022

Continuing the work from #575.

This is a:

  • documentation update
  • bug fix with no breaking changes
  • new functionality
  • a breaking change

Checklist

  • I have verified that these changes work locally on the following warehouses (Note: it's okay if you do not have access to all warehouses, this helps us understand what has been covered)
    • BigQuery* (caveat: dateadd test is skipped currently)
    • Postgres
    • Redshift
    • Snowflake
  • I have added tests & descriptions to my models (and macros if applicable)
  • I have added an entry to CHANGELOG.md

Description & motivation

Followed the provided pattern for functional testing for all cross-database macros except as noted below.

Questions

  1. Is there a way to export the dbt projects that are created by these tests (to enable hands-on troubleshooting)?
  2. Is there a way to inspect the target folder created by each test?

Details

Was mostly able to follow the assert_equal actual/expected pattern for test comparisons via YAML tests. Exceptions noted below.

Created @pytest.mark.only_profile to support different implementations for escape_single_quotes. Is there a better way to enable different implementations for each adapter?

Tests that are not yet implemented are marked as @pytest.mark.skip_profile.

Other tests not yet implemented contain the text TODO somewhere within the code (so it can be grep'ed easily).

Included

  • any_value
  • bool_or
  • cast_bool_to_text
  • concat
  • date_trunc
  • dateadd
  • datediff
  • escape_single_quotes
  • except
  • hash
  • intersect
  • last_day
  • length
  • listagg
  • position
  • replace
  • right
  • safe_cast
  • split_part
  • string_literal

These were a little funky, complicated, or different:

  • cast_bool_to_text
  • string_literal
  • escape_single_quotes
  • except
  • intersect

Not included

We'll need to decide how we want to approach testing for these macros

  • type_bigint
  • type_float
  • type_int
  • type_numeric
  • type_string
  • type_boolean
  • type_timestamp
  • _is_ephemeral
  • _is_relation
  • current_timestamp_in_utc
  • current_timestamp

These are either not going to be moved, will be deprecated, or merely haven't been fully discussed yet:

  • identifier
  • width_bucket
  • get_table_types_sql

To do

  • Decide how to test type_*, _is_*, and current_timestamp* macros
  • Get dateadd test to work for BigQuery
  • Clean up implementations as-needed

Reflections

  • Postgres, Snowflake, BigQuery, and Redshift all allow selection of attributes without a from clause. But other databases do require a from clause (most notably Oracle's DUAL table).
    • We could consider creating a cross-database from_dual macro that returns an empty string in the default case. This would allow us to create datasets on the fly in a cross-database manner using CTE for the purposes of testing.
  • It looks like the implementation of check_relations_equal might rely on the existence of EXCEPT within the database. Once we have the except macro moved from dbt-utils to dbt-core, we might want to consider dispatching EXCEPT instead (i.e., to use MINUS when applicable for databases like Oracle and MySQL).

Approaches for testing cross-db macros

It seems there are 3 types of uses for cross-db macros:

  1. Produce a value for an attribute. This is most common. Examples: concat and date_trunc.
  2. Produce a relation. This is relatively rare. Examples: intersect and except.
  3. Produce a string literal. This is most rare. Examples: string_literal and cast_bool_to_text.

A way to test each:

  1. Standard assert_equal actual/expected pattern for test comparisons via YAML tests.
  2. Override the main test case (e.g.test_build_assert_equal) and utilize check_relations_equal to compare the actual/expected relations.
  3. Create the actual + expected dataset using a CTE and union all to separate each case. See discussion regarding from_dual. Use assert_equal like the standard case.

@dbeatty10 dbeatty10 requested a review from jtcohen6 May 9, 2022 14:38
@gshank
Copy link

gshank commented May 9, 2022

If you run tests with -s you should get a line that lists the temporary project directory, like: === Test project_root: /private/var/folders/qt/vw8wqdgx4w381wh14b9y25m40000gn/T/pytest-of-gerda/pytest-487/project0. You can use that look at the project directory. We were also thinking about a pytest custom option to copy that somewhere, but so far haven't needed it.

We have a number of tests that look at things in the target directory. If you grep for "get_artifact" and "get_manifest" you'll find some of them.

Copy link
Contributor

@jtcohen6 jtcohen6 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the diligent work here! And thanks especially for calling out what's odd & left out. I think you managed to get something workable in just about all cases.


The three types of cross-database macros which you've called out as being most difficult to test, are also the ones that might want to be adapter methods (Python), IMO:

  • _is_relation, _is_ephemeral: These should absolutely become Python context methods—not even adapter methods, just good old standard base/provider context stuff. They'll be able to perform real Python isinstance, instead of the nonsense they have to do right now, checking side-effect class attributes of the available object.
  • type_* macros: These feel duplicative with the Column object methods (overridable by adapter), and the adapter convert_*_type macros, which are used during type inference while loading seeds. These feel like the right places to define them. If we can find a reasonable way to use them as one-offs, I bet we can plumb this logic back into dbt_utils (for backwards compatibility) from the Column object or those adapter methods. They're not perfectly tested today, but I think it better to test them "closer to the metal," by hooking into the adapter's Python client (if available/supported). E.g. we use Column.convert_type here to translate between BigQuery's schema object and dbt's Column objects.
  • current_timestamp: I'm on the fence about this one, and how best to test it. There's already a macro for this (snapshot_get_time) and an (unused, untested) adapter method date_function. Let's consolidate them together? current_timestamp_in_utc is a bit trickier, and I definitely see the value. I'm actually surprised we don't have a convert_timezone macro here — the snowplow package does.

The trade-offs with all of these: If these are defined as Python methods, users can still use them, but they can't override them, and the source code is less accessible / trickier to find. That said, the whole initiative here with cross-database macros is recognizing there exists some unopinionated low-level functionality that is best left to the adapter plugin maintainer, not the package / project maintainer.


Not mentioned in our issues / PRs so far, but worth calling out in a similar vein:

There's tremendous overlap between:

  • the equality, cardinality_equality, and equal_rowcount custom generic tests in dbt-utils
  • the theurgical query we use to power the check_relations_equal utility in the New Core Testing Framework

This code does more visible work, so I think it's good to have it visible, rather than hidden away in adapter code. But I also think it's tremendously valuable to end users (unit testing SQL transformations!), and we should aim to consolidate between the logic that's living in both places right now. Right now, all the testing framework logic is Python-only methods and f-stringified SQL, inaccessible to user-space code. See: recent slack thread with Marius from Trino/Starburst

}


@pytest.mark.skip_profile("bigquery", reason="TODO - need to figure out timestamp vs. datetime behavior!")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -0,0 +1,26 @@

# escape_single_quotes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the way you've factored this one. Ideally, we'd have a single fixture that works across the board. The fact that it's hard to do, justifies the existence of the cross-db macro :)

I think it might make sense to define two "variants" of this test. Those could be two different test cases:

  • models__test_escape_single_quotes_quoteBaseEscapeSingleQuotesQuote
  • models__test_escape_single_quotes_backslashBaseEscapeSingleQuotesBackslash

Or a single test case with a new fixture, which subclasses can override:

    @pytest.fixture(scope="class")
    def escape_character():
        return "quote" # or "backslash"

    @pytest.fixture(scope="class")
    def models():
        if self.escape_character() == "quote":
            return ...
        elif self.escape_character() == "backslash":
            return ...
        else: # pseudo code
            raise "I don't know that one"

Then, each adapter (including Postgres/Redshift/Snowflake/BigQuery) can opt into one of the two standard variants with no changes:

@pytest.mark.only_profile("postgres")
class TestEscapeSingleQuotesPostgres(BaseEscapeSingleQuotesQuote):
    pass

@pytest.mark.only_profile("snowflake")
class TestEscapeSingleQuotesSnowflake(BaseEscapeSingleQuotesBackslash):
    pass

Or:

@pytest.mark.only_profile("postgres")
class TestEscapeSingleQuotesPostgres(BaseEscapeSingleQuotes):
    pass

@pytest.mark.only_profile("snowflake")
class TestEscapeSingleQuotesBigQuery(BaseEscapeSingleQuotes):
    @pytest.fixture(scope="class")
    def escape_character():
        return "backslash"

Of course, if neither of those standard variants work, an adapter can define its own fixture and override the test case from the ground up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision

I chose the "two different test cases" option. The implementation came out very straightforward.

This allowed me to bypass reasoning about the case of an unknown escape_character value. 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative

However, the single test case with the escape_character definition was really interesting. I wonder if it would be worth adding the escape_character definition within each adapter itself?

An idea

Create a collection of low-level definitions within each adapter:

  • literal_quote - the symbol used to demarcate a (string) literal value within the database.
    • ANSI standard is single quote.
    • We'd then use this within the default implementation of the string_literal macro.
  • standard_escape_character - The symbol used to interpret a character literally rather than interpret it.
    • The ANSI standard is the backslash character.
  • invalid_for_standard_escape - List of symbols that can't be escaped by the standard_escape_character.
    • The ANSI standard includes both single and double quotes.
    • We'd default this to the literal_quote value (and maybe double quote too).
  • special_escape_character - The symbol used to escape invalid_for_standard_escape characters.
    • The ANSI standard is a single quote (same as literal_quote).
    • Use within default implementation of the escape_single_quotes macro.

These low-level configurations would then inform the intermediate-level macros like the following:

  • string_literal
  • escape_single_quotes (maybe rename this to something else more abstract)

References

  1. https://www.ibm.com/docs/en/informix-servers/12.10?topic=statements-quotation-marks-escape-characters
  2. https://4js.com/online_documentation/fjs-fgl-3.00.05-manual-html/c_fgl_sql_programming_080.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sidenote

Some macros assume single quotes to demarcate string literals rather than utilizing string_literal. This is one example. Not sure how common this is or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines 42 to 43
class TestExcept(BaseExcept):
def test_build_assert_equal(self, project):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is cleverly written!

Any reason to have test_build_assert_equal defined on TestExcept, rather than on BaseExcept and then

class TestExcept(BaseExcept):
    pass

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Done.

}


class TestIntersect(BaseIntersect):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here as for TestExcept — let's move test_build_assert_equal into BaseIntersect, I think

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +4 to +9
# TODO how can we test this better?
models__test_current_timestamp_sql = """
select
{{ dbt_utils.current_timestamp() }} as actual,
{{ dbt_utils.current_timestamp() }} as expected
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Juice might not be worth the squeeze on this one.

We could use Python/Jinja {{ datetime.datetime.now() }}, and perform a comparison that shaves off seconds? To be clear, I think this is a bad idea:

models__test_current_timestamp_sql = """
select
    left(cast({{ dbt_utils.current_timestamp() }} as text), 16) as actual,
    '{{ modules.datetime.datetime.now().strftime("%Y-%m-%d %H:%M") }}' as expected
"""

Worth calling out that dbt_utils.current_timestamp is actually duplicative with:

  • The adapter classmethod date_now, which is required for all adapters but actually unused / untested (lol): (docs, abstractmethod, Postgres implementation)
  • snapshot_get_time, which is indeed a macro (not method) — not tested directly, but well tested insofar as snapshots are well tested, including their "right now" behavior (the tests for which can be flakey)

@dbeatty10
Copy link
Contributor Author

If you run tests with -s you should get a line that lists the temporary project directory, like: === Test project_root: /private/var/folders/qt/vw8wqdgx4w381wh14b9y25m40000gn/T/pytest-of-gerda/pytest-487/project0. You can use that look at the project directory. We were also thinking about a pytest custom option to copy that somewhere, but so far haven't needed it.

We have a number of tests that look at things in the target directory. If you grep for "get_artifact" and "get_manifest" you'll find some of them.

This was very helpful -- exactly what I needed. Thank you @gshank ! 🏆

Was able to see that directory as you described when I ran a single test module locally:

python3 -m pytest tests/functional/cross_db_utils/test_dateadd.py --profile bigquery -s

@dbeatty10 dbeatty10 requested a review from jtcohen6 May 11, 2022 17:22
@dbeatty10
Copy link
Contributor Author

@jtcohen6 I think I've responded to all your crucial feedback, so requested another review.

Do you see any remaining barriers we should overcome before merging this?

Feedback not incorporated here

You surfaced a few things that we should split out into new issues for either dbt-utils or dbt-core, namely:

@jtcohen6
Copy link
Contributor

jtcohen6 commented May 12, 2022

No blockers from me! Let's move forward!

I see you managed to get the dateadd test passing for BigQuery! I've gone back and forth on the actual logic in the bigquery__dateadd macro. By converting to datetime, it loses time zone information—but that's actually consistent with the behavior of timestamp (a.k.a. timestamp_ntz a.k.a. timestamp without time zone) on other databases.

So, on the whole — it sounds like we didn't actually have to make any breaking changes to these macros, which means that we don't first need a dbt-utils minor version.

Next steps

I think next up is lift & shift. I think this is the order of operations:

  1. Move dispatched macros, default__ implementations, and test cases into the dbt-core repo. The test cases should land in the "adapter zone," a.k.a. dbt-tests-adapter.
  2. In this repo, open a PR that installs dbt-core + dbt-tests-adapter from git+.../dbt-core@branchname. That PR can:
    • Inherit the new functional test cases from dbt-tests-adapter.
    • Keep macros as lightweight wrappers that just return dbt.this_macro_name(). (Or, to maintain backwards compatibility with older versions of dbt-core: Use dbt_version to check the installed version.)
    • By setting the dispatch config in the integration_tests project, we should be able to keep all tests passing while the base/default macros move into dbt-core, and the adapter-specific versions still live here.
  3. Move the adapter-specific versions into each adapter plugin, and inherit the "adapter zone". For backwards compatibility, we should probably leave each adapter-specific macro here, too—in case someone's using an older version of dbt, and in case someone has been in the habit of calling dbt_utils.redshift__dateadd directly.
  4. With help from @dataders: Start doing the same for spark-utilsdbt-spark, tsql-utilsdbt-sqlserver, etc. The test cases will be in the "adapter zone," so they should be much easier to inherit + run than the current submodule nonsense.

Related work items

Thanks for catching all those other items! Let's open up some issues to keep track of this work. These issues are interrelated, so I'm taking best guesses at where they should live, and which units of work can be pursued independently.

To be clear: Not saying you need to go create all these issues. Also not saying you can't, if you feel inspired!

New dbt-utils issues

New dbt-core issues

New docs.getdbt.com issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants