chore: import upstream snapshot with attribution
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:35:45 +08:00
commit bf2343b7e4
16049 changed files with 3531137 additions and 0 deletions
@@ -0,0 +1,381 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Athena connection handling."""
import socket
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError
from metadata.core.connections.test_connection.check import collect_checks
from metadata.core.connections.test_connection.checks.database import DatabaseStep
from metadata.core.connections.test_connection.records import Evidence
from metadata.generated.schema.entity.services.connections.database.athenaConnection import (
AthenaConnection as AthenaConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.athenaConnection import (
AthenaScheme,
)
from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials
from metadata.generated.schema.type.filterPattern import FilterPattern
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.athena import connection as athena_connection
from metadata.ingestion.source.database.athena.connection import (
ATHENA_ERRORS,
AthenaChecks,
AthenaConnection,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.athena.connection"
def _config(**kwargs) -> AthenaConnectionConfig:
base = {
"awsConfig": AWSCredentials(awsAccessKeyId="key", awsRegion="us-east-2", awsSecretAccessKey="secret_key"),
"s3StagingDir": "s3://postgres/input/",
"workgroup": "primary",
"scheme": AthenaScheme.awsathena_rest,
}
base.update(kwargs)
return AthenaConnectionConfig(**base)
def _checks(**config_kwargs) -> AthenaChecks:
conn = AthenaConnection(_config(**config_kwargs))
conn._client = MagicMock()
return conn.checks()
def _client_error(code: str, message: str = "denied") -> ClientError:
return ClientError({"Error": {"Code": code, "Message": message}}, "StartQueryExecution")
def _raising_factory(exc: BaseException):
def factory():
raise exc
return factory
def test_athena_connection_is_base_connection():
assert issubclass(AthenaConnection, BaseConnection)
def test_get_client_uses_the_class_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = AthenaConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
def test_get_client_registers_engine_disposal():
conn = AthenaConnection(_config())
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
engine = mock_connection.return_value
_ = conn.client
conn.close()
engine.dispose.assert_called_once_with()
def test_checks_returns_provider_over_the_client():
conn = AthenaConnection(_config())
conn._client = MagicMock()
provider = conn.checks()
assert isinstance(provider, AthenaChecks)
assert provider.client is conn._client
def test_checks_wires_filter_pattern_and_catalog_from_service_connection():
pattern = FilterPattern(includes=["db2"])
conn = AthenaConnection(_config(catalogId="my_catalog", schemaFilterPattern=pattern))
conn._client = MagicMock()
provider = conn.checks()
assert provider.schema_filter_pattern == pattern
assert provider.catalog_id == "my_catalog"
def test_every_athena_step_resolves_to_a_check():
provider = AthenaChecks(client_factory=MagicMock)
resolved = collect_checks(provider)
assert set(resolved) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
}
def test_every_check_reports_the_command_it_ran():
# pyathena reflects via the AWS API, so the shared SQL capture yields nothing;
# each reflection step names its API operation instead.
provider = _checks()
inspector = MagicMock()
inspector.get_schema_names.return_value = ["ecommerce"]
inspector.get_table_names.return_value = ["orders"]
inspector.get_view_names.return_value = ["v_orders"]
with (
patch(f"{CONNECTION_MODULE}.list_schemas", return_value=Evidence(summary="1 schema enumerated")),
patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector),
):
assert provider.get_schemas().command == "athena:ListDatabases (CatalogName=AwsDataCatalog)"
assert provider.get_tables().command == "athena:ListTableMetadata (CatalogName=AwsDataCatalog)"
assert provider.get_views().command == "athena:ListTableMetadata (CatalogName=AwsDataCatalog)"
def test_command_names_a_configured_catalog():
provider = _checks(catalogId="my_catalog")
with patch(f"{CONNECTION_MODULE}.list_schemas", return_value=Evidence(summary="ok")):
assert provider.get_schemas().command == "athena:ListDatabases (CatalogName=my_catalog)"
def test_error_pack_classifies_access_denied_as_not_authorized():
diagnosis = ATHENA_ERRORS.classify(_client_error("AccessDeniedException"))
assert diagnosis is not None
assert diagnosis.title == "Not authorized"
def test_error_pack_classifies_unrecognized_client_as_unknown_access_key():
# Athena is json-protocol: an unknown access key ID arrives as this.
diagnosis = ATHENA_ERRORS.classify(_client_error("UnrecognizedClientException"))
assert diagnosis is not None
assert diagnosis.title == "AWS access key not recognized"
def test_error_pack_classifies_a_signature_error_through_a_wrapping_cause():
wrapped = RuntimeError("query failed")
wrapped.__cause__ = _client_error("InvalidSignatureException")
diagnosis = ATHENA_ERRORS.classify(wrapped)
assert diagnosis is not None
assert diagnosis.title == "AWS secret key does not match"
def test_error_pack_classifies_missing_workgroup():
error = RuntimeError("WorkGroup primary is not found")
diagnosis = ATHENA_ERRORS.classify(error)
assert diagnosis is not None
assert diagnosis.title == "Workgroup not found"
def test_error_pack_classifies_missing_result_location():
error = RuntimeError("No output location provided for query")
diagnosis = ATHENA_ERRORS.classify(error)
assert diagnosis is not None
assert diagnosis.title == "Query result location not configured"
def test_error_pack_classifies_unwritable_result_location():
error = RuntimeError("Access denied when writing to location: s3://my-bucket/query-results/abc.csv")
diagnosis = ATHENA_ERRORS.classify(error)
assert diagnosis is not None
assert diagnosis.title == "Cannot write query results"
def test_error_pack_classifies_an_unusable_result_bucket():
error = RuntimeError("An error occurred (InvalidRequestException): Unable to verify/create output bucket my-bucket")
diagnosis = ATHENA_ERRORS.classify(error)
assert diagnosis is not None
assert diagnosis.title == "Query result bucket not usable"
def test_error_pack_classifies_unreachable_endpoint():
error = RuntimeError('Could not connect to the endpoint URL: "https://athena.bad.amazonaws.com/"')
diagnosis = ATHENA_ERRORS.classify(error)
assert diagnosis is not None
assert diagnosis.title == "Cannot reach the AWS Athena endpoint"
def test_error_pack_classifies_not_authorized():
diagnosis = ATHENA_ERRORS.classify(
_client_error("SomeOtherException", "User is not authorized to perform: glue:GetTables")
)
assert diagnosis is not None
assert diagnosis.title == "Not authorized"
def test_error_pack_folds_in_the_network_pack():
diagnosis = ATHENA_ERRORS.classify(socket.gaierror("name resolution failed"))
assert diagnosis is not None
assert diagnosis.title == "Host could not be resolved"
def test_error_pack_classifies_sts_assume_role_denied_as_not_authorized():
diagnosis = ATHENA_ERRORS.classify(_client_error("AccessDenied", "not authorized to perform: sts:AssumeRole"))
assert diagnosis is not None
assert diagnosis.title == "Not authorized"
def test_error_pack_classifies_invalid_client_token_as_unknown_access_key():
# STS's code, from the assume-role leg.
diagnosis = ATHENA_ERRORS.classify(_client_error("InvalidClientTokenId"))
assert diagnosis is not None
assert diagnosis.title == "AWS access key not recognized"
def test_error_pack_classifies_signature_mismatch_as_wrong_secret():
diagnosis = ATHENA_ERRORS.classify(_client_error("SignatureDoesNotMatch"))
assert diagnosis is not None
assert diagnosis.title == "AWS secret key does not match"
def test_error_pack_classifies_expired_token():
diagnosis = ATHENA_ERRORS.classify(_client_error("ExpiredToken"))
assert diagnosis is not None
assert diagnosis.title == "AWS session token expired"
def test_error_pack_prefers_an_authentication_code_over_not_authorized_text():
# The authorization rule sits above AWS_ERRORS; its message fallback must not
# swallow a rejected identity.
diagnosis = ATHENA_ERRORS.classify(_client_error("ExpiredToken", "not authorized: token expired"))
assert diagnosis is not None
assert diagnosis.title == "AWS session token expired"
def test_error_pack_returns_none_for_unknown_error():
assert ATHENA_ERRORS.classify(RuntimeError("something unrelated")) is None
def test_client_is_built_lazily_not_at_construction():
calls = []
provider = AthenaChecks(client_factory=lambda: calls.append(1) or MagicMock())
assert calls == []
_ = provider.client
_ = provider.client
assert calls == [1]
def test_check_access_surfaces_client_build_failure_for_classification():
# An assume-role / credential failure while building the engine now happens
# inside CheckAccess (the gate), so its exception propagates from the check and
# the runner can classify it - instead of raising at provider construction.
error = _client_error("InvalidClientTokenId", "The security token included in the request is invalid")
provider = AthenaChecks(client_factory=_raising_factory(error))
with pytest.raises(ClientError) as exc_info:
provider.check_access()
assert ATHENA_ERRORS.classify(exc_info.value).title == "AWS access key not recognized"
def test_athena_url():
expected = (
"awsathena+rest://key:secret_key@athena.us-east-2.amazonaws.com:443"
"?s3_staging_dir=s3%3A%2F%2Fpostgres%2Finput%2F&work_group=primary"
)
assert AthenaConnection.get_connection_url(_config()) == expected
def test_athena_url_other_staging_dir():
expected = (
"awsathena+rest://key:secret_key@athena.us-east-2.amazonaws.com:443"
"?s3_staging_dir=s3%3A%2F%2Fpostgres%2Fintput%2F&work_group=primary"
)
assert AthenaConnection.get_connection_url(_config(s3StagingDir="s3://postgres/intput/")) == expected
def test_get_tables_caveats_when_all_targeted_schemas_empty():
checks = _checks(catalogId="my_catalog")
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1", "db2"]
inspector.get_table_names.return_value = []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_tables()
assert evidence.caveat is not None
assert evidence.caveat.title == "No readable tables"
def test_get_tables_passes_without_caveat_when_a_schema_has_tables():
checks = _checks()
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1", "db2"]
inspector.get_table_names.side_effect = lambda schema: ["t1"] if schema == "db2" else []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_tables()
assert evidence.caveat is None
def test_get_tables_honors_schema_filter_pattern():
checks = _checks(schemaFilterPattern=FilterPattern(includes=["db2"]))
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1", "db2"]
inspector.get_table_names.side_effect = lambda schema: ["t1"] if schema == "db2" else []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
checks.get_tables()
probed = {call.args[0] for call in inspector.get_table_names.call_args_list}
assert probed == {"db2"}
def test_get_tables_caveats_when_filter_matches_nothing():
checks = _checks(catalogId="my_catalog", schemaFilterPattern=FilterPattern(includes=["nope"]))
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1", "db2"]
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_tables()
assert evidence.caveat is not None
assert evidence.caveat.title == "No schemas visible"
assert "my_catalog" in evidence.caveat.remediation
inspector.get_table_names.assert_not_called()
def test_get_views_does_not_raise_or_caveat_when_empty():
checks = _checks()
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1"]
inspector.get_view_names.return_value = []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_views()
assert evidence.caveat is None
def test_get_views_reports_visible_views():
checks = _checks()
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1"]
inspector.get_view_names.return_value = ["v1"]
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_views()
assert evidence.caveat is None
assert "views visible" in evidence.summary
def test_get_tables_caveat_is_actionable():
checks = _checks(catalogId="my_catalog")
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1"]
inspector.get_table_names.return_value = []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_tables()
remediation = evidence.caveat.remediation
assert "Lake Formation" in remediation
assert any(keyword in remediation.lower() for keyword in ("describe", "select", "grant"))
def test_get_tables_caps_probing_at_max_schemas():
checks = _checks(catalogId="my_catalog")
inspector = MagicMock()
inspector.get_schema_names.return_value = [f"db{i}" for i in range(500)]
inspector.get_table_names.return_value = []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
evidence = checks.get_tables()
assert evidence.caveat is not None
assert inspector.get_table_names.call_count <= athena_connection.MAX_SCHEMAS_TO_PROBE
def test_targeted_schemas_listed_once_across_table_and_view_steps():
checks = _checks()
inspector = MagicMock()
inspector.get_schema_names.return_value = ["db1"]
inspector.get_table_names.return_value = ["t1"]
inspector.get_view_names.return_value = []
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
checks.get_tables()
checks.get_views()
assert inspector.get_schema_names.call_count == 1
@@ -0,0 +1,45 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the AzureSQL BaseConnection wiring.
The URL building (Active Directory ODBC and standard) is covered in
tests/unit/test_build_connection_url.py.
"""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.azureSQLConnection import (
AzureSQLConnection as AzureSQLConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.azuresql.connection import AzureSQLConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.azuresql.connection"
def _config() -> AzureSQLConnectionConfig:
return AzureSQLConnectionConfig(
username="user",
password="pass",
hostPort="myhost:1433",
database="mydb",
driver="ODBC Driver 18 for SQL Server",
)
def test_azuresql_connection_is_base_connection():
assert issubclass(AzureSQLConnection, BaseConnection)
def test_get_client_uses_the_module_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = AzureSQLConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
@@ -0,0 +1,244 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for BigQuery test-connection checks and error classification.
Cover the wiring (steps resolve to checks, the network pack is folded in, nothing
connects at construction) and the error-pack mapping (each GCP auth / api_core
scenario classifies to the intended diagnosis).
"""
from unittest.mock import MagicMock, patch
import pytest
from google.api_core.exceptions import Forbidden, NotFound
from google.auth.exceptions import DefaultCredentialsError, RefreshError
from metadata.core.connections.test_connection import Evidence
from metadata.core.connections.test_connection.check import collect_checks
from metadata.core.connections.test_connection.checks.database import DatabaseStep
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.bigQueryConnection import (
BigQueryConnection as BigQueryConnectionConfig,
)
from metadata.ingestion.source.database.bigquery.connection import (
BIGQUERY_ERRORS,
BigQueryChecks,
_enumerated,
probe_table_view_enumeration,
)
from metadata.utils.credentials import InvalidPrivateKeyException
_CONNECTION_MODULE = "metadata.ingestion.source.database.bigquery.connection"
_GCP_CONFIG = {
"type": "service_account",
"projectId": "placeholder",
"privateKeyId": "key-id",
"privateKey": "private-key",
"clientEmail": "user@example.com",
"clientId": "1234",
"authUri": "https://accounts.google.com/o/oauth2/auth",
"tokenUri": "https://oauth2.googleapis.com/token",
"authProviderX509CertUrl": "https://www.googleapis.com/oauth2/v1/certs",
"clientX509CertUrl": "https://www.googleapis.com/oauth2/v1/certs",
}
def _config(**overrides) -> BigQueryConnectionConfig:
base = {"type": "BigQuery", "credentials": {"gcpConfig": {**_GCP_CONFIG}}}
base.update(overrides)
return BigQueryConnectionConfig.model_validate(base)
class _ApiError(Exception):
"""Mirror how SQLAlchemy surfaces a driver error: the wrapper's message embeds
the original and its ``__cause__`` chains to it (via ``raise ... from``), which
is what the classifier walks."""
def __init__(self, orig: Exception) -> None:
super().__init__(f"(google.api_core.exceptions.{type(orig).__name__}) {orig}")
self.orig = orig
self.__cause__ = orig
def test_invalid_grant_is_classified():
error = RefreshError("('invalid_grant: Invalid grant: account not found', {})")
assert BIGQUERY_ERRORS.classify(error).title == "Invalid service account credentials"
def test_default_credentials_error_is_classified():
error = DefaultCredentialsError("Could not automatically determine credentials.")
assert BIGQUERY_ERRORS.classify(error).title == "Could not determine GCP credentials"
def test_refresh_error_is_classified():
error = RefreshError("Unable to acquire impersonated credentials")
assert BIGQUERY_ERRORS.classify(error).title == "Failed to obtain a GCP access token"
def test_missing_jobs_create_permission_is_classified():
error = _ApiError(
Forbidden("403 POST https://bigquery.googleapis.com: User does not have bigquery.jobs.create permission")
)
assert BIGQUERY_ERRORS.classify(error).title == "Missing permission to run BigQuery jobs"
def test_jobs_create_beats_generic_forbidden():
# A bigquery.jobs.create denial is a Forbidden; the sharper token rule is
# ordered first so the user gets the actionable role, not a generic message.
error = Forbidden("403 User does not have bigquery.jobs.create permission in project x")
assert BIGQUERY_ERRORS.classify(error).title != "Permission denied"
def test_access_denied_is_classified():
error = _ApiError(Forbidden("403 Access Denied: Table project:dataset.INFORMATION_SCHEMA.JOBS_BY_PROJECT"))
assert BIGQUERY_ERRORS.classify(error).title == "Access denied"
def test_generic_forbidden_is_permission_denied():
error = Forbidden("403 The caller does not have permission")
assert BIGQUERY_ERRORS.classify(error).title == "Permission denied"
def test_not_found_is_classified():
error = _ApiError(NotFound("404 Not found: Dataset project:dataset was not found"))
assert BIGQUERY_ERRORS.classify(error).title == "Project or dataset not found"
def test_unknown_error_returns_no_diagnosis():
assert BIGQUERY_ERRORS.classify(ValueError("something unexpected")) is None
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("bigquery.googleapis.com:443 is not reachable")
error.__cause__ = TimeoutError("timed out")
assert BIGQUERY_ERRORS.classify(error).title == "Connection timed out"
def test_malformed_private_key_is_classified():
# A malformed PEM raises InvalidPrivateKeyException while the engine is built;
# it must be classified (and, being first, win over any message-token rule).
error = InvalidPrivateKeyException("Cannot serialise key: Unable to load PEM file.")
assert BIGQUERY_ERRORS.classify(error).title == "Malformed service account private key"
def _checks(service_connection, client=None) -> BigQueryChecks:
engine = client if client is not None else MagicMock()
return BigQueryChecks(get_client=lambda: engine, service_connection=service_connection)
def test_checks_cover_exactly_the_wired_steps():
checks = _checks(_config())
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetTags,
DatabaseStep.GetQueries,
}
def test_construction_does_not_build_the_client():
# The engine is built lazily inside CheckAccess, never at construction - so
# credential parsing (and any connect) happens behind the gate, not before it.
get_client = MagicMock()
BigQueryChecks(get_client=get_client, service_connection=_config())
get_client.assert_not_called()
def test_check_access_builds_client_lazily_and_pings():
get_client = MagicMock()
checks = BigQueryChecks(get_client=get_client, service_connection=_config())
with patch(f"{_CONNECTION_MODULE}.ping", return_value=Evidence(summary="connection established")) as mock_ping:
evidence = checks.check_access()
get_client.assert_called_once_with()
mock_ping.assert_called_once_with(get_client.return_value)
assert evidence.summary == "connection established"
def test_check_access_surfaces_malformed_key_error():
# A malformed key fails while building the engine; because the engine is built
# inside CheckAccess, the error reaches the runner (here: the caller) to be
# classified, instead of escaping before the test starts.
error = InvalidPrivateKeyException("Cannot serialise key: Unable to load PEM file.")
checks = BigQueryChecks(get_client=MagicMock(side_effect=error), service_connection=_config())
with pytest.raises(InvalidPrivateKeyException):
checks.check_access()
def test_get_queries_formats_statement_lazily():
# The INFORMATION_SCHEMA.JOBS_BY_PROJECT statement must be built inside the
# check (behind the gate), scoped to the configured usage region.
checks = _checks(_config(usageLocation="us-east1"))
captured = {}
def fake(client, statement, summarize, *args, **kwargs):
captured["statement"] = statement
return Evidence(summary=summarize([]), command=statement)
with patch(f"{_CONNECTION_MODULE}.run_sql", side_effect=fake):
evidence = checks.get_queries()
assert "region-us-east1" in captured["statement"]
assert "JOBS_BY_PROJECT" in captured["statement"]
assert evidence.summary == "query history accessible"
def test_get_tags_skips_when_policy_tags_disabled():
checks = _checks(_config(includePolicyTags=False))
assert checks.get_tags() is None
def test_get_tags_skips_when_no_taxonomy_project():
client = MagicMock()
client.url.host = None
checks = _checks(_config(includePolicyTags=True), client=client)
assert checks.get_tags() is None
def test_get_tags_skips_when_no_location():
client = MagicMock()
client.url.host = "proj-a"
checks = _checks(_config(includePolicyTags=True, taxonomyLocation=None), client=client)
assert checks.get_tags() is None
def test_get_tags_counts_policy_tags():
client = MagicMock()
client.url.host = "proj-a"
checks = _checks(_config(includePolicyTags=True, taxonomyLocation="us"), client=client)
tag_client = MagicMock()
tag_client.list_taxonomies.return_value = [MagicMock(name="tax")]
tag_client.list_policy_tags.return_value = [object(), object()]
with patch(f"{_CONNECTION_MODULE}.PolicyTagManagerClient", return_value=tag_client):
evidence = checks.get_tags()
assert evidence.summary == "2 policy tags enumerated"
def test_probe_table_view_enumeration_tolerates_deleted_dataset():
# A dataset dropped between listing datasets and listing its tables raises
# NotFound; the probe skips it and still reports the datasets it enumerated.
conn = MagicMock()
engine = MagicMock()
engine.connect.return_value.__enter__.return_value = conn
bq_client = conn.connection._client
dataset = MagicMock()
bq_client.list_datasets.return_value = [dataset]
bq_client.list_tables.side_effect = NotFound("404 Not found: Dataset was deleted")
evidence = probe_table_view_enumeration(engine)
assert evidence.summary == "1 dataset enumerated"
def test_enumerated_pluralizes_by_count():
assert _enumerated(1, "dataset") == "1 dataset enumerated"
assert _enumerated(3, "dataset") == "3 datasets enumerated"
assert _enumerated(1, "policy tag") == "1 policy tag enumerated"
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the BigTable BaseConnection wiring (non-Engine: MultiProjectClient).
Client building requires a full GCP service-account credential; the end-to-end
behaviour is exercised by tests/unit/topology/database/test_bigtable.py.
"""
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.bigtable.connection import BigTableConnection
def test_bigtable_connection_is_base_connection():
assert issubclass(BigTableConnection, BaseConnection)
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the BurstIQConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.burstiq.connection")
def test_burstiq_connection_is_base_connection():
assert issubclass(connection.BurstIQConnection, BaseConnection)
@@ -0,0 +1,45 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Cassandra BaseConnection wiring (non-Engine: driver Session)."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.cassandraConnection import (
CassandraConnection as CassandraConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.cassandra.connection import CassandraConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.cassandra.connection"
def _config() -> CassandraConnectionConfig:
return CassandraConnectionConfig(hostPort="localhost:9042")
def test_cassandra_connection_is_base_connection():
assert issubclass(CassandraConnection, BaseConnection)
def test_get_client_connects_a_cluster_session():
with patch(f"{CONNECTION_MODULE}.Cluster") as mock_cluster:
session = CassandraConnection(_config()).client
mock_cluster.assert_called_once()
mock_cluster.return_value.connect.assert_called_once_with()
assert session is mock_cluster.return_value.connect.return_value
def test_close_shuts_down_the_cluster():
with patch(f"{CONNECTION_MODULE}.Cluster") as mock_cluster:
connection = CassandraConnection(_config())
_ = connection.client
connection.close()
mock_cluster.return_value.shutdown.assert_called_once_with()
@@ -0,0 +1,63 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Clickhouse connection handling."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.clickhouseConnection import (
ClickhouseConnection as ClickhouseConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.clickhouseConnection import (
ClickhouseScheme,
)
from metadata.ingestion.connections.builders import get_connection_url_common
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.clickhouse.connection import ClickhouseConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.clickhouse.connection"
def test_clickhouse_connection_is_base_connection():
assert issubclass(ClickhouseConnection, BaseConnection)
def test_url_without_database_schema():
connection = ClickhouseConnectionConfig(
username="username",
hostPort="localhost:8123",
scheme=ClickhouseScheme.clickhouse_http,
databaseSchema=None,
)
assert get_connection_url_common(connection) == "clickhouse+http://username:@localhost:8123"
def test_url_with_database_schema():
connection = ClickhouseConnectionConfig(
username="username",
hostPort="localhost:8123",
scheme=ClickhouseScheme.clickhouse_http,
databaseSchema="default",
)
assert get_connection_url_common(connection) == "clickhouse+http://username:@localhost:8123/default"
def test_secure_and_keyfile_move_to_connection_arguments():
connection = ClickhouseConnectionConfig(
username="username",
hostPort="localhost:8123",
scheme=ClickhouseScheme.clickhouse_http,
secure=True,
keyfile="/path/to/key.pem",
)
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection"):
_ = ClickhouseConnection(connection).client
assert connection.connectionArguments.root["secure"] is True
assert connection.connectionArguments.root["keyfile"] == "/path/to/key.pem"
@@ -0,0 +1,37 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for CockroachDB connection handling."""
from metadata.generated.schema.entity.services.connections.database.cockroachConnection import (
CockroachConnection as CockroachConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.cockroachConnection import (
CockroachScheme,
)
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.ingestion.source.database.cockroach.connection import CockroachConnection
def test_basic_auth_builds_expected_url():
connection = CockroachConnectionConfig(
username="openmetadata_user",
authType=BasicAuth(password="openmetadata_password"),
hostPort="localhost:26257",
database="openmetadata_db",
scheme=CockroachScheme.cockroachdb_psycopg2,
)
engine = CockroachConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "cockroachdb+psycopg2://openmetadata_user:openmetadata_password@localhost:26257/openmetadata_db"
)
@@ -0,0 +1,23 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Couchbase BaseConnection wiring (non-Engine: SDK Cluster).
The couchbase SDK is an optional dependency imported lazily inside
``_get_client``/``test_connection``, so this module only asserts the
BaseConnection wiring without importing the SDK.
"""
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.couchbase.connection import CouchbaseConnection
def test_couchbase_connection_is_base_connection():
assert issubclass(CouchbaseConnection, BaseConnection)
@@ -0,0 +1,348 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Databricks BaseConnection wiring and test-connection checks."""
from unittest.mock import MagicMock, patch
import pytest
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.core.connections.test_connection.checks.database import DatabaseStep
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.databricks.personalAccessToken import (
PersonalAccessToken,
)
from metadata.generated.schema.entity.services.connections.database.databricksConnection import (
DatabricksConnection as DatabricksConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.databricks.connection import (
DATABRICKS_ERRORS,
DatabricksChecks,
DatabricksConnection,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.databricks.connection"
def _config() -> DatabricksConnectionConfig:
return DatabricksConnectionConfig(
hostPort="my-workspace.cloud.databricks.com:443",
authType=PersonalAccessToken(token="dapi-secret"),
httpPath="/sql/1.0/warehouses/abc123",
catalog="my_catalog",
)
class _SqlAlchemyError(Exception):
"""Mirror ``sqlalchemy.exc.DBAPIError``: wraps the driver error on ``.orig``."""
def __init__(self, orig: Exception) -> None:
super().__init__(str(orig))
self.orig = orig
def test_databricks_connection_is_base_connection():
assert issubclass(DatabricksConnection, BaseConnection)
def test_get_client_uses_the_databricks_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = DatabricksConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
def test_close_disposes_the_engine_pool():
engine = MagicMock()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection", return_value=engine):
connection = DatabricksConnection(_config())
assert connection.client is engine
connection.close()
engine.dispose.assert_called_once_with()
def test_connection_timeout_drives_the_per_step_budget():
from metadata.core.connections.test_connection.constants import STEP_TIMEOUT_SECONDS
# connectionTimeout (default 120) must win over the 60s framework default,
# fed through BaseConnection.step_timeout_seconds.
default_config = _config()
assert DatabricksConnection(default_config).step_timeout_seconds == default_config.connectionTimeout
slow_config = _config()
slow_config.connectionTimeout = 300
assert DatabricksConnection(slow_config).step_timeout_seconds == 300
unset_config = _config()
unset_config.connectionTimeout = None
assert DatabricksConnection(unset_config).step_timeout_seconds == STEP_TIMEOUT_SECONDS
def test_invalid_token_message_is_classified():
error = _SqlAlchemyError(Exception("Error during request to server: Invalid access token"))
assert DATABRICKS_ERRORS.classify(error).title == "Authentication failed"
def test_expired_token_message_is_classified():
error = _SqlAlchemyError(Exception("the access token is expired, please refresh it"))
assert DATABRICKS_ERRORS.classify(error).title == "Access token expired"
def test_forbidden_message_is_classified():
error = _SqlAlchemyError(Exception("HTTP Response code: 403, Forbidden"))
assert DATABRICKS_ERRORS.classify(error).title == "Access denied"
def test_malformed_http_path_is_classified():
# A bad httpPath fails CheckAccess with MALFORMED_REQUEST; needs a diagnosis.
error = _SqlAlchemyError(
Exception(
"MALFORMED_REQUEST: Path /sql/1.0/warehouses/39c390db3a5e19e must match pattern "
"/sql/1.0/endpoints/<endpointId> or /sql/1.0/warehouses/<warehouseId>"
)
)
assert DATABRICKS_ERRORS.classify(error).title == "Invalid HTTP path"
def test_permission_denied_is_classified():
error = _SqlAlchemyError(Exception("[PERMISSION_DENIED] User does not have SELECT on system.access.table_lineage"))
assert DATABRICKS_ERRORS.classify(error).title == "Insufficient privileges"
def test_insufficient_permissions_is_classified():
error = _SqlAlchemyError(Exception("INSUFFICIENT_PERMISSIONS: requires USAGE on catalog"))
assert DATABRICKS_ERRORS.classify(error).title == "Insufficient privileges"
def test_table_or_view_not_found_is_classified():
error = _SqlAlchemyError(Exception("[TABLE_OR_VIEW_NOT_FOUND] The table or view cannot be found"))
assert DATABRICKS_ERRORS.classify(error).title == "Table or view not found"
def test_schema_not_found_is_classified():
error = _SqlAlchemyError(Exception("[SCHEMA_NOT_FOUND] The schema cannot be found"))
assert DATABRICKS_ERRORS.classify(error).title == "Schema not found"
def test_catalog_does_not_exist_is_classified():
error = _SqlAlchemyError(Exception("Catalog 'nope' does not exist"))
assert DATABRICKS_ERRORS.classify(error).title == "Object not found"
def test_no_such_catalog_exception_is_classified():
# NO_SUCH_CATALOG_EXCEPTION says "was not found", not "does not exist".
error = _SqlAlchemyError(Exception("[NO_SUCH_CATALOG_EXCEPTION] Catalog 'batata' was not found. SQLSTATE: 42704"))
assert DATABRICKS_ERRORS.classify(error).title == "Catalog not found"
def test_no_such_schema_exception_is_classified():
error = _SqlAlchemyError(Exception("[NO_SUCH_SCHEMA_EXCEPTION] Schema 'nope' was not found"))
assert DATABRICKS_ERRORS.classify(error).title == "Schema not found"
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("workspace:443 is not reachable")
error.__cause__ = ConnectionRefusedError(61, "Connection refused")
assert DATABRICKS_ERRORS.classify(error).title == "Connection refused"
def test_unknown_error_returns_no_diagnosis():
error = _SqlAlchemyError(Exception("something entirely unexpected"))
assert DATABRICKS_ERRORS.classify(error) is None
def test_checks_cover_exactly_the_seeded_steps():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetQueries,
DatabaseStep.GetViewDefinitions,
DatabaseStep.GetCatalogTags,
DatabaseStep.GetSchemaTags,
DatabaseStep.GetTableTags,
DatabaseStep.GetColumnTags,
DatabaseStep.GetTableLineage,
DatabaseStep.GetColumnLineage,
}
def test_construction_does_not_touch_the_network():
# Building the provider must not connect - that belongs in a gated check.
engine = MagicMock()
DatabricksChecks(client=engine, service_connection=_config())
engine.connect.assert_not_called()
def test_construction_does_not_build_the_inspector():
# Regression: inspect(engine) connects eagerly, so building it in __init__ would
# connect before the gate (auth error escapes as a 500). Keep it lazy.
with patch(f"{CONNECTION_MODULE}.inspect") as mock_inspect:
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
mock_inspect.assert_not_called()
_ = checks._engine_wrapper.inspector
mock_inspect.assert_called_once()
def test_check_access_probes_the_host_port_then_pings():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with (
patch(f"{CONNECTION_MODULE}.tcp_probe") as mock_probe,
patch(f"{CONNECTION_MODULE}.run_sql", return_value=MagicMock()) as mock_run,
):
checks.check_access()
mock_probe.assert_called_once_with("my-workspace.cloud.databricks.com", 443)
assert mock_run.call_count == 1
def test_check_access_reports_unreachable_host_as_network_failure():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
probe_error = NetworkUnreachableError("my-workspace.cloud.databricks.com:443 is not reachable")
probe_error.__cause__ = ConnectionRefusedError(61, "Connection refused")
with (
patch(f"{CONNECTION_MODULE}.tcp_probe", side_effect=probe_error) as mock_probe,
pytest.raises(CheckError) as exc,
):
checks.check_access()
mock_probe.assert_called_once_with("my-workspace.cloud.databricks.com", 443)
assert exc.value.cause is probe_error
assert DATABRICKS_ERRORS.classify(exc.value.cause).title == "Connection refused"
def test_probe_target_defaults_to_443_when_no_port():
config = _config()
config.hostPort = "my-workspace.cloud.databricks.com"
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
assert checks._probe_target() == ("my-workspace.cloud.databricks.com", 443)
def test_first_catalog_uses_configured_catalog_without_querying():
engine = MagicMock()
checks = DatabricksChecks(client=engine, service_connection=_config())
assert checks._first_catalog() == "my_catalog"
engine.connect.assert_not_called()
def test_listing_caps_rows_at_the_sample_size_at_the_source():
# Bound the fetch (fetchmany), not fetchall-then-slice. Test-connection only;
# real ingestion goes through the common framework.
from metadata.core.connections.test_connection.checks.database import (
DEFAULT_SAMPLE_ROWS,
)
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
result = MagicMock()
result.fetchmany.return_value = [("t",)]
conn = MagicMock()
conn.execute.return_value = result
engine = MagicMock()
engine.connect.return_value.__enter__.return_value = conn
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = "my_catalog"
wrapper.first_schema = "my_schema"
wrapper.get_tables()
result.fetchmany.assert_called_once_with(DEFAULT_SAMPLE_ROWS)
result.fetchall.assert_not_called()
def test_get_databases_reports_catalog_count():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with patch.object(
checks._engine_wrapper, "get_catalogs", return_value=[("my_catalog",), ("other",)]
) as mock_catalogs:
evidence = checks.get_databases()
mock_catalogs.assert_called_once_with(catalog_name="my_catalog")
assert evidence.summary == "2 catalogs enumerated"
def test_get_databases_failure_reports_the_attempted_command_as_evidence():
config = _config()
config.catalog = None
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
with (
patch.object(checks._engine_wrapper, "get_catalogs", side_effect=Exception("boom")),
pytest.raises(CheckError) as exc,
):
checks.get_databases()
assert exc.value.evidence.command == "SHOW CATALOGS"
def test_get_databases_omits_the_command_when_catalog_is_configured():
# A configured catalog is trusted without running SHOW CATALOGS.
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with patch.object(checks._engine_wrapper, "get_catalogs", return_value=[("my_catalog",)]):
evidence = checks.get_databases()
assert evidence.command is None
def test_probe_target_strips_a_pasted_url_scheme_and_path():
# A pasted workspace URL must not reach the socket probe as the host, or the
# gate fails with a misleading DNS error before the driver.
config = _config()
config.hostPort = "https://my-workspace.cloud.databricks.com:443/sql/1.0/warehouses/x"
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
assert checks._probe_target() == ("my-workspace.cloud.databricks.com", 443)
def test_connection_url_strips_a_pasted_url_scheme():
from urllib.parse import urlparse
from metadata.ingestion.source.database.databricks.connection import get_connection_url
config = _config()
config.hostPort = "https://my-workspace.cloud.databricks.com:443"
parsed = urlparse(get_connection_url(config))
assert parsed.hostname == "my-workspace.cloud.databricks.com"
assert parsed.port == 443
def test_auth_host_derivation_strips_a_pasted_scheme():
# OAuth/Azure-AD derive the workspace host from hostPort too; a pasted scheme
# must not leak in as the host or auth fails before the driver.
from metadata.ingestion.source.database.databricks.auth import _host, normalize_host_port
assert normalize_host_port("https://ws.cloud.databricks.com:443/sql/x") == "ws.cloud.databricks.com:443"
connection = MagicMock()
connection.hostPort = "https://ws.cloud.databricks.com:443"
assert _host(connection) == "ws.cloud.databricks.com"
def test_schema_scoped_get_schemas_skips_use_catalog_when_catalog_unresolved():
# first_catalog None (GetDatabases failed/skipped) must not emit USE CATALOG `None`.
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
engine = MagicMock()
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = None
assert wrapper.get_schemas(schema_name="my_schema") == ["my_schema"]
engine.connect.assert_not_called()
def test_get_tables_returns_empty_when_catalog_unresolved():
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
engine = MagicMock()
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = None
wrapper.first_schema = "my_schema"
assert wrapper.get_tables() == []
engine.connect.assert_not_called()
@@ -0,0 +1,92 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for DB2 connection handling."""
from unittest.mock import patch
import pytest
from metadata.generated.schema.entity.services.connections.database.db2Connection import (
Db2Connection as Db2ConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.db2Connection import (
Db2Scheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.db2.connection import (
Db2Connection,
Db2IbmiStrategy,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.db2.connection"
def _ibmi_config() -> Db2ConnectionConfig:
return Db2ConnectionConfig(
scheme=Db2Scheme.ibmi,
username="openmetadata_user",
password="openmetadata_password",
hostPort="myhost:8471",
database="MYDB",
)
def _db2_config() -> Db2ConnectionConfig:
return Db2ConnectionConfig(
scheme=Db2Scheme.db2_ibm_db,
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:50000",
database="testdb",
)
def test_db2_connection_is_base_connection():
assert issubclass(Db2Connection, BaseConnection)
def test_ibmi_url_drops_the_port():
config = _ibmi_config()
assert (
Db2IbmiStrategy(config).get_connection_url(config)
== "ibmi://openmetadata_user:openmetadata_password@myhost/MYDB"
)
def test_ibmi_args_pass_the_port_via_connect_args():
config = _ibmi_config()
assert Db2IbmiStrategy(config).get_connection_args(config)["port"] == 8471
def test_ibmi_args_reject_a_non_numeric_port():
config = _ibmi_config()
config.hostPort = "myhost:not-a-port"
with pytest.raises(ValueError, match="Invalid port"):
Db2IbmiStrategy(config).get_connection_args(config)
def test_get_client_dispatches_ibmi_scheme_to_ibmi_strategy():
config = _ibmi_config()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = Db2Connection(config).client
url_fn = mock_connection.call_args.kwargs["get_connection_url_fn"]
assert url_fn.__name__ == "get_connection_url"
assert url_fn(config) == "ibmi://openmetadata_user:openmetadata_password@myhost/MYDB"
def test_get_client_dispatches_db2_scheme_to_standard_strategy():
config = _db2_config()
with (
patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection,
patch(f"{CONNECTION_MODULE}.check_ssl_and_init", return_value=None),
):
_ = Db2Connection(config).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url_common"
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the DeltaLakeConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.deltalake.connection")
def test_deltalake_connection_is_base_connection():
assert issubclass(connection.DeltaLakeConnection, BaseConnection)
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the DomoDatabaseConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.domodatabase.connection")
def test_domodatabase_connection_is_base_connection():
assert issubclass(connection.DomoDatabaseConnection, BaseConnection)
@@ -0,0 +1,42 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Doris connection handling."""
from metadata.generated.schema.entity.services.connections.database.dorisConnection import (
DorisConnection as DorisConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.dorisConnection import (
DorisScheme,
)
from metadata.ingestion.connections.builders import get_connection_url_common
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.doris.connection import DorisConnection
def test_doris_connection_is_base_connection():
assert issubclass(DorisConnection, BaseConnection)
def test_basic_auth_builds_expected_url():
connection = DorisConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:9030",
databaseSchema="openmetadata_db",
scheme=DorisScheme.doris,
)
# Assert via the URL builder, not .client: the doris dialect (pydoris-custom,
# mysqlclient DBAPI) is installed --no-deps in runtime images only and is
# absent from the unit-test environment.
assert (
get_connection_url_common(connection)
== "doris://openmetadata_user:openmetadata_password@localhost:9030/openmetadata_db"
)
@@ -0,0 +1,50 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Druid connection handling.
The Druid dialect (pydruid) is an optional extra absent from the unit-test
environment, so URL parity is asserted via the connector's get_connection_url
rather than by instantiating an engine.
"""
from metadata.generated.schema.entity.services.connections.database.druidConnection import (
DruidConnection as DruidConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.druidConnection import (
DruidScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.druid.connection import DruidConnection
def test_druid_connection_is_base_connection():
assert issubclass(DruidConnection, BaseConnection)
def test_get_connection_url_appends_druid_sql_path():
connection = DruidConnectionConfig(
scheme=DruidScheme.druid,
hostPort="localhost:8082",
)
assert DruidConnection.get_connection_url(connection) == "druid://localhost:8082/druid/v2/sql"
def test_get_connection_url_with_basic_auth():
connection = DruidConnectionConfig(
scheme=DruidScheme.druid,
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:8082",
)
assert (
DruidConnection.get_connection_url(connection)
== "druid://openmetadata_user:openmetadata_password@localhost:8082/druid/v2/sql"
)
@@ -0,0 +1,37 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the DynamoDB BaseConnection wiring (non-Engine: boto3 client)."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.dynamoDBConnection import (
DynamoDBConnection as DynamoDBConnectionConfig,
)
from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.dynamodb.connection import DynamoDBConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.dynamodb.connection"
def _config() -> DynamoDBConnectionConfig:
return DynamoDBConnectionConfig(awsConfig=AWSCredentials(awsRegion="us-east-1"))
def test_dynamodb_connection_is_base_connection():
assert issubclass(DynamoDBConnection, BaseConnection)
def test_get_client_builds_the_dynamo_client():
with patch(f"{CONNECTION_MODULE}.AWSClient") as mock_aws:
client = DynamoDBConnection(_config()).client
mock_aws.return_value.get_dynamo_client.assert_called_once_with()
assert client is mock_aws.return_value.get_dynamo_client.return_value
@@ -0,0 +1,56 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Exasol connection handling."""
from metadata.generated.schema.entity.services.connections.database.exasolConnection import (
ExasolConnection as ExasolConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.exasolConnection import (
ExasolScheme,
Tls,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.exasol.connection import ExasolConnection
def _config(tls: Tls) -> ExasolConnectionConfig:
return ExasolConnectionConfig(
scheme=ExasolScheme.exa_websocket,
username="admin",
password="password",
hostPort="localhost:8563",
tls=tls,
)
def test_exasol_connection_is_base_connection():
assert issubclass(ExasolConnection, BaseConnection)
def test_url_validate_certificate_has_no_tls_params():
assert (
ExasolConnection.get_connection_url(_config(Tls.validate_certificate))
== "exa+websocket://admin:password@localhost:8563"
)
def test_url_ignore_certificate():
assert (
ExasolConnection.get_connection_url(_config(Tls.ignore_certificate))
== "exa+websocket://admin:password@localhost:8563?SSLCertificate=SSL_VERIFY_NONE"
)
def test_url_disable_tls():
assert (
ExasolConnection.get_connection_url(_config(Tls.disable_tls)) == "exa+websocket://admin:password@localhost:8563"
"?SSLCertificate=SSL_VERIFY_NONE&ENCRYPTION=no"
)
@@ -0,0 +1,281 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Glue test-connection: its checks match its shipped definition (non-Engine: boto3 client)."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.generated.schema.entity.services.connections.database.glueConnection import (
GlueConnection as GlueConnectionConfig,
)
from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.glue.connection import (
GLUE_ERRORS,
GlueChecks,
GlueConnection,
list_databases,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.glue.connection"
_SEED = (
Path(__file__).parents[6]
/ "openmetadata-service/src/main/resources/json/data"
/ "testConnections/database/glue.json"
)
def _config() -> GlueConnectionConfig:
return GlueConnectionConfig(awsConfig=AWSCredentials(awsRegion="us-east-1"))
def _client(pages: dict[str, list[dict]] | None = None) -> MagicMock:
"""A Glue client whose paginators yield one page per operation."""
responses = pages or {}
client = MagicMock()
client.get_paginator.side_effect = lambda operation: _paginator(responses.get(operation, [{}]))
return client
def _paginator(pages: list[dict]) -> MagicMock:
paginator = MagicMock()
paginator.paginate.return_value = pages
return paginator
def _checks(client=None) -> GlueChecks:
return GlueChecks(connect=lambda: client)
def _check_names() -> set[str]:
return {step.value for step in collect_checks(_checks())}
def _client_error(code: str, operation: str = "GetDatabases") -> ClientError:
return ClientError({"Error": {"Code": code, "Message": "denied"}}, operation)
def test_glue_connection_is_base_connection():
assert issubclass(GlueConnection, BaseConnection)
def test_get_client_builds_the_glue_client():
with patch(f"{CONNECTION_MODULE}.AWSClient") as mock_aws:
client = GlueConnection(_config()).client
mock_aws.return_value.get_glue_client.assert_called_once_with()
assert client is mock_aws.return_value.get_glue_client.return_value
def test_glue_checks_cover_expected_steps():
assert _check_names() == {"GetDatabases", "GetTables"}
def test_glue_checks_match_definition_seed():
definition_steps = {step["name"] for step in json.loads(_SEED.read_text())["steps"]}
assert _check_names() == definition_steps
def test_get_databases_is_the_connection_gate():
steps = json.loads(_SEED.read_text())["steps"]
assert steps[0]["name"] == "GetDatabases"
assert steps[0]["category"] == "ConnectionGate"
def test_building_checks_does_not_build_the_client():
with patch.object(GlueConnection, "_get_client") as mock_build:
GlueConnection(_config()).checks()
mock_build.assert_not_called()
def test_get_databases_enumerates_the_catalog():
client = _client({"get_databases": [{"DatabaseList": [{"Name": "a"}, {"Name": "b"}]}]})
evidence = _checks(client).get_databases()
assert evidence.summary == "2 databases enumerated"
assert evidence.command == "glue:GetDatabases"
assert evidence.caveat is None
def test_get_databases_caps_reported_count_and_flags_more():
paginator = _paginator([{"DatabaseList": [{"Name": "a"}, {"Name": "b"}, {"Name": "c"}]}])
client = MagicMock()
client.get_paginator.return_value = paginator
evidence = list_databases(client, limit=2)
assert evidence.summary == "2 databases enumerated (showing first 2; more exist)"
client.get_paginator.assert_called_once_with("get_databases")
paginator.paginate.assert_called_once_with(PaginationConfig={"MaxItems": 3})
def test_get_databases_warns_when_catalog_is_empty():
client = _client({"get_databases": [{"DatabaseList": []}]})
evidence = _checks(client).get_databases()
assert evidence.summary == "0 databases enumerated"
assert evidence.caveat is not None
assert "No databases visible" in evidence.caveat.title
def test_get_databases_failure_reports_the_command_it_ran():
client = _client()
cause = _client_error("AccessDeniedException")
client.get_paginator.side_effect = cause
with pytest.raises(CheckError) as failure:
_checks(client).get_databases()
assert failure.value.cause is cause
assert failure.value.evidence.command == "glue:GetDatabases"
def test_get_tables_probes_the_first_database():
client = _client(
{
"get_databases": [{"DatabaseList": [{"Name": "sales"}]}],
"get_tables": [{"TableList": [{"Name": "orders"}]}],
}
)
evidence = _checks(client).get_tables()
assert evidence.summary == "1 table in database 'sales'"
assert evidence.command == "glue:GetTables (DatabaseName=sales)"
def test_get_tables_reports_a_caveat_when_no_database_can_be_probed():
client = _client({"get_databases": [{"DatabaseList": []}]})
evidence = _checks(client).get_tables()
assert evidence.command == "glue:GetDatabases"
assert evidence.caveat is not None
assert evidence.caveat.title == "No tables visible"
def test_get_tables_skips_an_empty_leading_database():
# A catalog commonly leads with an empty 'default'; the probe must not stop there.
client = MagicMock()
tables = {"default": [{"TableList": []}], "ecommerce": [{"TableList": [{"Name": "orders"}]}]}
calls = {"n": 0}
def paginator(operation):
if operation == "get_databases":
return _paginator([{"DatabaseList": [{"Name": "default"}, {"Name": "ecommerce"}]}])
calls["n"] += 1
return _paginator(tables["default" if calls["n"] == 1 else "ecommerce"])
client.get_paginator.side_effect = paginator
evidence = _checks(client).get_tables()
assert evidence.summary == "1 table in database 'ecommerce'"
assert evidence.command == "glue:GetTables (DatabaseName=ecommerce)"
assert evidence.caveat is None
def test_get_tables_warns_when_no_database_exposes_a_table():
client = _client(
{
"get_databases": [{"DatabaseList": [{"Name": "a"}, {"Name": "b"}]}],
"get_tables": [{"TableList": []}],
}
)
evidence = _checks(client).get_tables()
assert evidence.summary == "no tables in the first 2 databases"
assert evidence.caveat is not None
assert evidence.caveat.title == "No tables visible"
def test_get_tables_failure_names_the_database_it_probed():
cause = _client_error("EntityNotFoundException", "GetTables")
client = _client({"get_databases": [{"DatabaseList": [{"Name": "sales"}]}]})
tables_paginator = MagicMock()
tables_paginator.paginate.side_effect = cause
client.get_paginator.side_effect = lambda operation: (
tables_paginator if operation == "get_tables" else _paginator([{"DatabaseList": [{"Name": "sales"}]}])
)
with pytest.raises(CheckError) as failure:
_checks(client).get_tables()
assert failure.value.cause is cause
assert failure.value.evidence.command == "glue:GetTables (DatabaseName=sales)"
def test_error_pack_access_denied():
diagnosis = GLUE_ERRORS.classify(
ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "User is not authorized to perform: glue:GetDatabases",
}
},
"GetDatabases",
)
)
assert diagnosis is not None
assert "authorized" in diagnosis.title.lower()
def test_error_pack_entity_not_found():
diagnosis = GLUE_ERRORS.classify(_client_error("EntityNotFoundException", "GetTables"))
assert diagnosis is not None
assert diagnosis.title == "Glue database not found"
def test_error_pack_inherits_the_shared_aws_diagnoses():
# Glue is json-protocol: expired creds arrive as ExpiredTokenException.
diagnosis = GLUE_ERRORS.classify(_client_error("ExpiredTokenException"))
assert diagnosis is not None
assert diagnosis.title == "AWS session token expired"
def test_error_pack_classifies_an_unknown_access_key():
diagnosis = GLUE_ERRORS.classify(_client_error("UnrecognizedClientException"))
assert diagnosis is not None
assert diagnosis.title == "AWS access key not recognized"
def test_error_pack_classifies_a_wrong_secret():
diagnosis = GLUE_ERRORS.classify(_client_error("InvalidSignatureException"))
assert diagnosis is not None
assert diagnosis.title == "AWS secret key does not match"
def test_error_pack_classifies_an_sts_assume_role_denial():
# The assume-role leg denies with the suffix-less code.
diagnosis = GLUE_ERRORS.classify(_client_error("AccessDenied", "AssumeRole"))
assert diagnosis is not None
assert diagnosis.title == "Not authorized"
def test_error_pack_inherits_the_network_pack_through_aws_errors():
diagnosis = GLUE_ERRORS.classify(ConnectionRefusedError("refused"))
assert diagnosis is not None
assert "refused" in diagnosis.title.lower()
def test_error_pack_unmatched_returns_none():
assert GLUE_ERRORS.classify(Exception("novel error")) is None
@@ -0,0 +1,37 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Greenplum connection handling."""
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.generated.schema.entity.services.connections.database.greenplumConnection import (
GreenplumConnection as GreenplumConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.greenplumConnection import (
GreenplumScheme,
)
from metadata.ingestion.source.database.greenplum.connection import GreenplumConnection
def test_basic_auth_builds_expected_url():
connection = GreenplumConnectionConfig(
username="openmetadata_user",
authType=BasicAuth(password="openmetadata_password"),
hostPort="localhost:5432",
database="openmetadata_db",
scheme=GreenplumScheme.postgresql_psycopg2,
)
engine = GreenplumConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "postgresql+psycopg2://openmetadata_user:openmetadata_password@localhost:5432/openmetadata_db"
)
@@ -0,0 +1,167 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Hive connection handling."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.hiveConnection import (
Auth,
HiveScheme,
)
from metadata.generated.schema.entity.services.connections.database.hiveConnection import (
HiveConnection as HiveConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.hive.connection import HiveConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.hive.connection"
def _config(**kwargs) -> HiveConnectionConfig:
base = {
"scheme": HiveScheme.hive,
"hostPort": "localhost:10000",
"databaseSchema": "default",
}
base.update(kwargs)
return HiveConnectionConfig(**base)
def test_hive_connection_is_base_connection():
assert issubclass(HiveConnection, BaseConnection)
def test_get_client_injects_auth_and_kerberos_args():
config = _config(auth=Auth.LDAP, kerberosServiceName="hive")
with (
patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection,
patch(f"{CONNECTION_MODULE}.check_ssl_and_init", return_value=None),
):
_ = HiveConnection(config).client
root = mock_connection.call_args.kwargs["connection"].connectionArguments.root
assert root["auth"] == "LDAP"
assert root["kerberos_service_name"] == "hive"
def test_get_client_runs_ssl_setup_when_manager_present():
config = _config(useSSL=True)
ssl_manager = type("M", (), {"setup_ssl": staticmethod(lambda conn: conn)})()
with (
patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection,
patch(f"{CONNECTION_MODULE}.check_ssl_and_init", return_value=ssl_manager),
):
_ = HiveConnection(config).client
assert mock_connection.call_args.kwargs["connection"].connectionArguments.root["use_ssl"] is True
def test_hive_url():
assert (
HiveConnection.get_connection_url(HiveConnectionConfig(scheme=HiveScheme.hive, hostPort="localhost:10000"))
== "hive://localhost:10000"
)
assert (
HiveConnection.get_connection_url(HiveConnectionConfig(scheme=HiveScheme.hive_http, hostPort="localhost:1000"))
== "hive+http://localhost:1000"
)
assert (
HiveConnection.get_connection_url(HiveConnectionConfig(scheme=HiveScheme.hive_https, hostPort="localhost:1000"))
== "hive+https://localhost:1000"
)
def test_hive_url_custom_auth():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username",
password="password",
hostPort="localhost:10000",
connectionArguments={"auth": "CUSTOM"},
)
assert HiveConnection.get_connection_url(config) == "hive://username:password@localhost:10000"
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username@444",
password="password@333",
hostPort="localhost:10000",
connectionArguments={"auth": "CUSTOM"},
)
assert HiveConnection.get_connection_url(config) == "hive://username%40444:password%40333@localhost:10000"
def test_hive_url_conn_options_with_db():
config = HiveConnectionConfig(
hostPort="localhost:10000",
databaseSchema="test_db",
connectionOptions={"Key": "Value"},
)
assert HiveConnection.get_connection_url(config) == "hive://localhost:10000/test_db?Key=Value"
def test_hive_url_conn_options_without_db():
config = HiveConnectionConfig(
hostPort="localhost:10000",
connectionOptions={"Key": "Value"},
)
assert HiveConnection.get_connection_url(config) == "hive://localhost:10000?Key=Value"
def test_hive_url_with_kerberos_auth():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
hostPort="localhost:10000",
connectionArguments={
"auth": "KERBEROS",
"kerberos_service_name": "hive",
},
)
assert HiveConnection.get_connection_url(config) == "hive://localhost:10000"
def test_hive_url_with_ldap_auth():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username",
password="password",
hostPort="localhost:10000",
connectionArguments={"auth": "LDAP"},
)
assert HiveConnection.get_connection_url(config) == "hive://username:password@localhost:10000"
def test_hive_url_without_auth():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username",
password="password",
hostPort="localhost:10000",
connectionArguments={"customKey": "value"},
)
assert HiveConnection.get_connection_url(config) == "hive://username:password@localhost:10000"
def test_hive_url_without_connection_arguments():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username",
password="password",
hostPort="localhost:10000",
)
assert HiveConnection.get_connection_url(config) == "hive://username:password@localhost:10000"
def test_hive_url_without_connection_arguments_pass():
config = HiveConnectionConfig(
scheme=HiveScheme.hive.value,
username="username",
hostPort="localhost:10000",
)
assert HiveConnection.get_connection_url(config) == "hive://username@localhost:10000"
@@ -0,0 +1,140 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Impala connection handling."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.impalaConnection import (
AuthMechanism,
ImpalaScheme,
)
from metadata.generated.schema.entity.services.connections.database.impalaConnection import (
ImpalaConnection as ImpalaConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.impala.connection import ImpalaConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.impala.connection"
def _config(**kwargs) -> ImpalaConnectionConfig:
base = {
"scheme": ImpalaScheme.impala,
"hostPort": "localhost:21050",
"databaseSchema": "default",
}
base.update(kwargs)
return ImpalaConnectionConfig(**base)
def test_impala_connection_is_base_connection():
assert issubclass(ImpalaConnection, BaseConnection)
def test_get_client_injects_auth_kerberos_and_ssl_args():
config = _config(
authMechanism=AuthMechanism.GSSAPI,
kerberosServiceName="impala",
useSSL=True,
)
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = ImpalaConnection(config).client
built = mock_connection.call_args.kwargs["connection"]
root = built.connectionArguments.root
assert root["auth_mechanism"] == "GSSAPI"
assert root["kerberos_service_name"] == "impala"
assert root["use_ssl"] is True
def test_get_client_leaves_connection_arguments_unset_without_auth():
config = _config(authMechanism=None, useSSL=None)
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = ImpalaConnection(config).client
assert mock_connection.call_args.kwargs["connection"].connectionArguments is None
def test_get_client_uses_the_class_url_builder():
config = _config()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = ImpalaConnection(config).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
def test_impala_url():
config = ImpalaConnectionConfig(scheme=ImpalaScheme.impala, hostPort="localhost:21050")
assert ImpalaConnection.get_connection_url(config) == "impala://localhost:21050"
def test_impala_url_custom_auth():
config = ImpalaConnectionConfig(
scheme=ImpalaScheme.impala.value,
username="username",
password="password",
hostPort="localhost:21050",
connectionArguments={"auth": "CUSTOM"},
)
assert ImpalaConnection.get_connection_url(config) == "impala://username:password@localhost:21050"
config = ImpalaConnectionConfig(
scheme=ImpalaScheme.impala.value,
username="username@444",
password="password@333",
hostPort="localhost:21050",
connectionArguments={"auth": "CUSTOM"},
)
assert ImpalaConnection.get_connection_url(config) == "impala://username%40444:password%40333@localhost:21050"
def test_impala_url_conn_options_with_db():
config = ImpalaConnectionConfig(
hostPort="localhost:21050",
databaseSchema="test_db",
connectionOptions={"Key": "Value"},
)
assert ImpalaConnection.get_connection_url(config) == "impala://localhost:21050/test_db?Key=Value"
def test_impala_url_conn_options_without_db():
config = ImpalaConnectionConfig(
hostPort="localhost:21050",
connectionOptions={"Key": "Value"},
)
assert ImpalaConnection.get_connection_url(config) == "impala://localhost:21050?Key=Value"
def test_impala_url_with_ldap_auth():
config = ImpalaConnectionConfig(
scheme=ImpalaScheme.impala.value,
username="username",
password="password",
hostPort="localhost:21050",
connectionArguments={"auth_mechanism": "LDAP"},
)
assert ImpalaConnection.get_connection_url(config) == "impala://username:password@localhost:21050"
def test_impala_url_without_connection_arguments():
config = ImpalaConnectionConfig(
scheme=ImpalaScheme.impala.value,
username="username",
password="password",
hostPort="localhost:21050",
)
assert ImpalaConnection.get_connection_url(config) == "impala://username:password@localhost:21050"
def test_impala_url_without_connection_arguments_pass():
config = ImpalaConnectionConfig(
scheme=ImpalaScheme.impala.value,
username="username",
hostPort="localhost:21050",
)
assert ImpalaConnection.get_connection_url(config) == "impala://username@localhost:21050"
@@ -0,0 +1,37 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for IOMETE connection handling."""
from metadata.generated.schema.entity.services.connections.database.iometeConnection import (
IometeConnection as IometeConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.iomete.connection import IometeConnection
def test_iomete_connection_is_base_connection():
assert issubclass(IometeConnection, BaseConnection)
def test_get_connection_url_builds_iomete_dsn():
connection = IometeConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:443",
catalog="spark_catalog",
cluster="my_cluster",
dataPlane="my_dp",
)
url = IometeConnection.get_connection_url(connection)
assert (
url.render_as_string(hide_password=False) == "iomete://openmetadata_user:openmetadata_password@localhost:443"
"/spark_catalog?cluster=my_cluster&data_plane=my_dp"
)
@@ -0,0 +1,34 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for MariaDB connection handling."""
from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import (
MariaDBConnection as MariaDBConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import (
MariaDBScheme,
)
from metadata.ingestion.source.database.mariadb.connection import MariaDBConnection
def test_basic_auth_builds_expected_url():
connection = MariaDBConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:3306",
databaseSchema="openmetadata_db",
scheme=MariaDBScheme.mysql_pymysql,
)
engine = MariaDBConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "mysql+pymysql://openmetadata_user:openmetadata_password@localhost:3306/openmetadata_db"
)
@@ -0,0 +1,68 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the MongoDB BaseConnection wiring.
MongoDB is the first non-Engine connector: its client is a ``pymongo.MongoClient``
rather than a SQLAlchemy ``Engine``, so ``BaseConnection``'s client type is
``MongoClient`` and ``test_connection`` drives pymongo calls directly.
"""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.mongoDBConnection import (
MongoDBConnection as MongoDBConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.mongoDBConnection import (
MongoDBScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.mongodb.connection import MongoDBConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.mongodb.connection"
def _config(**kwargs) -> MongoDBConnectionConfig:
base = {
"scheme": MongoDBScheme.mongodb,
"username": "user",
"password": "pass",
"hostPort": "localhost:27017",
}
base.update(kwargs)
return MongoDBConnectionConfig(**base)
def test_mongodb_connection_is_base_connection():
assert issubclass(MongoDBConnection, BaseConnection)
def test_get_client_builds_a_mongo_client_from_the_url():
with patch(f"{CONNECTION_MODULE}.MongoClient") as mock_mongo:
client = MongoDBConnection(_config()).client
args, kwargs = mock_mongo.call_args
assert args[0].startswith("mongodb://")
assert kwargs == {}
assert client is mock_mongo.return_value
def test_get_client_passes_connection_options_as_kwargs():
config = _config(connectionOptions={"serverSelectionTimeoutMS": "5000"})
with patch(f"{CONNECTION_MODULE}.MongoClient") as mock_mongo:
_ = MongoDBConnection(config).client
assert mock_mongo.call_args.kwargs["serverSelectionTimeoutMS"] == "5000"
def test_close_releases_the_mongo_client_pool():
with patch(f"{CONNECTION_MODULE}.MongoClient") as mock_mongo:
connection = MongoDBConnection(_config())
_ = connection.client
connection.close()
mock_mongo.return_value.close.assert_called_once_with()
@@ -0,0 +1,197 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the MSSQL BaseConnection wiring.
URL building (including the pyodbc delegation to azuresql) is covered in
tests/unit/test_source_url.py and tests/unit/test_source_connection.py.
"""
import socket
from unittest.mock import MagicMock, patch
from metadata.core.connections.test_connection import collect_checks
from metadata.core.connections.test_connection.checks.database import DEFAULT_SAMPLE_ROWS, DatabaseStep
from metadata.generated.schema.entity.services.connections.database.mssqlConnection import (
MssqlConnection as MssqlConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.mssqlConnection import (
MssqlScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.mssql.connection import (
MSSQL_ERRORS,
MssqlChecks,
MssqlConnection,
_databases_enumerated,
get_connection_url,
)
from metadata.ingestion.source.database.mssql.queries import (
MSSQL_GET_CURRENT_DATABASE,
MSSQL_GET_DATABASE,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.mssql.connection"
def _config(scheme: MssqlScheme = MssqlScheme.mssql_pytds) -> MssqlConnectionConfig:
return MssqlConnectionConfig(
scheme=scheme,
username="user",
password="pass",
hostPort="myhost:1433",
database="mydb",
)
def test_mssql_connection_is_base_connection():
assert issubclass(MssqlConnection, BaseConnection)
def test_get_client_uses_the_module_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = MssqlConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
def test_pyodbc_scheme_delegates_to_azuresql_url_builder():
with patch(f"{CONNECTION_MODULE}.get_pyodbc_connection_url", return_value="delegated") as mock_pyodbc:
result = get_connection_url(_config(scheme=MssqlScheme.mssql_pyodbc))
mock_pyodbc.assert_called_once()
assert result == "delegated"
def test_non_pyodbc_scheme_uses_common_url_builder():
url = get_connection_url(_config(scheme=MssqlScheme.mssql_pytds))
assert url.startswith("mssql+pytds://")
def _checks() -> MssqlChecks:
return MssqlChecks(client=MagicMock(), get_databases_statement="SELECT 1")
def test_every_definition_step_resolves_to_a_check():
collected = collect_checks(_checks())
assert set(collected) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetQueries,
}
def test_close_disposes_the_engine():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection"):
connection = MssqlConnection(_config())
engine = connection.client
connection.close()
engine.dispose.assert_called_once()
def test_building_checks_does_not_touch_the_network():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_engine:
provider = MssqlConnection(_config()).checks()
engine = mock_engine.return_value
engine.connect.assert_not_called()
engine.exec_driver_sql.assert_not_called()
assert isinstance(provider, MssqlChecks)
def test_get_databases_statement_uses_current_db_when_not_ingest_all():
config = _config()
config.ingestAllDatabases = False
handler = MssqlConnection(config)
handler._client = MagicMock()
assert handler._get_databases_statement() == MSSQL_GET_CURRENT_DATABASE
def test_get_databases_statement_uses_all_dbs_when_ingest_all():
config = _config()
config.ingestAllDatabases = True
handler = MssqlConnection(config)
handler._client = MagicMock()
assert handler._get_databases_statement() == MSSQL_GET_DATABASE
def _pymssql_error(number: int, message: str) -> Exception:
"""A pymssql-style driver error: numeric SQL Server code in ``args[0]``,
wrapped by SQLAlchemy which preserves the original on ``.orig``."""
orig = Exception(number, message)
wrapper = Exception(f"({number}) {message}")
wrapper.orig = orig # type: ignore[attr-defined]
return wrapper
def _pyodbc_error(sqlstate: str, message: str) -> Exception:
"""A pyodbc-style driver error: SQLSTATE + message text, no numeric code."""
return Exception(f"('{sqlstate}', \"[{sqlstate}] {message}\")")
def test_pymssql_login_failure_classifies_as_auth():
diagnosis = MSSQL_ERRORS.classify(_pymssql_error(18456, "Login failed for user 'x'"))
assert diagnosis is not None
assert diagnosis.title == "Authentication failed"
def test_pyodbc_login_failure_classifies_as_auth():
diagnosis = MSSQL_ERRORS.classify(_pyodbc_error("28000", "Login failed for user 'x'."))
assert diagnosis is not None
assert diagnosis.title == "Authentication failed"
def test_pymssql_database_not_found_classifies():
diagnosis = MSSQL_ERRORS.classify(_pymssql_error(4060, 'Cannot open database "x" requested by the login.'))
assert diagnosis is not None
assert diagnosis.title == "Database not found or not accessible"
def test_pyodbc_cannot_open_database_classifies():
diagnosis = MSSQL_ERRORS.classify(_pyodbc_error("42000", 'Cannot open database "x" requested by the login.'))
assert diagnosis is not None
assert diagnosis.title == "Database not found or not accessible"
def test_cannot_open_database_wins_over_login_failed():
"""The SQL Server 4060 message embeds 'The login failed.', so the database
rule must be ordered before the login rule (regression for live-found bug)."""
message = "Cannot open database \"x\" requested by the login. The login failed. Login failed for user 'y'."
assert MSSQL_ERRORS.classify(Exception(message)).title == "Database not found or not accessible"
def test_pymssql_permission_denied_classifies():
diagnosis = MSSQL_ERRORS.classify(_pymssql_error(229, "The SELECT permission was denied on the object 'x'."))
assert diagnosis is not None
assert diagnosis.title == "Insufficient privileges"
def test_pyodbc_permission_denied_classifies():
diagnosis = MSSQL_ERRORS.classify(_pyodbc_error("42000", "The SELECT permission was denied on the object 'x'."))
assert diagnosis is not None
assert diagnosis.title == "Insufficient privileges"
def test_network_pack_is_folded_in():
diagnosis = MSSQL_ERRORS.classify(socket.gaierror("Name or service not known"))
assert diagnosis is not None
assert diagnosis.title == "Host could not be resolved"
def test_unknown_error_is_not_classified():
assert MSSQL_ERRORS.classify(Exception("something unexpected")) is None
def test_databases_summary_reports_exact_count_below_cap():
assert _databases_enumerated([object()] * 3) == "3 databases enumerated"
def test_databases_summary_reports_floor_when_capped():
assert _databases_enumerated([object()] * DEFAULT_SAMPLE_ROWS) == f"{DEFAULT_SAMPLE_ROWS}+ databases enumerated"
@@ -0,0 +1,85 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MySQL test-connection: its checks match its shipped definition."""
import json
from pathlib import Path
from metadata.core.connections.test_connection.check import collect_checks
from metadata.ingestion.source.database.mysql.connection import MYSQL_ERRORS, MySQLChecks
_SEED = (
Path(__file__).parents[6]
/ "openmetadata-service/src/main/resources/json/data"
/ "testConnections/database/mysql.json"
)
def _check_names():
checks = MySQLChecks(client=None, schema=None, queries_statement="")
return {step.value for step in collect_checks(checks)}
def test_mysql_checks_cover_expected_steps():
assert _check_names() == {
"CheckAccess",
"GetSchemas",
"GetTables",
"GetViews",
"GetQueries",
}
def test_mysql_checks_match_definition_seed():
definition_steps = {step["name"] for step in json.loads(_SEED.read_text())["steps"]}
assert _check_names() == definition_steps
def _driver_error(errno: int, message: str) -> Exception:
"""A PyMySQL-style error: numeric code in args[0]."""
error = Exception()
error.args = (errno, message)
return error
def test_error_pack_access_denied():
diagnosis = MYSQL_ERRORS.classify(_driver_error(1045, "Access denied for user 'x'@'h' (using password: YES)"))
assert diagnosis is not None
assert "Authentication" in diagnosis.title
def test_error_pack_unknown_database():
diagnosis = MYSQL_ERRORS.classify(_driver_error(1049, "Unknown database 'foo'"))
assert diagnosis is not None
assert "not found" in diagnosis.title.lower()
def test_error_pack_query_log_denied():
diagnosis = MYSQL_ERRORS.classify(_driver_error(1142, "SELECT command denied to user 'x' for table 'general_log'"))
assert diagnosis is not None
assert "Query history" in diagnosis.title
def test_error_pack_cannot_connect():
diagnosis = MYSQL_ERRORS.classify(_driver_error(2003, "Can't connect to MySQL server on 'h' ([Errno 111] refused)"))
assert diagnosis is not None
assert "reach" in diagnosis.title.lower()
def test_error_pack_caching_sha2_has_no_errno():
# PyMySQL raises this with the message in args[0], no numeric code.
diagnosis = MYSQL_ERRORS.classify(Exception("Couldn't receive server's public key"))
assert diagnosis is not None
assert "Secure connection" in diagnosis.title
def test_error_pack_unmatched_returns_none():
assert MYSQL_ERRORS.classify(_driver_error(9999, "novel error")) is None
@@ -0,0 +1,36 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for PinotDB connection handling."""
from metadata.generated.schema.entity.services.connections.database.pinotDBConnection import (
PinotDBConnection as PinotDBConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.pinotDBConnection import (
PinotDBScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.pinotdb.connection import PinotDBConnection
def test_pinotdb_connection_is_base_connection():
assert issubclass(PinotDBConnection, BaseConnection)
def test_get_connection_url_appends_controller():
connection = PinotDBConnectionConfig(
scheme=PinotDBScheme.pinot,
hostPort="localhost:8099",
pinotControllerHost="http://localhost:9000/",
)
assert (
PinotDBConnection.get_connection_url(connection)
== "pinot://localhost:8099/query/sql?controller=http://localhost:9000/"
)
@@ -0,0 +1,264 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for PostgreSQL connection handling (auth strategies + checks)."""
from unittest.mock import MagicMock, patch
import pytest
from azure.core.credentials import AccessToken
from azure.identity import ClientSecretCredential
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.core.connections.test_connection.checks.database import (
DEFAULT_SAMPLE_ROWS,
DatabaseStep,
)
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.common.azureConfig import (
AzureConfigurationSource,
)
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.generated.schema.entity.services.connections.database.postgresConnection import (
PostgresConnection as PostgresConnectionConfig,
)
from metadata.generated.schema.security.credentials.azureCredentials import (
AzureCredentials,
)
from metadata.ingestion.source.database.postgres.connection import (
POSTGRES_ERRORS,
PostgresChecks,
PostgresConnection,
)
def _azure_connection() -> PostgresConnectionConfig:
return PostgresConnectionConfig(
username="openmetadata_user",
authType=AzureConfigurationSource(
azureConfig=AzureCredentials(
clientId="clientid",
tenantId="tenantid",
clientSecret="clientsecret",
scopes="scope1,scope2",
)
),
hostPort="localhost:5432",
database="openmetadata_db",
)
def test_basic_auth_builds_expected_url():
connection = PostgresConnectionConfig(
username="openmetadata_user",
authType=BasicAuth(password="openmetadata_password"),
hostPort="localhost:5432",
database="openmetadata_db",
)
engine = PostgresConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "postgresql+psycopg2://openmetadata_user:openmetadata_password@localhost:5432/openmetadata_db"
)
def test_azure_ad_uses_token_as_password():
connection = _azure_connection()
with patch.object(
ClientSecretCredential,
"get_token",
return_value=AccessToken(token="mocked_token", expires_on=100),
):
engine = PostgresConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "postgresql+psycopg2://openmetadata_user:mocked_token@localhost:5432/openmetadata_db"
)
def test_azure_ad_does_not_mutate_caller_connection():
connection = _azure_connection()
with patch.object(
ClientSecretCredential,
"get_token",
return_value=AccessToken(token="mocked_token", expires_on=100),
):
assert PostgresConnection(connection).client is not None
assert isinstance(connection.authType, AzureConfigurationSource)
class _Psycopg2Error(Exception):
"""Mirror a psycopg2 DBAPI error.
The message and (for query-execution errors only) the SQLSTATE on ``.pgcode``
reproduce what psycopg2 actually raises - connect-phase failures carry no
code, captured live against PostgreSQL 15.
"""
def __init__(self, message: str = "", pgcode: str | None = None) -> None:
super().__init__(message)
self.pgcode = pgcode
class _SqlAlchemyError(Exception):
"""Mirror ``sqlalchemy.exc.DBAPIError``: wraps the driver error on ``.orig``."""
def __init__(self, orig: Exception) -> None:
super().__init__(str(orig))
self.orig = orig
# Real first-line messages captured from psycopg2 against PostgreSQL 15.
_AUTH_FAILED_MSG = (
'connection to server at "localhost" (::1), port 5432 failed: '
'FATAL: password authentication failed for user "postgres"'
)
_DB_NOT_FOUND_MSG = (
'connection to server at "localhost" (::1), port 5432 failed: FATAL: database "nope_db" does not exist'
)
def test_auth_failure_message_is_classified():
error = _SqlAlchemyError(_Psycopg2Error(_AUTH_FAILED_MSG))
assert POSTGRES_ERRORS.classify(error).title == "Authentication failed"
def test_database_not_found_message_is_classified():
error = _SqlAlchemyError(_Psycopg2Error(_DB_NOT_FOUND_MSG))
assert POSTGRES_ERRORS.classify(error).title == "Database not found"
def test_unknown_role_message_is_classified_as_auth_failure():
# Under trust/peer auth a missing role surfaces as `role "x" does not exist`
# (no SQLSTATE); it must map to Authentication failed, not Database not found -
# both share the `does not exist` suffix.
error = _SqlAlchemyError(_Psycopg2Error('FATAL: role "ghost" does not exist'))
assert POSTGRES_ERRORS.classify(error).title == "Authentication failed"
def test_insufficient_privilege_sqlstate_is_classified():
error = _SqlAlchemyError(_Psycopg2Error("permission denied for table secret_t", pgcode="42501"))
assert POSTGRES_ERRORS.classify(error).title == "Insufficient privileges"
# Real GetQueries failure when the source is absent: the error text embeds the
# probe SQL, which references pg_catalog.pg_database - the case that must NOT be
# misread as "Database not found". Keyed on SQLSTATE 42P01, so it holds for any
# configured queryStatementSource, not just the default pg_stat_statements.
_MISSING_PGSS_MSG = (
'relation "pg_stat_statements" does not exist\n[SQL: SELECT u.usename, d.datname '
"FROM pg_stat_statements s JOIN pg_catalog.pg_database d ON s.dbid = d.oid LIMIT 1]"
)
def test_missing_query_history_source_is_classified():
error = _SqlAlchemyError(_Psycopg2Error(_MISSING_PGSS_MSG, pgcode="42P01"))
assert POSTGRES_ERRORS.classify(error).title == "Query history source not found"
def test_missing_query_history_source_is_not_read_as_database_not_found():
# The probe SQL mentions pg_database; the diagnosis must be query-history, not db.
error = _SqlAlchemyError(_Psycopg2Error(_MISSING_PGSS_MSG, pgcode="42P01"))
assert POSTGRES_ERRORS.classify(error).title != "Database not found"
def test_missing_query_history_source_holds_for_a_custom_source_name():
# queryStatementSource is configurable; a custom source missing must still be
# diagnosed as a query-history problem, not left unclassified.
error = _SqlAlchemyError(_Psycopg2Error('relation "my_query_log" does not exist', pgcode="42P01"))
assert POSTGRES_ERRORS.classify(error).title == "Query history source not found"
def test_pg_hba_message_is_classified():
error = Exception('FATAL: no pg_hba.conf entry for host "1.2.3.4", user "u", SSL off')
assert POSTGRES_ERRORS.classify(error).title == "Connection not permitted by pg_hba.conf"
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("db:5432 is not reachable")
error.__cause__ = ConnectionRefusedError(61, "Connection refused")
assert POSTGRES_ERRORS.classify(error).title == "Connection refused"
def test_unknown_error_returns_no_diagnosis():
error = _SqlAlchemyError(_Psycopg2Error("something unexpected", pgcode="99999"))
assert POSTGRES_ERRORS.classify(error) is None
def test_checks_cover_exactly_the_seeded_steps():
engine = create_engine("sqlite://", poolclass=StaticPool)
checks = PostgresChecks(client=engine, query_statement_source=None)
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetTags,
DatabaseStep.GetQueries,
DatabaseStep.GetColumnMetadata,
DatabaseStep.GetTableComments,
DatabaseStep.GetInformationSchemaColumns,
}
def test_check_access_reports_unreachable_host_as_network_failure():
# Prove the wiring: check_access -> ping -> _preflight raises whatever
# tcp_probe raises, wrapped as a CheckError whose cause classifies as a
# network failure. tcp_probe is stubbed so the test is deterministic and
# fast - no real socket, hence no ephemeral-port TOCTOU and no 20s timeout
# waiting on an unreachable host. tcp_probe's own socket behaviour is
# covered in the network module's tests.
client = MagicMock()
client.url.host = "db.invalid"
client.url.port = 5432
checks = PostgresChecks(client=client, query_statement_source=None)
probe_error = NetworkUnreachableError("db.invalid:5432 is not reachable")
probe_error.__cause__ = ConnectionRefusedError(61, "Connection refused")
with (
patch(
"metadata.core.connections.test_connection.checks.database.tcp_probe",
side_effect=probe_error,
) as mock_probe,
pytest.raises(CheckError) as exc,
):
checks.check_access()
mock_probe.assert_called_once_with("db.invalid", 5432)
assert exc.value.cause is probe_error
assert POSTGRES_ERRORS.classify(exc.value.cause).title == "Connection refused"
def test_query_statement_is_built_lazily_not_at_construction():
# Regression: the version-probe query must run inside GetQueries (behind the
# CheckAccess gate), never at checks() construction - otherwise an unreachable
# host hangs on that eager connect instead of failing fast at the preflight.
calls = []
with patch(
"metadata.ingestion.source.database.postgres.connection.get_postgres_time_column_name",
side_effect=lambda engine: calls.append(1) or "total_exec_time",
):
engine = create_engine("sqlite://", poolclass=StaticPool)
PostgresChecks(client=engine, query_statement_source=None)
assert calls == []
def test_get_databases_summary_marks_the_sample_cap():
# run_sql fetches at most DEFAULT_SAMPLE_ROWS; below the cap the count is exact,
# at the cap it is a lower bound and must be reported as "N+".
assert PostgresChecks._summarize_databases([1, 2, 3]) == "3 databases enumerated"
assert (
PostgresChecks._summarize_databases(list(range(DEFAULT_SAMPLE_ROWS)))
== f"{DEFAULT_SAMPLE_ROWS}+ databases enumerated"
)
@@ -0,0 +1,57 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Presto connection handling."""
from metadata.generated.schema.entity.services.connections.database.prestoConnection import (
PrestoConnection as PrestoConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.prestoConnection import (
PrestoScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.presto.connection import PrestoConnection
def test_presto_connection_is_base_connection():
assert issubclass(PrestoConnection, BaseConnection)
def test_url_with_catalog_and_no_password():
connection = PrestoConnectionConfig(
username="admin",
hostPort="localhost:8080",
scheme=PrestoScheme.presto,
catalog="test_catalog",
)
assert PrestoConnection.get_connection_url(connection) == "presto://admin@localhost:8080/test_catalog"
def test_url_escapes_special_characters_in_credentials():
connection = PrestoConnectionConfig(
username="admin@333",
password="pass@111",
hostPort="localhost:8080",
scheme=PrestoScheme.presto,
catalog="test_catalog",
)
assert (
PrestoConnection.get_connection_url(connection) == "presto://admin%40333:pass%40111@localhost:8080/test_catalog"
)
def test_url_without_catalog():
connection = PrestoConnectionConfig(
scheme=PrestoScheme.presto,
hostPort="localhost:8080",
username="username",
password="pass",
)
assert PrestoConnection.get_connection_url(connection) == "presto://username:pass@localhost:8080"
@@ -0,0 +1,232 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Redshift BaseConnection wiring and test-connection checks.
The IAM/basic URL building and credential fetching are covered in
tests/unit/topology/database/test_redshift_connection.py.
"""
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.core.connections.test_connection.checks.database import (
DEFAULT_SAMPLE_ROWS,
DatabaseStep,
)
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.generated.schema.entity.services.connections.database.redshiftConnection import (
RedshiftConnection as RedshiftConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.connections.test_connections import SourceConnectionException
from metadata.ingestion.source.database.redshift.connection import (
REDSHIFT_ERRORS,
RedshiftChecks,
RedshiftConnection,
_summarize_databases,
)
from metadata.ingestion.source.database.redshift.models import RedshiftInstanceType
from metadata.ingestion.source.database.redshift.queries import (
REDSHIFT_TEST_GET_QUERIES_MAP,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.redshift.connection"
def _config() -> RedshiftConnectionConfig:
return RedshiftConnectionConfig(
hostPort="my-cluster.abc123.us-east-1.redshift.amazonaws.com:5439",
username="admin",
authType=BasicAuth(password="secret"),
database="mydb",
)
def test_redshift_connection_is_base_connection():
assert issubclass(RedshiftConnection, BaseConnection)
def test_get_client_uses_the_redshift_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = RedshiftConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_redshift_connection_url"
def test_close_disposes_the_engine_pool():
# The old test_connection called kill_active_connections(engine); the framework
# path relies on close() unwinding _on_close teardowns, so _get_client must
# register engine.dispose or the QueuePool leaks idle connections.
engine = MagicMock()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection", return_value=engine):
connection = RedshiftConnection(_config())
assert connection.client is engine
connection.close()
engine.dispose.assert_called_once_with()
class _Psycopg2Error(Exception):
"""Mirror a psycopg2 DBAPI error.
The Redshift dialect rides on psycopg2: the message and (for query-execution
errors only) the SQLSTATE on ``.pgcode`` reproduce what the driver raises -
connect-phase failures carry no code.
"""
def __init__(self, message: str = "", pgcode: str | None = None) -> None:
super().__init__(message)
self.pgcode = pgcode
class _SqlAlchemyError(Exception):
"""Mirror ``sqlalchemy.exc.DBAPIError``: wraps the driver error on ``.orig``."""
def __init__(self, orig: Exception) -> None:
super().__init__(str(orig))
self.orig = orig
_AUTH_FAILED_MSG = (
'connection to server at "my-cluster" port 5439 failed: FATAL: password authentication failed for user "admin"'
)
_DB_NOT_FOUND_MSG = 'connection to server at "my-cluster" port 5439 failed: FATAL: database "nope_db" does not exist'
def test_auth_failure_message_is_classified():
error = _SqlAlchemyError(_Psycopg2Error(_AUTH_FAILED_MSG))
assert REDSHIFT_ERRORS.classify(error).title == "Authentication failed"
def test_auth_failure_sqlstate_is_classified():
error = _SqlAlchemyError(_Psycopg2Error("authorization not valid", pgcode="28000"))
assert REDSHIFT_ERRORS.classify(error).title == "Authentication failed"
def test_database_not_found_message_is_classified():
error = _SqlAlchemyError(_Psycopg2Error(_DB_NOT_FOUND_MSG))
assert REDSHIFT_ERRORS.classify(error).title == "Database not found"
def test_insufficient_privilege_sqlstate_is_classified():
error = _SqlAlchemyError(_Psycopg2Error("permission denied for table secret_t", pgcode="42501"))
assert REDSHIFT_ERRORS.classify(error).title == "Insufficient privileges"
def test_missing_query_history_source_is_classified():
# An absent query-history view raises undefined_table (42P01) from the probe;
# keyed on SQLSTATE, so it holds whether the deployment is Provisioned or Serverless.
error = _SqlAlchemyError(_Psycopg2Error('relation "stl_query" does not exist', pgcode="42P01"))
assert REDSHIFT_ERRORS.classify(error).title == "Query history source not found"
def test_missing_query_history_source_is_not_read_as_database_not_found():
error = _SqlAlchemyError(_Psycopg2Error('relation "stl_query" does not exist', pgcode="42P01"))
assert REDSHIFT_ERRORS.classify(error).title != "Database not found"
def test_missing_query_privilege_is_classified():
# has_table_privilege returns False rather than raising; the GetQueries check
# turns that into a SourceConnectionException whose text the pack classifies.
error = Exception("Missing SELECT privilege on the Redshift stl query-history views - (True, False)")
assert REDSHIFT_ERRORS.classify(error).title == "Query history not accessible"
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("cluster:5439 is not reachable")
error.__cause__ = ConnectionRefusedError(61, "Connection refused")
assert REDSHIFT_ERRORS.classify(error).title == "Connection refused"
def test_unknown_error_returns_no_diagnosis():
error = _SqlAlchemyError(_Psycopg2Error("something unexpected", pgcode="99999"))
assert REDSHIFT_ERRORS.classify(error) is None
def test_database_summary_reports_exact_count_under_the_sample_cap():
assert _summarize_databases(["dev", "analytics"]) == "2 databases reachable"
def test_database_summary_flags_a_capped_count_with_a_plus():
# run_sql fetches at most DEFAULT_SAMPLE_ROWS, so a saturated sample must read
# as "at least N", never as an exact total.
capped = list(range(DEFAULT_SAMPLE_ROWS))
assert _summarize_databases(capped) == f"{DEFAULT_SAMPLE_ROWS}+ databases reachable"
def test_get_queries_failure_reports_the_attempted_command_as_evidence():
# A missing privilege raises from run_sql's summary callback, outside its own
# CheckError wrapping; get_queries must re-raise with the statement so the
# failed step still shows the command it ran, like every other step.
engine = MagicMock()
checks = RedshiftChecks(client=engine)
with (
patch(f"{CONNECTION_MODULE}.get_redshift_instance_type", return_value=RedshiftInstanceType.PROVISIONED),
patch(f"{CONNECTION_MODULE}.run_sql", side_effect=SourceConnectionException("missing privilege")) as mock_run,
pytest.raises(CheckError) as exc,
):
checks.get_queries()
assert exc.value.evidence.command == REDSHIFT_TEST_GET_QUERIES_MAP[RedshiftInstanceType.PROVISIONED]
assert isinstance(exc.value.cause, SourceConnectionException)
assert mock_run.call_count == 1
def test_checks_cover_exactly_the_seeded_steps():
engine = create_engine("sqlite://", poolclass=StaticPool)
checks = RedshiftChecks(client=engine)
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetQueries,
}
def test_check_access_reports_unreachable_host_as_network_failure():
client = MagicMock()
client.url.host = "cluster.invalid"
client.url.port = 5439
checks = RedshiftChecks(client=client)
probe_error = NetworkUnreachableError("cluster.invalid:5439 is not reachable")
probe_error.__cause__ = ConnectionRefusedError(61, "Connection refused")
with (
patch(
"metadata.core.connections.test_connection.checks.database.tcp_probe",
side_effect=probe_error,
) as mock_probe,
pytest.raises(CheckError) as exc,
):
checks.check_access()
mock_probe.assert_called_once_with("cluster.invalid", 5439)
assert exc.value.cause is probe_error
assert REDSHIFT_ERRORS.classify(exc.value.cause).title == "Connection refused"
def test_instance_type_probe_is_run_lazily_not_at_construction():
# Regression: the instance-type probe must run inside GetQueries (behind the
# CheckAccess gate), never at checks() construction - otherwise an unreachable
# host hangs on that eager connect instead of failing fast at the preflight.
calls = []
with patch(
f"{CONNECTION_MODULE}.get_redshift_instance_type",
side_effect=lambda engine: calls.append(1),
):
engine = create_engine("sqlite://", poolclass=StaticPool)
RedshiftChecks(client=engine)
assert calls == []
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the SalesforceConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.salesforce.connection")
def test_salesforce_connection_is_base_connection():
assert issubclass(connection.SalesforceConnection, BaseConnection)
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the SapErpConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.saperp.connection")
def test_saperp_connection_is_base_connection():
assert issubclass(connection.SapErpConnection, BaseConnection)
@@ -0,0 +1,124 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for SAP HANA connection handling.
The SAP HANA dialect is an optional extra absent from the unit-test
environment, so URL parity is asserted via each connection-mode strategy
rather than by instantiating an engine.
"""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.sapHana.sapHanaHDBConnection import (
SapHanaHDBConnection,
)
from metadata.generated.schema.entity.services.connections.database.sapHana.sapHanaSQLConnection import (
SapHanaSQLConnection,
)
from metadata.generated.schema.entity.services.connections.database.sapHanaConnection import (
SapHanaConnection as SapHanaConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.sapHanaConnection import (
SapHanaScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.saphana.connection import (
SapHanaConnection,
SapHanaHDBStrategy,
SapHanaSQLStrategy,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.saphana.connection"
def _sql_config(**sql_kwargs) -> SapHanaConnectionConfig:
return SapHanaConnectionConfig(
scheme=SapHanaScheme.hana,
connection=SapHanaSQLConnection(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:39041",
**sql_kwargs,
),
)
def _hdb_config() -> SapHanaConnectionConfig:
return SapHanaConnectionConfig(
scheme=SapHanaScheme.hana,
connection=SapHanaHDBConnection(userKey="USER1UserKey"),
)
def test_saphana_connection_is_base_connection():
assert issubclass(SapHanaConnection, BaseConnection)
def test_sql_url_includes_database():
config = _sql_config(database="HXE")
url = SapHanaSQLStrategy(config, config.connection).get_url()
assert url == "hana://openmetadata_user:openmetadata_password@localhost:39041/HXE"
def test_sql_url_appends_options_with_database():
config = SapHanaConnectionConfig(
scheme=SapHanaScheme.hana,
connection=SapHanaSQLConnection(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:39041",
database="HXE",
),
connectionOptions={"encrypt": "true"},
)
url = SapHanaSQLStrategy(config, config.connection).get_url()
assert url == "hana://openmetadata_user:openmetadata_password@localhost:39041/HXE?encrypt=true"
def test_sql_url_without_database():
config = _sql_config()
url = SapHanaSQLStrategy(config, config.connection).get_url()
assert url == "hana://openmetadata_user:openmetadata_password@localhost:39041"
def test_hdb_url_uses_user_key():
config = _hdb_config()
url = SapHanaHDBStrategy(config, config.connection).get_url()
assert url == "hana://userkey=USER1UserKey"
def test_get_client_selects_sql_strategy():
config = _sql_config(database="HXE")
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = SapHanaConnection(config).client
url_fn = mock_connection.call_args.kwargs["get_connection_url_fn"]
assert url_fn(config) == "hana://openmetadata_user:openmetadata_password@localhost:39041/HXE"
def test_get_client_selects_hdb_strategy():
config = _hdb_config()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = SapHanaConnection(config).client
url_fn = mock_connection.call_args.kwargs["get_connection_url_fn"]
assert url_fn(config) == "hana://userkey=USER1UserKey"
@patch(f"{CONNECTION_MODULE}.inspect")
@patch(f"{CONNECTION_MODULE}.create_generic_db_connection")
def test_hdb_test_functions_tolerate_missing_database_schema(_mock_connection, mock_inspect):
mock_inspect.return_value.get_schema_names.return_value = []
connection = SapHanaConnection(_hdb_config())
test_functions = connection._build_test_functions()
test_functions["GetTables"]()
test_functions["GetViews"]()
mock_inspect.return_value.get_schema_names.assert_called()
@@ -0,0 +1,22 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the SASConnection BaseConnection wiring (non-Engine client)."""
import pytest
from metadata.ingestion.connections.connection import BaseConnection
# The connector SDK may be an optional dependency absent from the unit env.
connection = pytest.importorskip("metadata.ingestion.source.database.sas.connection")
def test_sas_connection_is_base_connection():
assert issubclass(connection.SASConnection, BaseConnection)
@@ -0,0 +1,34 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for SingleStore connection handling."""
from metadata.generated.schema.entity.services.connections.database.singleStoreConnection import (
SingleStoreConnection as SingleStoreConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.singleStoreConnection import (
SingleStoreScheme,
)
from metadata.ingestion.source.database.singlestore.connection import SingleStoreConnection
def test_basic_auth_builds_expected_url():
connection = SingleStoreConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:3306",
databaseSchema="openmetadata_db",
scheme=SingleStoreScheme.mysql_pymysql,
)
engine = SingleStoreConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "mysql+pymysql://openmetadata_user:openmetadata_password@localhost:3306/openmetadata_db"
)
@@ -0,0 +1,344 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Snowflake test-connection checks and error classification.
Cover the wiring (steps resolve to checks, the network pack is folded in, nothing
connects at construction) and the error-pack mapping (each scenario classifies to
the intended diagnosis).
"""
from unittest.mock import MagicMock, patch
import pytest
from metadata.core.connections.test_connection import Evidence
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.core.connections.test_connection.checks.database import (
DEFAULT_SAMPLE_ROWS,
DatabaseStep,
)
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.snowflakeConnection import (
SnowflakeConnection as SnowflakeConnectionConfig,
)
from metadata.ingestion.source.database.snowflake.connection import (
SNOWFLAKE_ERRORS,
SNOWFLAKE_PORT,
SnowflakeChecks,
_count_summary,
)
def _config(**overrides) -> SnowflakeConnectionConfig:
base = {"username": "user", "account": "ue18849.us-east-2.aws", "warehouse": "wh"}
base.update(overrides)
return SnowflakeConnectionConfig(**base)
class _SnowflakeError(Exception):
"""Mirror a snowflake.connector error: the code lives on ``.errno`` (not in
``args[0]`` as for PyMySQL), the message is the first arg."""
def __init__(self, message: str = "", errno: int | None = None) -> None:
super().__init__(message)
self.errno = errno
class _SqlAlchemyError(Exception):
"""Mirror ``sqlalchemy.exc.DBAPIError``: wraps the driver error on ``.orig``."""
def __init__(self, orig: Exception) -> None:
super().__init__(str(orig))
self.orig = orig
def test_auth_failure_errno_is_classified():
error = _SqlAlchemyError(_SnowflakeError("250001 (08001): Failed to connect to DB", errno=250001))
assert SNOWFLAKE_ERRORS.classify(error).title == "Authentication failed"
def test_auth_failure_message_is_classified():
error = _SqlAlchemyError(_SnowflakeError("Incorrect username or password were specified."))
assert SNOWFLAKE_ERRORS.classify(error).title == "Authentication failed"
def test_bad_account_login_404_is_classified():
error = _SqlAlchemyError(
_SnowflakeError(
"None: 404 Not Found: post nope99999.us-east-1.snowflakecomputing.com:443/session/v1/login-request",
errno=290404,
)
)
assert SNOWFLAKE_ERRORS.classify(error).title == "Snowflake account not found"
def test_mfa_required_beats_generic_auth():
# errno 250001 alone would read as "Authentication failed"; the MFA message is
# ordered first so the user gets an actionable, specific diagnosis.
error = _SqlAlchemyError(
_SnowflakeError(
"Failed to connect to DB: acc.snowflakecomputing.com:443. "
"Multi-factor authentication is required for this account. Log in to Snowsight to enroll.",
errno=250001,
)
)
assert SNOWFLAKE_ERRORS.classify(error).title == "Multi-factor authentication required"
def test_missing_role_beats_generic_auth():
# A missing role also raises errno 250001; match the role-specific token first.
error = _SqlAlchemyError(
_SnowflakeError(
"Failed to connect to DB: acc.snowflakecomputing.com:443. Role 'NO_SUCH_ROLE' specified in the "
"connect string is not granted to this user, or is not permitted for the credentials being used.",
errno=250001,
)
)
assert SNOWFLAKE_ERRORS.classify(error).title == "Role not granted"
def test_account_usage_denied_is_classified():
error = _SqlAlchemyError(
_SnowflakeError(
"Object 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY' does not exist or not authorized.",
errno=2003,
)
)
assert SNOWFLAKE_ERRORS.classify(error).title == "Account usage not accessible"
def test_account_usage_denied_wins_over_generic_object_not_found():
# The same message also matches the generic "does not exist or not authorized"
# rule; the account_usage rule is ordered first so the sharper diagnosis wins.
error = _SnowflakeError(
"SQL compilation error: Object 'SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY' does not exist or not authorized.",
errno=2003,
)
assert SNOWFLAKE_ERRORS.classify(error).title != "Object not found"
def test_account_usage_denial_honors_custom_schema():
# The pack is built from the configured accountUsageSchema, so a denial on a
# custom schema is still diagnosed as an account_usage gap - not "Object not found".
schema = "MYDB.MY_USAGE"
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config(accountUsageSchema=schema))
error = _SnowflakeError("Object 'MYDB.MY_USAGE.QUERY_HISTORY' does not exist or not authorized.", errno=2003)
assert checks.errors.classify(error).title == "Account usage not accessible"
# the default pack, keyed on snowflake.account_usage, does not recognize the custom schema
assert SNOWFLAKE_ERRORS.classify(error).title == "Object not found"
def test_insufficient_privileges_is_classified():
error = _SnowflakeError("SQL access control error: Insufficient privileges to operate on schema 'PUBLIC'")
assert SNOWFLAKE_ERRORS.classify(error).title == "Insufficient privileges"
def test_object_not_found_errno_is_classified():
error = _SqlAlchemyError(_SnowflakeError("Database 'NOPE' does not exist or not authorized.", errno=2003))
assert SNOWFLAKE_ERRORS.classify(error).title == "Object not found"
def test_missing_database_use_is_classified():
# USE DATABASE on a missing DB -> errno 2043 with a different message
# ("Object does not exist, or operation cannot be performed") than the 2003 form.
error = _SqlAlchemyError(
_SnowflakeError(
"SQL compilation error:\nObject does not exist, or operation cannot be performed.",
errno=2043,
)
)
assert SNOWFLAKE_ERRORS.classify(error).title == "Object not found"
def test_no_active_warehouse_is_classified():
error = _SnowflakeError("No active warehouse selected in the current session.")
assert SNOWFLAKE_ERRORS.classify(error).title == "No active warehouse"
def test_unknown_error_returns_no_diagnosis():
error = _SqlAlchemyError(_SnowflakeError("something unexpected", errno=99999))
assert SNOWFLAKE_ERRORS.classify(error) is None
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("acc.snowflakecomputing.com:443 is not reachable")
error.__cause__ = TimeoutError("timed out")
assert SNOWFLAKE_ERRORS.classify(error).title == "Connection timed out"
def test_count_summary_marks_empty_count_and_cap():
assert _count_summary([], "table") == "no tables enumerated"
assert _count_summary([object()] * 3, "database") == "3 databases enumerated"
assert _count_summary([object()] * 3, "view") == "3 views enumerated"
assert _count_summary([object()] * 1, "stream") == "1 stream enumerated"
assert _count_summary([object()] * 1, "table") == "1 table enumerated"
capped = _count_summary([object()] * DEFAULT_SAMPLE_ROWS, "table")
assert capped == f"{DEFAULT_SAMPLE_ROWS}+ tables enumerated"
def _fake_run_sql(returned_rows):
def fake(client, statement, summarize, *args, **kwargs):
return Evidence(summary=summarize(returned_rows), command="cmd")
return fake
def test_get_tables_warns_when_no_user_tables():
# Empty probe (INFORMATION_SCHEMA filtered out) -> passing step with a caveat,
# which the runner records as a Warning.
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config(database="MYDB"))
with patch(
"metadata.ingestion.source.database.snowflake.connection.run_sql",
side_effect=_fake_run_sql([]),
):
evidence = checks.get_tables()
assert evidence.summary == "no tables enumerated"
assert evidence.caveat is not None
assert evidence.caveat.title == "No tables visible in database 'MYDB'"
assert "privileges" in evidence.caveat.remediation.lower()
def test_get_tables_has_no_caveat_when_tables_exist():
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config(database="MYDB"))
with patch(
"metadata.ingestion.source.database.snowflake.connection.run_sql",
side_effect=_fake_run_sql([object(), object()]),
):
evidence = checks.get_tables()
assert evidence.summary == "2 tables enumerated"
assert evidence.caveat is None
def test_table_and_view_probes_exclude_information_schema():
from metadata.ingestion.source.database.snowflake.queries import (
SNOWFLAKE_TEST_GET_TABLES,
SNOWFLAKE_TEST_GET_VIEWS,
)
for query in (SNOWFLAKE_TEST_GET_TABLES, SNOWFLAKE_TEST_GET_VIEWS):
assert "INFORMATION_SCHEMA" in query
assert "LIMIT 100" in query
def test_checks_cover_exactly_the_wired_steps():
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config())
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetStreams,
DatabaseStep.GetTags,
DatabaseStep.GetQueries,
DatabaseStep.GetAccessHistory,
}
def test_get_access_history_is_wired():
# ACCESS_HISTORY is the default lineage source; its probe is the GetAccessHistory step.
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config())
assert DatabaseStep.GetAccessHistory in collect_checks(checks)
def test_construction_touches_no_network():
# Regression for gotcha #2: building the provider must not connect or resolve a
# database - that would run before the gate and bypass the preflight.
client = MagicMock()
SnowflakeChecks(client=client, service_connection=_config())
client.connect.assert_not_called()
client.execute.assert_not_called()
def test_check_access_probes_account_host_and_reports_network_failure():
# check_access -> tcp_probe(<account>.snowflakecomputing.com, 443); a probe
# failure is wrapped as a CheckError whose cause classifies via the network
# pack. tcp_probe is stubbed so the test is deterministic and fast.
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config(account="acc"))
probe_error = NetworkUnreachableError("acc.snowflakecomputing.com:443 is not reachable")
probe_error.__cause__ = TimeoutError("timed out")
with (
patch(
"metadata.ingestion.source.database.snowflake.connection.tcp_probe",
side_effect=probe_error,
) as mock_probe,
pytest.raises(CheckError) as exc,
):
checks.check_access()
mock_probe.assert_called_once_with("acc.snowflakecomputing.com", SNOWFLAKE_PORT)
assert exc.value.cause is probe_error
assert SNOWFLAKE_ERRORS.classify(exc.value.cause).title == "Connection timed out"
def test_check_access_prefers_explicit_connection_argument_host():
# A proxy / load balancer / PrivateLink endpoint set as connectionArguments
# host is what the driver dials, so the gate probe must target it - not the
# synthesized public account host (which may be unreachable for that deployment).
checks = SnowflakeChecks(
client=MagicMock(),
service_connection=_config(account="acc", connectionArguments={"host": "proxy.internal"}),
)
probe_error = NetworkUnreachableError("proxy.internal:443 is not reachable")
with (
patch(
"metadata.ingestion.source.database.snowflake.connection.tcp_probe",
side_effect=probe_error,
) as mock_probe,
pytest.raises(CheckError),
):
checks.check_access()
mock_probe.assert_called_once_with("proxy.internal", SNOWFLAKE_PORT)
def test_check_access_honors_explicit_connection_argument_port():
# A custom host may listen on a non-443 port; the preflight must probe that
# port, not the default 443, or it false-gates the whole connection test.
checks = SnowflakeChecks(
client=MagicMock(),
service_connection=_config(account="acc", connectionArguments={"host": "proxy.internal", "port": 8443}),
)
with (
patch(
"metadata.ingestion.source.database.snowflake.connection.tcp_probe",
side_effect=NetworkUnreachableError("proxy.internal:8443 is not reachable"),
) as mock_probe,
pytest.raises(CheckError),
):
checks.check_access()
mock_probe.assert_called_once_with("proxy.internal", 8443)
def test_get_schemas_runs_show_schemas_with_command_and_count():
# GetSchemas goes through run_sql (like the other probes), so it reports the
# database-scoped SHOW SCHEMAS command and a counted summary - and a failure
# would surface as a CheckError carrying that command.
checks = SnowflakeChecks(client=MagicMock(), service_connection=_config(database="MYDB"))
captured = {}
def fake(client, statement, summarize, *args, **kwargs):
captured["statement"] = statement
return Evidence(summary=summarize([object(), object()]), command=statement)
with patch("metadata.ingestion.source.database.snowflake.connection.run_sql", side_effect=fake):
evidence = checks.get_schemas()
assert 'SHOW SCHEMAS IN DATABASE "MYDB"' in captured["statement"]
assert evidence.summary == "2 schemas enumerated"
assert evidence.command == captured["statement"]
def test_account_usage_queries_built_lazily_not_at_construction():
# The account_usage statements must be formatted inside their checks (behind
# the gate), never at construction; constructing must not read the engine.
client = MagicMock()
checks = SnowflakeChecks(client=client, service_connection=_config(account="acc"))
assert checks._engine_wrapper.database_name is None
client.connect.assert_not_called()
@@ -0,0 +1,34 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for SQLite connection handling."""
from metadata.generated.schema.entity.services.connections.database.sqliteConnection import (
SQLiteConnection as SQLiteConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.sqliteConnection import (
SQLiteScheme,
)
from metadata.ingestion.source.database.sqlite.connection import SQLiteConnection
def test_in_memory_url_when_no_database_mode():
connection = SQLiteConnectionConfig(scheme=SQLiteScheme.sqlite_pysqlite)
engine = SQLiteConnection(connection).client
assert engine.url.render_as_string(hide_password=False) == "sqlite+pysqlite:///:memory:"
def test_file_url_when_database_mode_set():
connection = SQLiteConnectionConfig(
scheme=SQLiteScheme.sqlite_pysqlite,
databaseMode="/tmp/openmetadata.db",
)
engine = SQLiteConnection(connection).client
assert engine.url.render_as_string(hide_password=False) == "sqlite+pysqlite:////tmp/openmetadata.db"
@@ -0,0 +1,34 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for StarRocks connection handling."""
from metadata.generated.schema.entity.services.connections.database.starrocksConnection import (
StarRocksConnection as StarRocksConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.starrocksConnection import (
StarrocksScheme,
)
from metadata.ingestion.source.database.starrocks.connection import StarRocksConnection
def test_basic_auth_builds_expected_url():
connection = StarRocksConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:9030",
databaseSchema="openmetadata_db",
scheme=StarrocksScheme.mysql_pymysql,
)
engine = StarRocksConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "mysql+pymysql://openmetadata_user:openmetadata_password@localhost:9030/openmetadata_db"
)
@@ -0,0 +1,37 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Teradata connection handling."""
from metadata.generated.schema.entity.services.connections.database.teradataConnection import (
TeradataConnection as TeradataConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.teradataConnection import (
TeradataScheme,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.teradata.connection import TeradataConnection
def test_teradata_connection_is_base_connection():
assert issubclass(TeradataConnection, BaseConnection)
def test_get_connection_url_with_credentials_and_defaults():
connection = TeradataConnectionConfig(
scheme=TeradataScheme.teradatasql,
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:1025",
)
assert (
TeradataConnection.get_connection_url(connection) == "teradatasql://localhost:1025/?user=openmetadata_user"
"&password=openmetadata_password&logmech=TD2&tmode=DEFAULT"
)
@@ -0,0 +1,25 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Structural tests for DatabaseServiceSource — guards against re-introducing
progress-projection responsibilities that now live in the topology runner
and connector-specific modules.
"""
from metadata.ingestion.source.database.database_service import DatabaseServiceSource
class TestDatabaseServiceHasNoProgressProjection:
def test_no_container_expected_count(self):
assert "container_expected_count" not in vars(DatabaseServiceSource)
def test_no_progress_snapshot(self):
assert "progress_snapshot" not in vars(DatabaseServiceSource)
@@ -0,0 +1,902 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Unit tests for JSON schema extraction from sampled data.
"""
import json
from metadata.generated.schema.entity.data.table import DataType
from metadata.ingestion.source.database.json_schema_extractor import (
_build_column_children,
_build_json_schema,
_merge_json_structures,
_parse_json_values,
infer_json_schema_from_sample,
)
class TestParseJsonValues:
"""Tests for _parse_json_values function."""
def test_parse_dict_values(self):
"""Test parsing already-parsed dict values."""
values = [{"name": "John"}, {"name": "Jane"}]
result = _parse_json_values(values)
assert len(result) == 2
assert result[0] == {"name": "John"}
assert result[1] == {"name": "Jane"}
def test_parse_string_json_values(self):
"""Test parsing JSON string values."""
values = ['{"name": "John"}', '{"name": "Jane"}']
result = _parse_json_values(values)
assert len(result) == 2
assert result[0] == {"name": "John"}
assert result[1] == {"name": "Jane"}
def test_parse_mixed_values(self):
"""Test parsing mixed dict and string values."""
values = [{"name": "John"}, '{"name": "Jane"}']
result = _parse_json_values(values)
assert len(result) == 2
def test_filter_none_values(self):
"""Test that None values are filtered out."""
values = [{"name": "John"}, None, {"name": "Jane"}]
result = _parse_json_values(values)
assert len(result) == 2
def test_filter_non_dict_values(self):
"""Test that non-dict JSON values are filtered out."""
values = [{"name": "John"}, '"just a string"', "[1, 2, 3]"]
result = _parse_json_values(values)
assert len(result) == 1
assert result[0] == {"name": "John"}
def test_handle_invalid_json(self):
"""Test that invalid JSON strings are filtered out."""
values = [{"name": "John"}, "not valid json", '{"name": "Jane"}']
result = _parse_json_values(values)
assert len(result) == 2
def test_empty_list(self):
"""Test handling empty list."""
result = _parse_json_values([])
assert result == []
class TestMergeJsonStructures:
"""Tests for _merge_json_structures function."""
def test_merge_simple_objects(self):
"""Test merging simple objects with same keys."""
dicts = [{"name": "John", "age": 30}, {"name": "Jane", "age": 25}]
result = _merge_json_structures(dicts)
assert "name" in result
assert "age" in result
def test_merge_objects_with_different_keys(self):
"""Test merging objects with different keys."""
dicts = [{"name": "John"}, {"age": 30}, {"city": "NYC"}]
result = _merge_json_structures(dicts)
assert "name" in result
assert "age" in result
assert "city" in result
def test_merge_nested_objects(self):
"""Test merging nested objects."""
dicts = [
{"user": {"name": "John"}},
{"user": {"age": 30}},
]
result = _merge_json_structures(dicts)
assert "user" in result
assert isinstance(result["user"], dict)
assert "name" in result["user"]
assert "age" in result["user"]
def test_merge_with_arrays(self):
"""Test merging objects containing arrays."""
dicts = [
{"tags": ["python", "java"]},
{"tags": ["javascript"]},
]
result = _merge_json_structures(dicts)
assert "tags" in result
assert isinstance(result["tags"], list)
def test_merge_array_of_objects(self):
"""Test merging arrays containing objects."""
dicts = [
{"items": [{"id": 1}]},
{"items": [{"name": "Item 2"}]},
]
result = _merge_json_structures(dicts)
assert "items" in result
assert isinstance(result["items"], list)
assert len(result["items"]) == 1
assert "id" in result["items"][0]
assert "name" in result["items"][0]
def test_dict_takes_precedence_over_scalar(self):
"""Test that dict value takes precedence over scalar."""
dicts = [
{"field": "string_value"},
{"field": {"nested": "value"}},
]
result = _merge_json_structures(dicts)
assert isinstance(result["field"], dict)
assert result["field"]["nested"] == "value"
def test_handle_null_values(self):
"""Test handling null values in objects."""
dicts = [
{"name": "John", "email": None},
{"name": "Jane", "email": "jane@example.com"},
]
result = _merge_json_structures(dicts)
assert "email" in result
assert result["email"] == "jane@example.com"
class TestBuildJsonSchema:
"""Tests for _build_json_schema function."""
def test_build_simple_object_schema(self):
"""Test building schema for simple object."""
structure = {"name": "John", "age": 30}
schema = _build_json_schema(structure)
assert schema["type"] == "object"
assert "properties" in schema
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["age"]["type"] == "integer"
def test_build_nested_object_schema(self):
"""Test building schema for nested object."""
structure = {"user": {"name": "John"}}
schema = _build_json_schema(structure)
assert schema["type"] == "object"
assert schema["properties"]["user"]["type"] == "object"
assert schema["properties"]["user"]["properties"]["name"]["type"] == "string"
def test_build_array_schema(self):
"""Test building schema for array."""
structure = {"tags": ["python"]}
schema = _build_json_schema(structure)
assert schema["properties"]["tags"]["type"] == "array"
assert schema["properties"]["tags"]["items"]["type"] == "string"
def test_build_array_of_objects_schema(self):
"""Test building schema for array of objects."""
structure = {"items": [{"id": 1, "name": "test"}]}
schema = _build_json_schema(structure)
assert schema["properties"]["items"]["type"] == "array"
assert schema["properties"]["items"]["items"]["type"] == "object"
assert "id" in schema["properties"]["items"]["items"]["properties"]
def test_build_schema_with_boolean(self):
"""Test building schema with boolean type."""
structure = {"active": True}
schema = _build_json_schema(structure)
assert schema["properties"]["active"]["type"] == "boolean"
def test_build_schema_with_float(self):
"""Test building schema with float type."""
structure = {"price": 19.99}
schema = _build_json_schema(structure)
assert schema["properties"]["price"]["type"] == "number"
def test_build_schema_with_null(self):
"""Test building schema with null type."""
structure = {"value": None}
schema = _build_json_schema(structure)
assert schema["properties"]["value"]["type"] == "null"
class TestBuildColumnChildren:
"""Tests for _build_column_children function."""
def test_build_simple_children(self):
"""Test building children for simple structure."""
structure = {"name": "John", "age": 30}
children = _build_column_children(structure)
assert children is not None
assert len(children) == 2
names = {child.name.root for child in children}
assert "name" in names
assert "age" in names
def test_build_nested_children(self):
"""Test building children with nested objects."""
structure = {"user": {"name": "John", "email": "john@example.com"}}
children = _build_column_children(structure)
assert children is not None
assert len(children) == 1
user_child = children[0]
assert user_child.name.root == "user"
assert user_child.dataType == DataType.JSON
assert user_child.children is not None
assert len(user_child.children) == 2
def test_build_array_children(self):
"""Test building children with array type."""
structure = {"tags": ["python", "java"]}
children = _build_column_children(structure)
assert children is not None
tags_child = children[0]
assert tags_child.name.root == "tags"
assert tags_child.dataType == DataType.ARRAY
assert tags_child.arrayDataType == DataType.STRING
def test_build_array_of_objects_children(self):
"""Test building children for array of objects."""
structure = {"items": [{"id": 1, "name": "test"}]}
children = _build_column_children(structure)
assert children is not None
items_child = children[0]
assert items_child.name.root == "items"
assert items_child.dataType == DataType.ARRAY
assert items_child.arrayDataType == DataType.JSON
assert items_child.children is not None
assert len(items_child.children) == 2
def test_returns_none_for_non_dict(self):
"""Test that non-dict input returns None."""
result = _build_column_children("not a dict")
assert result is None
class TestInferJsonSchemaFromSample:
"""Tests for the main infer_json_schema_from_sample function."""
def test_infer_schema_basic(self):
"""Test basic schema inference."""
json_values = [
{"name": "John", "age": 30},
{"name": "Jane", "age": 25},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "age" in schema["properties"]
assert children is not None
assert len(children) == 2
def test_infer_schema_from_json_strings(self):
"""Test schema inference from JSON strings."""
json_values = [
'{"product": "laptop", "price": 999.99}',
'{"product": "mouse", "price": 29.99}',
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
assert children is not None
schema = json.loads(schema_str)
assert schema["properties"]["product"]["type"] == "string"
assert schema["properties"]["price"]["type"] == "number"
def test_infer_schema_nested_structure(self):
"""Test schema inference for nested structures."""
json_values = [
{
"user": {"name": "John", "email": "john@example.com"},
"active": True,
}
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["properties"]["user"]["type"] == "object"
assert "name" in schema["properties"]["user"]["properties"]
def test_infer_schema_with_arrays(self):
"""Test schema inference for arrays."""
json_values = [
{"tags": ["python", "data"], "scores": [85, 90, 95]},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["properties"]["tags"]["type"] == "array"
assert schema["properties"]["scores"]["type"] == "array"
def test_infer_schema_empty_list(self):
"""Test that empty list returns None."""
schema_str, children = infer_json_schema_from_sample([])
assert schema_str is None
assert children is None
def test_infer_schema_all_none(self):
"""Test that list of all None values returns None."""
schema_str, children = infer_json_schema_from_sample([None, None])
assert schema_str is None
assert children is None
def test_infer_schema_all_invalid(self):
"""Test that list of all invalid JSON returns None."""
json_values = ["not json", "also not json"]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is None
assert children is None
def test_infer_schema_handles_mixed_valid_invalid(self):
"""Test that valid values are processed even with some invalid ones."""
json_values = [
{"name": "John"},
"invalid json",
None,
{"name": "Jane", "age": 25},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "name" in schema["properties"]
assert "age" in schema["properties"]
def test_infer_schema_real_world_event_data(self):
"""Test with realistic event data structure."""
json_values = [
{
"ab_test_group": "group_1",
"campaign_id": "cmp_01",
"user_agent": "Mozilla/5.0",
},
{
"ab_test_group": "group_2",
"campaign_id": "cmp_02",
"amount_cents": 5000,
},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "ab_test_group" in schema["properties"]
assert "campaign_id" in schema["properties"]
assert "user_agent" in schema["properties"]
assert "amount_cents" in schema["properties"]
def test_infer_schema_deeply_nested(self):
"""Test with deeply nested structure."""
json_values = [{"level1": {"level2": {"level3": {"value": "deep"}}}}]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["properties"]["level1"]["type"] == "object"
level2_props = schema["properties"]["level1"]["properties"]["level2"]
assert level2_props["type"] == "object"
level3_props = level2_props["properties"]["level3"]
assert level3_props["type"] == "object"
assert level3_props["properties"]["value"]["type"] == "string"
class TestJsonSchemaExtractionEdgeCases:
"""Edge case tests for JSON schema extraction."""
def test_empty_object(self):
"""Test handling empty JSON objects."""
json_values = [{}]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["type"] == "object"
assert schema["properties"] == {}
def test_special_characters_in_keys(self):
"""Test handling special characters in JSON keys."""
json_values = [{"user-name": "John", "email@domain": "test@test.com"}]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
assert children is not None
def test_unicode_values(self):
"""Test handling unicode values."""
json_values = [{"name": "日本語", "emoji": "🎉"}]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
assert children is not None
def test_large_numbers(self):
"""Test handling large numbers."""
json_values = [{"big_int": 9999999999999999, "big_float": 1.7976931348623157e308}]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
class TestJsonColumnTypeDetection:
"""Tests for JSON schema extraction with different column types."""
def test_json_column_type_json_datatype(self):
"""Test JSON schema extraction for DataType.JSON columns."""
json_values = [
{"user_id": 123, "preferences": {"theme": "dark"}},
{"user_id": 456, "preferences": {"theme": "light", "language": "en"}},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["type"] == "object"
assert "user_id" in schema["properties"]
assert "preferences" in schema["properties"]
assert schema["properties"]["preferences"]["type"] == "object"
assert children is not None
child_names = {child.name.root for child in children}
assert "user_id" in child_names
assert "preferences" in child_names
def test_json_column_type_jsonb(self):
"""Test JSON schema extraction for JSONB columns (PostgreSQL)."""
json_values = [
{"metadata": {"created_at": "2024-01-01", "updated_at": "2024-01-02"}},
{"metadata": {"created_at": "2024-02-01", "version": 2}},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "metadata" in schema["properties"]
assert children is not None
def test_json_column_type_variant(self):
"""Test JSON schema extraction for VARIANT columns (Snowflake)."""
json_values = [
{"event_type": "click", "payload": {"x": 100, "y": 200}},
{"event_type": "scroll", "payload": {"direction": "down", "amount": 50}},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "event_type" in schema["properties"]
assert "payload" in schema["properties"]
assert children is not None
def test_json_column_type_object(self):
"""Test JSON schema extraction for OBJECT columns (Snowflake/Databricks)."""
json_values = [
{"config": {"enabled": True, "max_retries": 3}},
{"config": {"enabled": False, "timeout": 30}},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "config" in schema["properties"]
assert children is not None
class TestStringColumnTypeAsJson:
"""Tests for JSON schema extraction from STRING type columns containing JSON data."""
def test_string_column_with_json_data(self):
"""Test JSON schema extraction from STRING columns containing JSON."""
json_values = [
'{"name": "Alice", "score": 95}',
'{"name": "Bob", "score": 87}',
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "score" in schema["properties"]
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["score"]["type"] == "integer"
assert children is not None
child_names = {child.name.root for child in children}
assert "name" in child_names
assert "score" in child_names
def test_varchar_column_with_json_data(self):
"""Test JSON schema extraction from VARCHAR columns containing JSON."""
json_values = [
'{"product": "laptop", "price": 999.99, "in_stock": true}',
'{"product": "mouse", "price": 29.99, "in_stock": false}',
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "product" in schema["properties"]
assert "price" in schema["properties"]
assert "in_stock" in schema["properties"]
assert schema["properties"]["price"]["type"] == "number"
assert schema["properties"]["in_stock"]["type"] == "boolean"
def test_text_column_with_json_data(self):
"""Test JSON schema extraction from TEXT columns containing JSON."""
json_values = [
'{"log_level": "INFO", "message": "Application started", "timestamp": 1704067200}',
'{"log_level": "ERROR", "message": "Connection failed", "details": {"code": 500}}',
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "log_level" in schema["properties"]
assert "message" in schema["properties"]
assert "details" in schema["properties"]
assert children is not None
def test_string_column_with_nested_json(self):
"""Test JSON schema extraction from STRING columns with deeply nested JSON."""
json_values = [
'{"user": {"profile": {"address": {"city": "NYC", "zip": "10001"}}}}',
'{"user": {"profile": {"address": {"city": "LA", "state": "CA"}}}}',
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "user" in schema["properties"]
user_props = schema["properties"]["user"]
assert user_props["type"] == "object"
assert "profile" in user_props["properties"]
assert children is not None
user_child = next(c for c in children if c.name.root == "user")
assert user_child.dataType == DataType.JSON
assert user_child.children is not None
def test_string_column_with_array_json(self):
"""Test JSON schema extraction from STRING columns with JSON arrays."""
json_values = [
'{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]}',
'{"items": [{"id": 3, "name": "Item 3", "price": 10.99}]}',
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
schema = json.loads(schema_str)
assert "items" in schema["properties"]
items_schema = schema["properties"]["items"]
assert items_schema["type"] == "array"
assert items_schema["items"]["type"] == "object"
assert children is not None
items_child = next(c for c in children if c.name.root == "items")
assert items_child.dataType == DataType.ARRAY
assert items_child.arrayDataType == DataType.JSON
def test_string_column_with_mixed_valid_invalid_json(self):
"""Test STRING column where some values are valid JSON and some are not."""
json_values = [
'{"valid": true}',
"plain text not json",
'{"also_valid": 123}',
None,
"",
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "valid" in schema["properties"]
assert "also_valid" in schema["properties"]
def test_string_column_all_invalid_json(self):
"""Test STRING column where all values are invalid JSON."""
json_values = [
"plain text",
"another plain text",
"not json at all",
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is None
assert children is None
def test_string_column_with_json_primitives(self):
"""Test STRING column with JSON primitives (not objects) - should be filtered."""
json_values = [
'"just a string"',
"123",
"true",
"[1, 2, 3]",
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is None
assert children is None
class TestAllJsonColumnTypes:
"""Comprehensive tests covering all JSON_COLUMN_TYPES defined in sql_column_handler.py."""
def test_all_json_types_with_complex_structure(self):
"""Test that all JSON column types can handle complex nested structures."""
complex_json = [
{
"id": 1,
"name": "Test",
"attributes": {
"color": "red",
"size": "large",
"tags": ["tag1", "tag2"],
},
"measurements": [
{"width": 10, "height": 20},
{"width": 15, "height": 25},
],
"metadata": {
"created": "2024-01-01",
"nested": {"level1": {"level2": {"value": 42}}},
},
"active": True,
"score": 98.5,
"nullable_field": None,
}
]
schema_str, children = infer_json_schema_from_sample(complex_json)
assert schema_str is not None
schema = json.loads(schema_str)
assert schema["properties"]["id"]["type"] == "integer"
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["attributes"]["type"] == "object"
assert schema["properties"]["measurements"]["type"] == "array"
assert schema["properties"]["metadata"]["type"] == "object"
assert schema["properties"]["active"]["type"] == "boolean"
assert schema["properties"]["score"]["type"] == "number"
assert schema["properties"]["nullable_field"]["type"] == "null"
assert children is not None
child_names = {c.name.root for c in children}
assert child_names == {
"id",
"name",
"attributes",
"measurements",
"metadata",
"active",
"score",
"nullable_field",
}
def test_json_type_with_empty_objects_and_arrays(self):
"""Test JSON columns with empty objects and arrays."""
json_values = [
{"empty_obj": {}, "empty_arr": [], "normal": "value"},
{"empty_obj": {"key": "value"}, "empty_arr": [1, 2], "normal": "other"},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "empty_obj" in schema["properties"]
assert "empty_arr" in schema["properties"]
assert "normal" in schema["properties"]
def test_jsonb_type_with_special_postgres_patterns(self):
"""Test JSONB columns with patterns common in PostgreSQL."""
json_values = [
{
"audit_log": {
"action": "UPDATE",
"old_values": {"status": "pending"},
"new_values": {"status": "completed"},
"changed_by": "user_123",
}
},
{
"audit_log": {
"action": "INSERT",
"new_values": {"id": 456, "name": "New Record"},
"changed_by": "system",
}
},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
audit_props = schema["properties"]["audit_log"]["properties"]
assert "action" in audit_props
assert "old_values" in audit_props
assert "new_values" in audit_props
assert "changed_by" in audit_props
def test_variant_type_with_semi_structured_data(self):
"""Test VARIANT columns with semi-structured data (Snowflake pattern)."""
json_values = [
{
"raw_event": {
"event_id": "evt_001",
"timestamp": "2024-01-15T10:30:00Z",
"properties": {"source": "web", "browser": "Chrome"},
}
},
{
"raw_event": {
"event_id": "evt_002",
"timestamp": "2024-01-15T10:31:00Z",
"properties": {"source": "mobile", "os": "iOS", "version": "17.0"},
}
},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
event_props = schema["properties"]["raw_event"]["properties"]
assert "event_id" in event_props
assert "timestamp" in event_props
assert "properties" in event_props
def test_object_type_with_nested_structures(self):
"""Test OBJECT columns with nested structures (Databricks/Snowflake pattern)."""
json_values = [
{
"customer": {
"id": 1001,
"details": {
"first_name": "John",
"last_name": "Doe",
"contacts": {
"email": "john@example.com",
"phones": ["+1-555-0100", "+1-555-0101"],
},
},
}
},
{
"customer": {
"id": 1002,
"details": {
"first_name": "Jane",
"last_name": "Smith",
"contacts": {"email": "jane@example.com"},
},
}
},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
customer_schema = schema["properties"]["customer"]
assert customer_schema["type"] == "object"
details_schema = customer_schema["properties"]["details"]
assert "first_name" in details_schema["properties"]
assert "contacts" in details_schema["properties"]
class TestAllStringColumnTypes:
"""Comprehensive tests covering all STRING_COLUMN_TYPES defined in sql_column_handler.py."""
def test_string_datatype_value(self):
"""Test DataType.STRING.value column with JSON data."""
json_values = [
{"field1": "value1", "field2": 100},
{"field1": "value2", "field2": 200, "field3": True},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "field1" in schema["properties"]
assert "field2" in schema["properties"]
assert "field3" in schema["properties"]
def test_string_literal_type(self):
"""Test literal 'STRING' type column with JSON data."""
json_values = [
'{"api_response": {"status": 200, "data": {"items": [1, 2, 3]}}}',
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "api_response" in schema["properties"]
api_response = schema["properties"]["api_response"]
assert "status" in api_response["properties"]
assert "data" in api_response["properties"]
def test_varchar_type_with_various_lengths(self):
"""Test VARCHAR type columns with JSON of various complexities."""
short_json = [{"a": 1}]
schema_str, children = infer_json_schema_from_sample(short_json)
assert schema_str is not None
long_json = [
{
"very_long_key_name_that_might_be_stored_in_varchar": {
"nested": {"deeply": {"structured": {"data": "value"}}}
}
}
]
schema_str, children = infer_json_schema_from_sample(long_json) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "very_long_key_name_that_might_be_stored_in_varchar" in schema["properties"]
def test_text_type_with_large_json(self):
"""Test TEXT type columns that might store large JSON documents."""
large_json = [
{
"document": {
"sections": [{"title": f"Section {i}", "content": f"Content for section {i}"} for i in range(10)],
"metadata": {
"author": "Test Author",
"created": "2024-01-01",
"tags": ["tag1", "tag2", "tag3"],
},
}
}
]
schema_str, children = infer_json_schema_from_sample(large_json) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
doc_schema = schema["properties"]["document"]
assert "sections" in doc_schema["properties"]
assert "metadata" in doc_schema["properties"]
def test_string_types_with_unicode_json(self):
"""Test STRING types with JSON containing unicode characters."""
json_values = [
{"name": "日本語テスト", "emoji": "🎉🚀", "special": "café résumé naïve"},
{"name": "中文测试", "emoji": "✨💻", "special": "über straße"},
]
schema_str, children = infer_json_schema_from_sample(json_values)
assert schema_str is not None
assert children is not None
def test_string_types_with_escaped_json(self):
"""Test STRING types with JSON containing escaped characters."""
json_values = [
'{"path": "C:\\\\Users\\\\test", "quote": "\\"quoted\\"", "newline": "line1\\nline2"}',
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "path" in schema["properties"]
assert "quote" in schema["properties"]
assert "newline" in schema["properties"]
def test_string_types_with_numeric_keys(self):
"""Test STRING types with JSON containing numeric-like keys."""
json_values = [
{"123": "numeric key", "456abc": "mixed key", "normal_key": "value"},
]
schema_str, children = infer_json_schema_from_sample(json_values) # noqa: RUF059
assert schema_str is not None
schema = json.loads(schema_str)
assert "123" in schema["properties"]
assert "456abc" in schema["properties"]
assert "normal_key" in schema["properties"]
@@ -0,0 +1,206 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Tests for GCP CloudSQL MySQL connection handling
"""
import inspect
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
from metadata.generated.schema.entity.services.connections.database.common.gcpCloudSqlConfig import (
GcpCloudsqlConfigurationSource,
)
from metadata.generated.schema.entity.services.connections.database.mysqlConnection import (
MysqlConnection,
)
from metadata.ingestion.source.database.mysql.connection import _CloudSqlStrategy
_NAMESPACE_MODULES = (
"google",
"google.cloud",
"google.cloud.sql",
"google.cloud.sql.connector",
)
def _package_module(name: str) -> ModuleType:
"""Build a stub namespace module that behaves like a package.
A ``__path__`` is required so that ``from google.cloud.sql.connector import
Connector`` resolves; without it Python treats the parent as a non-package
and raises "not a package" when the real google namespace isn't installed."""
module = ModuleType(name)
module.__path__ = [] # type: ignore[attr-defined]
return module
@pytest.fixture(autouse=True)
def mock_connector():
"""Inject a fake google.cloud.sql.connector module into sys.modules so the
lazy import inside _CloudSqlStrategy.build works without installing
cloud-sql-python-connector."""
connector_instance = MagicMock()
connector_cls = MagicMock(return_value=connector_instance)
connector_mod = ModuleType("google.cloud.sql.connector")
connector_mod.Connector = connector_cls
originals = {k: sys.modules.get(k) for k in _NAMESPACE_MODULES}
sys.modules.setdefault("google", _package_module("google"))
sys.modules.setdefault("google.cloud", _package_module("google.cloud"))
sys.modules.setdefault("google.cloud.sql", _package_module("google.cloud.sql"))
sys.modules["google.cloud.sql.connector"] = connector_mod
yield connector_cls, connector_instance
for key, val in originals.items():
if val is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = val
class TestMySQLCloudSQLConnection:
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_password_auth(self, mock_create_conn, mock_connector):
mock_connector_cls, mock_connector_inst = mock_connector # noqa: RUF059
mock_create_conn.return_value = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="dbuser",
authType=GcpCloudsqlConfigurationSource(
password="dbpassword",
),
)
_CloudSqlStrategy(connection).build()
mock_create_conn.assert_called_once()
call_kwargs = mock_create_conn.call_args
assert "creator" in call_kwargs.kwargs
creator_fn = call_kwargs.kwargs["creator"]
creator_fn()
mock_connector_inst.connect.assert_called_once()
connect_kwargs = mock_connector_inst.connect.call_args.kwargs
assert connect_kwargs["instance_connection_string"] == "my-project:us-central1:my-instance"
assert connect_kwargs["driver"] == "pymysql"
assert connect_kwargs["user"] == "dbuser"
assert connect_kwargs["password"] == "dbpassword"
assert "enable_iam_auth" not in connect_kwargs
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_iam_auth(self, mock_create_conn, mock_connector):
_, mock_connector_inst = mock_connector
mock_create_conn.return_value = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="sa@my-project.iam",
authType=GcpCloudsqlConfigurationSource(
enableIamAuth=True,
),
)
_CloudSqlStrategy(connection).build()
creator_fn = mock_create_conn.call_args.kwargs["creator"]
creator_fn()
connect_kwargs = mock_connector_inst.connect.call_args.kwargs
assert connect_kwargs["enable_iam_auth"] is True
assert "password" not in connect_kwargs
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_url_is_bare_scheme(self, mock_create_conn, mock_connector):
mock_create_conn.return_value = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="dbuser",
authType=GcpCloudsqlConfigurationSource(password="pw"),
)
_CloudSqlStrategy(connection).build()
url_fn = mock_create_conn.call_args.kwargs["get_connection_url_fn"]
assert url_fn(connection) == "mysql+pymysql://"
@patch("metadata.ingestion.source.database.mysql.connection.set_google_credentials")
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_sets_gcp_credentials_when_provided(self, mock_create_conn, mock_set_creds, mock_connector):
mock_create_conn.return_value = MagicMock()
gcp_config = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="dbuser",
authType=GcpCloudsqlConfigurationSource(password="pw"),
)
connection.authType.gcpConfig = gcp_config
_CloudSqlStrategy(connection).build()
mock_set_creds.assert_called_once_with(gcp_config)
@patch("metadata.ingestion.source.database.mysql.connection.set_google_credentials")
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_skips_gcp_credentials_when_not_provided(self, mock_create_conn, mock_set_creds, mock_connector):
mock_create_conn.return_value = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="dbuser",
authType=GcpCloudsqlConfigurationSource(password="pw"),
)
_CloudSqlStrategy(connection).build()
mock_set_creds.assert_not_called()
@patch("metadata.ingestion.source.database.mysql.connection.create_generic_db_connection")
def test_cloudsql_passes_database_schema(self, mock_create_conn, mock_connector):
_, mock_connector_inst = mock_connector
mock_create_conn.return_value = MagicMock()
connection = MysqlConnection(
hostPort="my-project:us-central1:my-instance",
username="dbuser",
databaseSchema="mydb",
authType=GcpCloudsqlConfigurationSource(password="pw"),
)
_CloudSqlStrategy(connection).build()
creator_fn = mock_create_conn.call_args.kwargs["creator"]
creator_fn()
connect_kwargs = mock_connector_inst.connect.call_args.kwargs
assert connect_kwargs["db"] == "mydb"
def test_cloudsql_imports_singular_connector_module(self):
"""Guard against the plural-module typo (google.cloud.sql.connectors).
The other tests mock the module into sys.modules, so they pass under
either name; this asserts the real source imports the correct singular
google.cloud.sql.connector module the library actually exposes."""
source = inspect.getsource(_CloudSqlStrategy.build)
assert "from google.cloud.sql.connector import Connector" in source
assert "google.cloud.sql.connectors" not in source
@@ -0,0 +1,322 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Tests for MySQL RDS IAM authentication (finding B1 fix).
RDS IAM tokens expire after ~15 minutes. The MySQL connector must inject a fresh
token on every new connection (via a SQLAlchemy ``do_connect`` listener) instead
of baking a single token into the connection URL, so that connections opened
later in a long ingestion still authenticate successfully.
"""
import datetime
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import MagicMock, patch
import pytest
import metadata.ingestion.source.database.mysql.connection as connection_module
from metadata.clients.aws_client import RdsIamAuthTokenManager
from metadata.generated.schema.entity.services.connections.database.common.iamAuthConfig import (
IamAuthConfigurationSource,
)
from metadata.generated.schema.entity.services.connections.database.mysqlConnection import (
MysqlConnection,
)
from metadata.generated.schema.security.credentials.awsCredentials import (
AWSCredentials,
)
from metadata.ingestion.source.database.mysql.connection import _IamStrategy
HOST = "myrds.abc.us-east-2.rds.amazonaws.com"
PORT = "3306"
USERNAME = "iam_user"
REGION = "us-east-2"
def _iam_connection() -> MysqlConnection:
return MysqlConnection(
username=USERNAME,
hostPort=f"{HOST}:{PORT}",
authType=IamAuthConfigurationSource(awsConfig=AWSCredentials(awsRegion=REGION)),
)
def _presigned_token(issued_at: datetime.datetime, expires_seconds: int = 900) -> str:
amz_date = issued_at.strftime("%Y%m%dT%H%M%SZ")
return f"{HOST}:{PORT}/?Action=connect&DBUser={USERNAME}&X-Amz-Date={amz_date}&X-Amz-Expires={expires_seconds}"
class TestMySQLIamEngine:
"""The connector wires a per-connection token-refresh listener for IAM auth."""
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_iam_token_is_not_baked_into_url(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
url_fn = mock_create.call_args.kwargs["get_connection_url_fn"]
url = url_fn(connection)
assert "FRESH_TOKEN" not in url
assert "Action=connect" not in url
assert url.startswith(f"mysql+pymysql://{USERNAME}:@{HOST}:{PORT}")
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_username_is_url_encoded(self, mock_create, mock_listen, mock_token_manager):
connection = MysqlConnection(
username="iam@user/db",
hostPort=f"{HOST}:{PORT}",
authType=IamAuthConfigurationSource(awsConfig=AWSCredentials(awsRegion=REGION)),
)
_IamStrategy(connection).build()
url_fn = mock_create.call_args.kwargs["get_connection_url_fn"]
url = url_fn(connection)
assert "iam%40user%2Fdb" in url
assert "iam@user/db@" not in url
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_url_preserves_database_schema_and_options(self, mock_create, mock_listen, mock_token_manager):
connection = MysqlConnection(
username=USERNAME,
hostPort=f"{HOST}:{PORT}",
databaseSchema="analytics",
connectionOptions={"charset": "utf8mb4"},
authType=IamAuthConfigurationSource(awsConfig=AWSCredentials(awsRegion=REGION)),
)
_IamStrategy(connection).build()
url_fn = mock_create.call_args.kwargs["get_connection_url_fn"]
url = url_fn(connection)
assert "/analytics" in url
assert "charset=utf8mb4" in url
assert "Action=connect" not in url
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_do_connect_listener_is_registered(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
event_name = mock_listen.call_args.args[1]
assert event_name == "do_connect"
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_listener_injects_fresh_token_per_connection(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
listener = mock_listen.call_args.args[2]
first_cparams = {}
listener(None, None, None, first_cparams)
second_cparams = {}
listener(None, None, None, second_cparams)
assert first_cparams["password"] == "FRESH_TOKEN"
assert second_cparams["password"] == "FRESH_TOKEN"
assert mock_token_manager.return_value.get_token.call_count == 2
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_listener_enables_ssl_required_by_pymysql_for_iam(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
listener = mock_listen.call_args.args[2]
cparams = {}
listener(None, None, None, cparams)
assert "ssl" in cparams
# Must be truthy so PyMySQL treats TLS as required (not PREFERRED).
assert cparams["ssl"]
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_injected_ssl_makes_pymysql_require_tls(self, mock_create, mock_listen, mock_token_manager):
"""Assert real PyMySQL behavior: the injected ssl value enables required TLS.
An empty dict only yields PREFERRED mode (_ssl_required=False), which can
fall back to plaintext; the injected value must produce _ssl_required=True.
"""
import pymysql
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
listener = mock_listen.call_args.args[2]
cparams = {}
listener(None, None, None, cparams)
with patch.object(pymysql.connections.Connection, "connect", lambda self: None):
conn = pymysql.connections.Connection(host="h", user="u", ssl=cparams["ssl"])
assert conn.ssl is True
assert conn._ssl_required is True
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_listener_preserves_existing_ssl_config(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
listener = mock_listen.call_args.args[2]
existing_ssl = {"ssl_ca": "/path/to/ca.pem"}
cparams = {"ssl": existing_ssl}
listener(None, None, None, cparams)
assert cparams["ssl"] == existing_ssl
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_listener_does_not_overwrite_explicit_empty_ssl(self, mock_create, mock_listen, mock_token_manager):
mock_token_manager.return_value.get_token.return_value = "FRESH_TOKEN"
connection = _iam_connection()
_IamStrategy(connection).build()
listener = mock_listen.call_args.args[2]
sentinel_ssl = {}
cparams = {"ssl": sentinel_ssl}
listener(None, None, None, cparams)
assert cparams["ssl"] is sentinel_ssl
@patch.object(connection_module, "RdsIamAuthTokenManager")
@patch.object(connection_module, "listen")
@patch.object(connection_module, "create_generic_db_connection")
def test_token_manager_built_with_split_host_and_port(self, mock_create, mock_listen, mock_token_manager):
connection = _iam_connection()
_IamStrategy(connection).build()
assert mock_token_manager.call_args.kwargs["host"] == HOST
assert mock_token_manager.call_args.kwargs["port"] == PORT
assert mock_token_manager.call_args.kwargs["username"] == USERNAME
class TestRdsIamAuthTokenManager:
"""The token manager caches a token and refreshes it before expiry."""
@pytest.fixture
def mock_rds(self):
with patch("metadata.clients.aws_client.AWSClient") as mock_aws_client:
rds_client = MagicMock()
mock_aws_client.return_value.get_rds_client.return_value = rds_client
yield rds_client
def _manager(self) -> RdsIamAuthTokenManager:
return RdsIamAuthTokenManager(
host=HOST,
port=PORT,
username=USERNAME,
aws_config=AWSCredentials(awsRegion=REGION),
)
def test_first_call_generates_token(self, mock_rds):
now = datetime.datetime.now(datetime.timezone.utc)
mock_rds.generate_db_auth_token.return_value = _presigned_token(now)
token = self._manager().get_token()
assert token == _presigned_token(now)
assert mock_rds.generate_db_auth_token.call_count == 1
def test_token_cached_within_ttl(self, mock_rds):
now = datetime.datetime.now(datetime.timezone.utc)
mock_rds.generate_db_auth_token.return_value = _presigned_token(now)
manager = self._manager()
manager.get_token()
manager.get_token()
assert mock_rds.generate_db_auth_token.call_count == 1
def test_token_refreshed_after_expiry(self, mock_rds):
now = datetime.datetime.now(datetime.timezone.utc)
mock_rds.generate_db_auth_token.return_value = _presigned_token(now)
manager = self._manager()
manager.get_token()
manager._expires_at = now - datetime.timedelta(seconds=1)
manager.get_token()
assert mock_rds.generate_db_auth_token.call_count == 2
def test_expiry_parsed_from_presigned_url(self, mock_rds):
now = datetime.datetime.now(datetime.timezone.utc)
mock_rds.generate_db_auth_token.return_value = _presigned_token(now, expires_seconds=900)
manager = self._manager()
manager.get_token()
seconds_until_expiry = (manager._expires_at - now).total_seconds()
assert 890 <= seconds_until_expiry <= 910
def test_malformed_token_falls_back_to_default_ttl(self, mock_rds):
mock_rds.generate_db_auth_token.return_value = "not-a-presigned-url"
manager = self._manager()
manager.get_token()
assert manager._expires_at is not None
def test_concurrent_get_token_refreshes_only_once(self, mock_rds):
"""Many threads hitting a cold manager must trigger a single refresh.
Each worker thread calls engine.connect() -> the shared do_connect listener
-> the shared manager's get_token(). Without locking, threads racing on a
cold/expired token each call generate_db_auth_token. A slow token generator
widens the race window so the assertion is meaningful.
"""
now = datetime.datetime.now(datetime.timezone.utc)
def slow_generate(**_kwargs):
time.sleep(0.05)
return _presigned_token(now)
mock_rds.generate_db_auth_token.side_effect = slow_generate
manager = self._manager()
barrier = threading.Barrier(10)
def call():
barrier.wait()
return manager.get_token()
with ThreadPoolExecutor(max_workers=10) as executor:
tokens = [future.result() for future in [executor.submit(call) for _ in range(10)]]
assert mock_rds.generate_db_auth_token.call_count == 1
assert all(token == _presigned_token(now) for token in tokens)
@@ -0,0 +1,52 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MySQL declares the single-database ``Database`` denominator and defers the
``DatabaseSchema`` total to runner reconciliation."""
from types import SimpleNamespace
import pytest
from metadata.ingestion.progress.modes import ProgressMode, TotalsDeclarer
from metadata.ingestion.progress.tracking import ProgressTracking
from metadata.ingestion.source.database.mysql.metadata import MysqlSource
def _source(service_connection):
source = object.__new__(MysqlSource)
source.service_connection = service_connection
source.__dict__["_progress_tracking"] = ProgressTracking(ProgressMode.AUTO, "Test")
return source
@pytest.fixture
def mysql_source():
return _source(SimpleNamespace(databaseName=None, database="mydb"))
def test_declare_progress_totals_seeds_single_database(mysql_source):
mysql_source.declare_progress_totals(TotalsDeclarer(mysql_source.progress_tracking.registry))
counters = {t: (done, total) for t, done, total in mysql_source.progress_tracking.registry.global_counters()}
assert counters["Database"] == (0, 1)
def test_declare_progress_totals_marks_schema_reconcilable(mysql_source):
mysql_source.declare_progress_totals(TotalsDeclarer(mysql_source.progress_tracking.registry))
assert mysql_source.progress_tracking.registry.is_reconcilable("DatabaseSchema") is True
counters = {t: (done, total) for t, done, total in mysql_source.progress_tracking.registry.global_counters()}
assert counters["DatabaseSchema"] == (0, None)
def test_declare_progress_totals_defaults_database_name_when_unset(mysql_source):
source = _source(SimpleNamespace(databaseName=None, database=None))
source.declare_progress_totals(TotalsDeclarer(source.progress_tracking.registry))
counters = {t: (done, total) for t, done, total in source.progress_tracking.registry.global_counters()}
assert counters["Database"] == (0, 1)
@@ -0,0 +1,138 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Snowflake supplies cheap progress denominators by enumerating + filtering
names, without running the heavy per-database/per-schema setup."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from metadata.ingestion.progress.modes import TotalsDeclarer
from metadata.ingestion.source.database.snowflake import metadata as snowflake_metadata
SnowflakeSource = snowflake_metadata.SnowflakeSource
def _source(raw_databases, configured_db=None):
source = object.__new__(SnowflakeSource)
source.config = SimpleNamespace(
serviceConnection=SimpleNamespace(root=SimpleNamespace(config=SimpleNamespace(database=configured_db)))
)
source.source_config = SimpleNamespace(useFqnForFiltering=False, databaseFilterPattern=None)
source.metadata = MagicMock()
source.status = MagicMock()
source.context = MagicMock()
source.context.get.return_value = SimpleNamespace(database_service="svc")
source.get_database_names_raw = MagicMock(return_value=iter(raw_databases))
return source
class TestSnowflakeDatabaseCount:
def test_count_is_the_filtered_name_count(self):
source = _source(["A", "B", "C"])
with patch.object(snowflake_metadata, "filter_by_database", return_value=False):
assert len(source._filtered_database_names()) == 3
def test_filtered_out_databases_are_excluded_from_count(self):
source = _source(["KEEP", "DROP"])
with patch.object(snowflake_metadata, "filter_by_database", side_effect=lambda _pattern, name: name == "DROP"):
assert len(source._filtered_database_names()) == 1
source.status.filter.assert_called_once()
def test_configured_database_counts_as_one(self):
source = _source([], configured_db="ONLY_DB")
assert len(source._filtered_database_names()) == 1
source.get_database_names_raw.assert_not_called()
def test_filtered_names_are_cached_so_status_is_not_double_emitted(self):
source = _source(["A", "DROP"])
with patch.object(snowflake_metadata, "filter_by_database", side_effect=lambda _pattern, name: name == "DROP"):
source._filtered_database_names()
source._filtered_database_names()
source.get_database_names_raw.assert_called_once()
source.status.filter.assert_called_once()
def test_producer_setup_runs_per_kept_database_without_re_filtering(self):
from metadata.ingestion.progress.modes import ProgressMode
from metadata.ingestion.progress.tracking import ProgressTracking
source = _source(["A", "B"])
for setup in (
"set_inspector",
"set_session_query_tag",
"set_partition_details",
"set_schema_description_map",
"set_database_description_map",
"set_external_location_map",
"set_schema_tags_map",
"set_database_tags_map",
):
setattr(source, setup, MagicMock())
source.__dict__["_progress_tracking"] = ProgressTracking(ProgressMode.AUTO, "Test")
with patch.object(snowflake_metadata, "filter_by_database", return_value=False):
source._filtered_database_names() # warms the database names cache
yielded = list(source.get_database_names())
assert yielded == ["A", "B"]
assert source.set_inspector.call_count == 2 # setup ran per database, lazily
source.get_database_names_raw.assert_called_once() # enumeration reused from cache
@pytest.fixture
def snowflake_source():
"""Minimal SnowflakeSource stub for push-totals tests."""
from metadata.ingestion.progress.modes import ProgressMode
from metadata.ingestion.progress.tracking import ProgressTracking
source = object.__new__(SnowflakeSource)
source.config = SimpleNamespace(
serviceConnection=SimpleNamespace(root=SimpleNamespace(config=SimpleNamespace(database=None)))
)
source.source_config = SimpleNamespace(
useFqnForFiltering=False,
databaseFilterPattern=None,
schemaFilterPattern=None,
)
source.metadata = MagicMock()
source.status = MagicMock()
source.context = MagicMock()
source.context.get.return_value = SimpleNamespace(database=None, database_service="svc")
source.__dict__["_progress_tracking"] = ProgressTracking(ProgressMode.AUTO, "Test")
return source
def test_declare_progress_totals_seeds_database_and_schema(snowflake_source):
snowflake_source._filtered_database_names = lambda: ["db1", "db2"]
snowflake_source._schema_names_by_database = lambda: {"db1": ["s1", "s2"], "db2": ["s3"]}
snowflake_source._is_schema_filtered = lambda db, sch: False
snowflake_source.declare_progress_totals(TotalsDeclarer(snowflake_source.progress_tracking.registry))
counters = {t: (d, total) for t, d, total in snowflake_source.progress_tracking.registry.global_counters()}
assert counters["Database"] == (0, 2)
assert counters["DatabaseSchema"] == (0, 3)
def test_declare_progress_totals_applies_schema_filter(snowflake_source):
snowflake_source._filtered_database_names = lambda: ["db1"]
snowflake_source._schema_names_by_database = lambda: {"db1": ["keep", "drop"]}
snowflake_source._is_schema_filtered = lambda db, sch: sch == "drop"
snowflake_source.declare_progress_totals(TotalsDeclarer(snowflake_source.progress_tracking.registry))
counters = {t: (d, total) for t, d, total in snowflake_source.progress_tracking.registry.global_counters()}
assert counters["DatabaseSchema"] == (0, 1)
def test_declare_progress_totals_falls_back_when_account_show_unavailable(snowflake_source):
snowflake_source._filtered_database_names = lambda: ["db1"]
snowflake_source._schema_names_by_database = lambda: None
snowflake_source.declare_progress_totals(TotalsDeclarer(snowflake_source.progress_tracking.registry))
assert snowflake_source.progress_tracking.registry.is_reconcilable("DatabaseSchema") is True
counters = {t: (d, total) for t, d, total in snowflake_source.progress_tracking.registry.global_counters()}
assert counters["Database"] == (0, 1)
assert counters["DatabaseSchema"] == (0, None)
@@ -0,0 +1,114 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Snowflake uses the inherited lazy progress path; no COUNT pass remains."""
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock
from metadata.ingestion.progress.modes import ProgressMode
from metadata.ingestion.progress.registry import ProgressRegistry
from metadata.ingestion.source.database import database_service
from metadata.ingestion.source.database.snowflake import metadata as snowflake_metadata
class _ConcreteSource(database_service.DatabaseServiceSource):
"""Minimal concrete subclass so object.__new__ works on the abstract base."""
close = create = get_database_names = get_database_schema_names = None # type: ignore[assignment]
get_stored_procedures = get_tables_name_and_type = yield_database = None # type: ignore[assignment]
yield_database_schema = yield_stored_procedure = yield_table = yield_tag = None # type: ignore[assignment]
def _path_for(database, schema, entity_type_name):
source = object.__new__(_ConcreteSource)
ctx = SimpleNamespace(database=database, database_schema=schema)
source.context = MagicMock()
source.context.get.return_value = ctx
return source.progress_tracker.current_path(entity_type_name)
class TestSnowflakeProgressPath:
def test_database_node_opens_at_root(self):
assert _path_for("db", None, "Database") == []
def test_schema_node_opens_under_its_database(self):
assert _path_for("db", None, "DatabaseSchema") == ["db"]
def test_table_node_opens_under_database_and_schema(self):
assert _path_for("db", "sales", "Table") == ["db", "sales"]
def test_stored_procedure_node_opens_under_database_and_schema(self):
assert _path_for("db", "sales", "StoredProcedure") == ["db", "sales"]
def test_schema_node_ignores_stale_sibling_schema_context(self):
# database_schema still holds the previous database's last schema; the
# schema level must NOT inherit it, or the new database's schema-count
# open lands one level too deep and the database never completes.
assert _path_for("db2", "stale_schema_from_db1", "DatabaseSchema") == ["db2"]
def test_no_declare_totals_on_snowflake_source(self):
assert not hasattr(snowflake_metadata.SnowflakeSource, "declare_totals")
def test_connector_references_no_count_constants(self):
source = inspect.getsource(snowflake_metadata)
assert "SNOWFLAKE_COUNT" not in source
assert "TotalsRunner" not in source
assert "declare_totals" not in source
class TestProgressOptIn:
def test_database_family_is_auto_by_default(self):
assert database_service.DatabaseServiceSource.progress_mode is ProgressMode.AUTO
def test_concrete_db_source_inherits_auto(self):
assert _ConcreteSource.progress_mode is ProgressMode.AUTO
def test_snowflake_is_auto(self):
assert snowflake_metadata.SnowflakeSource.progress_mode is ProgressMode.AUTO
class TestStaleContextDoesNotStrandDatabases:
"""End-to-end: drive the registry through the real path logic across two
single-threaded databases, with database_schema left stale when the second
database starts (exactly the runner's behaviour). Every database must reach
completion and prune; before the fix the second one stranded as 'live'."""
def _path(self, source, ctx, entity_type_name):
source.context.get.return_value = ctx
return source.progress_tracker.current_path(entity_type_name)
def test_second_database_completes_and_prunes(self):
source = object.__new__(_ConcreteSource)
source.context = MagicMock()
registry = ProgressRegistry()
ctx = SimpleNamespace(database=None, database_schema=None)
registry.open(self._path(source, ctx, "Database"), "Database", 2)
ctx.database, ctx.database_schema = "db1", None
registry.open(self._path(source, ctx, "DatabaseSchema"), "DatabaseSchema", 1)
ctx.database_schema = "s1"
registry.open(self._path(source, ctx, "Table"), "Table", 1)
registry.advance(self._path(source, ctx, "Table")) # db1 fully done
ctx.database = "db2" # database_schema is left as the stale "s1"
registry.open(self._path(source, ctx, "DatabaseSchema"), "DatabaseSchema", 1)
ctx.database_schema = "x1"
registry.open(self._path(source, ctx, "Table"), "Table", 1)
registry.advance(self._path(source, ctx, "Table")) # db2 fully done
snapshot = registry.snapshot()
assert snapshot.child_type == "Database"
assert snapshot.processed == 2
assert snapshot.expected == 2
assert snapshot.active is False
assert snapshot.children == () # both databases completed and pruned
@@ -0,0 +1,85 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for TimescaleDB connection handling (auth strategies)."""
from unittest.mock import patch
from azure.core.credentials import AccessToken
from azure.identity import ClientSecretCredential
from metadata.generated.schema.entity.services.connections.database.common.azureConfig import (
AzureConfigurationSource,
)
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.generated.schema.entity.services.connections.database.timescaleConnection import (
TimescaleConnection as TimescaleConnectionConfig,
)
from metadata.generated.schema.security.credentials.azureCredentials import (
AzureCredentials,
)
from metadata.ingestion.source.database.timescale.connection import TimescaleConnection
def _azure_connection() -> TimescaleConnectionConfig:
return TimescaleConnectionConfig(
username="openmetadata_user",
authType=AzureConfigurationSource(
azureConfig=AzureCredentials(
clientId="clientid",
tenantId="tenantid",
clientSecret="clientsecret",
scopes="scope1,scope2",
)
),
hostPort="localhost:5432",
database="openmetadata_db",
)
def test_basic_auth_builds_expected_url():
connection = TimescaleConnectionConfig(
username="openmetadata_user",
authType=BasicAuth(password="openmetadata_password"),
hostPort="localhost:5432",
database="openmetadata_db",
)
engine = TimescaleConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "postgresql+psycopg2://openmetadata_user:openmetadata_password@localhost:5432/openmetadata_db"
)
def test_azure_ad_uses_token_as_password():
connection = _azure_connection()
with patch.object(
ClientSecretCredential,
"get_token",
return_value=AccessToken(token="mocked_token", expires_on=100),
):
engine = TimescaleConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "postgresql+psycopg2://openmetadata_user:mocked_token@localhost:5432/openmetadata_db"
)
def test_azure_ad_does_not_mutate_caller_connection():
connection = _azure_connection()
with patch.object(
ClientSecretCredential,
"get_token",
return_value=AccessToken(token="mocked_token", expires_on=100),
):
assert TimescaleConnection(connection).client is not None
assert isinstance(connection.authType, AzureConfigurationSource)
@@ -0,0 +1,139 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Unit tests for Trino connection handling
"""
import pytest
from metadata.generated.schema.entity.services.connections.connectionBasicType import (
ConnectionArguments,
)
from metadata.generated.schema.entity.services.connections.database.common.basicAuth import (
BasicAuth,
)
from metadata.generated.schema.entity.services.connections.database.common.jwtAuth import (
JwtAuth,
)
from metadata.generated.schema.entity.services.connections.database.common.noConfigAuthenticationTypes import (
NoConfigAuthenticationTypes,
)
from metadata.generated.schema.entity.services.connections.database.trinoConnection import (
TrinoConnection as TrinoConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.trinoConnection import (
TrinoScheme,
)
from metadata.ingestion.connections.builders import init_empty_connection_arguments
from metadata.ingestion.source.database.trino.connection import TrinoConnection
class TestTrinoConnectionHttpScheme:
"""Test http_scheme handling in Trino connection auth methods"""
@pytest.fixture
def basic_connection_config(self) -> TrinoConnectionConfig:
return TrinoConnectionConfig(
scheme=TrinoScheme.trino,
hostPort="localhost:8080",
username="test_user",
authType=BasicAuth(password="test_password"),
)
@pytest.fixture
def jwt_connection_config(self) -> TrinoConnectionConfig:
return TrinoConnectionConfig(
scheme=TrinoScheme.trino,
hostPort="localhost:8080",
username="test_user",
authType=JwtAuth(jwt="test_jwt_token"),
)
@pytest.fixture
def oauth2_connection_config(self) -> TrinoConnectionConfig:
return TrinoConnectionConfig(
scheme=TrinoScheme.trino,
hostPort="localhost:8080",
username="test_user",
authType=NoConfigAuthenticationTypes.OAuth2,
)
def test_basic_auth_defaults_to_https(self, basic_connection_config):
connection_args = init_empty_connection_arguments()
TrinoConnection.set_basic_auth(basic_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "https"
assert "auth" in connection_args.root
def test_basic_auth_preserves_explicit_http_scheme(self, basic_connection_config):
connection_args = init_empty_connection_arguments()
connection_args.root["http_scheme"] = "http"
TrinoConnection.set_basic_auth(basic_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "http"
assert "auth" in connection_args.root
def test_basic_auth_preserves_custom_https(self, basic_connection_config):
connection_args = init_empty_connection_arguments()
connection_args.root["http_scheme"] = "https"
TrinoConnection.set_basic_auth(basic_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "https"
def test_jwt_auth_defaults_to_https(self, jwt_connection_config):
connection_args = init_empty_connection_arguments()
TrinoConnection.set_jwt_auth(jwt_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "https"
assert "auth" in connection_args.root
def test_jwt_auth_preserves_explicit_http_scheme(self, jwt_connection_config):
connection_args = init_empty_connection_arguments()
connection_args.root["http_scheme"] = "http"
TrinoConnection.set_jwt_auth(jwt_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "http"
assert "auth" in connection_args.root
def test_oauth2_auth_defaults_to_https(self, oauth2_connection_config):
connection_args = init_empty_connection_arguments()
TrinoConnection.set_oauth2_auth(oauth2_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "https"
assert "auth" in connection_args.root
def test_oauth2_auth_preserves_explicit_http_scheme(self, oauth2_connection_config):
connection_args = init_empty_connection_arguments()
connection_args.root["http_scheme"] = "http"
TrinoConnection.set_oauth2_auth(oauth2_connection_config, connection_args)
assert connection_args.root["http_scheme"] == "http"
assert "auth" in connection_args.root
def test_build_connection_args_preserves_http_scheme(self, basic_connection_config):
basic_connection_config.connectionArguments = ConnectionArguments(root={"http_scheme": "http"})
result = TrinoConnection.build_connection_args(basic_connection_config)
assert result.root["http_scheme"] == "http"
def test_build_connection_args_defaults_http_scheme(self, basic_connection_config):
result = TrinoConnection.build_connection_args(basic_connection_config)
assert result.root["http_scheme"] == "https"
@@ -0,0 +1,157 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Regression tests for Trino cross-database lineage (Issue #27419)."""
from unittest.mock import MagicMock, patch
from metadata.generated.schema.entity.data.database import Database
from metadata.generated.schema.entity.data.table import Table
from metadata.ingestion.api.models import Either
from metadata.ingestion.source.database.trino.lineage import TrinoLineageSource
class TrinoLineageSourceTestDouble(TrinoLineageSource):
"""Minimal Trino lineage source for unit testing."""
def __init__(self, metadata):
self.metadata = metadata
self.config = MagicMock()
self.config.serviceName = "repro_trino"
self.source_config = MagicMock()
self.source_config.crossDatabaseServiceNames = ["repro_postgres"]
def _mock_column(column_name):
column = MagicMock()
column.name.root = column_name
return column
def test_check_same_table_is_case_insensitive_for_names_and_columns():
"""Issue #27419: table and column comparisons should ignore case."""
metadata = MagicMock()
lineage_source = TrinoLineageSourceTestDouble(metadata)
source_table = MagicMock()
source_table.name.root = "CUSTOMER"
source_table.columns = [_mock_column("ID"), _mock_column("NAME")]
target_table = MagicMock()
target_table.name.root = "customer"
target_table.columns = [_mock_column("id"), _mock_column("name")]
assert lineage_source.check_same_table(source_table, target_table)
def test_yield_cross_database_lineage_finds_uppercase_source_table():
"""Issue #27419: resolve uppercase Postgres source table in cross-db lineage."""
metadata = MagicMock()
trino_database = MagicMock()
trino_database.fullyQualifiedName.root = "repro_trino.postgres"
source_database = MagicMock()
source_database.fullyQualifiedName.root = "repro_postgres.source_db"
source_schema = MagicMock()
source_schema.name.root = "SOURCE_SCHEMA"
source_schema.fullyQualifiedName.root = "repro_postgres.source_db.SOURCE_SCHEMA"
trino_table = MagicMock()
trino_table.id.root = "11111111-1111-1111-1111-111111111111"
trino_table.fullyQualifiedName.root = "repro_trino.postgres.source_schema.customer"
trino_table.name.root = "customer"
trino_table.databaseSchema.name.root = "source_schema"
trino_table.databaseSchema.fullyQualifiedName.root = "repro_trino.postgres.source_schema"
trino_table.columns = [_mock_column("id"), _mock_column("name")]
source_table = MagicMock()
source_table.id.root = "22222222-2222-2222-2222-222222222222"
source_table.fullyQualifiedName.root = "repro_postgres.source_db.SOURCE_SCHEMA.CUSTOMER"
source_table.name.root = "CUSTOMER"
source_table.databaseSchema.name.root = "SOURCE_SCHEMA"
source_table.databaseSchema.fullyQualifiedName.root = "repro_postgres.source_db.SOURCE_SCHEMA"
source_table.columns = [_mock_column("id"), _mock_column("name")]
def list_all_entities_side_effect(entity, params=None, **_kwargs):
if entity is Database and params == {"service": "repro_trino"}:
return [trino_database]
if entity is Database and params == {"service": "repro_postgres"}:
return [source_database]
if entity is Table and params == {"database": "repro_trino.postgres"}:
return [trino_table]
return []
metadata.list_all_entities.side_effect = list_all_entities_side_effect
metadata.get_by_name.return_value = None
lineage_source = TrinoLineageSourceTestDouble(metadata)
with (
patch.object(
TrinoLineageSource,
"get_cross_database_lineage",
return_value=Either(right="cross-database-edge"),
) as mock_get_cross_database_lineage,
patch(
"metadata.ingestion.source.database.trino.lineage.fqn.search_database_schema_from_es",
return_value=[source_schema],
) as mock_search_database_schema,
patch(
"metadata.ingestion.source.database.trino.lineage.fqn.search_table_from_es",
return_value=[source_table],
) as mock_search_table,
):
result = list(lineage_source.yield_cross_database_lineage())
assert len(result) == 1
assert result[0].right == "cross-database-edge"
mock_get_cross_database_lineage.assert_called_once_with(source_table, trino_table)
mock_search_database_schema.assert_called_once_with(
metadata=metadata,
database_name="source_db",
schema_name="source_schema",
service_name="repro_postgres",
fetch_multiple_entities=True,
fields="fullyQualifiedName,name",
)
mock_search_table.assert_called_once_with(
metadata=metadata,
database_name="source_db",
schema_name="SOURCE_SCHEMA",
service_name="repro_postgres",
table_name="customer",
fetch_multiple_entities=True,
fields="fullyQualifiedName,name,columns,databaseSchema",
)
def test_get_cross_database_schema_fqn_parses_quoted_schema_from_fqn():
"""Issue #27419: parse quoted schema names with dots from table FQNs."""
metadata = MagicMock()
trino_table = MagicMock()
trino_table.databaseSchema = None
trino_table.fullyQualifiedName.root = 'repro_trino.postgres."source.schema".customer'
lineage_source = TrinoLineageSourceTestDouble(metadata)
with patch(
"metadata.ingestion.source.database.trino.lineage.fqn.search_database_schema_from_es",
return_value=None,
):
result = lineage_source._get_cross_database_schema_fqn(
"repro_postgres.source_db",
trino_table,
{},
)
assert result == 'repro_postgres.source_db."source.schema"'
@@ -0,0 +1,42 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Unity Catalog BaseConnection wiring (non-Engine: WorkspaceClient)."""
from unittest.mock import patch
from metadata.generated.schema.entity.services.connections.database.databricks.personalAccessToken import (
PersonalAccessToken,
)
from metadata.generated.schema.entity.services.connections.database.unityCatalogConnection import (
UnityCatalogConnection as UnityCatalogConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.unitycatalog.connection import UnityCatalogConnection
CONNECTION_MODULE = "metadata.ingestion.source.database.unitycatalog.connection"
def _config() -> UnityCatalogConnectionConfig:
return UnityCatalogConnectionConfig(
hostPort="test-host:443",
authType=PersonalAccessToken(token="test-token"),
)
def test_unitycatalog_connection_is_base_connection():
assert issubclass(UnityCatalogConnection, BaseConnection)
def test_get_client_delegates_to_the_workspace_client_builder():
with patch(f"{CONNECTION_MODULE}.get_connection") as mock_get_connection:
client = UnityCatalogConnection(_config()).client
mock_get_connection.assert_called_once()
assert client is mock_get_connection.return_value
@@ -0,0 +1,49 @@
# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Vertica connection handling."""
from metadata.generated.schema.entity.services.connections.database.verticaConnection import (
VerticaConnection as VerticaConnectionConfig,
)
from metadata.generated.schema.entity.services.connections.database.verticaConnection import (
VerticaScheme,
)
from metadata.ingestion.source.database.vertica.connection import VerticaConnection
def test_basic_auth_builds_expected_url():
connection = VerticaConnectionConfig(
username="openmetadata_user",
password="openmetadata_password",
hostPort="localhost:5433",
database="openmetadata_db",
scheme=VerticaScheme.vertica_vertica_python,
)
engine = VerticaConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "vertica+vertica_python://openmetadata_user:openmetadata_password@localhost:5433/openmetadata_db"
)
def test_special_characters_in_credentials_are_escaped():
connection = VerticaConnectionConfig(
username="openmetadata_user@444",
password="openmetadata_password@123",
hostPort="localhost:5433",
database="openmetadata_db",
scheme=VerticaScheme.vertica_vertica_python,
)
engine = VerticaConnection(connection).client
assert (
engine.url.render_as_string(hide_password=False)
== "vertica+vertica_python://openmetadata_user%40444:openmetadata_password%40123@localhost:5433/openmetadata_db"
)