diff --git a/pyatlan/generator/templates/methods/asset/sql_insight_business_question.jinja2 b/pyatlan/generator/templates/methods/asset/sql_insight_business_question.jinja2 new file mode 100644 index 000000000..791a32f14 --- /dev/null +++ b/pyatlan/generator/templates/methods/asset/sql_insight_business_question.jinja2 @@ -0,0 +1,56 @@ + @staticmethod + def generate_qualified_name( + *, dataset_qualified_name: str, question_text: str + ) -> str: + """ + Derive the deterministic qualifiedName for a SqlInsightBusinessQuestion, + identical to the SQL-Intelligence miner's formula — so a human-confirmed + question and a later mined observation of the same question converge on + one entity instead of duplicating: + + ``dataset_qn || '/question/' || md5(question_text)`` + + The hash is over the question TEXT alone, so rewording a question makes a + new entity rather than updating the old one. + + :param dataset_qualified_name: unique name of the dataset the question is about + :param question_text: the business question, verbatim + :returns: the deterministic qualifiedName for the business-question entity + """ + digest = hashlib.md5( # noqa: S324 (miner-compatible identity, not crypto) + question_text.encode() + ).hexdigest() + return f"{dataset_qualified_name}/question/{digest}" + + @classmethod + @init_guid + def creator( + cls, + *, + dataset: SQL, + question_text: str, + canonical_sql: Optional[str] = None, + name: Optional[str] = None, + ) -> SqlInsightBusinessQuestion: + """ + Create a SqlInsightBusinessQuestion against a SQL dataset, carrying both + the string qualified-name attribute and the dataset relationship edge — + plus a deterministic, miner-identical qualifiedName so repeated + confirmation or a later mined observation converges on the same entity. + + :param dataset: the dataset the question is about, e.g. + ``Table.ref_by_qualified_name(...)`` or a search result — must carry + its real type (Table / View / MaterialisedView) and qualifiedName + :param question_text: the business question, verbatim. The qualifiedName is + derived from this, so rewording it creates a NEW entity + :param canonical_sql: optional SQL that answers the question + :param name: optional display name (defaults to the question text) + :returns: the minimal request to create the SqlInsightBusinessQuestion + """ + attributes = SqlInsightBusinessQuestion.Attributes.creator( + dataset=dataset, + question_text=question_text, + canonical_sql=canonical_sql, + name=name, + ) + return cls(attributes=attributes) diff --git a/pyatlan/generator/templates/methods/asset/sql_insight_filter.jinja2 b/pyatlan/generator/templates/methods/asset/sql_insight_filter.jinja2 new file mode 100644 index 000000000..b2fa5bd36 --- /dev/null +++ b/pyatlan/generator/templates/methods/asset/sql_insight_filter.jinja2 @@ -0,0 +1,62 @@ + @staticmethod + def generate_qualified_name(*, column_qualified_name: str, operator: str) -> str: + """ + Derive the deterministic qualifiedName for a SqlInsightFilter, identical to + the SQL-Intelligence miner's formula — so a human-confirmed filter and a + later mined observation of the same filter converge on one entity instead + of duplicating: + + ``column_qn || '/filter/' || md5(operator)`` + + Note this hangs off the COLUMN, not the dataset: filters sit one level + deeper than joins and business questions. + + :param column_qualified_name: unique name of the column being filtered + :param operator: the filter operator, e.g. ``IN``, ``=``, ``BETWEEN`` + :returns: the deterministic qualifiedName for the filter entity + """ + digest = hashlib.md5( # noqa: S324 (miner-compatible identity, not crypto) + operator.encode() + ).hexdigest() + return f"{column_qualified_name}/filter/{digest}" + + @classmethod + @init_guid + def creator( + cls, + *, + column: Column, + operator: str, + predicate_sql: Optional[str] = None, + when_to_use: Optional[str] = None, + common_values: Optional[Set[str]] = None, + name: Optional[str] = None, + ) -> SqlInsightFilter: + """ + Create a SqlInsightFilter on a column, carrying both the string + qualified-name attributes (the dataset attribute is how the asset page's + Usage & Intelligence tab finds filters) and the column relationship edge — + plus a deterministic, miner-identical qualifiedName so repeated + confirmation or a later mined observation converges on the same entity. + + :param column: the column being filtered, e.g. + ``Column.ref_by_qualified_name(...)`` or a search result — must carry + its qualifiedName + :param operator: the filter operator, e.g. ``IN``, ``=``, ``BETWEEN``. + The qualifiedName is derived from this, so two filters on the same + column with the same operator are ONE entity + :param predicate_sql: optional SQL predicate the filter applies + :param when_to_use: optional guidance on when this filter should be used + :param common_values: optional set of values commonly filtered on + :param name: optional display name (defaults to " ") + :returns: the minimal request to create the SqlInsightFilter + """ + attributes = SqlInsightFilter.Attributes.creator( + column=column, + operator=operator, + predicate_sql=predicate_sql, + when_to_use=when_to_use, + common_values=common_values, + name=name, + ) + return cls(attributes=attributes) diff --git a/pyatlan/generator/templates/methods/attribute/sql_insight_business_question.jinja2 b/pyatlan/generator/templates/methods/attribute/sql_insight_business_question.jinja2 new file mode 100644 index 000000000..70007018c --- /dev/null +++ b/pyatlan/generator/templates/methods/attribute/sql_insight_business_question.jinja2 @@ -0,0 +1,31 @@ + @classmethod + @init_guid + def creator( + cls, + *, + dataset: SQL, + question_text: str, + canonical_sql: Optional[str] = None, + name: Optional[str] = None, + ) -> SqlInsightBusinessQuestion.Attributes: + validate_required_fields( + ["dataset", "question_text"], [dataset, question_text] + ) + dataset_qualified_name = dataset.qualified_name + validate_required_fields( + ["dataset.qualified_name"], [dataset_qualified_name] + ) + return SqlInsightBusinessQuestion.Attributes( + name=name or question_text, + qualified_name=SqlInsightBusinessQuestion.generate_qualified_name( + dataset_qualified_name=dataset_qualified_name, # type: ignore[arg-type] + question_text=question_text, + ), + sql_insight_business_question_dataset_qualified_name=dataset_qualified_name, + sql_insight_business_question_text=question_text, + sql_insight_business_question_canonical_s_q_l=canonical_sql, + # A human-declared question has no observed usage: never claim any. + sql_insight_business_question_query_count=0, + sql_insight_business_question_unique_users=0, + sql_insight_dataset=dataset, + ) diff --git a/pyatlan/generator/templates/methods/attribute/sql_insight_filter.jinja2 b/pyatlan/generator/templates/methods/attribute/sql_insight_filter.jinja2 new file mode 100644 index 000000000..7c617d35d --- /dev/null +++ b/pyatlan/generator/templates/methods/attribute/sql_insight_filter.jinja2 @@ -0,0 +1,41 @@ + @classmethod + @init_guid + def creator( + cls, + *, + column: Column, + operator: str, + predicate_sql: Optional[str] = None, + when_to_use: Optional[str] = None, + common_values: Optional[Set[str]] = None, + name: Optional[str] = None, + ) -> SqlInsightFilter.Attributes: + validate_required_fields(["column", "operator"], [column, operator]) + column_qualified_name = column.qualified_name + validate_required_fields( + ["column.qualified_name"], [column_qualified_name] + ) + # A filter's qualifiedName is /filter/md5(operator), and a + # column's own qualifiedName is /. So the dataset is + # the column's parent, never a separate input that could disagree with it. + dataset_qualified_name = column_qualified_name.rsplit("/", 1)[0] # type: ignore[union-attr] + return SqlInsightFilter.Attributes( + name=name or f"{column_qualified_name.rsplit('/', 1)[-1]} {operator}", # type: ignore[union-attr] + qualified_name=SqlInsightFilter.generate_qualified_name( + column_qualified_name=column_qualified_name, # type: ignore[arg-type] + operator=operator, + ), + # Both are load-bearing: the Usage & Intelligence tab finds filters by + # the dataset ATTRIBUTE, while the column RELATIONSHIP is what renders + # the row on the column itself. A filter missing either is half-visible. + sql_insight_filter_dataset_qualified_name=dataset_qualified_name, + sql_insight_filter_column_qualified_name=column_qualified_name, + sql_insight_filter_operator=operator, + sql_insight_filter_predicate_s_q_l=predicate_sql, + sql_insight_filter_when_to_use=when_to_use, + sql_insight_filter_common_values=common_values, + # A human-declared filter has no observed usage: never claim any. + sql_insight_filter_query_count=0, + sql_insight_filter_unique_users=0, + sql_insight_column=column, + ) diff --git a/pyatlan/model/assets/core/sql_insight_business_question.py b/pyatlan/model/assets/core/sql_insight_business_question.py index 038561f7b..670669a07 100644 --- a/pyatlan/model/assets/core/sql_insight_business_question.py +++ b/pyatlan/model/assets/core/sql_insight_business_question.py @@ -4,6 +4,7 @@ from __future__ import annotations +import hashlib from datetime import datetime from typing import ClassVar, List, Optional @@ -11,6 +12,7 @@ from pyatlan.model.fields.atlan_fields import KeywordField, NumericField, RelationField from pyatlan.model.structs import PopularityInsights +from pyatlan.utils import init_guid, validate_required_fields from .sql_insight import SqlInsight @@ -18,6 +20,63 @@ class SqlInsightBusinessQuestion(SqlInsight): """Description""" + @staticmethod + def generate_qualified_name( + *, dataset_qualified_name: str, question_text: str + ) -> str: + """ + Derive the deterministic qualifiedName for a SqlInsightBusinessQuestion, + identical to the SQL-Intelligence miner's formula — so a human-confirmed + question and a later mined observation of the same question converge on + one entity instead of duplicating: + + ``dataset_qn || '/question/' || md5(question_text)`` + + The hash is over the question TEXT alone, so rewording a question makes a + new entity rather than updating the old one. + + :param dataset_qualified_name: unique name of the dataset the question is about + :param question_text: the business question, verbatim + :returns: the deterministic qualifiedName for the business-question entity + """ + digest = hashlib.md5( # noqa: S324 (miner-compatible identity, not crypto) + question_text.encode() + ).hexdigest() + return f"{dataset_qualified_name}/question/{digest}" + + @classmethod + @init_guid + def creator( + cls, + *, + dataset: SQL, + question_text: str, + canonical_sql: Optional[str] = None, + name: Optional[str] = None, + ) -> SqlInsightBusinessQuestion: + """ + Create a SqlInsightBusinessQuestion against a SQL dataset, carrying both + the string qualified-name attribute and the dataset relationship edge — + plus a deterministic, miner-identical qualifiedName so repeated + confirmation or a later mined observation converges on the same entity. + + :param dataset: the dataset the question is about, e.g. + ``Table.ref_by_qualified_name(...)`` or a search result — must carry + its real type (Table / View / MaterialisedView) and qualifiedName + :param question_text: the business question, verbatim. The qualifiedName is + derived from this, so rewording it creates a NEW entity + :param canonical_sql: optional SQL that answers the question + :param name: optional display name (defaults to the question text) + :returns: the minimal request to create the SqlInsightBusinessQuestion + """ + attributes = SqlInsightBusinessQuestion.Attributes.creator( + dataset=dataset, + question_text=question_text, + canonical_sql=canonical_sql, + name=name, + ) + return cls(attributes=attributes) + type_name: str = Field(default="SqlInsightBusinessQuestion", allow_mutation=False) @validator("type_name") @@ -264,6 +323,38 @@ class Attributes(SqlInsight.Attributes): default=None, description="" ) # relationship + @classmethod + @init_guid + def creator( + cls, + *, + dataset: SQL, + question_text: str, + canonical_sql: Optional[str] = None, + name: Optional[str] = None, + ) -> SqlInsightBusinessQuestion.Attributes: + validate_required_fields( + ["dataset", "question_text"], [dataset, question_text] + ) + dataset_qualified_name = dataset.qualified_name + validate_required_fields( + ["dataset.qualified_name"], [dataset_qualified_name] + ) + return SqlInsightBusinessQuestion.Attributes( + name=name or question_text, + qualified_name=SqlInsightBusinessQuestion.generate_qualified_name( + dataset_qualified_name=dataset_qualified_name, # type: ignore[arg-type] + question_text=question_text, + ), + sql_insight_business_question_dataset_qualified_name=dataset_qualified_name, + sql_insight_business_question_text=question_text, + sql_insight_business_question_canonical_s_q_l=canonical_sql, + # A human-declared question has no observed usage: never claim any. + sql_insight_business_question_query_count=0, + sql_insight_business_question_unique_users=0, + sql_insight_dataset=dataset, + ) + attributes: SqlInsightBusinessQuestion.Attributes = Field( default_factory=lambda: SqlInsightBusinessQuestion.Attributes(), description=( diff --git a/pyatlan/model/assets/core/sql_insight_filter.py b/pyatlan/model/assets/core/sql_insight_filter.py index 94012bd37..e4512a3c0 100644 --- a/pyatlan/model/assets/core/sql_insight_filter.py +++ b/pyatlan/model/assets/core/sql_insight_filter.py @@ -4,6 +4,7 @@ from __future__ import annotations +import hashlib from datetime import datetime from typing import ClassVar, List, Optional, Set @@ -11,6 +12,7 @@ from pyatlan.model.fields.atlan_fields import KeywordField, NumericField, RelationField from pyatlan.model.structs import PopularityInsights +from pyatlan.utils import init_guid, validate_required_fields from .sql_insight import SqlInsight @@ -18,6 +20,69 @@ class SqlInsightFilter(SqlInsight): """Description""" + @staticmethod + def generate_qualified_name(*, column_qualified_name: str, operator: str) -> str: + """ + Derive the deterministic qualifiedName for a SqlInsightFilter, identical to + the SQL-Intelligence miner's formula — so a human-confirmed filter and a + later mined observation of the same filter converge on one entity instead + of duplicating: + + ``column_qn || '/filter/' || md5(operator)`` + + Note this hangs off the COLUMN, not the dataset: filters sit one level + deeper than joins and business questions. + + :param column_qualified_name: unique name of the column being filtered + :param operator: the filter operator, e.g. ``IN``, ``=``, ``BETWEEN`` + :returns: the deterministic qualifiedName for the filter entity + """ + digest = hashlib.md5( # noqa: S324 (miner-compatible identity, not crypto) + operator.encode() + ).hexdigest() + return f"{column_qualified_name}/filter/{digest}" + + @classmethod + @init_guid + def creator( + cls, + *, + column: Column, + operator: str, + predicate_sql: Optional[str] = None, + when_to_use: Optional[str] = None, + common_values: Optional[Set[str]] = None, + name: Optional[str] = None, + ) -> SqlInsightFilter: + """ + Create a SqlInsightFilter on a column, carrying both the string + qualified-name attributes (the dataset attribute is how the asset page's + Usage & Intelligence tab finds filters) and the column relationship edge — + plus a deterministic, miner-identical qualifiedName so repeated + confirmation or a later mined observation converges on the same entity. + + :param column: the column being filtered, e.g. + ``Column.ref_by_qualified_name(...)`` or a search result — must carry + its qualifiedName + :param operator: the filter operator, e.g. ``IN``, ``=``, ``BETWEEN``. + The qualifiedName is derived from this, so two filters on the same + column with the same operator are ONE entity + :param predicate_sql: optional SQL predicate the filter applies + :param when_to_use: optional guidance on when this filter should be used + :param common_values: optional set of values commonly filtered on + :param name: optional display name (defaults to " ") + :returns: the minimal request to create the SqlInsightFilter + """ + attributes = SqlInsightFilter.Attributes.creator( + column=column, + operator=operator, + predicate_sql=predicate_sql, + when_to_use=when_to_use, + common_values=common_values, + name=name, + ) + return cls(attributes=attributes) + type_name: str = Field(default="SqlInsightFilter", allow_mutation=False) @validator("type_name") @@ -326,6 +391,46 @@ class Attributes(SqlInsight.Attributes): default=None, description="" ) # relationship + @classmethod + @init_guid + def creator( + cls, + *, + column: Column, + operator: str, + predicate_sql: Optional[str] = None, + when_to_use: Optional[str] = None, + common_values: Optional[Set[str]] = None, + name: Optional[str] = None, + ) -> SqlInsightFilter.Attributes: + validate_required_fields(["column", "operator"], [column, operator]) + column_qualified_name = column.qualified_name + validate_required_fields(["column.qualified_name"], [column_qualified_name]) + # A filter's qualifiedName is /filter/md5(operator), and a + # column's own qualifiedName is /. So the dataset is + # the column's parent, never a separate input that could disagree with it. + dataset_qualified_name = column_qualified_name.rsplit("/", 1)[0] # type: ignore[union-attr] + return SqlInsightFilter.Attributes( + name=name or f"{column_qualified_name.rsplit('/', 1)[-1]} {operator}", # type: ignore[union-attr] + qualified_name=SqlInsightFilter.generate_qualified_name( + column_qualified_name=column_qualified_name, # type: ignore[arg-type] + operator=operator, + ), + # Both are load-bearing: the Usage & Intelligence tab finds filters by + # the dataset ATTRIBUTE, while the column RELATIONSHIP is what renders + # the row on the column itself. A filter missing either is half-visible. + sql_insight_filter_dataset_qualified_name=dataset_qualified_name, + sql_insight_filter_column_qualified_name=column_qualified_name, + sql_insight_filter_operator=operator, + sql_insight_filter_predicate_s_q_l=predicate_sql, + sql_insight_filter_when_to_use=when_to_use, + sql_insight_filter_common_values=common_values, + # A human-declared filter has no observed usage: never claim any. + sql_insight_filter_query_count=0, + sql_insight_filter_unique_users=0, + sql_insight_column=column, + ) + attributes: SqlInsightFilter.Attributes = Field( default_factory=lambda: SqlInsightFilter.Attributes(), description=( diff --git a/tests/unit/model/constants.py b/tests/unit/model/constants.py index 85ed95819..07177284e 100644 --- a/tests/unit/model/constants.py +++ b/tests/unit/model/constants.py @@ -341,3 +341,43 @@ DQ_RULE_DESCRIPTION_UPDATED = "Updated test data quality rule" DQ_TABLE_QUALIFIED_NAME = TABLE_QUALIFIED_NAME DQ_COLUMN_QUALIFIED_NAME = TABLE_COLUMN_QUALIFIED_NAME + +# SqlInsightFilter / SqlInsightBusinessQuestion +# +# The qualifiedNames below are PINNED LITERALS, not values recomputed from the +# formula. Their contract is byte-identity with the SQL-Intelligence miner +# (`STRING_AGG`/md5 in the sql_intelligence DAG) and the UI's authoring path +# (`sqlInsightIdentity.ts`) -- a row whose identity differs by one byte is a +# DUPLICATE of the mined one rather than a convergence with it. Deriving the +# expectation from the same code under test would assert nothing. +SQL_INSIGHT_FILTER_OPERATOR = "IN" +SQL_INSIGHT_FILTER_OPERATOR_OTHER = "=" +SQL_INSIGHT_FILTER_PREDICATE_SQL = "MyColumn IN ('EMEA', 'APAC')" +SQL_INSIGHT_FILTER_WHEN_TO_USE = "scope results to a sales region" +SQL_INSIGHT_FILTER_NAME = f"{COLUMN_NAME} {SQL_INSIGHT_FILTER_OPERATOR}" +# /filter/md5("IN") +SQL_INSIGHT_FILTER_QUALIFIED_NAME = ( + "default/snowflake/1686532494/MyDB/MySchema/MyTable/MyColumn" + "/filter/c86ee0d9d7ed3e7b4fdbf486fa6c0ebb" +) +# /filter/md5("=") -- a different operator must be a different entity +SQL_INSIGHT_FILTER_QUALIFIED_NAME_OTHER_OPERATOR = ( + "default/snowflake/1686532494/MyDB/MySchema/MyTable/MyColumn" + "/filter/43ec3e5dee6e706af7766fffea512721" +) + +SQL_INSIGHT_QUESTION_TEXT = "What is total revenue by region?" +SQL_INSIGHT_QUESTION_TEXT_OTHER = "What is total revenue by country?" +SQL_INSIGHT_QUESTION_CANONICAL_SQL = ( + "SELECT region, SUM(amount) FROM MyTable GROUP BY 1" +) +# /question/md5("What is total revenue by region?") +SQL_INSIGHT_QUESTION_QUALIFIED_NAME = ( + "default/snowflake/1686532494/MyDB/MySchema/MyTable" + "/question/685c806ed064f19da9d071f41f391054" +) +# Rewording is a NEW question, never an update to the old one. +SQL_INSIGHT_QUESTION_QUALIFIED_NAME_OTHER_TEXT = ( + "default/snowflake/1686532494/MyDB/MySchema/MyTable" + "/question/92a9c6c2952bce015e07a2ee59f414bf" +) diff --git a/tests/unit/model/sql_insight_business_question_test.py b/tests/unit/model/sql_insight_business_question_test.py new file mode 100644 index 000000000..a4b3fe8dc --- /dev/null +++ b/tests/unit/model/sql_insight_business_question_test.py @@ -0,0 +1,130 @@ +import hashlib +import re + +import pytest + +from pyatlan.model.assets import SqlInsightBusinessQuestion, Table +from tests.unit.model.constants import ( + SQL_INSIGHT_QUESTION_CANONICAL_SQL, + SQL_INSIGHT_QUESTION_QUALIFIED_NAME, + SQL_INSIGHT_QUESTION_QUALIFIED_NAME_OTHER_TEXT, + SQL_INSIGHT_QUESTION_TEXT, + SQL_INSIGHT_QUESTION_TEXT_OTHER, + TABLE_QUALIFIED_NAME, +) + + +def _dataset(): + return Table.ref_by_qualified_name(TABLE_QUALIFIED_NAME) + + +@pytest.mark.parametrize( + "dataset, question_text, message", + [ + (None, SQL_INSIGHT_QUESTION_TEXT, "dataset is required"), + (_dataset(), None, "question_text is required"), + (_dataset(), "", "question_text cannot be blank"), + ], +) +def test_creator_with_missing_parameters_raise_value_error( + dataset: Table, question_text: str, message: str +): + with pytest.raises(ValueError, match=message): + SqlInsightBusinessQuestion.creator(dataset=dataset, question_text=question_text) + + +def test_md5_matches_rfc_1321(): + """Separates "md5 is broken" from "the formula is wrong".""" + assert hashlib.md5(b"abc").hexdigest() == "900150983cd24fb0d6963f7d28e17f72" + + +def test_generate_qualified_name(): + """Pinned to a literal, never to the formula recomputed here.""" + assert ( + SqlInsightBusinessQuestion.generate_qualified_name( + dataset_qualified_name=TABLE_QUALIFIED_NAME, + question_text=SQL_INSIGHT_QUESTION_TEXT, + ) + == SQL_INSIGHT_QUESTION_QUALIFIED_NAME + ) + + +def test_qualified_name_shape(): + """This type shipped once as `/businessQuestion/` plus a truncated sha256, so + the segment literal and the full 32-hex lowercase digest are asserted directly + rather than left implicit in the golden.""" + qn = SqlInsightBusinessQuestion.generate_qualified_name( + dataset_qualified_name=TABLE_QUALIFIED_NAME, + question_text=SQL_INSIGHT_QUESTION_TEXT, + ) + prefix, segment, digest = qn.rsplit("/", 2) + assert prefix == TABLE_QUALIFIED_NAME + assert segment == "question" + assert re.fullmatch(r"[0-9a-f]{32}", digest) + + +def test_same_question_converges_on_one_qualified_name(): + """Re-confirming a question, or the miner observing it later, must land on the + SAME entity rather than duplicating it.""" + first = SqlInsightBusinessQuestion.creator( + dataset=_dataset(), question_text=SQL_INSIGHT_QUESTION_TEXT + ) + second = SqlInsightBusinessQuestion.creator( + dataset=_dataset(), + question_text=SQL_INSIGHT_QUESTION_TEXT, + canonical_sql="SELECT 1 -- a different answer to the same question", + ) + assert first.qualified_name == second.qualified_name + + +def test_reworded_question_is_a_new_entity(): + """The hash is over the question TEXT alone, so rewording creates a new entity + rather than silently rewriting the old one. Called out because it is a real + consequence callers need to expect, not an accident of the formula.""" + assert ( + SqlInsightBusinessQuestion.generate_qualified_name( + dataset_qualified_name=TABLE_QUALIFIED_NAME, + question_text=SQL_INSIGHT_QUESTION_TEXT_OTHER, + ) + == SQL_INSIGHT_QUESTION_QUALIFIED_NAME_OTHER_TEXT + ) + + +def test_creator(): + question = SqlInsightBusinessQuestion.creator( + dataset=_dataset(), + question_text=SQL_INSIGHT_QUESTION_TEXT, + canonical_sql=SQL_INSIGHT_QUESTION_CANONICAL_SQL, + ) + + assert question.qualified_name == SQL_INSIGHT_QUESTION_QUALIFIED_NAME + assert question.name == SQL_INSIGHT_QUESTION_TEXT + assert question.sql_insight_business_question_text == SQL_INSIGHT_QUESTION_TEXT + assert question.sql_insight_business_question_canonical_s_q_l == ( + SQL_INSIGHT_QUESTION_CANONICAL_SQL + ) + + +def test_creator_writes_both_anchorings(): + """The dataset ATTRIBUTE and the dataset RELATIONSHIP are both load-bearing; a + question carrying only one is half-visible on the asset.""" + question = SqlInsightBusinessQuestion.creator( + dataset=_dataset(), question_text=SQL_INSIGHT_QUESTION_TEXT + ) + + assert question.sql_insight_business_question_dataset_qualified_name == ( + TABLE_QUALIFIED_NAME + ) + assert question.sql_insight_dataset is not None + assert question.sql_insight_dataset.qualified_name == TABLE_QUALIFIED_NAME + + +def test_creator_claims_no_observed_usage(): + """A human-declared question has no query history; reporting one would put + invented popularity on the asset page.""" + question = SqlInsightBusinessQuestion.creator( + dataset=_dataset(), question_text=SQL_INSIGHT_QUESTION_TEXT + ) + + assert question.sql_insight_business_question_query_count == 0 + assert question.sql_insight_business_question_unique_users == 0 diff --git a/tests/unit/model/sql_insight_filter_test.py b/tests/unit/model/sql_insight_filter_test.py new file mode 100644 index 000000000..1cee5d1bf --- /dev/null +++ b/tests/unit/model/sql_insight_filter_test.py @@ -0,0 +1,160 @@ +import hashlib +import re + +import pytest + +from pyatlan.model.assets import Column, SqlInsightFilter +from tests.unit.model.constants import ( + COLUMN_NAME, + SQL_INSIGHT_FILTER_NAME, + SQL_INSIGHT_FILTER_OPERATOR, + SQL_INSIGHT_FILTER_OPERATOR_OTHER, + SQL_INSIGHT_FILTER_PREDICATE_SQL, + SQL_INSIGHT_FILTER_QUALIFIED_NAME, + SQL_INSIGHT_FILTER_QUALIFIED_NAME_OTHER_OPERATOR, + SQL_INSIGHT_FILTER_WHEN_TO_USE, + TABLE_COLUMN_QUALIFIED_NAME, + TABLE_QUALIFIED_NAME, +) + + +def _column(): + return Column.ref_by_qualified_name(TABLE_COLUMN_QUALIFIED_NAME) + + +@pytest.mark.parametrize( + "column, operator, message", + [ + (None, SQL_INSIGHT_FILTER_OPERATOR, "column is required"), + (_column(), None, "operator is required"), + (_column(), "", "operator cannot be blank"), + ], +) +def test_creator_with_missing_parameters_raise_value_error( + column: Column, operator: str, message: str +): + with pytest.raises(ValueError, match=message): + SqlInsightFilter.creator(column=column, operator=operator) + + +def test_md5_matches_rfc_1321(): + """Separates "md5 is broken" from "the formula is wrong". + + Every golden below is an md5 of something. Without this, a change to encoding + or digest handling fails all of them at once with no signal about the cause. + """ + assert hashlib.md5(b"abc").hexdigest() == "900150983cd24fb0d6963f7d28e17f72" + + +def test_generate_qualified_name(): + """Pinned to a literal, never to the formula recomputed here. + + The contract is byte-identity with the SQL-Intelligence miner and the UI, so an + expectation derived from the code under test would assert nothing. + """ + assert ( + SqlInsightFilter.generate_qualified_name( + column_qualified_name=TABLE_COLUMN_QUALIFIED_NAME, + operator=SQL_INSIGHT_FILTER_OPERATOR, + ) + == SQL_INSIGHT_FILTER_QUALIFIED_NAME + ) + + +def test_qualified_name_shape(): + """The two ways this identity has been got wrong before: the wrong segment + literal, and a digest that is not a full 32-hex lowercase md5 (a business + question once shipped as `/businessQuestion/` + a truncated sha256).""" + qn = SqlInsightFilter.generate_qualified_name( + column_qualified_name=TABLE_COLUMN_QUALIFIED_NAME, + operator=SQL_INSIGHT_FILTER_OPERATOR, + ) + prefix, segment, digest = qn.rsplit("/", 2) + assert prefix == TABLE_COLUMN_QUALIFIED_NAME + assert segment == "filter" + assert re.fullmatch(r"[0-9a-f]{32}", digest) + + +def test_same_filter_converges_on_one_qualified_name(): + """The reason the method exists: re-confirming a filter, or the miner observing + it later, must land on the SAME entity rather than duplicating it.""" + first = SqlInsightFilter.creator( + column=_column(), operator=SQL_INSIGHT_FILTER_OPERATOR + ) + second = SqlInsightFilter.creator( + column=_column(), + operator=SQL_INSIGHT_FILTER_OPERATOR, + when_to_use="a different note, same filter", + ) + assert first.qualified_name == second.qualified_name + + +def test_different_operator_is_a_different_filter(): + """Discrimination. A formula that ignored its input would pass convergence.""" + assert ( + SqlInsightFilter.generate_qualified_name( + column_qualified_name=TABLE_COLUMN_QUALIFIED_NAME, + operator=SQL_INSIGHT_FILTER_OPERATOR_OTHER, + ) + == SQL_INSIGHT_FILTER_QUALIFIED_NAME_OTHER_OPERATOR + ) + + +def test_creator(): + sql_insight_filter = SqlInsightFilter.creator( + column=_column(), + operator=SQL_INSIGHT_FILTER_OPERATOR, + predicate_sql=SQL_INSIGHT_FILTER_PREDICATE_SQL, + when_to_use=SQL_INSIGHT_FILTER_WHEN_TO_USE, + ) + + assert sql_insight_filter.qualified_name == SQL_INSIGHT_FILTER_QUALIFIED_NAME + assert sql_insight_filter.name == SQL_INSIGHT_FILTER_NAME + assert sql_insight_filter.sql_insight_filter_operator == ( + SQL_INSIGHT_FILTER_OPERATOR + ) + assert sql_insight_filter.sql_insight_filter_predicate_s_q_l == ( + SQL_INSIGHT_FILTER_PREDICATE_SQL + ) + assert sql_insight_filter.sql_insight_filter_when_to_use == ( + SQL_INSIGHT_FILTER_WHEN_TO_USE + ) + + +def test_creator_writes_both_anchorings(): + """A filter carrying only one of these is half-visible: the asset page's Usage + & Intelligence tab finds filters by the DATASET ATTRIBUTE, while the COLUMN + RELATIONSHIP is what renders the row on the column itself.""" + sql_insight_filter = SqlInsightFilter.creator( + column=_column(), operator=SQL_INSIGHT_FILTER_OPERATOR + ) + + assert sql_insight_filter.sql_insight_filter_dataset_qualified_name == ( + TABLE_QUALIFIED_NAME + ) + assert sql_insight_filter.sql_insight_filter_column_qualified_name == ( + TABLE_COLUMN_QUALIFIED_NAME + ) + assert sql_insight_filter.sql_insight_column is not None + assert sql_insight_filter.sql_insight_column.qualified_name == ( + TABLE_COLUMN_QUALIFIED_NAME + ) + + +def test_creator_claims_no_observed_usage(): + """A human-declared filter has no query history; reporting one would put + invented popularity on the asset page.""" + sql_insight_filter = SqlInsightFilter.creator( + column=_column(), operator=SQL_INSIGHT_FILTER_OPERATOR + ) + + assert sql_insight_filter.sql_insight_filter_query_count == 0 + assert sql_insight_filter.sql_insight_filter_unique_users == 0 + + +def test_creator_default_name_reads_as_the_filter(): + sql_insight_filter = SqlInsightFilter.creator( + column=_column(), operator=SQL_INSIGHT_FILTER_OPERATOR + ) + + assert sql_insight_filter.name == f"{COLUMN_NAME} {SQL_INSIGHT_FILTER_OPERATOR}"