-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Custom names for generic tests #4898
Conversation
Thank you for your pull request! We could not find a changelog entry for this change. For details on how to document a change, see the contributing guide. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks pretty good to me! Thanks for the very clear description. I have one important thought on the keys we use to define this and a few minor notes. I'm also withholding approval till we get the integration tests in this PR.
[blocker] I don't like the distinction between name
and test_name
. I'd like to see the functionality of defining unique test names associated with exactly one key regardless of the test its being applied to.
I opted against it, to keep the changes here minimal and additive-only.
I think this is a good approach. The advantage of modifying the internal hashing strategy is that a dbt version upgrade with zero code changes would fix the issue where long test names can't be run, and that can absolutely be done in another PR. We should probably open a clear ticket to that effect so the work doesn't get lost.
test_type: str, test_name: str, args: Dict[str, Any] | ||
) -> Tuple[str, str]: | ||
# Using the type, name, and arguments to this generic test, synthesize a (hopefully) unique name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] If we're going to use the word "hopefully" in this comment let's lay out exactly when this would not be unique.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for adding that!
self.fqn_name: str = "" | ||
|
||
if "name" in self.args: | ||
# TODO: Should we append the model name to the test name here? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[blocker] I believe the result of this name mangling strategy should include the model name so our test-uniqueness constraints can be mechanically enforced if we want them to be.
@@ -401,7 +428,8 @@ def macro_name(self) -> str: | |||
macro_name = "{}.{}".format(self.namespace, macro_name) | |||
return macro_name | |||
|
|||
def get_test_name(self) -> Tuple[str, str]: | |||
def get_synthetic_test_names(self) -> Tuple[str, str]: | |||
# Returns two names: shorter (for the compiled file), full (for the unique_id + FQN) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type signature is a very easy-to-misuse one. good call adding a comment.
One concern I have is that people may not realize that they've defined an 'only_allow_abc' test in a different file. I'm thinking that we should lookup the test name and see if a test already exists with that name, and if it does issue an error message. Otherwise it feels like the tests could silently overwrite each other (like they sometimes do today). Always suffixing the model name would eliminate a lot of duplicates, but it would still be possible to name two tests on the same model with the same test name. |
@nathaniel-may @gshank Thank you both for the speedy review + feedback! Nomenclature
Could you say a bit more about this? I agree this nomenclature can be somewhat confusing:
I picked # select all instances of "unique" tests in the project
$ dbt test --select test_name:unique I'm not sure what you mean by "associated with exactly one key." If I define a test via just the test name ( Internal hashing strat
Agree! I think that ticket could be spun off #4684. FWIW our synthetic naming strategy is already meant to account for too-long test names, by hashing and truncating names longer than 64 characters. That's the motivation behind the The plus of such a fix would be, it works for users with zero code changes on their end. The downside would be, we potentially "reset" synthetic test names for lots of folks, causing a break in any metadata tracking. Very much worth it IMO, just something we'd want to include in a minor (not patch) release. How should uniqueness work?
It turns out, this actually works: version: 2
models:
- name: model_a
columns:
- name: id
tests:
- unique:
name: my_not_so_unique_test_name
- name: model_b
columns:
- name: id
tests:
- unique:
name: my_not_so_unique_test_name
Why? Because of the hash that we append to each test's
So the tests are disambiguated within dbt internally, just as they would be if the model name were prepended to the The only way to get actual dupes is by reproducing the setup from #4102, where the same generic test is defined on the same model + column, and the only thing different is the config (not factored into the hash, for all the reasons we've discussed): models:
- name: model_a
columns:
- name: id
tests:
- unique:
name: my_not_so_unique_test_name
where: 1=1
- unique:
name: my_not_so_unique_test_name
where: 1=2
The resolution there is to pick some actually unique test names. Those definitions are necessarily in the same file, defined on the same column, so they really cannot be more than a few lines apart from one another. I'm inclined to prefer keeping it this way, to let users actually set the exact test names they want? |
I didn't actually realize that we would still be appending the hash to the unique id. I think that's fine then. Will need documenting of course :) I do really like the idea of letting people name their tests. |
RE: How should uniqueness workI love this compilation error at the bottom of your comment. What a clear development experience. I definitely want to make sure we never silently override tests and it sounds like this would prevent that. If we're going to apply the uniqueness constraint to {model, test_name, column pairs} instead of just {model, test_name} I think we need to display that in the error which is why I don't think hashing in name mangling is a good practice. If you have 1599 columns in your table, it should be simple to see where the conflict is if every column has a test with this name and one test accidentally got copy-pasted twice for a column. to be concrete, I'd rather see:
RE: NomenclatureI think I totally misunderstood your proposal. My previous comment was under the assumption that sometimes you'd use RE: Internal hashing stratYeah that's fair, the max length bit might just need to be looked into independently. I know windows enforces a max path length of 255 characters so with longer-named directory structures, a 64 character file could easily overflow that. |
b220819
to
75b2f43
Compare
Functional testingLucky for me, these just got converted! #4826 I tried my hand at adding my functional tests. My first go in the new framework. Three test cases:
RE: How should uniqueness work
Totally fair call on the desirable end state. This message comes from dbt-core/core/dbt/exceptions.py Lines 849 to 850 in 69cdc41
I like the current behavior a lot more! Given that the error message already has resource-specific behavior, I've taken a crack at rewriting it to say something more helpful for tests:
Why did I rework the error message like this, rather than opting to show So, I don't think we should add the model name (and column name, if relevant) to the test's |
core/dbt/exceptions.py
Outdated
elif node_type == NodeType.Test and hasattr(node_1, "test_metadata"): | ||
column_name = f'column "{node_1.column_name}" in ' if node_1.column_name else "" | ||
# TODO: we need a better way of storing the target model/source/etc in generic tests | ||
model_name = ".".join((node_1.refs or node_1.sources)[0]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this would help, but I did add 'file_key_name' to ParsedGenericTestNode because I was having the same problem in partial parsing. It stores things like 'models.my_model' and 'sources.my_source'.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great call! This is much less hacky, so I think it's the right way to go. I do wish the file_key_name
for sources stored sources.my_source.my_src_table
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was thinking you might want that. It might be possible to update it to do that...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work here! I left many nits, but no blockers.
Thanks for clarifying in the previous comment that the output line is the node's unique_id
. I do think that is what should actually be changed in an ideal world, but the way we're doing it now works and has less far-reaching implications.
@@ -0,0 +1,7 @@ | |||
kind: Features | |||
body: Support custom names for generic tests |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it just generic tests or all tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Singular tests take their name from the file in which they're defined (tests/my_custom_test.sql
), just like models — so they've always required custom names
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even more evidence that this PR is a good idea!
core/dbt/exceptions.py
Outdated
f'dbt found two {pluralized} with the name "{duped_name}". ' | ||
f"\n" | ||
f"\nSince these resources have the same name, dbt will be unable to find the correct resource " | ||
f"\nwhen {action} {formatted_name}. " | ||
f"\n" | ||
f"\nTo fix this, change the name of one of these resources: " | ||
f"\n- {node_1.unique_id} ({node_1.original_file_path}) " | ||
f"\n- {node_2.unique_id} ({node_2.original_file_path})" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] for readability, I'd rather see a multiline f-string instead of relying on c-style string concatenation with explicit newline characters. For example:
f'''line one of {message_noun}
line two of {message_noun}
etc.'''
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The downside of a multiline f-string is that indentation gets messy:
$ dbt test
10:16:41 Running with dbt=1.1.0-b1
10:16:41 Encountered an error:
Compilation Error
dbt found two tests with the name "my_not_so_unique_column_level_test" defined on column "id" in "sources.my_src".
Since these resources have the same name, dbt will be unable to find the correct resource
when running tests.
To fix this, change the name of one of these resources:
- test.testy.my_not_so_unique_column_level_test.345c7a643d (models/one_file.yml)
- test.testy.my_not_so_unique_column_level_test.345c7a643d (models/one_file.yml)
There is a way to get the indentation right, but then it looks a bit odd in the source code:
raise_compiler_error(f"""
dbt found two {pluralized} with the name "{duped_name}".
Since these resources have the same name, dbt will be unable to find the correct resource
when {action} {formatted_name}.
To fix this, change the name of one of these resources:
- {node_1.unique_id} ({node_1.original_file_path})
- {node_2.unique_id} ({node_2.original_file_path})
""".strip())
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flake + black seem to be happy with the latter, so I think that's what I'll go with
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yup. this is exactly the trade off, but I most commonly see the latter across languages that support "raw strings" so I'm inclined to do it here as well.
test_type: str, test_name: str, args: Dict[str, Any] | ||
) -> Tuple[str, str]: | ||
# Using the type, name, and arguments to this generic test, synthesize a (hopefully) unique name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for adding that!
core/dbt/exceptions.py
Outdated
# duplicate generic tests | ||
elif node_type == NodeType.Test and hasattr(node_1, "test_metadata"): | ||
column_name = f'column "{node_1.column_name}" in ' if node_1.column_name else "" | ||
# TODO: we need a better way of storing the target model/source/etc in generic tests |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's log a tech debt ticket and put the github issue number in the comment here, unless @gshank's suggestion is the solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gerda's suggestion is definitely better than what I'm doing here, and I think it's enough for now—but I agree that this is a limitation in generic tests, which also prevents us from being able to do things like #4723
self.fqn_name: str = "" | ||
|
||
if "name" in self.args: | ||
# Trust the user to have a unique name for this model + column combo |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] "trust the user" makes it sounds like we're relying on them to enforce uniqueness themselves. We aren't because we raise an error. I'd change this comment to something more like "assign the user-defined name here, but it will be checked for uniqueness later"
test_name, test_args = test[0] | ||
# If the test is a dictionary with top-level keys, the test name is "test_name" | ||
# and the rest are arguments | ||
# {'name': 'my_favorite_test', 'test_name': 'unique', 'config': {'where': '1=1'}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test_name
and test name
are confusing, so this example really helps 👍
@@ -658,6 +661,60 @@ def test_collision_test_names_get_hash( | |||
assert test_results[1].node.unique_id in expected_unique_ids | |||
|
|||
|
|||
class TestGenericTestsCollide: | |||
@pytest.fixture(scope="class") | |||
def models(self, dupe_tests_collide): # noqa: F811 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] # noqa: F811
is a little unexpected here since that's usually for import statements. Does it work without this comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I copied this from the other test definitions, it looks like this is a flake8
complaint:
tests/functional/schema_tests/test_schema_v2_tests.py:666:22: F811 redefinition of unused 'dupe_tests_collide' from line 6
Indeed because dupe_tests_collide
is being imported at the top, from the separate file of fixtures (tests.functional.schema_tests.fixtures
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
interesting. that seems like a flake8 bug counting a reference as a redefinition. thanks for double checking!
def models(self, custom_generic_test_names): # noqa: F811 | ||
return custom_generic_test_names | ||
|
||
def test_collision_test_names_get_hash( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] let's add a comment to all the test methods describing why we need this test. (imagine the reader is considering deleting it)
Thanks for addressing all the feedback! Looks like a stellar PR to me. |
resolves #3348 (comment)
resolves #4012
The fuller issue is about rethinking how we construct
unique_id
. The comment, and this PR, seeks to address the narrower issue that we've seen come up most often in the community (and in #4684, #4102, etc).TODO
pytest
framework :)Description
Support this:
Why this change?
See the linked comment for thorough explanation. Quick answers below:
What this PR doesn't do: Alter, in any way, the logic that dbt uses to synthesize generic test names if no custom name is provided. The proposal in #3348 (comment) does include a cleaner more consistent approach to hashed test names. I opted against it, to keep the changes here minimal and additive-only. We can discuss whether that change feels necessary.
While we're here...
Also add support for defining tests as unified dictionaries, like other resource types, in addition to nested dictionaries with the test name as the top-level key. If done in this way, the name of the generic test (
unique
,not_null
, etc) is defined astest_name
.I like the second formulation a bit better when defining lots of custom properties and configurations. It's important to continue supporting the first spec; after all, most generic tests are defined with a single keyword (the test name alone).
Checklist