diff --git a/.github/workflows/auto-cherry-pick-labeled-prs.yaml b/.github/workflows/auto-cherry-pick-labeled-prs.yaml
index d3c8529fea42..30168ca04bf1 100644
--- a/.github/workflows/auto-cherry-pick-labeled-prs.yaml
+++ b/.github/workflows/auto-cherry-pick-labeled-prs.yaml
@@ -5,13 +5,14 @@ run-name: OpenMetadata release cherry-pick PR #${{ github.event.pull_request.num
# yamllint disable-line rule:truthy
on:
- pull_request:
+ pull_request_target:
types: [closed]
branches:
- main
permissions:
contents: write
pull-requests: write
+ issues: write
env:
CURRENT_RELEASE_ENDPOINT: ${{ vars.CURRENT_RELEASE_ENDPOINT }} # Endpoint that returns the current release version in json format
jobs:
diff --git a/.github/workflows/playwright-mysql-e2e-skip.yml b/.github/workflows/playwright-mysql-e2e-skip.yml
index 29b27a8287ac..54298c6561d1 100644
--- a/.github/workflows/playwright-mysql-e2e-skip.yml
+++ b/.github/workflows/playwright-mysql-e2e-skip.yml
@@ -31,9 +31,9 @@ jobs:
playwright-ci-mysql:
runs-on: ubuntu-latest
strategy:
- fail-fast: false
- matrix:
- shardIndex: [1, 2]
- shardTotal: [2]
+ fail-fast: false
+ matrix:
+ shardIndex: [1, 2]
+ shardTotal: [2]
steps:
- run: 'echo "Step is not required"'
diff --git a/.github/workflows/playwright-mysql-e2e.yml b/.github/workflows/playwright-mysql-e2e.yml
index dd97f72597a6..0857cc61c65e 100644
--- a/.github/workflows/playwright-mysql-e2e.yml
+++ b/.github/workflows/playwright-mysql-e2e.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
playwright-ci-mysql:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
environment: test
strategy:
fail-fast: false
diff --git a/.github/workflows/playwright-postgresql-e2e-skip.yml b/.github/workflows/playwright-postgresql-e2e-skip.yml
index 119e36fbb650..f9be1a79152c 100644
--- a/.github/workflows/playwright-postgresql-e2e-skip.yml
+++ b/.github/workflows/playwright-postgresql-e2e-skip.yml
@@ -31,9 +31,9 @@ jobs:
playwright-ci-postgresql:
runs-on: ubuntu-latest
strategy:
- fail-fast: false
- matrix:
- shardIndex: [1, 2]
- shardTotal: [2]
+ fail-fast: false
+ matrix:
+ shardIndex: [1, 2]
+ shardTotal: [2]
steps:
- run: 'echo "Step is not required"'
diff --git a/.github/workflows/playwright-postgresql-e2e.yml b/.github/workflows/playwright-postgresql-e2e.yml
index b3ea6c978623..11d8d4408f9b 100644
--- a/.github/workflows/playwright-postgresql-e2e.yml
+++ b/.github/workflows/playwright-postgresql-e2e.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
playwright-ci-postgresql:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
environment: test
strategy:
fail-fast: false
diff --git a/.github/workflows/py-cli-e2e-tests.yml b/.github/workflows/py-cli-e2e-tests.yml
index 20572da9a070..51ed3263c153 100644
--- a/.github/workflows/py-cli-e2e-tests.yml
+++ b/.github/workflows/py-cli-e2e-tests.yml
@@ -65,7 +65,7 @@ jobs:
- name: configure aws credentials
if: contains('quicksight', matrix.e2e-test) || contains('datalake_s3', matrix.e2e-test) || contains('athena', matrix.e2e-test)
- uses: aws-actions/configure-aws-credentials@v1
+ uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.E2E_AWS_IAM_ROLE_ARN }}
role-session-name: github-ci-aws-e2e-tests
diff --git a/.github/workflows/py-tests.yml b/.github/workflows/py-tests.yml
index 586489b012d8..7ca4a5f620d4 100644
--- a/.github/workflows/py-tests.yml
+++ b/.github/workflows/py-tests.yml
@@ -55,7 +55,7 @@ jobs:
docker-images: false
- name: Wait for the labeler
- uses: lewagon/wait-on-check-action@v1.3.3
+ uses: lewagon/wait-on-check-action@v1.3.4
if: ${{ github.event_name == 'pull_request_target' }}
with:
ref: ${{ github.event.pull_request.head.sha }}
diff --git a/bootstrap/sql/migrations/native/1.5.15/mysql/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/1.5.15/mysql/postDataMigrationSQLScript.sql
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql
new file mode 100644
index 000000000000..19762625a9d8
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.5.15/mysql/schemaChanges.sql
@@ -0,0 +1,5 @@
+-- Make domain policy and role non-system
+UPDATE policy_entity SET json = JSON_SET(json, '$.provider', 'user') where name = 'DomainOnlyAccessPolicy';
+UPDATE policy_entity SET json = JSON_SET(json, '$.allowDelete', true) where name = 'DomainOnlyAccessPolicy';
+UPDATE role_entity SET json = JSON_SET(json, '$.provider', 'user') where name = 'DomainOnlyAccessRole';
+UPDATE role_entity SET json = JSON_SET(json, '$.allowDelete', true) where name = 'DomainOnlyAccessRole';
\ No newline at end of file
diff --git a/bootstrap/sql/migrations/native/1.5.15/postgres/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/1.5.15/postgres/postDataMigrationSQLScript.sql
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql
new file mode 100644
index 000000000000..6f92fbea754c
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.5.15/postgres/schemaChanges.sql
@@ -0,0 +1,5 @@
+-- Make domain policy and role non-system
+UPDATE policy_entity SET json = JSONB_SET(json::jsonb, '{provider}', '"user"', true) where name = 'DomainOnlyAccessPolicy';
+UPDATE policy_entity SET json = JSONB_SET(json::jsonb, '{allowDelete}', 'true', true) WHERE name = 'DomainOnlyAccessPolicy';
+UPDATE role_entity SET json = JSONB_SET(json::jsonb, '{provider}', '"user"', true) where name = 'DomainOnlyAccessRole';
+UPDATE role_entity SET json = JSONB_SET(json::jsonb, '{allowDelete}', 'true', true) WHERE name = 'DomainOnlyAccessRole';
diff --git a/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql
new file mode 100644
index 000000000000..49ea32e96d49
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.6.2/mysql/schemaChanges.sql
@@ -0,0 +1,44 @@
+-- add timestamp index for test case result reindex performance
+ALTER TABLE data_quality_data_time_series ADD INDEX `idx_timestamp_desc` (timestamp DESC);
+
+CREATE TABLE background_jobs (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ jobType VARCHAR(256) NOT NULL,
+ methodName VARCHAR(256) NOT NULL,
+ jobArgs JSON NOT NULL,
+ status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
+ createdBy VARCHAR(256) NOT NULL,
+ createdAt BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW(3)) * 1000),
+ updatedAt BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP(NOW(3)) * 1000)
+);
+
+CREATE INDEX idx_status_createdAt ON background_jobs (status, createdAt);
+CREATE INDEX idx_createdBy ON background_jobs (createdBy);
+CREATE INDEX idx_status ON background_jobs (status);
+CREATE INDEX idx_jobType ON background_jobs (jobType);
+CREATE INDEX idx_updatedAt ON background_jobs (updatedAt);
+
+-- rename executable -> basic for test suites
+UPDATE test_suite
+SET json = JSON_INSERT(
+ JSON_REMOVE(json, '$.executable'),
+ '$.basic',
+ JSON_EXTRACT(json, '$.executable')
+)
+WHERE JSON_EXTRACT(json, '$.executable') IS NOT NULL;
+
+-- rename executableEntityReference -> basicEntityReference for test suites
+UPDATE test_suite
+SET json = JSON_INSERT(
+ JSON_REMOVE(json, '$.executableEntityReference'),
+ '$.basicEntityReference',
+ JSON_EXTRACT(json, '$.executableEntityReference')
+)
+WHERE JSON_EXTRACT(json, '$.executableEntityReference') IS NOT NULL;
+
+-- clean up the testSuites
+UPDATE test_case SET json = json_remove(json, '$.testSuites');
+
+-- clean up the testSuites in the version history too
+UPDATE entity_extension SET json = json_remove(json, '$.testSuites') WHERE jsonSchema = 'testCase';
+
diff --git a/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql
new file mode 100644
index 000000000000..2b517db2ee7e
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.6.2/postgres/schemaChanges.sql
@@ -0,0 +1,46 @@
+-- add timestamp index for test case result reindex performance
+CREATE INDEX idx_timestamp_desc ON data_quality_data_time_series (timestamp DESC);
+
+CREATE TABLE background_jobs (
+ id BIGSERIAL PRIMARY KEY,
+ jobType VARCHAR(256) NOT NULL,
+ methodName VARCHAR(256) NOT NULL,
+ jobArgs JSONB NOT NULL,
+ status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
+ createdBy VARCHAR(256) NOT NULL,
+ createdAt BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
+ updatedAt BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
+);
+
+CREATE INDEX idx_status_createdAt ON background_jobs (status, createdAt);
+CREATE INDEX idx_createdBy ON background_jobs (createdBy);
+CREATE INDEX idx_status ON background_jobs (status);
+CREATE INDEX idx_jobType ON background_jobs (jobType);
+CREATE INDEX idx_updatedAt ON background_jobs (updatedAt);
+
+-- rename executable -> basic for test suites
+UPDATE test_suite
+SET json = jsonb_set(
+ json::jsonb #- '{executable}',
+ '{basic}',
+ (json #> '{executable}')::jsonb,
+ true
+)
+WHERE json #>> '{executable}' IS NOT NULL;
+
+-- rename executableEntityReference -> basicEntityReference for test suites
+UPDATE test_suite
+SET json = jsonb_set(
+ json::jsonb #- '{executableEntityReference}',
+ '{basicEntityReference}',
+ (json #> '{executableEntityReference}')::jsonb,
+ true
+)
+WHERE json #>> '{executableEntityReference}' IS NOT NULL;
+
+-- clean up the testSuites
+UPDATE test_case SET json = json::jsonb #- '{testSuites}';
+
+-- clean up the testSuites in the version history too
+UPDATE entity_extension SET json = json::jsonb #- '{testSuites}' WHERE jsonSchema = 'testCase';
+
diff --git a/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql
new file mode 100644
index 000000000000..0c3231f17dad
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.6.3/mysql/schemaChanges.sql
@@ -0,0 +1,6 @@
+-- Add constraint to apps_data_store
+ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type);
+UPDATE user_entity SET json = JSON_SET(json, '$.isBot', false) WHERE JSON_EXTRACT(json, '$.isBot') IS NULL;
+ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS (json -> '$.isBot') NOT NULL;
+CREATE INDEX idx_isBot ON user_entity (isBot);
+DELETE from apps_data_store;
\ No newline at end of file
diff --git a/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql
new file mode 100644
index 000000000000..cb734e7b0c6e
--- /dev/null
+++ b/bootstrap/sql/migrations/native/1.6.3/postgres/schemaChanges.sql
@@ -0,0 +1,6 @@
+-- Add constraint to apps_data_store
+ALTER TABLE apps_data_store ADD CONSTRAINT entity_relationship_pky PRIMARY KEY (identifier, type);
+UPDATE user_entity SET json = jsonb_set(json::jsonb, '{isBot}', 'false'::jsonb, true) WHERE NOT (json ?? 'isBot');
+ALTER TABLE user_entity ADD COLUMN isBot BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED NOT NULL;
+CREATE INDEX idx_isBot ON user_entity (isBot);
+DELETE from apps_data_store;
\ No newline at end of file
diff --git a/common/pom.xml b/common/pom.xml
index 65d7d15b61dd..d357ed0d6a1d 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -18,7 +18,7 @@
platform
org.open-metadata
- 1.6.0-SNAPSHOT
+ 1.6.4
4.0.0
diff --git a/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java b/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java
new file mode 100644
index 000000000000..98e568274a37
--- /dev/null
+++ b/common/src/main/java/org/openmetadata/annotations/DeprecatedAnnotator.java
@@ -0,0 +1,76 @@
+package org.openmetadata.annotations;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.sun.codemodel.JAnnotationUse;
+import com.sun.codemodel.JClass;
+import com.sun.codemodel.JDefinedClass;
+import com.sun.codemodel.JFieldVar;
+import com.sun.codemodel.JMethod;
+import java.lang.reflect.Field;
+import java.util.TreeMap;
+import org.jsonschema2pojo.AbstractAnnotator;
+
+/** Add {@link Deprecated} annotation to generated Java classes */
+public class DeprecatedAnnotator extends AbstractAnnotator {
+
+ /** Add {@link Deprecated} annotation to property fields */
+ @Override
+ public void propertyField(
+ JFieldVar field, JDefinedClass clazz, String propertyName, JsonNode propertyNode) {
+ super.propertyField(field, clazz, propertyName, propertyNode);
+ if (propertyNode.get("deprecated") != null && propertyNode.get("deprecated").asBoolean()) {
+ field.annotate(Deprecated.class);
+ }
+ }
+
+ /** Add {@link Deprecated} annotation to getter methods */
+ @Override
+ public void propertyGetter(JMethod getter, JDefinedClass clazz, String propertyName) {
+ super.propertyGetter(getter, clazz, propertyName);
+ addDeprecatedAnnotationIfApplies(getter, propertyName);
+ }
+
+ /** Add {@link Deprecated} annotation to setter methods */
+ @Override
+ public void propertySetter(JMethod setter, JDefinedClass clazz, String propertyName) {
+ super.propertySetter(setter, clazz, propertyName);
+ addDeprecatedAnnotationIfApplies(setter, propertyName);
+ }
+
+ /**
+ * Use reflection methods to access the {@link JDefinedClass} of the {@link JMethod} object. If
+ * the {@link JMethod} is pointing to a field annotated with {@link Deprecated} then annotates
+ * the {@link JMethod} object with {@link Deprecated}
+ */
+ private void addDeprecatedAnnotationIfApplies(JMethod jMethod, String propertyName) {
+ try {
+ Field outerClassField = JMethod.class.getDeclaredField("outer");
+ outerClassField.setAccessible(true);
+ JDefinedClass outerClass = (JDefinedClass) outerClassField.get(jMethod);
+
+ TreeMap insensitiveFieldsMap =
+ new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ insensitiveFieldsMap.putAll(outerClass.fields());
+
+ if (insensitiveFieldsMap.containsKey(propertyName)
+ && insensitiveFieldsMap.get(propertyName).annotations().stream()
+ .anyMatch(
+ annotation ->
+ Deprecated.class.getName().equals(getAnnotationClassName(annotation)))) {
+ jMethod.annotate(Deprecated.class);
+ }
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private String getAnnotationClassName(JAnnotationUse annotation) {
+ try {
+ Field clazzField = JAnnotationUse.class.getDeclaredField("clazz");
+ clazzField.setAccessible(true);
+ return ((JClass) clazzField.get(annotation)).fullName();
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java b/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java
index 15f0bcd948be..79209d8515d5 100644
--- a/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java
+++ b/common/src/main/java/org/openmetadata/annotations/OpenMetadataAnnotator.java
@@ -19,6 +19,10 @@ public class OpenMetadataAnnotator extends CompositeAnnotator {
public OpenMetadataAnnotator() {
// we can add multiple annotators
- super(new ExposedAnnotator(), new MaskedAnnotator(), new PasswordAnnotator());
+ super(
+ new ExposedAnnotator(),
+ new MaskedAnnotator(),
+ new PasswordAnnotator(),
+ new DeprecatedAnnotator());
}
}
diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml
index dedceb705dd9..cc6909053c8a 100644
--- a/conf/openmetadata.yaml
+++ b/conf/openmetadata.yaml
@@ -180,6 +180,7 @@ authenticationConfiguration:
# This will only be valid when provider type specified is customOidc
providerName: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""}
publicKeyUrls: ${AUTHENTICATION_PUBLIC_KEYS:-[http://localhost:8585/api/v1/system/config/jwks]}
+ tokenValidationAlgorithm: ${AUTHENTICATION_TOKEN_VALIDATION_ALGORITHM:-"RS256"}
authority: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com}
clientId: ${AUTHENTICATION_CLIENT_ID:-""}
callbackUrl: ${AUTHENTICATION_CALLBACK_URL:-""}
@@ -295,6 +296,7 @@ eventMonitoringConfiguration:
batchSize: ${EVENT_MONITOR_BATCH_SIZE:-10}
pathPattern: ${EVENT_MONITOR_PATH_PATTERN:-["/api/v1/tables/*", "/api/v1/health-check"]}
latency: ${EVENT_MONITOR_LATENCY:-[0.99, 0.90]} # For value p99=0.99, p90=0.90, p50=0.50 etc.
+ servicesHealthCheckInterval: ${EVENT_MONITOR_SERVICES_HEALTH_CHECK_INTERVAL:-300}
# it will use the default auth provider for AWS services if parameters are not set
# parameters:
# region: ${OM_MONITOR_REGION:-""}
diff --git a/docker/development/docker-compose.yml b/docker/development/docker-compose.yml
index b5509137a521..ceaef3cececc 100644
--- a/docker/development/docker-compose.yml
+++ b/docker/development/docker-compose.yml
@@ -492,7 +492,7 @@ services:
DB_HOST: ${AIRFLOW_DB_HOST:-mysql}
DB_PORT: ${AIRFLOW_DB_PORT:-3306}
AIRFLOW_DB: ${AIRFLOW_DB:-airflow_db}
- DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+pymysql}
+ DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+mysqldb}
DB_USER: ${AIRFLOW_DB_USER:-airflow_user}
DB_PASSWORD: ${AIRFLOW_DB_PASSWORD:-airflow_pass}
diff --git a/docker/docker-compose-ingestion/docker-compose-ingestion.yml b/docker/docker-compose-ingestion/docker-compose-ingestion.yml
index b6adb2319e31..d20b7d6e11f5 100644
--- a/docker/docker-compose-ingestion/docker-compose-ingestion.yml
+++ b/docker/docker-compose-ingestion/docker-compose-ingestion.yml
@@ -18,7 +18,7 @@ volumes:
services:
ingestion:
container_name: openmetadata_ingestion
- image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/ingestion:1.6.4
environment:
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session"
AIRFLOW__CORE__EXECUTOR: LocalExecutor
@@ -26,7 +26,7 @@ services:
DB_HOST: ${AIRFLOW_DB_HOST:-mysql}
DB_PORT: ${AIRFLOW_DB_PORT:-3306}
AIRFLOW_DB: ${AIRFLOW_DB:-airflow_db}
- DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+pymysql}
+ DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+mysqldb}
DB_USER: ${AIRFLOW_DB_USER:-airflow_user}
DB_PASSWORD: ${AIRFLOW_DB_PASSWORD:-airflow_pass}
# extra connection-string properties for the database
diff --git a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml
index bfc903d62f73..c5ced415e891 100644
--- a/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml
+++ b/docker/docker-compose-openmetadata/docker-compose-openmetadata.yml
@@ -14,7 +14,7 @@ services:
execute-migrate-all:
container_name: execute_migrate_all
command: "./bootstrap/openmetadata-ops.sh migrate"
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
SERVER_PORT: ${SERVER_PORT:-8585}
@@ -227,7 +227,7 @@ services:
openmetadata-server:
container_name: openmetadata_server
restart: always
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
SERVER_PORT: ${SERVER_PORT:-8585}
diff --git a/docker/docker-compose-quickstart/Dockerfile b/docker/docker-compose-quickstart/Dockerfile
index 81c9750f2d9f..e756eb4fdd44 100644
--- a/docker/docker-compose-quickstart/Dockerfile
+++ b/docker/docker-compose-quickstart/Dockerfile
@@ -11,7 +11,7 @@
# Build stage
FROM alpine:3 AS build
-ARG RI_VERSION="1.6.0-SNAPSHOT"
+ARG RI_VERSION="1.6.4"
ENV RELEASE_URL="https://github.com/open-metadata/OpenMetadata/releases/download/${RI_VERSION}-release/openmetadata-${RI_VERSION}.tar.gz"
RUN mkdir -p /opt/openmetadata && \
@@ -21,7 +21,7 @@ RUN mkdir -p /opt/openmetadata && \
# Final stage
FROM alpine:3
-ARG RI_VERSION="1.6.0-SNAPSHOT"
+ARG RI_VERSION="1.6.4"
ARG BUILD_DATE
ARG COMMIT_ID
LABEL maintainer="OpenMetadata"
diff --git a/docker/docker-compose-quickstart/docker-compose-postgres.yml b/docker/docker-compose-quickstart/docker-compose-postgres.yml
index 4b279df6cb28..875e90dc4336 100644
--- a/docker/docker-compose-quickstart/docker-compose-postgres.yml
+++ b/docker/docker-compose-quickstart/docker-compose-postgres.yml
@@ -18,7 +18,7 @@ volumes:
services:
postgresql:
container_name: openmetadata_postgresql
- image: docker.getcollate.io/openmetadata/postgresql:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/postgresql:1.6.4
restart: always
command: "--work_mem=10MB"
environment:
@@ -61,7 +61,7 @@ services:
execute-migrate-all:
container_name: execute_migrate_all
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
command: "./bootstrap/openmetadata-ops.sh migrate"
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
@@ -275,7 +275,7 @@ services:
openmetadata-server:
container_name: openmetadata_server
restart: always
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
SERVER_PORT: ${SERVER_PORT:-8585}
@@ -483,7 +483,7 @@ services:
ingestion:
container_name: openmetadata_ingestion
- image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/ingestion:1.6.4
depends_on:
elasticsearch:
condition: service_started
diff --git a/docker/docker-compose-quickstart/docker-compose.yml b/docker/docker-compose-quickstart/docker-compose.yml
index a66b57ddfb63..187e05dad997 100644
--- a/docker/docker-compose-quickstart/docker-compose.yml
+++ b/docker/docker-compose-quickstart/docker-compose.yml
@@ -18,7 +18,7 @@ volumes:
services:
mysql:
container_name: openmetadata_mysql
- image: docker.getcollate.io/openmetadata/db:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/db:1.6.4
command: "--sort_buffer_size=10M"
restart: always
environment:
@@ -59,7 +59,7 @@ services:
execute-migrate-all:
container_name: execute_migrate_all
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
command: "./bootstrap/openmetadata-ops.sh migrate"
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
@@ -273,7 +273,7 @@ services:
openmetadata-server:
container_name: openmetadata_server
restart: always
- image: docker.getcollate.io/openmetadata/server:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/server:1.6.4
environment:
OPENMETADATA_CLUSTER_NAME: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
SERVER_PORT: ${SERVER_PORT:-8585}
@@ -482,7 +482,7 @@ services:
ingestion:
container_name: openmetadata_ingestion
- image: docker.getcollate.io/openmetadata/ingestion:1.5.0-SNAPSHOT
+ image: docker.getcollate.io/openmetadata/ingestion:1.6.4
depends_on:
elasticsearch:
condition: service_started
@@ -497,7 +497,7 @@ services:
DB_HOST: ${AIRFLOW_DB_HOST:-mysql}
DB_PORT: ${AIRFLOW_DB_PORT:-3306}
AIRFLOW_DB: ${AIRFLOW_DB:-airflow_db}
- DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+pymysql}
+ DB_SCHEME: ${AIRFLOW_DB_SCHEME:-mysql+mysqldb}
DB_USER: ${AIRFLOW_DB_USER:-airflow_user}
DB_PASSWORD: ${AIRFLOW_DB_PASSWORD:-airflow_pass}
# extra connection-string properties for the database
diff --git a/ingestion/Dockerfile b/ingestion/Dockerfile
index cc0df5ad259c..ef4b874f8b5b 100644
--- a/ingestion/Dockerfile
+++ b/ingestion/Dockerfile
@@ -1,6 +1,6 @@
FROM mysql:8.3 as mysql
-FROM apache/airflow:2.9.1-python3.10
+FROM apache/airflow:2.9.3-python3.10
USER root
RUN curl -sS https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
RUN curl -sS https://packages.microsoft.com/config/debian/11/prod.list > /etc/apt/sources.list.d/mssql-release.list
@@ -76,9 +76,9 @@ ARG INGESTION_DEPENDENCY="all"
ENV PIP_NO_CACHE_DIR=1
# Make pip silent
ENV PIP_QUIET=1
-ARG RI_VERSION="1.6.0.0.dev0"
+ARG RI_VERSION="1.6.4.0"
RUN pip install --upgrade pip
-RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt"
+RUN pip install "openmetadata-managed-apis~=${RI_VERSION}" --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-2.9.3/constraints-3.10.txt"
RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}"
# Temporary workaround for https://github.com/open-metadata/OpenMetadata/issues/9593
diff --git a/ingestion/Dockerfile.ci b/ingestion/Dockerfile.ci
index f8433f776ca3..6e7c6e2a6406 100644
--- a/ingestion/Dockerfile.ci
+++ b/ingestion/Dockerfile.ci
@@ -1,6 +1,6 @@
FROM mysql:8.3 as mysql
-FROM apache/airflow:2.9.1-python3.10
+FROM apache/airflow:2.9.3-python3.10
USER root
RUN curl -sS https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
RUN curl -sS https://packages.microsoft.com/config/debian/11/prod.list > /etc/apt/sources.list.d/mssql-release.list
@@ -73,7 +73,7 @@ COPY --chown=airflow:0 openmetadata-airflow-apis /home/airflow/openmetadata-airf
COPY --chown=airflow:0 ingestion/examples/airflow/dags /opt/airflow/dags
USER airflow
-ARG AIRFLOW_CONSTRAINTS_LOCATION="https://raw.githubusercontent.com/apache/airflow/constraints-2.9.1/constraints-3.10.txt"
+ARG AIRFLOW_CONSTRAINTS_LOCATION="https://raw.githubusercontent.com/apache/airflow/constraints-2.9.3/constraints-3.10.txt"
# Disable pip cache dir
# https://pip.pypa.io/en/stable/topics/caching/#avoiding-caching
diff --git a/ingestion/ingestion_dependency.sh b/ingestion/ingestion_dependency.sh
index ee54d6f6ac95..2b8372f852f4 100755
--- a/ingestion/ingestion_dependency.sh
+++ b/ingestion/ingestion_dependency.sh
@@ -15,7 +15,7 @@ DB_PORT=${DB_PORT:-3306}
AIRFLOW_DB=${AIRFLOW_DB:-airflow_db}
DB_USER=${DB_USER:-airflow_user}
-DB_SCHEME=${DB_SCHEME:-mysql+pymysql}
+DB_SCHEME=${DB_SCHEME:-mysql+mysqldb}
DB_PASSWORD=${DB_PASSWORD:-airflow_pass}
DB_PROPERTIES=${DB_PROPERTIES:-""}
diff --git a/ingestion/operators/docker/Dockerfile b/ingestion/operators/docker/Dockerfile
index d5d57088d256..f5a8d94bf048 100644
--- a/ingestion/operators/docker/Dockerfile
+++ b/ingestion/operators/docker/Dockerfile
@@ -41,6 +41,7 @@ RUN dpkg --configure -a \
&& rm -rf /var/lib/apt/lists/*
# Add updated postgres/redshift dependencies based on libq
+ENV DEBIAN_FRONTEND=noninteractive
RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN echo "deb https://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list; \
apt-get -qq update; \
@@ -81,7 +82,7 @@ ENV PIP_QUIET=1
RUN pip install --upgrade pip
ARG INGESTION_DEPENDENCY="all"
-ARG RI_VERSION="1.6.0.0.dev0"
+ARG RI_VERSION="1.6.4.0"
RUN pip install --upgrade pip
RUN pip install "openmetadata-ingestion[airflow]~=${RI_VERSION}"
RUN pip install "openmetadata-ingestion[${INGESTION_DEPENDENCY}]~=${RI_VERSION}"
diff --git a/ingestion/operators/docker/Dockerfile.ci b/ingestion/operators/docker/Dockerfile.ci
index 27752f954383..a12409b92956 100644
--- a/ingestion/operators/docker/Dockerfile.ci
+++ b/ingestion/operators/docker/Dockerfile.ci
@@ -41,6 +41,7 @@ RUN apt-get -qq update \
&& rm -rf /var/lib/apt/lists/*
# Add updated postgres/redshift dependencies based on libq
+ENV DEBIAN_FRONTEND=noninteractive
RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN echo "deb https://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list; \
apt-get -qq update; \
diff --git a/ingestion/pyproject.toml b/ingestion/pyproject.toml
index ec2b46ea578a..44d98481dc26 100644
--- a/ingestion/pyproject.toml
+++ b/ingestion/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
# since it helps us organize and isolate version management
[project]
name = "openmetadata-ingestion"
-version = "1.6.0.0.dev0"
+version = "1.6.4.0"
dynamic = ["readme", "dependencies", "optional-dependencies"]
authors = [
{ name = "OpenMetadata Committers" }
diff --git a/ingestion/setup.py b/ingestion/setup.py
index b117351d6a0f..b35f1f7e340b 100644
--- a/ingestion/setup.py
+++ b/ingestion/setup.py
@@ -19,7 +19,7 @@
# Add here versions required for multiple plugins
VERSIONS = {
- "airflow": "apache-airflow==2.9.1",
+ "airflow": "apache-airflow==2.9.3",
"adlfs": "adlfs>=2023.1.0",
"avro": "avro>=1.11.3,<1.12",
"boto3": "boto3>=1.20,<2.0", # No need to add botocore separately. It's a dep from boto3
@@ -56,7 +56,13 @@
"elasticsearch8": "elasticsearch8~=8.9.0",
"giturlparse": "giturlparse",
"validators": "validators~=0.22.0",
- "teradata": "teradatasqlalchemy>=20.0.0.0",
+ "teradata": "teradatasqlalchemy==20.0.0.2",
+ "cockroach": "sqlalchemy-cockroachdb~=2.0",
+ "cassandra": "cassandra-driver>=3.28.0",
+ "pydoris": "pydoris==1.0.2",
+ "pyiceberg": "pyiceberg==0.5.1",
+ "google-cloud-bigtable": "google-cloud-bigtable>=2.0.0",
+ "pyathena": "pyathena~=3.0",
}
COMMONS = {
@@ -76,7 +82,7 @@
},
"kafka": {
VERSIONS["avro"],
- "confluent_kafka==2.1.1",
+ "confluent_kafka>=2.1.1,<=2.6.1",
"fastavro>=1.2.0",
# Due to https://github.com/grpc/grpc/issues/30843#issuecomment-1303816925
# use >= v1.47.2 https://github.com/grpc/grpc/blob/v1.47.2/tools/distrib/python/grpcio_tools/grpc_version.py#L17
@@ -96,7 +102,7 @@
DATA_DIFF = {
driver: f"collate-data-diff[{driver}]"
# data-diff uses different drivers out-of-the-box than OpenMetadata
- # the exrtas are described here:
+ # the extras are described here:
# https://github.com/open-metadata/collate-data-diff/blob/main/pyproject.toml#L68
# install all data diffs with "pip install collate-data-diff[all-dbs]"
for driver in [
@@ -137,12 +143,17 @@
"requests>=2.23",
"requests-aws4auth~=1.1", # Only depends on requests as external package. Leaving as base.
"sqlalchemy>=1.4.0,<2",
- "collate-sqllineage~=1.5.0",
+ "collate-sqllineage~=1.6.0",
"tabulate==0.9.0",
"typing-inspect",
"packaging", # For version parsing
+ "setuptools~=70.0",
"shapely",
"collate-data-diff",
+ # TODO: Remove one once we have updated datadiff version
+ "snowflake-connector-python>=3.13.1,<4.0.0",
+ "mysql-connector-python>=8.0.29;python_version<'3.9'",
+ "mysql-connector-python>=9.1;python_version>='3.9'",
}
plugins: Dict[str, Set[str]] = {
@@ -183,7 +194,7 @@
"google-cloud",
VERSIONS["boto3"],
VERSIONS["google-cloud-storage"],
- "dbt-artifacts-parser",
+ "collate-dbt-artifacts-parser",
VERSIONS["azure-storage-blob"],
VERSIONS["azure-identity"],
},
@@ -306,7 +317,7 @@
VERSIONS["geoalchemy2"],
},
"sagemaker": {VERSIONS["boto3"]},
- "salesforce": {"simple_salesforce~=1.11"},
+ "salesforce": {"simple_salesforce~=1.11", "authlib>=1.3.1"},
"sample-data": {VERSIONS["avro"], VERSIONS["grpc-tools"]},
"sap-hana": {"hdbcli", "sqlalchemy-hana"},
"sas": {},
@@ -356,7 +367,7 @@
"pytest-order",
"dirty-equals",
# install dbt dependency
- "dbt-artifacts-parser",
+ "collate-dbt-artifacts-parser",
"freezegun",
VERSIONS["sqlalchemy-databricks"],
VERSIONS["databricks-sdk"],
diff --git a/ingestion/src/metadata/clients/aws_client.py b/ingestion/src/metadata/clients/aws_client.py
index 8e87c600f96c..751f64da61dc 100644
--- a/ingestion/src/metadata/clients/aws_client.py
+++ b/ingestion/src/metadata/clients/aws_client.py
@@ -11,12 +11,16 @@
"""
Module containing AWS Client
"""
+import datetime
from enum import Enum
-from typing import Any, Optional
+from functools import partial
+from typing import Any, Callable, Dict, Optional, Type, TypeVar
import boto3
from boto3 import Session
-from pydantic import BaseModel
+from botocore.credentials import RefreshableCredentials
+from botocore.session import get_session
+from pydantic import BaseModel, Field
from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials
from metadata.ingestion.models.custom_pydantic import CustomSecretStr
@@ -44,10 +48,28 @@ class AWSAssumeRoleException(Exception):
"""
+class AWSAssumeRoleCredentialResponse(BaseModel):
+ AccessKeyId: str = Field()
+ SecretAccessKey: str = Field()
+ SessionToken: Optional[str] = Field(
+ default=None,
+ )
+ Expiration: Optional[datetime.datetime] = None
+
+
class AWSAssumeRoleCredentialWrapper(BaseModel):
- accessKeyId: str
- secretAccessKey: CustomSecretStr
- sessionToken: Optional[str] = None
+ accessKeyId: str = Field(alias="access_key")
+ secretAccessKey: CustomSecretStr = Field(alias="secret_key")
+ sessionToken: Optional[str] = Field(default=None, alias="token")
+ expiryTime: Optional[str] = Field(alias="expiry_time")
+
+ class Config:
+ populate_by_name = True
+
+
+AWSAssumeRoleCredentialFormat = TypeVar(
+ "AWSAssumeRoleCredentialFormat", AWSAssumeRoleCredentialWrapper, Dict
+)
class AWSClient:
@@ -65,7 +87,10 @@ def __init__(self, config: "AWSCredentials"):
@staticmethod
def get_assume_role_config(
config: AWSCredentials,
- ) -> Optional[AWSAssumeRoleCredentialWrapper]:
+ return_type: Type[
+ AWSAssumeRoleCredentialFormat
+ ] = AWSAssumeRoleCredentialWrapper,
+ ) -> Optional[AWSAssumeRoleCredentialFormat]:
"""
Get temporary credentials from assumed role
"""
@@ -90,12 +115,19 @@ def get_assume_role_config(
)
if resp:
- credentials = resp.get("Credentials", {})
- return AWSAssumeRoleCredentialWrapper(
- accessKeyId=credentials.get("AccessKeyId"),
- secretAccessKey=credentials.get("SecretAccessKey"),
- sessionToken=credentials.get("SessionToken"),
+ credentials: AWSAssumeRoleCredentialResponse = (
+ AWSAssumeRoleCredentialResponse(**resp.get("Credentials", {}))
+ )
+ creds_wrapper = AWSAssumeRoleCredentialWrapper(
+ accessKeyId=credentials.AccessKeyId,
+ secretAccessKey=credentials.SecretAccessKey,
+ sessionToken=credentials.SessionToken,
+ expiryTime=credentials.Expiration.isoformat(),
)
+ if return_type == Dict:
+ return creds_wrapper.model_dump(by_alias=True)
+ return creds_wrapper
+
return None
@staticmethod
@@ -105,17 +137,32 @@ def _get_session(
aws_session_token: Optional[str],
aws_region: str,
profile=None,
+ refresh_using: Optional[Callable] = None,
) -> Session:
"""
The only required param for boto3 is the region.
The rest of credentials will have fallback strategies based on
https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials
"""
+ if refresh_using:
+ refreshable_creds = RefreshableCredentials.create_from_metadata(
+ metadata=refresh_using(),
+ refresh_using=refresh_using,
+ method="sts-assume-role",
+ )
+ session = get_session()
+ session._credentials = refreshable_creds # pylint: disable=protected-access
+ return Session(
+ botocore_session=session, region_name=aws_region, profile_name=profile
+ )
+
return Session(
aws_access_key_id=aws_access_key_id,
- aws_secret_access_key=aws_secret_access_key.get_secret_value()
- if aws_secret_access_key
- else None,
+ aws_secret_access_key=(
+ aws_secret_access_key.get_secret_value()
+ if aws_secret_access_key
+ else None
+ ),
aws_session_token=aws_session_token,
region_name=aws_region,
profile_name=profile,
@@ -123,15 +170,16 @@ def _get_session(
def create_session(self) -> Session:
if self.config.assumeRoleArn:
- assume_creds = AWSClient.get_assume_role_config(self.config)
- if assume_creds:
- return AWSClient._get_session(
- assume_creds.accessKeyId,
- assume_creds.secretAccessKey,
- assume_creds.sessionToken,
- self.config.awsRegion,
- self.config.profileName,
- )
+ return AWSClient._get_session(
+ None,
+ None,
+ None,
+ self.config.awsRegion,
+ self.config.profileName,
+ refresh_using=partial(
+ AWSClient.get_assume_role_config, self.config, Dict
+ ),
+ )
return AWSClient._get_session(
self.config.awsAccessKeyId,
diff --git a/ingestion/src/metadata/data_quality/api/models.py b/ingestion/src/metadata/data_quality/api/models.py
index 82de6625cffb..39af90c06de6 100644
--- a/ingestion/src/metadata/data_quality/api/models.py
+++ b/ingestion/src/metadata/data_quality/api/models.py
@@ -23,6 +23,7 @@
from metadata.config.common import ConfigModel
from metadata.generated.schema.api.tests.createTestSuite import CreateTestSuiteRequest
from metadata.generated.schema.entity.data.table import Table
+from metadata.generated.schema.entity.services.databaseService import DatabaseConnection
from metadata.generated.schema.tests.basic import TestCaseResult
from metadata.generated.schema.tests.testCase import TestCase, TestCaseParameterValue
from metadata.ingestion.models.custom_pydantic import BaseModel
@@ -63,6 +64,9 @@ class TableAndTests(BaseModel):
executable_test_suite: Optional[CreateTestSuiteRequest] = Field(
None, description="If no executable test suite is found, we'll create one"
)
+ service_connection: DatabaseConnection = Field(
+ ..., description="Service connection for the given table"
+ )
class TestCaseResults(BaseModel):
diff --git a/ingestion/src/metadata/data_quality/processor/test_case_runner.py b/ingestion/src/metadata/data_quality/processor/test_case_runner.py
index 696caaae0ce9..0e363eafb18d 100644
--- a/ingestion/src/metadata/data_quality/processor/test_case_runner.py
+++ b/ingestion/src/metadata/data_quality/processor/test_case_runner.py
@@ -27,6 +27,7 @@
from metadata.data_quality.runner.core import DataTestsRunner
from metadata.generated.schema.api.tests.createTestCase import CreateTestCaseRequest
from metadata.generated.schema.entity.data.table import Table
+from metadata.generated.schema.entity.services.databaseService import DatabaseConnection
from metadata.generated.schema.entity.services.ingestionPipelines.status import (
StackTraceError,
)
@@ -93,7 +94,9 @@ def _run(self, record: TableAndTests) -> Either:
record.table, openmetadata_test_cases
)
- test_suite_runner = self.get_test_suite_runner(record.table)
+ test_suite_runner = self.get_test_suite_runner(
+ record.table, record.service_connection
+ )
logger.debug(
f"Found {len(openmetadata_test_cases)} test cases for table {record.table.fullyQualifiedName.root}"
@@ -351,9 +354,9 @@ def filter_incompatible_test_cases(
result.append(tc)
return result
- def get_test_suite_runner(self, table: Table):
+ def get_test_suite_runner(
+ self, table: Table, service_connection: DatabaseConnection
+ ):
return BaseTestSuiteRunner(
- self.config,
- self.metadata,
- table,
+ self.config, self.metadata, table, service_connection
).get_data_quality_runner()
diff --git a/ingestion/src/metadata/data_quality/runner/base_test_suite_source.py b/ingestion/src/metadata/data_quality/runner/base_test_suite_source.py
index bf4897843a9f..7fbfcb29caba 100644
--- a/ingestion/src/metadata/data_quality/runner/base_test_suite_source.py
+++ b/ingestion/src/metadata/data_quality/runner/base_test_suite_source.py
@@ -46,11 +46,12 @@ def __init__(
config: OpenMetadataWorkflowConfig,
ometa_client: OpenMetadata,
entity: Table,
+ service_connection: DatabaseConnection,
):
self.validator_builder_class = ValidatorBuilder
self._interface = None
self.entity = entity
- self.service_conn_config = self._copy_service_config(config, self.entity.database) # type: ignore
+ self.service_conn_config = self._copy_service_config(service_connection, self.entity.database) # type: ignore
self._interface_type: str = self.service_conn_config.type.value.lower()
self.source_config = TestSuitePipeline.model_validate(
@@ -67,7 +68,7 @@ def interface(self, interface):
self._interface = interface
def _copy_service_config(
- self, config: OpenMetadataWorkflowConfig, database: EntityReference
+ self, service_connection: DatabaseConnection, database: EntityReference
) -> DatabaseConnection:
"""Make a copy of the service config and update the database name
@@ -77,9 +78,7 @@ def _copy_service_config(
Returns:
DatabaseService.__config__
"""
- config_copy = deepcopy(
- config.source.serviceConnection.root.config # type: ignore
- )
+ config_copy = deepcopy(service_connection.config) # type: ignore
if hasattr(
config_copy, # type: ignore
"supportsDatabase",
@@ -121,9 +120,9 @@ def create_data_quality_interface(self) -> TestSuiteInterface:
schema_entity=schema_entity,
database_entity=database_entity,
default_sample_config=SampleConfig(
- profile_sample=self.source_config.profileSample,
- profile_sample_type=self.source_config.profileSampleType,
- sampling_method_type=self.source_config.samplingMethodType,
+ profileSample=self.source_config.profileSample,
+ profileSampleType=self.source_config.profileSampleType,
+ samplingMethodType=self.source_config.samplingMethodType,
),
)
diff --git a/ingestion/src/metadata/data_quality/source/test_suite.py b/ingestion/src/metadata/data_quality/source/test_suite.py
index a2fb2cfae2ee..9558d36a1785 100644
--- a/ingestion/src/metadata/data_quality/source/test_suite.py
+++ b/ingestion/src/metadata/data_quality/source/test_suite.py
@@ -14,11 +14,17 @@
The main goal is to get the configured table from the API.
"""
-from typing import Iterable, List, Optional, cast
+import itertools
+import traceback
+from typing import Dict, Iterable, List, Optional, cast
from metadata.data_quality.api.models import TableAndTests
from metadata.generated.schema.api.tests.createTestSuite import CreateTestSuiteRequest
from metadata.generated.schema.entity.data.table import Table
+from metadata.generated.schema.entity.services.databaseService import (
+ DatabaseConnection,
+ DatabaseService,
+)
from metadata.generated.schema.entity.services.ingestionPipelines.status import (
StackTraceError,
)
@@ -36,7 +42,7 @@
from metadata.ingestion.api.step import Step
from metadata.ingestion.api.steps import Source
from metadata.ingestion.ometa.ometa_api import OpenMetadata
-from metadata.utils import fqn
+from metadata.utils import entity_link, fqn
from metadata.utils.constants import CUSTOM_CONNECTOR_PREFIX
from metadata.utils.logger import test_suite_logger
from metadata.utils.service_spec.service_spec import import_source_class
@@ -61,18 +67,36 @@ def __init__(
self.source_config: TestSuitePipeline = self.config.source.sourceConfig.config
+ # Build at runtime - if not informed in the yaml - the service connection map
+ self.service_connection_map: Dict[
+ str, DatabaseConnection
+ ] = self._load_yaml_service_connections()
+
self.test_connection()
@property
def name(self) -> str:
return "OpenMetadata"
+ def _load_yaml_service_connections(self) -> Dict[str, DatabaseConnection]:
+ """Load the service connections from the YAML file"""
+ service_connections = self.source_config.serviceConnections
+ if not service_connections:
+ return {}
+ return {
+ conn.serviceName: cast(DatabaseConnection, conn.serviceConnection.root)
+ for conn in service_connections
+ }
+
def _get_table_entity(self) -> Optional[Table]:
"""given an entity fqn return the table entity
Args:
entity_fqn: entity fqn for the test case
"""
+ # Logical test suites don't have associated tables
+ if self.source_config.entityFullyQualifiedName is None:
+ return None
table: Table = self.metadata.get_by_name(
entity=Table,
fqn=self.source_config.entityFullyQualifiedName.root,
@@ -81,23 +105,51 @@ def _get_table_entity(self) -> Optional[Table]:
return table
- def _get_test_cases_from_test_suite(
- self, test_suite: Optional[TestSuite]
- ) -> List[TestCase]:
+ def _get_table_service_connection(self, table: Table) -> DatabaseConnection:
+ """Get the service connection for the table"""
+ service_name = table.service.name
+
+ if service_name not in self.service_connection_map:
+ try:
+ service: DatabaseService = self.metadata.get_by_name(
+ DatabaseService, service_name
+ )
+ if not service:
+ raise ConnectionError(
+ f"Could not retrieve service with name `{service_name}`. "
+ "Typically caused by the `entityFullyQualifiedName` does not exists in OpenMetadata "
+ "or the JWT Token is invalid."
+ )
+ if not service.connection:
+ raise ConnectionError(
+ f"Service with name `{service_name}` does not have a connection. "
+ "If the connection is not stored in OpenMetadata, please provide it in the YAML file."
+ )
+ self.service_connection_map[service_name] = service.connection
+
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.error(
+ f"Error getting service connection for service name [{service_name}]"
+ f" using the secrets manager provider [{self.metadata.config.secretsManagerProvider}]: {exc}"
+ )
+ raise exc
+
+ return self.service_connection_map[service_name]
+
+ def _get_test_cases_from_test_suite(self, test_suite: TestSuite) -> List[TestCase]:
"""Return test cases if the test suite exists and has them"""
- if test_suite:
- test_cases = self.metadata.list_all_entities(
- entity=TestCase,
- fields=["testSuite", "entityLink", "testDefinition"],
- params={"testSuiteId": test_suite.id.root},
- )
- test_cases = cast(List[TestCase], test_cases) # satisfy type checker
- if self.source_config.testCases is not None:
- test_cases = [
- t for t in test_cases if t.name in self.source_config.testCases
- ]
- return test_cases
- return []
+ test_cases = self.metadata.list_all_entities(
+ entity=TestCase,
+ fields=["testSuite", "entityLink", "testDefinition"],
+ params={"testSuiteId": test_suite.id.root},
+ )
+ test_cases = cast(List[TestCase], test_cases) # satisfy type checker
+ if self.source_config.testCases is not None:
+ test_cases = [
+ t for t in test_cases if t.name in self.source_config.testCases
+ ]
+ return test_cases
def prepare(self):
"""Nothing to prepare"""
@@ -106,6 +158,7 @@ def test_connection(self) -> None:
self.metadata.health_check()
def _iter(self) -> Iterable[Either[TableAndTests]]:
+ # Basic tests suites will have a table informed
table: Table = self._get_table_entity()
if table:
source_type = table.serviceType.value.lower()
@@ -119,20 +172,29 @@ def _iter(self) -> Iterable[Either[TableAndTests]]:
)
yield from self._process_table_suite(table)
+ # Logical test suites won't have a table, we'll need to group the execution by tests
else:
- yield Either(
- left=StackTraceError(
- name="Missing Table",
- error=f"Could not retrieve table entity for {self.source_config.entityFullyQualifiedName.root}."
- " Make sure the table exists in OpenMetadata and/or the JWT Token provided is valid.",
- )
- )
+ yield from self._process_logical_suite()
def _process_table_suite(self, table: Table) -> Iterable[Either[TableAndTests]]:
"""
Check that the table has the proper test suite built in
"""
+ try:
+ service_connection: DatabaseConnection = self._get_table_service_connection(
+ table
+ )
+ except Exception as exc:
+ yield Either(
+ left=StackTraceError(
+ name="Error getting service connection",
+ error=f"Error getting the service connection for table {table.name.root}: {exc}",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+ return
# If there is no executable test suite yet for the table, we'll need to create one
+ # Then, the suite won't have yet any tests
if not table.testSuite:
executable_test_suite = CreateTestSuiteRequest(
name=fqn.build(
@@ -143,38 +205,80 @@ def _process_table_suite(self, table: Table) -> Iterable[Either[TableAndTests]]:
displayName=f"{self.source_config.entityFullyQualifiedName.root} Test Suite",
description="Test Suite created from YAML processor config file",
owners=None,
- executableEntityReference=self.source_config.entityFullyQualifiedName.root,
+ basicEntityReference=self.source_config.entityFullyQualifiedName.root,
)
yield Either(
right=TableAndTests(
executable_test_suite=executable_test_suite,
- service_type=self.config.source.serviceConnection.root.config.type.value,
+ service_type=service_connection.config.type.value,
+ service_connection=service_connection,
)
)
+ test_suite_cases = []
- test_suite: Optional[TestSuite] = None
- if table.testSuite:
- test_suite = self.metadata.get_by_id(
+ # Otherwise, we pick the tests already registered in the suite
+ else:
+ test_suite: Optional[TestSuite] = self.metadata.get_by_id(
entity=TestSuite, entity_id=table.testSuite.id.root
)
+ test_suite_cases = self._get_test_cases_from_test_suite(test_suite)
+
+ yield Either(
+ right=TableAndTests(
+ table=table,
+ test_cases=test_suite_cases,
+ service_type=service_connection.config.type.value,
+ service_connection=service_connection,
+ )
+ )
- if test_suite and not test_suite.executable:
+ def _process_logical_suite(self):
+ """Process logical test suite, collect all test cases and yield them in batches by table"""
+ test_suite = self.metadata.get_by_name(
+ entity=TestSuite, fqn=self.config.source.serviceName
+ )
+ if test_suite is None:
yield Either(
left=StackTraceError(
- name="Non-executable Test Suite",
- error=f"The table {self.source_config.entityFullyQualifiedName.root} "
- "has a test suite that is not executable.",
+ name="Test Suite not found",
+ error=f"Test Suite with name {self.config.source.serviceName} not found",
)
)
+ test_cases: List[TestCase] = self._get_test_cases_from_test_suite(test_suite)
+ grouped_by_table = itertools.groupby(
+ test_cases, key=lambda t: entity_link.get_table_fqn(t.entityLink.root)
+ )
+ for table_fqn, group in grouped_by_table:
+ table_entity: Table = self.metadata.get_by_name(Table, table_fqn)
+ if table_entity is None:
+ yield Either(
+ left=StackTraceError(
+ name="Table not found",
+ error=f"Table with fqn {table_fqn} not found for test suite {test_suite.name.root}",
+ )
+ )
+ continue
- else:
- test_suite_cases = self._get_test_cases_from_test_suite(test_suite)
+ try:
+ service_connection: DatabaseConnection = (
+ self._get_table_service_connection(table_entity)
+ )
+ except Exception as exc:
+ yield Either(
+ left=StackTraceError(
+ name="Error getting service connection",
+ error=f"Error getting the service connection for table {table_entity.name.root}: {exc}",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+ continue
yield Either(
right=TableAndTests(
- table=table,
- test_cases=test_suite_cases,
- service_type=self.config.source.serviceConnection.root.config.type.value,
+ table=table_entity,
+ test_cases=list(group),
+ service_type=service_connection.config.type.value,
+ service_connection=service_connection,
)
)
diff --git a/ingestion/src/metadata/great_expectations/action.py b/ingestion/src/metadata/great_expectations/action.py
index c51d146785c7..62fc07309a36 100644
--- a/ingestion/src/metadata/great_expectations/action.py
+++ b/ingestion/src/metadata/great_expectations/action.py
@@ -257,7 +257,7 @@ def _check_or_create_test_suite(self, table_entity: Table) -> TestSuite:
create_test_suite = CreateTestSuiteRequest(
name=f"{table_entity.fullyQualifiedName.root}.TestSuite",
- executableEntityReference=table_entity.fullyQualifiedName.root,
+ basicEntityReference=table_entity.fullyQualifiedName.root,
) # type: ignore
test_suite = self.ometa_conn.create_or_update_executable_test_suite(
create_test_suite
diff --git a/ingestion/src/metadata/ingestion/lineage/masker.py b/ingestion/src/metadata/ingestion/lineage/masker.py
index 49a5999e72c6..e55783934052 100644
--- a/ingestion/src/metadata/ingestion/lineage/masker.py
+++ b/ingestion/src/metadata/ingestion/lineage/masker.py
@@ -14,8 +14,7 @@
import traceback
-import sqlparse
-from sqlfluff.core import Linter
+from collate_sqllineage.runner import SQLPARSE_DIALECT, LineageRunner
from sqlparse.sql import Comparison
from sqlparse.tokens import Literal, Number, String
@@ -24,6 +23,7 @@
MASK_TOKEN = "?"
+# pylint: disable=protected-access
def get_logger():
# pylint: disable=import-outside-toplevel
from metadata.utils.logger import utils_logger
@@ -31,18 +31,14 @@ def get_logger():
return utils_logger()
-def mask_literals_with_sqlparse(query: str):
+def mask_literals_with_sqlparse(query: str, parser: LineageRunner):
"""
Mask literals in a query using sqlparse.
"""
logger = get_logger()
try:
- parsed = sqlparse.parse(query) # Parse the query
-
- if not parsed:
- return query
- parsed = parsed[0]
+ parsed = parser._parsed_result
def mask_token(token):
# Mask all literals: strings, numbers, or other literal values
@@ -79,17 +75,16 @@ def mask_token(token):
return query
-def mask_literals_with_sqlfluff(query: str, dialect: str = Dialect.ANSI.value) -> str:
+def mask_literals_with_sqlfluff(query: str, parser: LineageRunner) -> str:
"""
Mask literals in a query using SQLFluff.
"""
logger = get_logger()
try:
- # Initialize SQLFluff linter
- linter = Linter(dialect=dialect)
+ if not parser._evaluated:
+ parser._eval()
- # Parse the query
- parsed = linter.parse_string(query)
+ parsed = parser._parsed_result
def replace_literals(segment):
"""Recursively replace literals with placeholders."""
@@ -114,18 +109,22 @@ def replace_literals(segment):
return query
-def mask_query(query: str, dialect: str = Dialect.ANSI.value) -> str:
+def mask_query(
+ query: str, dialect: str = Dialect.ANSI.value, parser: LineageRunner = None
+) -> str:
logger = get_logger()
try:
- sqlfluff_masked_query = mask_literals_with_sqlfluff(query, dialect)
- sqlparse_masked_query = mask_literals_with_sqlparse(query)
- # compare both masked queries and return the one with more masked tokens
- if sqlfluff_masked_query.count(MASK_TOKEN) >= sqlparse_masked_query.count(
- MASK_TOKEN
- ):
- return sqlfluff_masked_query
- return sqlparse_masked_query
+ if not parser:
+ try:
+ parser = LineageRunner(query, dialect=dialect)
+ len(parser.source_tables)
+ except Exception:
+ parser = LineageRunner(query)
+ len(parser.source_tables)
+ if parser._dialect == SQLPARSE_DIALECT:
+ return mask_literals_with_sqlparse(query, parser)
+ return mask_literals_with_sqlfluff(query, parser)
except Exception as exc:
logger.debug(f"Failed to mask query with sqlfluff: {exc}")
logger.debug(traceback.format_exc())
- return query
+ return None
diff --git a/ingestion/src/metadata/ingestion/lineage/models.py b/ingestion/src/metadata/ingestion/lineage/models.py
index 6e0e4385854c..a3f50208c5f7 100644
--- a/ingestion/src/metadata/ingestion/lineage/models.py
+++ b/ingestion/src/metadata/ingestion/lineage/models.py
@@ -43,6 +43,9 @@
from metadata.generated.schema.entity.services.connections.database.impalaConnection import (
ImpalaType,
)
+from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import (
+ MariaDBType,
+)
from metadata.generated.schema.entity.services.connections.database.mssqlConnection import (
MssqlType,
)
@@ -58,6 +61,9 @@
from metadata.generated.schema.entity.services.connections.database.redshiftConnection import (
RedshiftType,
)
+from metadata.generated.schema.entity.services.connections.database.singleStoreConnection import (
+ SingleStoreType,
+)
from metadata.generated.schema.entity.services.connections.database.snowflakeConnection import (
SnowflakeType,
)
@@ -120,6 +126,8 @@ class Dialect(Enum):
str(MssqlType.Mssql.value): Dialect.TSQL,
str(AzureSQLType.AzureSQL.value): Dialect.TSQL,
str(TeradataType.Teradata.value): Dialect.TERADATA,
+ str(MariaDBType.MariaDB.value): Dialect.MYSQL,
+ str(SingleStoreType.SingleStore.value): Dialect.MYSQL,
}
diff --git a/ingestion/src/metadata/ingestion/lineage/parser.py b/ingestion/src/metadata/ingestion/lineage/parser.py
index f7fad5fe812a..b9925fb1e35e 100644
--- a/ingestion/src/metadata/ingestion/lineage/parser.py
+++ b/ingestion/src/metadata/ingestion/lineage/parser.py
@@ -71,12 +71,13 @@ def __init__(
self.query_parsing_success = True
self.query_parsing_failure_reason = None
self.dialect = dialect
- self._masked_query = mask_query(self.query, dialect.value)
+ self.masked_query = None
self._clean_query = self.clean_raw_query(query)
- self._masked_clean_query = mask_query(self._clean_query, dialect.value)
self.parser = self._evaluate_best_parser(
self._clean_query, dialect=dialect, timeout_seconds=timeout_seconds
)
+ if self.masked_query is None:
+ self.masked_query = mask_query(self._clean_query, parser=self.parser)
@cached_property
def involved_tables(self) -> Optional[List[Table]]:
@@ -95,7 +96,7 @@ def involved_tables(self) -> Optional[List[Table]]:
except SQLLineageException as exc:
logger.debug(traceback.format_exc())
logger.warning(
- f"Cannot extract source table information from query [{self._masked_query}]: {exc}"
+ f"Cannot extract source table information from query [{self.masked_query or self.query}]: {exc}"
)
return None
@@ -334,12 +335,10 @@ def stateful_add_joins_from_statement(
)
if not table_left or not table_right:
- logger.warning(
- f"Can't extract table names when parsing JOIN information from {comparison}"
- )
logger.debug(
- f"Query: {mask_query(sql_statement, self.dialect.value)}"
+ f"Can't extract table names when parsing JOIN information from {comparison}"
)
+ logger.debug(f"Query: {self.masked_query or self.query}")
continue
left_table_column = TableColumn(table=table_left, column=column_left)
@@ -422,10 +421,9 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner:
lr_dialect.get_column_lineage()
return lr_dialect
- sqlfluff_count = 0
try:
lr_sqlfluff = get_sqlfluff_lineage_runner(query, dialect.value)
- sqlfluff_count = len(lr_sqlfluff.get_column_lineage()) + len(
+ _ = len(lr_sqlfluff.get_column_lineage()) + len(
set(lr_sqlfluff.source_tables).union(
set(lr_sqlfluff.target_tables).union(
set(lr_sqlfluff.intermediate_tables)
@@ -438,23 +436,20 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner:
f"Lineage with SqlFluff failed for the [{dialect.value}]. "
f"Parser has been running for more than {timeout_seconds} seconds."
)
- logger.debug(
- f"{self.query_parsing_failure_reason}] query: [{self._masked_clean_query}]"
- )
lr_sqlfluff = None
except Exception:
self.query_parsing_success = False
self.query_parsing_failure_reason = (
f"Lineage with SqlFluff failed for the [{dialect.value}]"
)
- logger.debug(
- f"{self.query_parsing_failure_reason} query: [{self._masked_clean_query}]"
- )
lr_sqlfluff = None
+ if lr_sqlfluff:
+ return lr_sqlfluff
+
lr_sqlparser = LineageRunner(query)
try:
- sqlparser_count = len(lr_sqlparser.get_column_lineage()) + len(
+ _ = len(lr_sqlparser.get_column_lineage()) + len(
set(lr_sqlparser.source_tables).union(
set(lr_sqlparser.target_tables).union(
set(lr_sqlparser.intermediate_tables)
@@ -463,21 +458,13 @@ def get_sqlfluff_lineage_runner(qry: str, dlct: str) -> LineageRunner:
)
except Exception:
# if both runner have failed we return the usual one
+ logger.debug(f"Failed to parse query with sqlparse & sqlfluff: {query}")
return lr_sqlfluff if lr_sqlfluff else lr_sqlparser
- if lr_sqlfluff:
- # if sqlparser retrieve more lineage info that sqlfluff
- if sqlparser_count > sqlfluff_count:
- self.query_parsing_success = False
- self.query_parsing_failure_reason = (
- "Lineage computed with SqlFluff did not perform as expected "
- f"for the [{dialect.value}]"
- )
- logger.debug(
- f"{self.query_parsing_failure_reason} query: [{self._masked_clean_query}]"
- )
- return lr_sqlparser
- return lr_sqlfluff
+ self.masked_query = mask_query(self._clean_query, parser=lr_sqlparser)
+ logger.debug(
+ f"Using sqlparse for lineage parsing for query: {self.masked_query or self.query}"
+ )
return lr_sqlparser
@staticmethod
diff --git a/ingestion/src/metadata/ingestion/lineage/sql_lineage.py b/ingestion/src/metadata/ingestion/lineage/sql_lineage.py
index c8954fad6f0f..577e6a87498a 100644
--- a/ingestion/src/metadata/ingestion/lineage/sql_lineage.py
+++ b/ingestion/src/metadata/ingestion/lineage/sql_lineage.py
@@ -16,8 +16,10 @@
from collections import defaultdict
from typing import Any, Iterable, List, Optional, Tuple, Union
+import networkx as nx
from collate_sqllineage.core.models import Column, DataFunction
from collate_sqllineage.core.models import Table as LineageTable
+from networkx import DiGraph
from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest
from metadata.generated.schema.entity.data.storedProcedure import (
@@ -37,7 +39,6 @@
from metadata.generated.schema.type.entityLineage import Source as LineageSource
from metadata.generated.schema.type.entityReference import EntityReference
from metadata.ingestion.api.models import Either
-from metadata.ingestion.lineage.masker import mask_query
from metadata.ingestion.lineage.models import (
Dialect,
QueryParsingError,
@@ -510,6 +511,7 @@ def _create_lineage_by_table_name(
column_lineage_map: dict,
lineage_source: LineageSource = LineageSource.QueryLineage,
procedure: Optional[EntityReference] = None,
+ graph: DiGraph = None,
) -> Iterable[Either[AddLineageRequest]]:
"""
This method is to create a lineage between two tables
@@ -540,6 +542,11 @@ def _create_lineage_by_table_name(
logger.debug(
f"WARNING: Table entity [{table_name}] not found in OpenMetadata"
)
+ if graph is not None and (not from_table_entities or not to_table_entities):
+ graph.add_node(from_table, entity=from_table_entities)
+ graph.add_node(to_table, entity=to_table_entities)
+ graph.add_edge(from_table, to_table, query=masked_query)
+ return
for from_entity, to_entity in itertools.product(
from_table_entities or [], to_table_entities or []
@@ -607,6 +614,7 @@ def get_lineage_by_query(
dialect: Dialect,
timeout_seconds: int = LINEAGE_PARSING_TIMEOUT,
lineage_source: LineageSource = LineageSource.QueryLineage,
+ graph: DiGraph = None,
) -> Iterable[Either[AddLineageRequest]]:
"""
This method parses the query to get source, target and intermediate table names to create lineage,
@@ -614,11 +622,11 @@ def get_lineage_by_query(
"""
column_lineage = {}
query_parsing_failures = QueryParsingFailures()
- masked_query = mask_query(query, dialect.value)
try:
- logger.debug(f"Running lineage with query: {masked_query}")
lineage_parser = LineageParser(query, dialect, timeout_seconds=timeout_seconds)
+ masked_query = lineage_parser.masked_query
+ logger.debug(f"Running lineage with query: {masked_query or query}")
raw_column_lineage = lineage_parser.column_lineage
column_lineage.update(populate_column_lineage_map(raw_column_lineage))
@@ -646,6 +654,7 @@ def get_lineage_by_query(
column_lineage_map=column_lineage,
lineage_source=lineage_source,
procedure=procedure,
+ graph=graph,
)
for target_table in lineage_parser.target_tables:
yield from _create_lineage_by_table_name(
@@ -683,11 +692,12 @@ def get_lineage_by_query(
column_lineage_map=column_lineage,
lineage_source=lineage_source,
procedure=procedure,
+ graph=graph,
)
if not lineage_parser.query_parsing_success:
query_parsing_failures.add(
QueryParsingError(
- query=masked_query,
+ query=masked_query or query,
error=lineage_parser.query_parsing_failure_reason,
)
)
@@ -711,15 +721,18 @@ def get_lineage_via_table_entity(
dialect: Dialect,
timeout_seconds: int = LINEAGE_PARSING_TIMEOUT,
lineage_source: LineageSource = LineageSource.QueryLineage,
+ graph: DiGraph = None,
) -> Iterable[Either[AddLineageRequest]]:
"""Get lineage from table entity"""
column_lineage = {}
query_parsing_failures = QueryParsingFailures()
- masked_query = mask_query(query, dialect.value)
try:
- logger.debug(f"Getting lineage via table entity using query: {masked_query}")
lineage_parser = LineageParser(query, dialect, timeout_seconds=timeout_seconds)
+ masked_query = lineage_parser.masked_query
+ logger.debug(
+ f"Getting lineage via table entity using query: {masked_query or query}"
+ )
to_table_name = table_entity.name.root
for from_table_name in lineage_parser.source_tables:
@@ -744,6 +757,7 @@ def get_lineage_via_table_entity(
column_lineage_map=column_lineage,
lineage_source=lineage_source,
procedure=procedure,
+ graph=graph,
) or []
if not lineage_parser.query_parsing_success:
query_parsing_failures.add(
@@ -760,3 +774,94 @@ def get_lineage_via_table_entity(
stackTrace=traceback.format_exc(),
)
)
+
+
+def _process_sequence(
+ sequence: List[Any], graph: DiGraph
+) -> Iterable[Either[AddLineageRequest]]:
+ """
+ Process a sequence of nodes to generate lineage information.
+ """
+ from_node = None
+ queries = set()
+ clean_queries = False
+ previous_node = None
+ for node in sequence:
+ try:
+ if clean_queries:
+ queries.clear()
+ clean_queries = False
+ current_node = graph.nodes[node]
+ current_entity = current_node.get("entity")
+
+ if (
+ previous_node is not None
+ and graph.edges[(previous_node, node)].get("query") is not None
+ ):
+ queries.add(graph.edges[(previous_node, node)].get("query"))
+
+ if current_entity and from_node is not None:
+ for from_entity, to_entity in itertools.product(
+ from_node.get("entity") or [], current_entity or []
+ ):
+ if to_entity and from_entity:
+ yield _build_table_lineage(
+ to_entity=to_entity,
+ from_entity=from_entity,
+ to_table_raw_name=str(node),
+ from_table_raw_name=str(from_node),
+ masked_query="\n--------\n".join(queries),
+ column_lineage_map={},
+ lineage_source=LineageSource.QueryLineage,
+ procedure=None,
+ )
+ clean_queries = True
+
+ if current_entity:
+ from_node = graph.nodes[node]
+ previous_node = node
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.error(f"Error creating lineage for node [{node}]: {exc}")
+
+
+def get_lineage_by_graph(
+ graph: DiGraph,
+) -> Iterable[Either[AddLineageRequest]]:
+ """
+ Generate lineage information from a directed graph.
+ This method processes a directed graph to extract lineage information by identifying
+ weakly connected components and traversing each component to generate sequences of nodes.
+ It then yields lineage information for each sequence.
+ Args:
+ graph (DiGraph): A directed graph representing the lineage.
+ Raises:
+ Exception: If an error occurs during the lineage creation process, it logs the error.
+ """
+ if graph is None:
+ return
+
+ # Get all weakly connected components
+ components = list(nx.weakly_connected_components(graph))
+
+ # Extract each component as an independent subgraph
+ independent_subtrees = [
+ graph.subgraph(component).copy() for component in components
+ ]
+
+ # Print results in the desired format
+ for subtree in independent_subtrees:
+ # Find a root node (node with no incoming edges)
+ root = [node for node in subtree if subtree.in_degree(node) == 0][0]
+
+ # Traverse from the root to get the sequence of nodes
+ current = root
+ sequence = [current]
+ while True:
+ successors = list(subtree.successors(current))
+ if not successors:
+ break
+ current = successors[0]
+ sequence.append(current)
+
+ yield from _process_sequence(sequence, subtree)
diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py
index 27d4e6cef9e5..fbd84b4e92e1 100644
--- a/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py
+++ b/ingestion/src/metadata/ingestion/ometa/mixins/es_mixin.py
@@ -344,7 +344,7 @@ def _paginate_es_internal(
# Get next page
last_hit = response.hits.hits[-1] if response.hits.hits else None
if not last_hit or not last_hit.sort:
- logger.info("No more pages to fetch")
+ logger.debug("No more pages to fetch")
break
after = ",".join(last_hit.sort)
@@ -402,10 +402,51 @@ def yield_es_view_def(
"query": {
"bool": {
"must": [
- {"term": {"service.name.keyword": service_name}},
- {"term": {"tableType": TableType.View.value}},
- {"term": {"deleted": False}},
- {"exists": {"field": "schemaDefinition"}},
+ {
+ "bool": {
+ "should": [
+ {"term": {"service.name.keyword": service_name}}
+ ]
+ }
+ },
+ {
+ "bool": {
+ "must": [
+ {
+ "bool": {
+ "should": [
+ {
+ "term": {
+ "tableType": TableType.View.value
+ }
+ },
+ {
+ "term": {
+ "tableType": TableType.MaterializedView.value
+ }
+ },
+ {
+ "term": {
+ "tableType": TableType.SecureView.value
+ }
+ },
+ {
+ "term": {
+ "tableType": TableType.Dynamic.value
+ }
+ },
+ ]
+ }
+ }
+ ]
+ }
+ },
+ {"bool": {"should": [{"term": {"deleted": False}}]}},
+ {
+ "bool": {
+ "should": [{"exists": {"field": "schemaDefinition"}}]
+ }
+ },
]
}
}
@@ -420,10 +461,11 @@ def yield_es_view_def(
_, database_name, schema_name, table_name = fqn.split(
hit.source["fullyQualifiedName"]
)
- yield TableView(
- view_definition=hit.source["schemaDefinition"],
- service_name=service_name,
- db_name=database_name,
- schema_name=schema_name,
- table_name=table_name,
- )
+ if hit.source.get("schemaDefinition"):
+ yield TableView(
+ view_definition=hit.source["schemaDefinition"],
+ service_name=service_name,
+ db_name=database_name,
+ schema_name=schema_name,
+ table_name=table_name,
+ )
diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py
index 45769f5935c4..5adc2c18296f 100644
--- a/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py
+++ b/ingestion/src/metadata/ingestion/ometa/mixins/lineage_mixin.py
@@ -63,7 +63,7 @@ def _merge_column_lineage(
)
for column in updated or []:
if not isinstance(column, dict):
- data = column.dict()
+ data = column.model_dump()
else:
data = column
if data.get("toColumn") and data.get("fromColumns"):
diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/query_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/query_mixin.py
index be2de4fbf4a0..c0b217a804e8 100644
--- a/ingestion/src/metadata/ingestion/ometa/mixins/query_mixin.py
+++ b/ingestion/src/metadata/ingestion/ometa/mixins/query_mixin.py
@@ -42,6 +42,8 @@ def _get_query_hash(self, query: str) -> str:
return str(result.hexdigest())
def _get_or_create_query(self, query: CreateQueryRequest) -> Optional[Query]:
+ if query.query.root is None:
+ return None
query_hash = self._get_query_hash(query=query.query.root)
query_entity = self.get_by_name(entity=Query, fqn=query_hash)
if query_entity is None:
diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py
index c76ddc896318..d4eb2fd1b742 100644
--- a/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py
+++ b/ingestion/src/metadata/ingestion/ometa/mixins/tests_mixin.py
@@ -228,7 +228,7 @@ def get_or_create_executable_test_suite(
create_test_suite = CreateTestSuiteRequest(
name=f"{table_entity.fullyQualifiedName.root}.TestSuite",
- executableEntityReference=table_entity.fullyQualifiedName.root,
+ basicEntityReference=table_entity.fullyQualifiedName.root,
) # type: ignore
test_suite = self.create_or_update_executable_test_suite(create_test_suite)
return test_suite
diff --git a/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py b/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py
index 2ee604973f1a..c389fbc2842a 100644
--- a/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py
+++ b/ingestion/src/metadata/ingestion/ometa/mixins/user_mixin.py
@@ -13,6 +13,7 @@
To be used by OpenMetadata class
"""
+import json
from functools import lru_cache
from typing import Optional, Type
@@ -46,16 +47,29 @@ def email_search_query_es(entity: Type[T]) -> str:
)
@staticmethod
- def name_search_query_es(entity: Type[T]) -> str:
+ def name_search_query_es(entity: Type[T], name: str, from_: int, size: int) -> str:
"""
Allow for more flexible lookup following what the UI is doing when searching users.
We don't want to stick to `q=name:{name}` since in case a user is named `random.user`
but looked as `Random User`, we want to find this match.
+
+ Search should only look in name and displayName fields and should not return bots.
"""
+ query_filter = {
+ "query": {
+ "query_string": {
+ "query": f"{name} AND isBot:false",
+ "fields": ["name", "displayName"],
+ "default_operator": "AND",
+ "fuzziness": "AUTO",
+ }
+ }
+ }
+
return (
- "/search/query?q={name} AND isBot:false&from={from_}&size={size}&index="
- + ES_INDEX_MAP[entity.__name__]
+ f"""/search/query?query_filter={json.dumps(query_filter)}"""
+ f"&from={from_}&size={size}&index=" + ES_INDEX_MAP[entity.__name__]
)
def _search_by_email(
@@ -103,8 +117,8 @@ def _search_by_name(
fields: Optional field list to pass to ES request
"""
if name:
- query_string = self.name_search_query_es(entity=entity).format(
- name=name, from_=from_count, size=size
+ query_string = self.name_search_query_es(
+ entity=entity, name=name, from_=from_count, size=size
)
return self.get_entity_from_es(
entity=entity, query_string=query_string, fields=fields
diff --git a/ingestion/src/metadata/ingestion/ometa/routes.py b/ingestion/src/metadata/ingestion/ometa/routes.py
index 2750991a0537..b8538923110a 100644
--- a/ingestion/src/metadata/ingestion/ometa/routes.py
+++ b/ingestion/src/metadata/ingestion/ometa/routes.py
@@ -54,6 +54,7 @@
)
from metadata.generated.schema.api.data.createTable import CreateTableRequest
from metadata.generated.schema.api.data.createTopic import CreateTopicRequest
+from metadata.generated.schema.api.docStore.createDocument import CreateDocumentRequest
from metadata.generated.schema.api.domains.createDataProduct import (
CreateDataProductRequest,
)
@@ -91,6 +92,7 @@
from metadata.generated.schema.api.services.ingestionPipelines.createIngestionPipeline import (
CreateIngestionPipelineRequest,
)
+from metadata.generated.schema.api.teams.createPersona import CreatePersonaRequest
from metadata.generated.schema.api.teams.createRole import CreateRoleRequest
from metadata.generated.schema.api.teams.createTeam import CreateTeamRequest
from metadata.generated.schema.api.teams.createUser import CreateUserRequest
@@ -136,6 +138,7 @@
from metadata.generated.schema.entity.data.storedProcedure import StoredProcedure
from metadata.generated.schema.entity.data.table import Table
from metadata.generated.schema.entity.data.topic import Topic
+from metadata.generated.schema.entity.docStore.document import Document
from metadata.generated.schema.entity.domains.dataProduct import DataProduct
from metadata.generated.schema.entity.domains.domain import Domain
from metadata.generated.schema.entity.feed.suggestion import Suggestion
@@ -155,6 +158,7 @@
from metadata.generated.schema.entity.services.pipelineService import PipelineService
from metadata.generated.schema.entity.services.searchService import SearchService
from metadata.generated.schema.entity.services.storageService import StorageService
+from metadata.generated.schema.entity.teams.persona import Persona
from metadata.generated.schema.entity.teams.role import Role
from metadata.generated.schema.entity.teams.team import Team
from metadata.generated.schema.entity.teams.user import AuthenticationMechanism, User
@@ -198,6 +202,8 @@
CreateAPIEndpointRequest.__name__: "/apiEndpoints",
APICollection.__name__: "/apiCollections",
CreateAPICollectionRequest.__name__: "/apiCollections",
+ Document.__name__: "/docStore",
+ CreateDocumentRequest.__name__: "/docStore",
# Classifications
Tag.__name__: "/tags",
CreateTagRequest.__name__: "/tags",
@@ -213,6 +219,8 @@
CreateTeamRequest.__name__: "/teams",
User.__name__: "/users",
CreateUserRequest.__name__: "/users",
+ Persona.__name__: "/personas",
+ CreatePersonaRequest.__name__: "/personas",
AuthenticationMechanism.__name__: "/users/auth-mechanism",
Bot.__name__: "/bots",
CreateBot.__name__: "/bots",
diff --git a/ingestion/src/metadata/ingestion/source/api/rest/metadata.py b/ingestion/src/metadata/ingestion/source/api/rest/metadata.py
index 925c0f1e943f..bf59b1db6767 100644
--- a/ingestion/src/metadata/ingestion/source/api/rest/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/api/rest/metadata.py
@@ -171,7 +171,7 @@ def _filter_collection_endpoints(
break
return filtered_paths
except Exception as err:
- logger.info(
+ logger.warning(
f"Error while filtering endpoints for collection {collection.name.root}"
)
return None
@@ -184,7 +184,7 @@ def _prepare_endpoint_data(self, path, method_type, info) -> Optional[RESTEndpoi
endpoint.name = f"{path} - {method_type}"
return endpoint
except Exception as err:
- logger.info(f"Error while parsing endpoint data: {err}")
+ logger.warning(f"Error while parsing endpoint data: {err}")
return None
def _generate_collection_url(self, collection_name: str) -> Optional[AnyUrl]:
@@ -195,7 +195,7 @@ def _generate_collection_url(self, collection_name: str) -> Optional[AnyUrl]:
f"{self.config.serviceConnection.root.config.openAPISchemaURL}#tag/{collection_name}"
)
except Exception as err:
- logger.info(f"Error while generating collection url: {err}")
+ logger.warning(f"Error while generating collection url: {err}")
return None
def _generate_endpoint_url(self, endpoint_name: str) -> AnyUrl:
@@ -203,15 +203,14 @@ def _generate_endpoint_url(self, endpoint_name: str) -> AnyUrl:
base_url = self.config.serviceConnection.root.config.openAPISchemaURL
if endpoint_name:
return AnyUrl(f"{base_url}#operation/{endpoint_name}")
- else:
- return AnyUrl(base_url)
+ return AnyUrl(base_url)
def _get_api_request_method(self, method_type: str) -> Optional[str]:
"""fetch endpoint request method"""
try:
return ApiRequestMethod[method_type.upper()]
except KeyError as err:
- logger.info(f"Keyerror while fetching request method: {err}")
+ logger.warning(f"Keyerror while fetching request method: {err}")
return None
def _get_request_schema(self, info: dict) -> Optional[APISchema]:
@@ -227,9 +226,9 @@ def _get_request_schema(self, info: dict) -> Optional[APISchema]:
if not schema_ref:
logger.debug("No request schema found for the endpoint")
return None
- return self._process_schema(schema_ref)
+ return APISchema(schemaFields=self.process_schema_fields(schema_ref))
except Exception as err:
- logger.info(f"Error while parsing request schema: {err}")
+ logger.warning(f"Error while parsing request schema: {err}")
return None
def _get_response_schema(self, info: dict) -> Optional[APISchema]:
@@ -246,33 +245,43 @@ def _get_response_schema(self, info: dict) -> Optional[APISchema]:
if not schema_ref:
logger.debug("No response schema found for the endpoint")
return None
- return self._process_schema(schema_ref)
+ return APISchema(schemaFields=self.process_schema_fields(schema_ref))
except Exception as err:
- logger.info(f"Error while parsing response schema: {err}")
+ logger.warning(f"Error while parsing response schema: {err}")
return None
- def _process_schema(self, schema_ref: str) -> Optional[List[APISchema]]:
- """process schema"""
+ def process_schema_fields(self, schema_ref: str) -> Optional[List[FieldModel]]:
try:
- schema_ref = schema_ref.split("/")[-1]
+ schema_name = schema_ref.split("/")[-1]
schema_fields = (
- self.json_response.get("components").get("schemas").get(schema_ref)
+ self.json_response.get("components").get("schemas").get(schema_name)
)
fetched_fields = []
for key, val in schema_fields.get("properties", {}).items():
dtype = val.get("type")
- if not dtype:
- continue
- fetched_fields.append(
- FieldModel(
- name=key,
- dataType=DataTypeTopic[dtype.upper()]
+ if dtype:
+ parsed_dtype = (
+ DataTypeTopic[dtype.upper()]
if dtype.upper() in DataTypeTopic.__members__
- else DataTypeTopic.UNKNOWN,
+ else DataTypeTopic.UNKNOWN
)
- )
- return APISchema(schemaFields=fetched_fields)
+ fetched_fields.append(FieldModel(name=key, dataType=parsed_dtype))
+ else:
+ # If type of field is not defined then check for sub-schema
+ # Check if it's `object` type field
+ # check infinite recrusrion by comparing with parent(schema_ref)
+ object_children = None
+ if val.get("$ref") and val.get("$ref") != schema_ref:
+ object_children = self.process_schema_fields(val.get("$ref"))
+ fetched_fields.append(
+ FieldModel(
+ name=key,
+ dataType=DataTypeTopic.UNKNOWN,
+ children=object_children,
+ )
+ )
+ return fetched_fields
except Exception as err:
- logger.info(f"Error while processing request schema: {err}")
+ logger.warning(f"Error while processing schema fields: {err}")
return None
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py
index 4cf1858227f9..fafc59429bb5 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/looker/metadata.py
@@ -21,13 +21,26 @@
"""
import copy
import os
+import re
import traceback
from datetime import datetime
from pathlib import Path
-from typing import Dict, Iterable, List, Optional, Sequence, Set, Type, Union, cast
+from typing import (
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Sequence,
+ Set,
+ Type,
+ Union,
+ cast,
+ get_args,
+)
import giturlparse
import lkml
+import networkx as nx
from liquid import Template
from looker_sdk.sdk.api40.methods import Looker40SDK
from looker_sdk.sdk.api40.models import Dashboard as LookerDashboard
@@ -91,7 +104,7 @@
from metadata.generated.schema.type.usageRequest import UsageRequest
from metadata.ingestion.api.models import Either
from metadata.ingestion.api.steps import InvalidSourceException
-from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper
+from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper, Dialect
from metadata.ingestion.lineage.parser import LineageParser
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.dashboard.dashboard_service import (
@@ -108,7 +121,6 @@
LookMlView,
ViewName,
)
-from metadata.ingestion.source.dashboard.looker.parser import LkmlParser
from metadata.ingestion.source.dashboard.looker.utils import _clone_repo
from metadata.readers.file.api_reader import ReadersCredentials
from metadata.readers.file.base import Reader
@@ -121,10 +133,13 @@
logger = ingestion_logger()
-
LIST_DASHBOARD_FIELDS = ["id", "title"]
IMPORTED_PROJECTS_DIR = "imported_projects"
+# we need to find the derived references in the SQL query using regex
+# https://cloud.google.com/looker/docs/derived-tables#referencing_derived_tables_in_other_derived_tables
+DERIVED_REFERENCES = r"\${([\w\s\d_.]+)\.SQL_TABLE_NAME}"
+
# Here we can update the fields to get further information, such as:
# created_at, updated_at, last_updater_id, deleted_at, deleter_id, favorite_count, last_viewed_at
GET_DASHBOARD_FIELDS = [
@@ -156,6 +171,13 @@ def build_datamodel_name(model_name: str, explore_name: str) -> str:
return clean_dashboard_name(model_name + "_" + explore_name)
+def find_derived_references(sql_query: str) -> List[str]:
+ if sql_query is None:
+ return []
+ matches = re.findall(DERIVED_REFERENCES, sql_query)
+ return matches
+
+
class LookerSource(DashboardServiceSource):
"""
Looker Source Class.
@@ -163,6 +185,8 @@ class LookerSource(DashboardServiceSource):
Its client uses Looker 40 from the SDK: client = looker_sdk.init40()
"""
+ # pylint: disable=too-many-instance-attributes
+
config: WorkflowSource
metadata: OpenMetadata
client: Looker40SDK
@@ -178,11 +202,15 @@ def __init__(
self._explores_cache = {}
self._repo_credentials: Optional[ReadersCredentials] = None
self._reader_class: Optional[Type[Reader]] = None
- self._project_parsers: Optional[Dict[str, LkmlParser]] = None
+ self._project_parsers: Optional[Dict[str, BulkLkmlParser]] = None
self._main_lookml_repo: Optional[LookMLRepo] = None
self._main__lookml_manifest: Optional[LookMLManifest] = None
self._view_data_model: Optional[DashboardDataModel] = None
+ self._parsed_views: Optional[Dict[str, str]] = {}
+ self._unparsed_views: Optional[Dict[str, str]] = {}
+ self._derived_dependencies = nx.DiGraph()
+
self._added_lineage: Optional[Dict] = {}
@classmethod
@@ -260,7 +288,7 @@ def prepare(self):
self._main__lookml_manifest = self.__read_manifest(credentials)
@property
- def parser(self) -> Optional[Dict[str, LkmlParser]]:
+ def parser(self) -> Optional[Dict[str, BulkLkmlParser]]:
if self.repository_credentials:
return self._project_parsers
return None
@@ -282,7 +310,7 @@ def parser(self, all_lookml_models: Sequence[LookmlModel]) -> None:
"""
if self.repository_credentials:
all_projects: Set[str] = {model.project_name for model in all_lookml_models}
- self._project_parsers: Dict[str, LkmlParser] = {
+ self._project_parsers: Dict[str, BulkLkmlParser] = {
project_name: BulkLkmlParser(
reader=self.reader(Path(self._main_lookml_repo.path))
)
@@ -325,7 +353,7 @@ def repository_credentials(self) -> Optional[ReadersCredentials]:
"""
if not self._repo_credentials:
if self.service_connection.gitCredentials and isinstance(
- self.service_connection.gitCredentials, ReadersCredentials
+ self.service_connection.gitCredentials, get_args(ReadersCredentials)
):
self._repo_credentials = self.service_connection.gitCredentials
@@ -487,9 +515,13 @@ def _get_explore_sql(self, explore: LookmlModelExplore) -> Optional[str]:
try:
project_parser = self.parser.get(explore.project_name)
if project_parser:
- return project_parser.parsed_files.get(
+ explore_sql = project_parser.parsed_files.get(
Includes(get_path_from_link(explore.lookml_link))
)
+ logger.debug(
+ f"Explore SQL for project {explore.project_name}: \n{explore_sql}"
+ )
+ return explore_sql
except Exception as err:
logger.warning(f"Exception getting the model sql: {err}")
@@ -544,6 +576,68 @@ def _process_view(
)
)
+ def replace_derived_references(self, sql_query):
+ """
+ Replace all derived references with the parsed views sql query
+ will replace the derived references in the SQL query using regex
+ for e.g. It will replace ${view_name.SQL_TABLE_NAME} with the parsed view query for view_name
+ https://cloud.google.com/looker/docs/derived-tables#referencing_derived_tables_in_other_derived_tables
+ """
+ try:
+ sql_query = re.sub(
+ DERIVED_REFERENCES,
+ # from `${view_name.SQL_TABLE_NAME}` we want the `view_name`.
+ # match.group(1) will give us the `view_name`
+ lambda match: f"({self._parsed_views.get(match.group(1), match.group(0))})",
+ sql_query,
+ )
+ except Exception as e:
+ logger.warning(
+ f"Something went wrong while replacing derived view references: {e}"
+ )
+ return sql_query
+
+ def build_lineage_for_unparsed_views(self) -> Iterable[Either[AddLineageRequest]]:
+ """
+ build lineage by parsing the unparsed views containing derived references
+ """
+ try:
+ # Doing a reversed topological sort to process the views in the right order
+ for view_name in reversed(
+ list(nx.topological_sort(self._derived_dependencies))
+ ):
+ if view_name in self._parsed_views:
+ # Skip if already processed
+ continue
+ sql_query = self.replace_derived_references(
+ self._unparsed_views[view_name]
+ )
+ if view_references := find_derived_references(sql_query):
+ # There are still derived references in the view query
+ logger.debug(
+ f"Views {view_references} not found for {view_name}. Skipping."
+ )
+ continue
+ self._parsed_views[view_name] = sql_query
+ del self._unparsed_views[view_name]
+ yield from self._build_lineage_for_view(view_name, sql_query)
+
+ except Exception as err:
+ yield Either(
+ left=StackTraceError(
+ name="parse_unparsed_views",
+ error=f"Error parsing unparsed views: {err}",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+
+ def _add_dependency_edge(self, view_name: str, view_references: List[str]):
+ """
+ Add a dependency edge between the view and the derived reference
+ """
+ for dependent_view_name in view_references:
+ self._derived_dependencies.add_edge(view_name, dependent_view_name)
+
def add_view_lineage(
self, view: LookMlView, explore: LookmlModelExplore
) -> Iterable[Either[AddLineageRequest]]:
@@ -572,10 +666,13 @@ def add_view_lineage(
if view.sql_table_name:
sql_table_name = self._render_table_name(view.sql_table_name)
- source_table_name = self._clean_table_name(sql_table_name)
- # View to the source is only there if we are informing the dbServiceNames
for db_service_name in db_service_names or []:
+ dialect = self._get_db_dialect(db_service_name)
+ source_table_name = self._clean_table_name(sql_table_name, dialect)
+ self._parsed_views[view.name] = source_table_name
+
+ # View to the source is only there if we are informing the dbServiceNames
yield self.build_lineage_request(
source=source_table_name,
db_service_name=db_service_name,
@@ -586,25 +683,19 @@ def add_view_lineage(
sql_query = view.derived_table.sql
if not sql_query:
return
- for db_service_name in db_service_names or []:
- db_service = self.metadata.get_by_name(
- DatabaseService, db_service_name
- )
-
- lineage_parser = LineageParser(
- sql_query,
- ConnectionTypeDialectMapper.dialect_of(
- db_service.connection.config.type.value
- ),
- timeout_seconds=30,
- )
- if lineage_parser.source_tables:
- for from_table_name in lineage_parser.source_tables:
- yield self.build_lineage_request(
- source=str(from_table_name),
- db_service_name=db_service_name,
- to_entity=self._view_data_model,
- )
+ if find_derived_references(sql_query):
+ sql_query = self.replace_derived_references(sql_query)
+ # If we still have derived references, we cannot process the view
+ if view_references := find_derived_references(sql_query):
+ self._add_dependency_edge(view.name, view_references)
+ logger.warning(
+ f"Not all references are replaced for view [{view.name}]. Parsing it later."
+ )
+ return
+ logger.debug(f"Processing view [{view.name}] with SQL: \n[{sql_query}]")
+ yield from self._build_lineage_for_view(view.name, sql_query)
+ if self._unparsed_views:
+ self.build_lineage_for_unparsed_views()
except Exception as err:
yield Either(
@@ -615,6 +706,33 @@ def add_view_lineage(
)
)
+ def _build_lineage_for_view(
+ self, view_name: str, sql_query: str
+ ) -> Iterable[Either[AddLineageRequest]]:
+ """
+ Parse the SQL query and build lineage for the view.
+ """
+ for db_service_name in self.get_db_service_names() or []:
+ lineage_parser = LineageParser(
+ sql_query,
+ self._get_db_dialect(db_service_name),
+ timeout_seconds=30,
+ )
+ if lineage_parser.source_tables:
+ self._parsed_views[view_name] = sql_query
+ for from_table_name in lineage_parser.source_tables:
+ yield self.build_lineage_request(
+ source=str(from_table_name),
+ db_service_name=db_service_name,
+ to_entity=self._view_data_model,
+ )
+
+ def _get_db_dialect(self, db_service_name) -> Dialect:
+ db_service = self.metadata.get_by_name(DatabaseService, db_service_name)
+ return ConnectionTypeDialectMapper.dialect_of(
+ db_service.connection.config.type.value
+ )
+
def get_dashboards_list(self) -> List[DashboardBase]:
"""
Get List of all dashboards
@@ -718,7 +836,7 @@ def get_project_name(self, dashboard_details: LookerDashboard) -> Optional[str]:
return None
@staticmethod
- def _clean_table_name(table_name: str) -> str:
+ def _clean_table_name(table_name: str, dialect: Dialect = Dialect.ANSI) -> str:
"""
sql_table_names might be renamed when defining
an explore. E.g., customers as cust
@@ -726,7 +844,10 @@ def _clean_table_name(table_name: str) -> str:
:return: clean table name
"""
- return table_name.lower().split(" as ")[0].strip()
+ clean_table_name = table_name.lower().split(" as ")[0].strip()
+ if dialect == Dialect.BIGQUERY:
+ clean_table_name = clean_table_name.strip("`")
+ return clean_table_name
@staticmethod
def _render_table_name(table_name: str) -> str:
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/looker/parser.py b/ingestion/src/metadata/ingestion/source/dashboard/looker/parser.py
index 008c914ed15d..b954e26d4166 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/looker/parser.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/looker/parser.py
@@ -181,6 +181,9 @@ def get_view_from_cache(self, view_name: ViewName) -> Optional[LookMlView]:
Otherwise, return None
"""
if view_name in self._views_cache:
+ logger.debug(
+ f"Found view [{view_name}] in cache: \n{self._views_cache[view_name]}"
+ )
return self._views_cache[view_name]
return None
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py
index d1acc7901ac9..cfaa47379ef0 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/client.py
@@ -47,7 +47,10 @@
logger = utils_logger()
-
+GETGROUPS_DEFAULT_PARAMS = {"$top": "1", "$skip": "0"}
+API_RESPONSE_MESSAGE_KEY = "message"
+AUTH_TOKEN_MAX_RETRIES = 5
+AUTH_TOKEN_RETRY_WAIT = 120
# Similar inner methods with mode client. That's fine.
# pylint: disable=duplicate-code
class PowerBiApiClient:
@@ -59,6 +62,9 @@ class PowerBiApiClient:
def __init__(self, config: PowerBIConnection):
self.config = config
+ self.pagination_entity_per_page = min(
+ 100, self.config.pagination_entity_per_page
+ )
self.msal_client = msal.ConfidentialClientApplication(
client_id=self.config.clientId,
client_credential=self.config.clientSecret.get_secret_value(),
@@ -82,42 +88,84 @@ def get_auth_token(self) -> Tuple[str, str]:
"""
logger.info("Generating PowerBi access token")
- response_data = self.msal_client.acquire_token_silent(
- scopes=self.config.scope, account=None
- )
-
+ response_data = self.get_auth_token_from_cache()
if not response_data:
logger.info("Token does not exist in the cache. Getting a new token.")
- response_data = self.msal_client.acquire_token_for_client(
- scopes=self.config.scope
- )
+ response_data = self.generate_new_auth_token()
+ response_data = response_data or {}
auth_response = PowerBiToken(**response_data)
if not auth_response.access_token:
raise InvalidSourceException(
- "Failed to generate the PowerBi access token. Please check provided config"
+ f"Failed to generate the PowerBi access token. Please check provided config {response_data}"
)
logger.info("PowerBi Access Token generated successfully")
return auth_response.access_token, auth_response.expires_in
+ def generate_new_auth_token(self) -> Optional[dict]:
+ """generate new auth token"""
+ retry = AUTH_TOKEN_MAX_RETRIES
+ while retry:
+ try:
+ response_data = self.msal_client.acquire_token_for_client(
+ scopes=self.config.scope
+ )
+ return response_data
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(f"Error generating new auth token: {exc}")
+ # wait for time and retry
+ retry -= 1
+ if retry:
+ logger.warning(
+ f"Error generating new token: {exc}, "
+ f"sleep {AUTH_TOKEN_RETRY_WAIT} seconds retrying {retry} more times.."
+ )
+ sleep(AUTH_TOKEN_RETRY_WAIT)
+ else:
+ logger.warning(
+ "Could not generate new token after maximum retries, "
+ "Please check provided configs"
+ )
+ return None
+
+ def get_auth_token_from_cache(self) -> Optional[dict]:
+ """fetch auth token from cache"""
+ retry = AUTH_TOKEN_MAX_RETRIES
+ while retry:
+ try:
+ response_data = self.msal_client.acquire_token_silent(
+ scopes=self.config.scope, account=None
+ )
+ return response_data
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(f"Error getting token from cache: {exc}")
+ retry -= 1
+ if retry:
+ logger.warning(
+ f"Error getting token from cache: {exc}, "
+ f"sleep {AUTH_TOKEN_RETRY_WAIT} seconds retrying {retry} more times.."
+ )
+ sleep(AUTH_TOKEN_RETRY_WAIT)
+ else:
+ logger.warning(
+ "Could not get token from cache after maximum retries, "
+ "Please check provided configs"
+ )
+ return None
+
def fetch_dashboards(self) -> Optional[List[PowerBIDashboard]]:
"""Get dashboards method
Returns:
List[PowerBIDashboard]
"""
- try:
- if self.config.useAdminApis:
- response_data = self.client.get("/myorg/admin/dashboards")
- response = DashboardsResponse(**response_data)
- return response.value
- group = self.fetch_all_workspaces()[0]
- return self.fetch_all_org_dashboards(group_id=group.id)
-
- except Exception as exc: # pylint: disable=broad-except
- logger.debug(traceback.format_exc())
- logger.warning(f"Error fetching dashboards: {exc}")
-
- return None
+ if self.config.useAdminApis:
+ response_data = self.client.get("/myorg/admin/dashboards")
+ response = DashboardsResponse(**response_data)
+ return response.value
+ group = self.fetch_all_workspaces()[0]
+ return self.fetch_all_org_dashboards(group_id=group.id)
def fetch_all_org_dashboards(
self, group_id: str
@@ -205,6 +253,7 @@ def fetch_dataset_tables(
return None
+ # pylint: disable=too-many-branches,too-many-statements
def fetch_all_workspaces(self) -> Optional[List[Group]]:
"""Method to fetch all powerbi workspace details
Returns:
@@ -213,22 +262,94 @@ def fetch_all_workspaces(self) -> Optional[List[Group]]:
try:
admin = "admin/" if self.config.useAdminApis else ""
api_url = f"/myorg/{admin}groups"
- entities_per_page = self.config.pagination_entity_per_page
- params_data = {"$top": "1"}
- response_data = self.client.get(api_url, data=params_data)
- response = GroupsResponse(**response_data)
- count = response.odata_count
+ entities_per_page = self.pagination_entity_per_page
+ failed_indexes = []
+ params_data = GETGROUPS_DEFAULT_PARAMS
+ response = self.client.get(api_url, data=params_data)
+ if (
+ not response
+ or API_RESPONSE_MESSAGE_KEY in response
+ or len(response) != len(GroupsResponse.__annotations__)
+ ):
+ logger.warning("Error fetching workspaces between results: (0, 1)")
+ if response and response.get(API_RESPONSE_MESSAGE_KEY):
+ logger.warning(
+ "Error message from API response: "
+ f"{str(response.get(API_RESPONSE_MESSAGE_KEY))}"
+ )
+ failed_indexes.append(params_data)
+ count = 0
+ else:
+ try:
+ response = GroupsResponse(**response)
+ count = response.odata_count
+ except Exception as exc:
+ logger.warning(f"Error processing GetGroups response: {exc}")
+ count = 0
indexes = math.ceil(count / entities_per_page)
-
workspaces = []
for index in range(indexes):
params_data = {
"$top": str(entities_per_page),
"$skip": str(index * entities_per_page),
}
- response_data = self.client.get(api_url, data=params_data)
- response = GroupsResponse(**response_data)
- workspaces.extend(response.value)
+ response = self.client.get(api_url, data=params_data)
+ if (
+ not response
+ or API_RESPONSE_MESSAGE_KEY in response
+ or len(response) != len(GroupsResponse.__annotations__)
+ ):
+ index_range = (
+ int(params_data.get("$skip")),
+ int(params_data.get("$skip")) + int(params_data.get("$top")),
+ )
+ logger.warning(
+ f"Error fetching workspaces between results: {str(index_range)}"
+ )
+ if response and response.get(API_RESPONSE_MESSAGE_KEY):
+ logger.warning(
+ "Error message from API response: "
+ f"{str(response.get(API_RESPONSE_MESSAGE_KEY))}"
+ )
+ failed_indexes.append(params_data)
+ continue
+ try:
+ response = GroupsResponse(**response)
+ workspaces.extend(response.value)
+ except Exception as exc:
+ logger.warning(f"Error processing GetGroups response: {exc}")
+
+ if failed_indexes:
+ logger.info(
+ "Retrying one more time on failed indexes to get workspaces"
+ )
+ for params_data in failed_indexes:
+ response = self.client.get(api_url, data=params_data)
+ if (
+ not response
+ or API_RESPONSE_MESSAGE_KEY in response
+ or len(response) != len(GroupsResponse.__annotations__)
+ ):
+ index_range = (
+ int(params_data.get("$skip")),
+ int(params_data.get("$skip"))
+ + int(params_data.get("$top")),
+ )
+ logger.warning(
+ f"Workspaces between results {str(index_range)} "
+ "could not be fetched on multiple attempts"
+ )
+ if response and response.get(API_RESPONSE_MESSAGE_KEY):
+ logger.warning(
+ "Error message from API response: "
+ f"{str(response.get(API_RESPONSE_MESSAGE_KEY))}"
+ )
+ continue
+ try:
+ response = GroupsResponse(**response)
+ workspaces.extend(response.value)
+ except Exception as exc:
+ logger.warning(f"Error processing GetGroups response: {exc}")
return workspaces
except Exception as exc: # pylint: disable=broad-except
logger.debug(traceback.format_exc())
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py
index 685e699c281d..a918b46fd8a1 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/powerbi/metadata.py
@@ -92,6 +92,11 @@ def __init__(
self.datamodel_file_mappings = []
def prepare(self):
+ """
+ - Since we get all the required info i.e. reports, dashboards, charts, datasets
+ with workflow scan approach, we are populating bulk data for workspace.
+ - Some individual APIs are not able to yield data with details.
+ """
if self.service_connection.useAdminApis:
groups = self.get_admin_workspace_data()
else:
@@ -185,24 +190,41 @@ def get_admin_workspace_data(self) -> Optional[List[Group]]:
workspace_scan = self.client.api_client.initiate_workspace_scan(
workspace_ids_chunk
)
+ if not workspace_scan:
+ logger.error(
+ f"Error initiating workspace scan for ids:{str(workspace_ids_chunk)}\n moving to next set of workspaces"
+ )
+ count += 1
+ continue
# Keep polling the scan status endpoint to check if scan is succeeded
workspace_scan_status = self.client.api_client.wait_for_scan_complete(
scan_id=workspace_scan.id
)
- if workspace_scan_status:
- response = self.client.api_client.fetch_workspace_scan_result(
- scan_id=workspace_scan.id
+ if not workspace_scan_status:
+ logger.error(
+ f"Max poll hit to scan status for scan_id: {workspace_scan.id}, moving to next set of workspaces"
)
- groups.extend(
- [
- active_workspace
- for active_workspace in response.workspaces
- if active_workspace.state == "Active"
- ]
+ count += 1
+ continue
+
+ # Get scan result for successfull scan
+ response = self.client.api_client.fetch_workspace_scan_result(
+ scan_id=workspace_scan.id
+ )
+ if not response:
+ logger.error(
+ f"Error getting workspace scan result for scan_id: {workspace_scan.id}"
)
- else:
- logger.error("Error in fetching dashboards and charts")
+ count += 1
+ continue
+ groups.extend(
+ [
+ active_workspace
+ for active_workspace in response.workspaces
+ if active_workspace.state == "Active"
+ ]
+ )
count += 1
else:
logger.error("Unable to fetch any PowerBI workspaces")
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py b/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py
index 30cc9e9cd6c8..40a0e925aade 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/redash/client.py
@@ -13,6 +13,7 @@
"""
from metadata.ingestion.ometa.client import REST, ClientConfig
+from metadata.utils.helpers import clean_uri
from metadata.utils.logger import utils_logger
logger = utils_logger()
@@ -28,8 +29,8 @@ class RedashApiClient:
def __init__(self, config):
self.config = config
client_config = ClientConfig(
- base_url=str(config.hostPort),
- api_version="",
+ base_url=clean_uri(config.hostPort),
+ api_version="api",
access_token=config.apiKey.get_secret_value(),
auth_header="Authorization",
auth_token_mode="Key",
@@ -41,16 +42,11 @@ def dashboards(self, page=1, page_size=25):
"""GET api/dashboards"""
params_data = {"page": page, "page_size": page_size}
- return self.client.get(path="api/dashboards", data=params_data)
+ return self.client.get(path="/dashboards", data=params_data)
- def get_dashboard(self, slug):
- """GET api/dashboards/"""
-
- # The API changed from redash v9 onwards
- # legacy=true allows us to get the results in the old way
- return self.client.get(
- f"api/dashboards/{slug}?legacy=true",
- )
+ def get_dashboard(self, dashboard_id: int):
+ """GET api/dashboards/"""
+ return self.client.get(f"/dashboards/{dashboard_id}")
def paginate(self, resource, page=1, page_size=25, **kwargs):
"""Load all items of a paginated resource"""
diff --git a/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py b/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py
index 94ae4a0cb7dc..1067e1afcc76 100644
--- a/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/dashboard/redash/metadata.py
@@ -115,7 +115,7 @@ def get_dashboard_name(self, dashboard: dict) -> str:
return dashboard["name"]
def get_dashboard_details(self, dashboard: dict) -> dict:
- return self.client.get_dashboard(dashboard["slug"])
+ return self.client.get_dashboard(dashboard["id"])
def get_owner_ref(self, dashboard_details) -> Optional[EntityReferenceList]:
"""
@@ -160,9 +160,9 @@ def yield_dashboard(
dashboard_request = CreateDashboardRequest(
name=EntityName(str(dashboard_details["id"])),
displayName=dashboard_details.get("name"),
- description=Markdown(dashboard_description)
- if dashboard_description
- else None,
+ description=(
+ Markdown(dashboard_description) if dashboard_description else None
+ ),
charts=[
FullyQualifiedEntityName(
fqn.build(
@@ -275,9 +275,11 @@ def yield_dashboard_chart(
yield Either(
right=CreateChartRequest(
name=EntityName(str(widgets["id"])),
- displayName=chart_display_name
- if visualization and visualization["query"]
- else "",
+ displayName=(
+ chart_display_name
+ if visualization and visualization["query"]
+ else ""
+ ),
chartType=get_standard_chart_type(
visualization["type"] if visualization else ""
),
@@ -285,9 +287,11 @@ def yield_dashboard_chart(
self.context.get().dashboard_service
),
sourceUrl=SourceUrl(self.get_dashboard_url(dashboard_details)),
- description=Markdown(visualization["description"])
- if visualization
- else None,
+ description=(
+ Markdown(visualization["description"])
+ if visualization
+ else None
+ ),
)
)
except Exception as exc:
diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py b/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py
index 078853aca599..87e5b36c96f9 100644
--- a/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/bigquery/metadata.py
@@ -34,9 +34,11 @@
from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema
from metadata.generated.schema.entity.data.storedProcedure import StoredProcedureCode
from metadata.generated.schema.entity.data.table import (
+ ConstraintType,
PartitionColumnDetails,
PartitionIntervalTypes,
Table,
+ TableConstraint,
TablePartition,
TableType,
)
@@ -96,6 +98,7 @@
from metadata.ingestion.source.database.multi_db_source import MultiDBSource
from metadata.utils import fqn
from metadata.utils.credentials import GOOGLE_CREDENTIALS
+from metadata.utils.execution_time_tracker import calculate_execution_time
from metadata.utils.filters import filter_by_database, filter_by_schema
from metadata.utils.logger import ingestion_logger
from metadata.utils.sqlalchemy_utils import (
@@ -657,6 +660,42 @@ def _get_partition_column_name(
)
return None
+ @calculate_execution_time()
+ def update_table_constraints(
+ self,
+ table_name,
+ schema_name,
+ db_name,
+ table_constraints,
+ foreign_columns,
+ columns,
+ ) -> List[TableConstraint]:
+ """
+ From topology.
+ process the table constraints of all tables
+ """
+ table_constraints = super().update_table_constraints(
+ table_name,
+ schema_name,
+ db_name,
+ table_constraints,
+ foreign_columns,
+ columns,
+ )
+ try:
+ table = self.client.get_table(fqn._build(db_name, schema_name, table_name))
+ if hasattr(table, "clustering_fields") and table.clustering_fields:
+ table_constraints.append(
+ TableConstraint(
+ constraintType=ConstraintType.CLUSTER_KEY,
+ columns=table.clustering_fields,
+ )
+ )
+ except Exception as exc:
+ logger.warning(f"Error getting clustering fields for {table_name}: {exc}")
+ logger.debug(traceback.format_exc())
+ return table_constraints
+
def get_table_partition_details(
self, table_name: str, schema_name: str, inspector: Inspector
) -> Tuple[bool, Optional[TablePartition]]:
@@ -667,6 +706,33 @@ def get_table_partition_details(
database = self.context.get().database
table = self.client.get_table(fqn._build(database, schema_name, table_name))
columns = inspector.get_columns(table_name, schema_name, db_name=database)
+ if (
+ hasattr(table, "external_data_configuration")
+ and hasattr(table.external_data_configuration, "hive_partitioning")
+ and table.external_data_configuration.hive_partitioning
+ ):
+ # Ingesting External Hive Partitioned Tables
+ from google.cloud.bigquery.external_config import ( # pylint: disable=import-outside-toplevel
+ HivePartitioningOptions,
+ )
+
+ partition_details: HivePartitioningOptions = (
+ table.external_data_configuration.hive_partitioning
+ )
+ return True, TablePartition(
+ columns=[
+ PartitionColumnDetails(
+ columnName=self._get_partition_column_name(
+ columns=columns,
+ partition_field_name=field,
+ ),
+ interval=str(partition_details._properties.get("mode")),
+ intervalType=PartitionIntervalTypes.OTHER,
+ )
+ for field in partition_details._properties.get("fields")
+ ]
+ )
+
if table.time_partitioning is not None:
if table.time_partitioning.field:
table_partition = TablePartition(
@@ -708,6 +774,30 @@ def get_table_partition_details(
table_partition.interval = table.range_partitioning.range_.interval
table_partition.columnName = table.range_partitioning.field
return True, TablePartition(columns=[table_partition])
+ if (
+ hasattr(table, "_properties")
+ and table._properties.get("partitionDefinition")
+ and table._properties.get("partitionDefinition").get(
+ "partitionedColumn"
+ )
+ ):
+
+ return True, TablePartition(
+ columns=[
+ PartitionColumnDetails(
+ columnName=self._get_partition_column_name(
+ columns=columns,
+ partition_field_name=field.get("field"),
+ ),
+ intervalType=PartitionIntervalTypes.OTHER,
+ )
+ for field in table._properties.get("partitionDefinition").get(
+ "partitionedColumn"
+ )
+ if field and field.get("field")
+ ]
+ )
+
except Exception as exc:
logger.debug(traceback.format_exc())
logger.warning(
diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py
index 2cd0f225b31d..750ae61fb20d 100644
--- a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py
+++ b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/profiler.py
@@ -24,6 +24,7 @@ def _compute_system_metrics(
return self.system_metrics_computer.get_system_metrics(
table=runner.dataset,
usage_location=self.service_connection_config.usageLocation,
+ runner=runner,
)
def initialize_system_metrics_computer(self) -> BigQuerySystemMetricsComputer:
diff --git a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py
index bd04c112a682..64b9cf9c45ba 100644
--- a/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py
+++ b/ingestion/src/metadata/ingestion/source/database/bigquery/profiler/system.py
@@ -29,8 +29,8 @@ def get_kwargs(self, **kwargs):
runner: QueryRunner = kwargs.get("runner")
return {
"table": runner.table_name,
- "database": runner.session.get_bind().url.database,
- "schema": runner.schema_name,
+ "project_id": runner.session.get_bind().url.host,
+ "dataset_id": runner.schema_name,
"usage_location": kwargs.get("usage_location"),
}
diff --git a/ingestion/src/metadata/ingestion/source/database/common_db_source.py b/ingestion/src/metadata/ingestion/source/database/common_db_source.py
index 663b21628cbc..f5345aaafdd8 100644
--- a/ingestion/src/metadata/ingestion/source/database/common_db_source.py
+++ b/ingestion/src/metadata/ingestion/source/database/common_db_source.py
@@ -681,7 +681,7 @@ def _get_foreign_constraints(
foreign_constraint = self._prepare_foreign_constraints(
supports_database, column, table_name, schema_name, db_name, columns
)
- if foreign_constraint:
+ if foreign_constraint and foreign_constraint not in foreign_constraints:
foreign_constraints.append(foreign_constraint)
return foreign_constraints
@@ -705,7 +705,11 @@ def update_table_constraints(
)
if foreign_table_constraints:
if table_constraints:
- table_constraints.extend(foreign_table_constraints)
+ table_constraints.extend(
+ constraint
+ for constraint in foreign_table_constraints
+ if constraint not in table_constraints
+ )
else:
table_constraints = foreign_table_constraints
return table_constraints
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/connection.py b/ingestion/src/metadata/ingestion/source/database/databricks/connection.py
index 14d3d3e392e9..7bae5d4b7f8a 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/connection.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/connection.py
@@ -38,9 +38,9 @@
test_connection_steps,
)
from metadata.ingestion.ometa.ometa_api import OpenMetadata
-from metadata.ingestion.source.database.databricks.client import DatabricksClient
from metadata.ingestion.source.database.databricks.queries import (
DATABRICKS_GET_CATALOGS,
+ DATABRICKS_SQL_STATEMENT_TEST,
)
from metadata.utils.constants import THREE_MIN
from metadata.utils.logger import ingestion_logger
@@ -81,7 +81,6 @@ def test_connection(
Test connection. This can be executed either as part
of a metadata workflow or during an Automation Workflow
"""
- client = DatabricksClient(service_connection)
def test_database_query(engine: Engine, statement: str):
"""
@@ -106,7 +105,13 @@ def test_database_query(engine: Engine, statement: str):
engine=connection,
statement=DATABRICKS_GET_CATALOGS,
),
- "GetQueries": client.test_query_api_access,
+ "GetQueries": partial(
+ test_database_query,
+ engine=connection,
+ statement=DATABRICKS_SQL_STATEMENT_TEST.format(
+ query_history=service_connection.queryHistoryTable
+ ),
+ ),
}
return test_connection_steps(
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/lineage.py b/ingestion/src/metadata/ingestion/source/database/databricks/lineage.py
index a77cb780e56d..eb4b74b5d495 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/lineage.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/lineage.py
@@ -11,12 +11,10 @@
"""
Databricks lineage module
"""
-import traceback
-from datetime import datetime
-from typing import Iterator
-from metadata.generated.schema.type.basic import DateTime
-from metadata.generated.schema.type.tableQuery import TableQuery
+from metadata.ingestion.source.database.databricks.queries import (
+ DATABRICKS_SQL_STATEMENT,
+)
from metadata.ingestion.source.database.databricks.query_parser import (
DatabricksQueryParserSource,
)
@@ -31,23 +29,13 @@ class DatabricksLineageSource(DatabricksQueryParserSource, LineageSource):
Databricks Lineage Legacy Source
"""
- def yield_table_query(self) -> Iterator[TableQuery]:
- data = self.client.list_query_history(
- start_date=self.start,
- end_date=self.end,
+ sql_stmt = DATABRICKS_SQL_STATEMENT
+
+ filters = """
+ AND (
+ lower(statement_text) LIKE '%%create%%select%%'
+ OR lower(statement_text) LIKE '%%insert%%into%%select%%'
+ OR lower(statement_text) LIKE '%%update%%'
+ OR lower(statement_text) LIKE '%%merge%%'
)
- for row in data or []:
- try:
- if self.client.is_query_valid(row):
- yield TableQuery(
- dialect=self.dialect.value,
- query=row.get("query_text"),
- userName=row.get("user_name"),
- startTime=str(row.get("query_start_time_ms")),
- endTime=str(row.get("execution_end_time_ms")),
- analysisDate=DateTime(datetime.now()),
- serviceName=self.config.serviceName,
- )
- except Exception as exc:
- logger.debug(traceback.format_exc())
- logger.warning(f"Error processing query_dict {row}: {exc}")
+ """
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/metadata.py b/ingestion/src/metadata/ingestion/source/database/databricks/metadata.py
index 3bc7b99f6d0d..5166ca1f56de 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/metadata.py
@@ -113,11 +113,6 @@ class MAP(String):
)
-def format_schema_name(schema):
- # Adds back quotes(``) if hyphen(-) in schema name
- return f"`{schema}`" if "-" in schema else schema
-
-
# This method is from hive dialect originally but
# is overridden to optimize DESCRIBE query execution
def _get_table_columns(self, connection, table_name, schema, db_name):
@@ -158,7 +153,6 @@ def _get_table_columns(self, connection, table_name, schema, db_name):
def _get_column_rows(self, connection, table_name, schema, db_name):
# get columns and strip whitespace
- schema = format_schema_name(schema=schema)
table_columns = _get_table_columns( # pylint: disable=protected-access
self, connection, table_name, schema, db_name
)
@@ -212,11 +206,10 @@ def get_columns(self, connection, table_name, schema=None, **kw):
"system_data_type": raw_col_type,
}
if col_type in {"array", "struct", "map"}:
- col_name = f"`{col_name}`" if "." in col_name else col_name
try:
rows = dict(
connection.execute(
- f"DESCRIBE TABLE {kw.get('db_name')}.{schema}.{table_name} {col_name}"
+ f"DESCRIBE TABLE `{kw.get('db_name')}`.`{schema}`.`{table_name}` `{col_name}`"
).fetchall()
)
col_info["system_data_type"] = rows["data_type"]
@@ -393,8 +386,7 @@ def get_table_type(self, connection, database, schema, table):
database_name=database, schema_name=schema, table_name=table
)
else:
- schema = format_schema_name(schema=schema)
- query = f"DESCRIBE TABLE EXTENDED {schema}.{table}"
+ query = f"DESCRIBE TABLE EXTENDED `{schema}`.`{table}`"
rows = get_table_comment_result(
self,
connection=connection,
@@ -761,7 +753,6 @@ def get_table_description(
) -> str:
description = None
try:
- schema_name = format_schema_name(schema=schema_name)
query = DATABRICKS_GET_TABLE_COMMENTS.format(
database_name=self.context.get().database,
schema_name=schema_name,
@@ -816,9 +807,7 @@ def get_owner_ref(self, table_name: str) -> Optional[EntityReferenceList]:
try:
query = DATABRICKS_GET_TABLE_COMMENTS.format(
database_name=self.context.get().database,
- schema_name=format_schema_name(
- schema=self.context.get().database_schema
- ),
+ schema_name=self.context.get().database_schema,
table_name=table_name,
)
result = self.inspector.dialect.get_table_comment_result(
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/queries.py b/ingestion/src/metadata/ingestion/source/database/databricks/queries.py
index ca057716609e..25cdcedfc105 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/queries.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/queries.py
@@ -14,6 +14,30 @@
import textwrap
+DATABRICKS_SQL_STATEMENT = textwrap.dedent(
+ """
+ SELECT
+ statement_type AS query_type,
+ statement_text AS query_text,
+ executed_by AS user_name,
+ start_time AS start_time,
+ null AS database_name,
+ null AS schema_name,
+ end_time AS end_time,
+ total_duration_ms/1000 AS duration
+ from {query_history}
+ WHERE statement_text NOT LIKE '/* {{"app": "OpenMetadata", %%}} */%%'
+ AND statement_text NOT LIKE '/* {{"app": "dbt", %%}} */%%'
+ AND start_time between to_timestamp('{start_time}') and to_timestamp('{end_time}')
+ {filters}
+ LIMIT {result_limit}
+ """
+)
+
+DATABRICKS_SQL_STATEMENT_TEST = """
+ SELECT statement_text from {query_history} LIMIT 1
+"""
+
DATABRICKS_VIEW_DEFINITIONS = textwrap.dedent(
"""
select
@@ -25,27 +49,27 @@
)
DATABRICKS_GET_TABLE_COMMENTS = (
- "DESCRIBE TABLE EXTENDED {database_name}.{schema_name}.{table_name}"
+ "DESCRIBE TABLE EXTENDED `{database_name}`.`{schema_name}`.`{table_name}`"
)
DATABRICKS_GET_CATALOGS = "SHOW CATALOGS"
DATABRICKS_GET_CATALOGS_TAGS = textwrap.dedent(
- """SELECT * FROM {database_name}.information_schema.catalog_tags;"""
+ """SELECT * FROM `{database_name}`.information_schema.catalog_tags;"""
)
DATABRICKS_GET_SCHEMA_TAGS = textwrap.dedent(
"""
SELECT
*
- FROM {database_name}.information_schema.schema_tags"""
+ FROM `{database_name}`.information_schema.schema_tags"""
)
DATABRICKS_GET_TABLE_TAGS = textwrap.dedent(
"""
SELECT
*
- FROM {database_name}.information_schema.table_tags
+ FROM `{database_name}`.information_schema.table_tags
"""
)
@@ -53,8 +77,8 @@
"""
SELECT
*
- FROM {database_name}.information_schema.column_tags
+ FROM `{database_name}`.information_schema.column_tags
"""
)
-DATABRICKS_DDL = "SHOW CREATE TABLE {table_name}"
+DATABRICKS_DDL = "SHOW CREATE TABLE `{table_name}`"
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/query_parser.py b/ingestion/src/metadata/ingestion/source/database/databricks/query_parser.py
index 00628bfbddaa..c67b06aa30ed 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/query_parser.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/query_parser.py
@@ -22,7 +22,6 @@
)
from metadata.ingestion.api.steps import InvalidSourceException
from metadata.ingestion.ometa.ometa_api import OpenMetadata
-from metadata.ingestion.source.database.databricks.client import DatabricksClient
from metadata.ingestion.source.database.query_parser_source import QueryParserSource
from metadata.utils.logger import ingestion_logger
@@ -36,18 +35,6 @@ class DatabricksQueryParserSource(QueryParserSource, ABC):
filters: str
- def _init_super(
- self,
- config: WorkflowSource,
- metadata: OpenMetadata,
- ):
- super().__init__(config, metadata, False)
-
- # pylint: disable=super-init-not-called
- def __init__(self, config: WorkflowSource, metadata: OpenMetadata):
- self._init_super(config=config, metadata=metadata)
- self.client = DatabricksClient(self.service_connection)
-
@classmethod
def create(
cls, config_dict, metadata: OpenMetadata, pipeline_name: Optional[str] = None
@@ -61,7 +48,16 @@ def create(
)
return cls(config, metadata)
- def prepare(self):
+ def get_sql_statement(self, start_time, end_time):
"""
- By default, there's nothing to prepare
+ returns sql statement to fetch query logs.
+
+ Override if we have specific parameters
"""
+ return self.sql_stmt.format(
+ start_time=start_time,
+ end_time=end_time,
+ filters=self.get_filters(),
+ result_limit=self.source_config.resultLimit,
+ query_history=self.service_connection.queryHistoryTable,
+ )
diff --git a/ingestion/src/metadata/ingestion/source/database/databricks/usage.py b/ingestion/src/metadata/ingestion/source/database/databricks/usage.py
index 0e5364a465d8..fedbab2da486 100644
--- a/ingestion/src/metadata/ingestion/source/database/databricks/usage.py
+++ b/ingestion/src/metadata/ingestion/source/database/databricks/usage.py
@@ -11,12 +11,10 @@
"""
Databricks usage module
"""
-import traceback
-from datetime import datetime
-from typing import Iterable
-from metadata.generated.schema.type.basic import DateTime
-from metadata.generated.schema.type.tableQuery import TableQueries, TableQuery
+from metadata.ingestion.source.database.databricks.queries import (
+ DATABRICKS_SQL_STATEMENT,
+)
from metadata.ingestion.source.database.databricks.query_parser import (
DatabricksQueryParserSource,
)
@@ -31,36 +29,8 @@ class DatabricksUsageSource(DatabricksQueryParserSource, UsageSource):
Databricks Usage Source
"""
- def yield_table_queries(self) -> Iterable[TableQuery]:
- """
- Method to yield TableQueries
- """
- queries = []
- data = self.client.list_query_history(
- start_date=self.start,
- end_date=self.end,
- )
- for row in data or []:
- try:
- if self.client.is_query_valid(row):
- queries.append(
- TableQuery(
- dialect=self.dialect.value,
- query=row.get("query_text"),
- userName=row.get("user_name"),
- startTime=str(row.get("query_start_time_ms")),
- endTime=str(row.get("execution_end_time_ms")),
- analysisDate=DateTime(datetime.now()),
- serviceName=self.config.serviceName,
- duration=row.get("duration")
- if row.get("duration")
- else None,
- )
- )
- except Exception as err:
- logger.debug(traceback.format_exc())
- logger.warning(
- f"Failed to process query {row.get('query_text')} due to: {err}"
- )
+ sql_stmt = DATABRICKS_SQL_STATEMENT
- yield TableQueries(queries=queries)
+ filters = """
+ AND statement_type NOT IN ('SHOW', 'DESCRIBE', 'USE')
+ """
diff --git a/ingestion/src/metadata/ingestion/source/database/db2/connection.py b/ingestion/src/metadata/ingestion/source/database/db2/connection.py
index b25ac3efc757..c9efc348f730 100644
--- a/ingestion/src/metadata/ingestion/source/database/db2/connection.py
+++ b/ingestion/src/metadata/ingestion/source/database/db2/connection.py
@@ -50,7 +50,7 @@ def get_connection(connection: Db2Connection) -> Engine:
"w",
encoding=UTF_8,
) as file:
- file.write(connection.license)
+ file.write(connection.license.encode(UTF_8).decode("unicode-escape"))
return create_generic_db_connection(
connection=connection,
diff --git a/ingestion/src/metadata/ingestion/source/database/db2/metadata.py b/ingestion/src/metadata/ingestion/source/database/db2/metadata.py
index 794cb3b85903..44baf862e60d 100644
--- a/ingestion/src/metadata/ingestion/source/database/db2/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/db2/metadata.py
@@ -13,6 +13,7 @@
from typing import Iterable, Optional
from ibm_db_sa.base import ischema_names
+from ibm_db_sa.reflection import DB2Reflector, OS390Reflector
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.row import LegacyRow
from sqlalchemy.sql.sqltypes import BOOLEAN
@@ -26,6 +27,7 @@
from metadata.ingestion.api.steps import InvalidSourceException
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.database.common_db_source import CommonDbSourceService
+from metadata.ingestion.source.database.db2.utils import get_unique_constraints
from metadata.utils.logger import ingestion_logger
logger = ingestion_logger()
@@ -34,6 +36,10 @@
ischema_names.update({"BOOLEAN": BOOLEAN})
+DB2Reflector.get_unique_constraints = get_unique_constraints
+OS390Reflector.get_unique_constraints = get_unique_constraints
+
+
class Db2Source(CommonDbSourceService):
"""
Implements the necessary methods to extract
diff --git a/ingestion/src/metadata/ingestion/source/database/db2/utils.py b/ingestion/src/metadata/ingestion/source/database/db2/utils.py
new file mode 100644
index 000000000000..657942aca471
--- /dev/null
+++ b/ingestion/src/metadata/ingestion/source/database/db2/utils.py
@@ -0,0 +1,63 @@
+# Copyright 2021 Collate
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# 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.
+
+"""
+Module to define overriden dialect methods
+"""
+from sqlalchemy import and_, join, sql
+from sqlalchemy.engine import reflection
+
+
+@reflection.cache
+def get_unique_constraints(
+ self, connection, table_name, schema=None, **kw
+): # pylint: disable=unused-argument
+ """Small Method to override the Dialect default as it is not filtering properly the Schema and Table Name."""
+ current_schema = self.denormalize_name(schema or self.default_schema_name)
+ table_name = self.denormalize_name(table_name)
+ syskeycol = self.sys_keycoluse
+ sysconst = self.sys_tabconst
+ query = (
+ sql.select(syskeycol.c.constname, syskeycol.c.colname)
+ .select_from(
+ join(
+ syskeycol,
+ sysconst,
+ and_(
+ syskeycol.c.constname == sysconst.c.constname,
+ syskeycol.c.tabschema == sysconst.c.tabschema,
+ syskeycol.c.tabname == sysconst.c.tabname,
+ ),
+ )
+ )
+ .where(
+ and_(
+ sysconst.c.tabname == table_name,
+ sysconst.c.tabschema == current_schema,
+ sysconst.c.type == "U",
+ )
+ )
+ .order_by(syskeycol.c.constname)
+ )
+ unique_consts = []
+ curr_const = None
+ for r in connection.execute(query):
+ if curr_const == r[0]:
+ unique_consts[-1]["column_names"].append(self.normalize_name(r[1]))
+ else:
+ curr_const = r[0]
+ unique_consts.append(
+ {
+ "name": self.normalize_name(curr_const),
+ "column_names": [self.normalize_name(r[1])],
+ }
+ )
+ return unique_consts
diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py
index 5021f4c5fb22..40c5e96d9a80 100644
--- a/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py
+++ b/ingestion/src/metadata/ingestion/source/database/dbt/dbt_service.py
@@ -15,7 +15,7 @@
from abc import ABC, abstractmethod
from typing import Iterable, List
-from dbt_artifacts_parser.parser import (
+from collate_dbt_artifacts_parser.parser import (
parse_catalog,
parse_manifest,
parse_run_results,
@@ -122,6 +122,11 @@ class DbtServiceTopology(ServiceTopology):
processor="process_dbt_descriptions",
nullable=True,
),
+ NodeStage(
+ type_=DataModelLink,
+ processor="process_dbt_owners",
+ nullable=True,
+ ),
],
)
process_dbt_tests: Annotated[
@@ -293,6 +298,12 @@ def process_dbt_descriptions(self, data_model_link: DataModelLink):
Method to process DBT descriptions using patch APIs
"""
+ @abstractmethod
+ def process_dbt_owners(self, data_model_link: DataModelLink):
+ """
+ Method to process DBT owners using patch APIs
+ """
+
def get_dbt_tests(self) -> dict:
"""
Prepare the DBT tests
diff --git a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py
index 5c1b2cf81e31..a422fd6e4902 100644
--- a/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/dbt/metadata.py
@@ -15,7 +15,7 @@
import traceback
from copy import deepcopy
from datetime import datetime
-from typing import Any, Iterable, List, Optional, Union
+from typing import Any, Iterable, List, Optional
from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest
from metadata.generated.schema.api.tests.createTestCase import CreateTestCaseRequest
@@ -61,7 +61,9 @@
from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper
from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query
from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification
+from metadata.ingestion.models.patch_request import PatchedEntity, PatchRequest
from metadata.ingestion.models.table_metadata import ColumnDescription
+from metadata.ingestion.ometa.client import APIError
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.database.column_type_parser import ColumnTypeParser
from metadata.ingestion.source.database.database_service import DataModelLink
@@ -97,7 +99,7 @@
from metadata.utils.entity_link import get_table_fqn
from metadata.utils.logger import ingestion_logger
from metadata.utils.tag_utils import get_ometa_tag_and_classification, get_tag_labels
-from metadata.utils.time_utils import convert_timestamp_to_milliseconds
+from metadata.utils.time_utils import datetime_to_timestamp
logger = ingestion_logger()
@@ -325,11 +327,14 @@ def add_dbt_tests(
None,
)
- def _add_dbt_freshness_test_from_sources(
+ def add_dbt_sources(
self, key: str, manifest_node, manifest_entities, dbt_objects: DbtObjects
- ):
- # in dbt manifest sources node name is table/view name (not test name like with test nodes)
- # so in order for the test creation to be named precisely I am amending manifest node name within it's deepcopy
+ ) -> None:
+ """
+ Method to append dbt test cases based on sources file for later processing
+ In dbt manifest sources node name is table/view name (not test name like with test nodes)
+ So in order for the test creation to be named precisely I am amending manifest node name within it's deepcopy
+ """
manifest_node_new = deepcopy(manifest_node)
manifest_node_new.name = manifest_node_new.name + "_freshness"
@@ -349,17 +354,57 @@ def _add_dbt_freshness_test_from_sources(
DbtCommonEnum.RESULTS.value
] = freshness_test_result
- def add_dbt_sources(
- self, key: str, manifest_node, manifest_entities, dbt_objects: DbtObjects
- ) -> None:
- """
- Method to append dbt test cases based on sources file for later processing
- """
- self._add_dbt_freshness_test_from_sources(
- key, manifest_node, manifest_entities, dbt_objects
- )
+ def _get_table_entity(self, table_fqn) -> Optional[Table]:
+ def search_table(fqn_search_string: str) -> Optional[Table]:
+ table_entities = get_entity_from_es_result(
+ entity_list=self.metadata.es_search_from_fqn(
+ entity_type=Table,
+ fqn_search_string=fqn_search_string,
+ fields="sourceHash",
+ ),
+ fetch_multiple_entities=True,
+ )
+ logger.debug(
+ f"Found table entities from {fqn_search_string}: {table_entities}"
+ )
+ return next(iter(filter(None, table_entities)), None)
- # pylint: disable=too-many-locals, too-many-branches, too-many-statements
+ try:
+ table_entity = search_table(table_fqn)
+ if table_entity:
+ return table_entity
+
+ if self.source_config.searchAcrossDatabases:
+ logger.warning(
+ f"Table {table_fqn} not found under service: {self.config.serviceName}."
+ "Trying to find table across services"
+ )
+ _, database_name, schema_name, table_name = fqn.split(table_fqn)
+ table_fqn = fqn.build(
+ self.metadata,
+ entity_type=Table,
+ service_name="*",
+ database_name=database_name,
+ schema_name=schema_name,
+ table_name=table_name,
+ )
+ table_entity = search_table(table_fqn)
+ if table_entity:
+ return table_entity
+
+ logger.warning(
+ f"Unable to find the table '{table_fqn}' in OpenMetadata. "
+ "Please check if the table exists and is ingested in OpenMetadata. "
+ "Also, ensure the name, database, and schema of the manifest node"
+ "match the table present in OpenMetadata."
+ )
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(f"Failed to get table entity from OpenMetadata: {exc}")
+
+ return None
+
+ # pylint: disable=too-many-locals, too-many-branches
def yield_data_models(
self, dbt_objects: DbtObjects
) -> Iterable[Either[DataModelLink]]:
@@ -470,28 +515,19 @@ def yield_data_models(
dbt_compiled_query = get_dbt_compiled_query(manifest_node)
dbt_raw_query = get_dbt_raw_query(manifest_node)
- # Get the table entity from ES
table_fqn = fqn.build(
self.metadata,
entity_type=Table,
- service_name="*",
+ service_name=self.config.serviceName,
database_name=get_corrected_name(manifest_node.database),
schema_name=get_corrected_name(manifest_node.schema_),
table_name=model_name,
)
- table_entity: Optional[
- Union[Table, List[Table]]
- ] = get_entity_from_es_result(
- entity_list=self.metadata.es_search_from_fqn(
- entity_type=Table,
- fqn_search_string=table_fqn,
- fields="sourceHash",
- ),
- fetch_multiple_entities=False,
- )
-
- if table_entity:
+ if table_entity := self._get_table_entity(table_fqn=table_fqn):
+ logger.debug(
+ f"Using Table Entity for datamodel: {table_entity}"
+ )
data_model_link = DataModelLink(
table_entity=table_entity,
datamodel=DataModel(
@@ -522,13 +558,7 @@ def yield_data_models(
)
yield Either(right=data_model_link)
self.context.get().data_model_links.append(data_model_link)
- else:
- logger.warning(
- f"Unable to find the table '{table_fqn}' in OpenMetadata"
- "Please check if the table exists and is ingested in OpenMetadata"
- "Also name, database, schema of the manifest node matches with the table present "
- "in OpenMetadata"
- )
+
except Exception as exc:
yield Either(
left=StackTraceError(
@@ -572,22 +602,14 @@ def parse_upstream_nodes(self, manifest_entities, dbt_node):
parent_fqn = fqn.build(
self.metadata,
entity_type=Table,
- service_name="*",
+ service_name=self.config.serviceName,
database_name=get_corrected_name(parent_node.database),
schema_name=get_corrected_name(parent_node.schema_),
table_name=table_name,
)
# check if the parent table exists in OM before adding it to the upstream list
- parent_table_entity: Optional[
- Union[Table, List[Table]]
- ] = get_entity_from_es_result(
- entity_list=self.metadata.es_search_from_fqn(
- entity_type=Table, fqn_search_string=parent_fqn
- ),
- fetch_multiple_entities=False,
- )
- if parent_table_entity:
+ if self._get_table_entity(table_fqn=parent_fqn):
upstream_nodes.append(parent_fqn)
except Exception as exc: # pylint: disable=broad-except
logger.debug(traceback.format_exc())
@@ -600,22 +622,14 @@ def parse_upstream_nodes(self, manifest_entities, dbt_node):
parent_fqn = fqn.build(
self.metadata,
entity_type=Table,
- service_name="*",
+ service_name=self.config.serviceName,
database_name=get_corrected_name(dbt_node.database),
schema_name=get_corrected_name(dbt_node.schema_),
table_name=dbt_node.name,
)
# check if the parent table exists in OM before adding it to the upstream list
- parent_table_entity: Optional[
- Union[Table, List[Table]]
- ] = get_entity_from_es_result(
- entity_list=self.metadata.es_search_from_fqn(
- entity_type=Table, fqn_search_string=parent_fqn
- ),
- fetch_multiple_entities=False,
- )
- if parent_table_entity:
+ if self._get_table_entity(table_fqn=parent_fqn):
upstream_nodes.append(parent_fqn)
return upstream_nodes
@@ -707,14 +721,8 @@ def create_dbt_lineage(
for upstream_node in data_model_link.datamodel.upstream:
try:
- from_es_result = self.metadata.es_search_from_fqn(
- entity_type=Table,
- fqn_search_string=upstream_node,
- )
- from_entity: Optional[
- Union[Table, List[Table]]
- ] = get_entity_from_es_result(
- entity_list=from_es_result, fetch_multiple_entities=False
+ from_entity: Optional[Table] = self._get_table_entity(
+ table_fqn=upstream_node
)
if from_entity and to_entity:
yield Either(
@@ -886,6 +894,47 @@ def process_dbt_descriptions(self, data_model_link: DataModelLink):
f"to update dbt description: {exc}"
)
+ def process_dbt_owners(
+ self, data_model_link: DataModelLink
+ ) -> Iterable[Either[PatchedEntity]]:
+ """
+ Method to process DBT owners
+ """
+ table_entity: Table = data_model_link.table_entity
+ if table_entity:
+ logger.debug(
+ f"Processing DBT owners for: {table_entity.fullyQualifiedName.root}"
+ )
+ try:
+ data_model = data_model_link.datamodel
+ if (
+ data_model.resourceType != DbtCommonEnum.SOURCE.value
+ and self.source_config.dbtUpdateOwners
+ ):
+ logger.debug(
+ f"Overwriting owners with DBT owners: {table_entity.fullyQualifiedName.root}"
+ )
+ if data_model.owners:
+ new_entity = deepcopy(table_entity)
+ new_entity.owners = data_model.owners
+ yield Either(
+ right=PatchRequest(
+ original_entity=table_entity,
+ new_entity=new_entity,
+ override_metadata=True,
+ )
+ )
+
+ except Exception as exc: # pylint: disable=broad-except
+ yield Either(
+ left=StackTraceError(
+ name=str(table_entity.fullyQualifiedName.root),
+ error=f"Failed to parse the node"
+ f"{table_entity.fullyQualifiedName.root} to update dbt owner: {exc}",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+
def create_dbt_tests_definition(
self, dbt_test: dict
) -> Iterable[Either[CreateTestDefinitionRequest]]:
@@ -947,6 +996,7 @@ def create_dbt_test_case(
self.metadata, entity_link_str
)
table_fqn = get_table_fqn(entity_link_str)
+ logger.debug(f"Table fqn found: {table_fqn}")
source_elements = table_fqn.split(fqn.FQN_SEPARATOR)
test_case_fqn = fqn.build(
self.metadata,
@@ -982,6 +1032,7 @@ def create_dbt_test_case(
owners=None,
)
)
+ logger.debug(f"Test case Already Exists: {test_case_fqn}")
except Exception as err: # pylint: disable=broad-except
yield Either(
left=StackTraceError(
@@ -1043,7 +1094,7 @@ def add_dbt_test_result(self, dbt_test: dict):
# Create the test case result object
test_case_result = TestCaseResult(
timestamp=Timestamp(
- convert_timestamp_to_milliseconds(dbt_timestamp.timestamp())
+ datetime_to_timestamp(dbt_timestamp, milliseconds=True)
),
testCaseStatus=test_case_status,
testResultValue=[
@@ -1071,13 +1122,20 @@ def add_dbt_test_result(self, dbt_test: dict):
else None,
test_case_name=manifest_node.name,
)
- self.metadata.add_test_case_results(
- test_results=test_case_result,
- test_case_fqn=test_case_fqn,
- )
+
+ logger.debug(f"Adding test case results to {test_case_fqn} ")
+ try:
+ self.metadata.add_test_case_results(
+ test_results=test_case_result,
+ test_case_fqn=test_case_fqn,
+ )
+ except APIError as err:
+ if err.code != 409:
+ raise APIError(err) from err
+
except Exception as err: # pylint: disable=broad-except
logger.debug(traceback.format_exc())
- logger.error(
+ logger.debug(
f"Failed to capture tests results for node: {manifest_node.name} {err}"
)
diff --git a/ingestion/src/metadata/ingestion/source/database/lineage_source.py b/ingestion/src/metadata/ingestion/source/database/lineage_source.py
index 581467faf779..204d57b55077 100644
--- a/ingestion/src/metadata/ingestion/source/database/lineage_source.py
+++ b/ingestion/src/metadata/ingestion/source/database/lineage_source.py
@@ -13,11 +13,12 @@
"""
import csv
import os
+import time
import traceback
from abc import ABC
-from concurrent.futures import ThreadPoolExecutor, as_completed
+from concurrent.futures import ThreadPoolExecutor
from functools import partial
-from typing import Callable, Iterable, Iterator, Union
+from typing import Any, Callable, Iterable, Iterator, List, Union
from metadata.generated.schema.api.data.createQuery import CreateQueryRequest
from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest
@@ -25,8 +26,12 @@
from metadata.generated.schema.type.tableQuery import TableQuery
from metadata.ingestion.api.models import Either
from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper, Dialect
-from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query
+from metadata.ingestion.lineage.sql_lineage import (
+ get_lineage_by_graph,
+ get_lineage_by_query,
+)
from metadata.ingestion.models.ometa_lineage import OMetaLineageRequest
+from metadata.ingestion.models.topology import Queue
from metadata.ingestion.source.database.query_parser_source import QueryParserSource
from metadata.ingestion.source.models import TableView
from metadata.utils import fqn
@@ -36,6 +41,9 @@
logger = ingestion_logger()
+CHUNK_SIZE = 200
+
+
class LineageSource(QueryParserSource, ABC):
"""
This is the base source to handle Lineage-only ingestion.
@@ -96,27 +104,57 @@ def get_table_query(self) -> Iterator[TableQuery]:
)
yield from self.yield_table_query()
- def generate_lineage_in_thread(self, producer_fn: Callable, processor_fn: Callable):
- with ThreadPoolExecutor(max_workers=self.source_config.threads) as executor:
- futures = []
+ def generate_lineage_in_thread(
+ self,
+ producer_fn: Callable[[], Iterable[Any]],
+ processor_fn: Callable[[Any], Iterable[Any]],
+ chunk_size: int = CHUNK_SIZE,
+ ):
+ """
+ Optimized multithreaded lineage generation with improved error handling and performance.
- for produced_input in producer_fn():
- futures.append(executor.submit(processor_fn, produced_input))
+ Args:
+ producer_fn: Function that yields input items
+ processor_fn: Function to process each input item
+ chunk_size: Optional batching to reduce thread creation overhead
+ """
- # Handle remaining futures after the loop
- for future in as_completed(
- futures, timeout=self.source_config.parsingTimeoutLimit
- ):
- try:
- results = future.result(
- timeout=self.source_config.parsingTimeoutLimit
- )
- yield from results
- except Exception as exc:
- logger.debug(traceback.format_exc())
- logger.warning(
- f"Error processing result for {produced_input}: {exc}"
- )
+ def chunk_generator():
+ temp_chunk = []
+ for chunk in producer_fn():
+ temp_chunk.append(chunk)
+ if len(temp_chunk) >= chunk_size:
+ yield temp_chunk
+ temp_chunk = []
+
+ if temp_chunk:
+ yield temp_chunk
+
+ thread_pool = ThreadPoolExecutor(max_workers=self.source_config.threads)
+ queue = Queue()
+
+ futures = [
+ thread_pool.submit(
+ processor_fn,
+ chunk,
+ queue,
+ )
+ for chunk in chunk_generator()
+ ]
+ while True:
+ if queue.has_tasks():
+ yield from queue.process()
+
+ else:
+ if not futures:
+ break
+
+ for i, future in enumerate(futures):
+ if future.done():
+ future.result()
+ futures.pop(i)
+
+ time.sleep(0.01)
def yield_table_query(self) -> Iterator[TableQuery]:
"""
@@ -158,33 +196,45 @@ def _query_already_processed(self, table_query: TableQuery) -> bool:
return fqn.get_query_checksum(table_query.query) in checksums or {}
def query_lineage_generator(
- self, table_query: TableQuery
+ self, table_queries: List[TableQuery], queue: Queue
) -> Iterable[Either[Union[AddLineageRequest, CreateQueryRequest]]]:
- if not self._query_already_processed(table_query):
- lineages: Iterable[Either[AddLineageRequest]] = get_lineage_by_query(
- self.metadata,
- query=table_query.query,
- service_name=table_query.serviceName,
- database_name=table_query.databaseName,
- schema_name=table_query.databaseSchema,
- dialect=self.dialect,
- timeout_seconds=self.source_config.parsingTimeoutLimit,
- )
+ if self.graph is None and self.source_config.enableTempTableLineage:
+ import networkx as nx
+
+ # Create a directed graph
+ self.graph = nx.DiGraph()
+
+ for table_query in table_queries or []:
+ if not self._query_already_processed(table_query):
+ lineages: Iterable[Either[AddLineageRequest]] = get_lineage_by_query(
+ self.metadata,
+ query=table_query.query,
+ service_name=table_query.serviceName,
+ database_name=table_query.databaseName,
+ schema_name=table_query.databaseSchema,
+ dialect=self.dialect,
+ timeout_seconds=self.source_config.parsingTimeoutLimit,
+ graph=self.graph,
+ )
+
+ for lineage_request in lineages or []:
+ queue.put(lineage_request)
- for lineage_request in lineages or []:
- yield lineage_request
-
- # If we identified lineage properly, ingest the original query
- if lineage_request.right:
- yield Either(
- right=CreateQueryRequest(
- query=SqlQuery(table_query.query),
- query_type=table_query.query_type,
- duration=table_query.duration,
- processedLineage=True,
- service=FullyQualifiedEntityName(self.config.serviceName),
+ # If we identified lineage properly, ingest the original query
+ if lineage_request.right:
+ queue.put(
+ Either(
+ right=CreateQueryRequest(
+ query=SqlQuery(table_query.query),
+ query_type=table_query.query_type,
+ duration=table_query.duration,
+ processedLineage=True,
+ service=FullyQualifiedEntityName(
+ self.config.serviceName
+ ),
+ )
+ )
)
- )
def yield_query_lineage(
self,
@@ -197,28 +247,33 @@ def yield_query_lineage(
self.dialect = ConnectionTypeDialectMapper.dialect_of(connection_type)
producer_fn = self.get_table_query
processor_fn = self.query_lineage_generator
- yield from self.generate_lineage_in_thread(producer_fn, processor_fn)
+ yield from self.generate_lineage_in_thread(
+ producer_fn, processor_fn, CHUNK_SIZE
+ )
def view_lineage_generator(
- self, view: TableView
+ self, views: List[TableView], queue: Queue
) -> Iterable[Either[AddLineageRequest]]:
try:
- for lineage in get_view_lineage(
- view=view,
- metadata=self.metadata,
- service_name=self.config.serviceName,
- connection_type=self.service_connection.type.value,
- timeout_seconds=self.source_config.parsingTimeoutLimit,
- ):
- if lineage.right is not None:
- yield Either(
- right=OMetaLineageRequest(
- lineage_request=lineage.right,
- override_lineage=self.source_config.overrideViewLineage,
+ for view in views:
+ for lineage in get_view_lineage(
+ view=view,
+ metadata=self.metadata,
+ service_name=self.config.serviceName,
+ connection_type=self.service_connection.type.value,
+ timeout_seconds=self.source_config.parsingTimeoutLimit,
+ ):
+ if lineage.right is not None:
+ queue.put(
+ Either(
+ right=OMetaLineageRequest(
+ lineage_request=lineage.right,
+ override_lineage=self.source_config.overrideViewLineage,
+ )
+ )
)
- )
- else:
- yield lineage
+ else:
+ queue.put(lineage)
except Exception as exc:
logger.debug(traceback.format_exc())
logger.warning(f"Error processing view {view}: {exc}")
@@ -253,6 +308,7 @@ def _iter(
if self.source_config.processQueryLineage:
if hasattr(self.service_connection, "supportsLineageExtraction"):
yield from self.yield_query_lineage() or []
+ yield from get_lineage_by_graph(graph=self.graph)
else:
logger.warning(
f"Lineage extraction is not supported for {str(self.service_connection.type.value)} connection"
diff --git a/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py b/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py
index afbfcc398f3b..074ce5b8c6a0 100644
--- a/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py
+++ b/ingestion/src/metadata/ingestion/source/database/mariadb/lineage.py
@@ -14,7 +14,7 @@
from typing import Optional
from metadata.generated.schema.entity.services.connections.database.mariaDBConnection import (
- MariadbConnection,
+ MariaDBConnection,
)
from metadata.generated.schema.metadataIngestion.workflow import (
Source as WorkflowSource,
@@ -38,8 +38,8 @@ def create(
):
"""Create class instance"""
config: WorkflowSource = WorkflowSource.model_validate(config_dict)
- connection: MariadbConnection = config.serviceConnection.root.config
- if not isinstance(connection, MariadbConnection):
+ connection: MariaDBConnection = config.serviceConnection.root.config
+ if not isinstance(connection, MariaDBConnection):
raise InvalidSourceException(
f"Expected MariadbConnection, but got {connection}"
)
diff --git a/ingestion/src/metadata/ingestion/source/database/mssql/queries.py b/ingestion/src/metadata/ingestion/source/database/mssql/queries.py
index d9bab948e080..0ed3f06da060 100644
--- a/ingestion/src/metadata/ingestion/source/database/mssql/queries.py
+++ b/ingestion/src/metadata/ingestion/source/database/mssql/queries.py
@@ -20,8 +20,8 @@
db.NAME database_name,
t.text query_text,
s.last_execution_time start_time,
- DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time,
- s.total_elapsed_time/1000 duration,
+ DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time,
+ s.last_elapsed_time/1000000 duration,
NULL schema_name,
NULL query_type,
NULL user_name,
@@ -209,7 +209,7 @@
WITH SP_HISTORY (start_time, end_time, procedure_name, query_text) AS (
select
s.last_execution_time start_time,
- DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time,
+ DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time,
OBJECT_NAME(object_id, database_id) as procedure_name,
text as query_text
from sys.dm_exec_procedure_stats s
@@ -222,8 +222,8 @@
db.NAME database_name,
t.text query_text,
s.last_execution_time start_time,
- DATEADD(s, s.total_elapsed_time/1000, s.last_execution_time) end_time,
- s.total_elapsed_time/1000 duration,
+ DATEADD(s, s.last_elapsed_time/1000000, s.last_execution_time) end_time,
+ s.last_elapsed_time/1000000 duration,
case
when t.text LIKE '%%MERGE%%' then 'MERGE'
when t.text LIKE '%%UPDATE%%' then 'UPDATE'
diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py b/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py
index 845277a088c9..42a37bb49ebc 100644
--- a/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/postgres/metadata.py
@@ -67,7 +67,6 @@
get_columns,
get_etable_owner,
get_foreign_keys,
- get_json_fields_and_type,
get_table_comment,
get_table_owner,
get_view_definition,
@@ -147,7 +146,6 @@
Inspector.get_all_table_ddls = get_all_table_ddls
Inspector.get_table_ddl = get_table_ddl
Inspector.get_table_owner = get_etable_owner
-Inspector.get_json_fields_and_type = get_json_fields_and_type
PGDialect.get_foreign_keys = get_foreign_keys
diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py
index e6cf7df513cf..03a66ede0a31 100644
--- a/ingestion/src/metadata/ingestion/source/database/postgres/queries.py
+++ b/ingestion/src/metadata/ingestion/source/database/postgres/queries.py
@@ -193,7 +193,7 @@
"""
POSTGRES_GET_SERVER_VERSION = """
-show server_version
+show server_version_num
"""
POSTGRES_FETCH_FK = """
@@ -212,91 +212,6 @@
ORDER BY 1
"""
-POSTGRES_GET_JSON_FIELDS = """
- WITH RECURSIVE json_hierarchy AS (
- SELECT
- key AS path,
- json_typeof(value) AS type,
- value,
- json_build_object() AS properties,
- key AS title
- FROM
- {table_name} tbd,
- LATERAL json_each({column_name}::json)
- ),
- build_hierarchy AS (
- SELECT
- path,
- type,
- title,
- CASE
- WHEN type = 'object' THEN
- json_build_object(
- 'title', title,
- 'type', 'object',
- 'properties', (
- SELECT json_object_agg(
- key,
- json_build_object(
- 'title', key,
- 'type', json_typeof(value),
- 'properties', (
- CASE
- WHEN json_typeof(value) = 'object' THEN
- (
- SELECT json_object_agg(
- key,
- json_build_object(
- 'title', key,
- 'type', json_typeof(value),
- 'properties',
- json_build_object()
- )
- )
- FROM json_each(value::json) AS sub_key_value
- )
- ELSE json_build_object()
- END
- )
- )
- )
- FROM json_each(value::json) AS key_value
- )
- )
- WHEN type = 'array' THEN
- json_build_object(
- 'title', title,
- 'type', 'array',
- 'properties', json_build_object()
- )
- ELSE
- json_build_object(
- 'title', title,
- 'type', type
- )
- END AS hierarchy
- FROM
- json_hierarchy
- ),
- aggregate_hierarchy AS (
- select
- json_build_object(
- 'title','{column_name}',
- 'type','object',
- 'properties',
- json_object_agg(
- path,
- hierarchy
- )) AS result
- FROM
- build_hierarchy
- )
- SELECT
- result
- FROM
- aggregate_hierarchy;
-"""
-
POSTGRES_GET_STORED_PROCEDURES = """
SELECT proname AS procedure_name,
nspname AS schema_name,
diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/usage.py b/ingestion/src/metadata/ingestion/source/database/postgres/usage.py
index 579590b4ec59..522a89a9e2ee 100644
--- a/ingestion/src/metadata/ingestion/source/database/postgres/usage.py
+++ b/ingestion/src/metadata/ingestion/source/database/postgres/usage.py
@@ -15,6 +15,11 @@
from datetime import datetime
from typing import Iterable
+from sqlalchemy.exc import OperationalError
+
+from metadata.generated.schema.entity.services.ingestionPipelines.status import (
+ StackTraceError,
+)
from metadata.generated.schema.type.basic import DateTime
from metadata.generated.schema.type.tableQuery import TableQueries, TableQuery
from metadata.ingestion.source.connections import get_connection
@@ -67,6 +72,16 @@ def process_table_query(self) -> Iterable[TableQueries]:
logger.error(str(err))
if queries:
yield TableQueries(queries=queries)
+
+ except OperationalError as err:
+ self.status.failed(
+ StackTraceError(
+ name="Usage",
+ error=f"Source Usage failed due to - {err}",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+
except Exception as err:
if query:
logger.debug(
diff --git a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py
index 246519c00e26..6222dfa493ae 100644
--- a/ingestion/src/metadata/ingestion/source/database/postgres/utils.py
+++ b/ingestion/src/metadata/ingestion/source/database/postgres/utils.py
@@ -13,7 +13,6 @@
"""
Postgres SQLAlchemy util methods
"""
-import json
import re
import traceback
from typing import Dict, Optional, Tuple
@@ -24,18 +23,15 @@
from sqlalchemy.engine import reflection
from sqlalchemy.sql import sqltypes
-from metadata.generated.schema.entity.data.table import Column
from metadata.ingestion.source.database.postgres.queries import (
POSTGRES_COL_IDENTITY,
POSTGRES_FETCH_FK,
- POSTGRES_GET_JSON_FIELDS,
POSTGRES_GET_SERVER_VERSION,
POSTGRES_SQL_COLUMNS,
POSTGRES_TABLE_COMMENTS,
POSTGRES_TABLE_OWNERS,
POSTGRES_VIEW_DEFINITIONS,
)
-from metadata.parsers.json_schema_parser import parse_json_schema
from metadata.utils.logger import utils_logger
from metadata.utils.sqlalchemy_utils import (
get_table_comment_wrapper,
@@ -45,7 +41,7 @@
logger = utils_logger()
-OLD_POSTGRES_VERSION = "13.0"
+OLD_POSTGRES_VERSION = "130000"
def get_etable_owner(
@@ -190,28 +186,6 @@ def get_table_comment(
)
-@reflection.cache
-def get_json_fields_and_type(
- self, table_name, column_name, schema=None, **kw
-): # pylint: disable=unused-argument
- try:
- query = POSTGRES_GET_JSON_FIELDS.format(
- table_name=table_name, column_name=column_name
- )
- cursor = self.engine.execute(query)
- result = cursor.fetchone()
- if result:
- parsed_column = parse_json_schema(json.dumps(result[0]), Column)
- if parsed_column:
- return parsed_column[0].children
- except Exception as err:
- logger.warning(
- f"Unable to parse the json fields for {table_name}.{column_name} - {err}"
- )
- logger.debug(traceback.format_exc())
- return None
-
-
@reflection.cache
def get_columns( # pylint: disable=too-many-locals
self, connection, table_name, schema=None, **kw
@@ -523,9 +497,6 @@ def get_postgres_version(engine) -> Optional[str]:
results = engine.execute(POSTGRES_GET_SERVER_VERSION)
for res in results:
version_string = str(res[0])
- opening_parenthesis_index = version_string.find("(")
- if opening_parenthesis_index != -1:
- return version_string[:opening_parenthesis_index].strip()
return version_string
except Exception as err:
logger.warning(f"Unable to fetch the Postgres Version - {err}")
diff --git a/ingestion/src/metadata/ingestion/source/database/query_parser_source.py b/ingestion/src/metadata/ingestion/source/database/query_parser_source.py
index 2c4477a6969a..c897f2877e2a 100644
--- a/ingestion/src/metadata/ingestion/source/database/query_parser_source.py
+++ b/ingestion/src/metadata/ingestion/source/database/query_parser_source.py
@@ -67,6 +67,7 @@ def __init__(
self.engine = (
get_ssl_connection(self.service_connection) if get_engine else None
)
+ self.graph = None
@property
def name(self) -> str:
diff --git a/ingestion/src/metadata/ingestion/source/database/redshift/metadata.py b/ingestion/src/metadata/ingestion/source/database/redshift/metadata.py
index d1c87bd07289..244d96acce2a 100644
--- a/ingestion/src/metadata/ingestion/source/database/redshift/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/redshift/metadata.py
@@ -11,7 +11,6 @@
"""
Redshift source ingestion
"""
-
import re
import traceback
from typing import Iterable, List, Optional, Tuple
@@ -57,6 +56,9 @@
CommonDbSourceService,
TableNameAndType,
)
+from metadata.ingestion.source.database.external_table_lineage_mixin import (
+ ExternalTableLineageMixin,
+)
from metadata.ingestion.source.database.incremental_metadata_extraction import (
IncrementalConfig,
)
@@ -69,6 +71,7 @@
)
from metadata.ingestion.source.database.redshift.models import RedshiftStoredProcedure
from metadata.ingestion.source.database.redshift.queries import (
+ REDSHIFT_EXTERNAL_TABLE_LOCATION,
REDSHIFT_GET_ALL_RELATION_INFO,
REDSHIFT_GET_DATABASE_NAMES,
REDSHIFT_GET_STORED_PROCEDURES,
@@ -121,12 +124,13 @@
RedshiftDialect._get_all_relation_info = ( # pylint: disable=protected-access
_get_all_relation_info
)
-
Inspector.get_all_table_ddls = get_all_table_ddls
Inspector.get_table_ddl = get_table_ddl
-class RedshiftSource(LifeCycleQueryMixin, CommonDbSourceService, MultiDBSource):
+class RedshiftSource(
+ ExternalTableLineageMixin, LifeCycleQueryMixin, CommonDbSourceService, MultiDBSource
+):
"""
Implements the necessary methods to extract
Database metadata from Redshift Source
@@ -146,6 +150,7 @@ def __init__(
self.incremental_table_processor: Optional[
RedshiftIncrementalTableProcessor
] = None
+ self.external_location_map = {}
if self.incremental.enabled:
logger.info(
@@ -168,6 +173,14 @@ def create(
)
return cls(config, metadata, incremental_config)
+ def get_location_path(self, table_name: str, schema_name: str) -> Optional[str]:
+ """
+ Method to fetch the location path of the table
+ """
+ return self.external_location_map.get(
+ (self.context.get().database, schema_name, table_name)
+ )
+
def get_partition_details(self) -> None:
"""
Populate partition details
@@ -275,15 +288,23 @@ def _set_incremental_table_processor(self, database: str):
for schema_name, table_name in self.incremental_table_processor.get_deleted()
)
+ def set_external_location_map(self, database_name: str) -> None:
+ self.external_location_map.clear()
+ results = self.engine.execute(
+ REDSHIFT_EXTERNAL_TABLE_LOCATION.format(database_name=database_name)
+ ).all()
+ self.external_location_map = {
+ (database_name, row.schemaname, row.tablename): row.location
+ for row in results
+ }
+
def get_database_names(self) -> Iterable[str]:
if not self.config.serviceConnection.root.config.ingestAllDatabases:
+ configured_db = self.config.serviceConnection.root.config.database
self.get_partition_details()
-
- self._set_incremental_table_processor(
- self.config.serviceConnection.root.config.database
- )
-
- yield self.config.serviceConnection.root.config.database
+ self._set_incremental_table_processor(configured_db)
+ self.set_external_location_map(configured_db)
+ yield configured_db
else:
for new_database in self.get_database_names_raw():
database_fqn = fqn.build(
@@ -307,9 +328,8 @@ def get_database_names(self) -> Iterable[str]:
try:
self.set_inspector(database_name=new_database)
self.get_partition_details()
-
self._set_incremental_table_processor(new_database)
-
+ self.set_external_location_map(new_database)
yield new_database
except Exception as exc:
logger.debug(traceback.format_exc())
diff --git a/ingestion/src/metadata/ingestion/source/database/redshift/queries.py b/ingestion/src/metadata/ingestion/source/database/redshift/queries.py
index 50ef72085cf3..114ba4e33dd5 100644
--- a/ingestion/src/metadata/ingestion/source/database/redshift/queries.py
+++ b/ingestion/src/metadata/ingestion/source/database/redshift/queries.py
@@ -203,6 +203,11 @@
"""
)
+REDSHIFT_EXTERNAL_TABLE_LOCATION = """
+ SELECT schemaname, tablename, location
+ FROM svv_external_tables
+ where redshift_database_name='{database_name}'
+"""
REDSHIFT_PARTITION_DETAILS = """
select "schema", "table", diststyle
@@ -328,7 +333,7 @@
pid as query_session_id,
starttime as query_start_time,
endtime as query_end_time,
- b.usename as query_user_name
+ cast(b.usename as varchar) as query_user_name
from STL_QUERY q
join pg_catalog.pg_user b
on b.usesysid = q.userid
diff --git a/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py b/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py
index 0e68053ab90c..e4c46c7532a5 100644
--- a/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/salesforce/metadata.py
@@ -12,7 +12,7 @@
Salesforce source ingestion
"""
import traceback
-from typing import Any, Iterable, Optional, Tuple
+from typing import Any, Iterable, List, Optional, Tuple
from metadata.generated.schema.api.data.createDatabase import CreateDatabaseRequest
from metadata.generated.schema.api.data.createDatabaseSchema import (
@@ -231,6 +231,29 @@ def get_table_description(
)
return table_description if table_description else object_label
+ def get_table_column_description(self, table_name: str) -> Optional[List]:
+ """
+ Method to get the all columns' (field) description for Salesforce with the Tooling API.
+ """
+ all_column_description = None
+ try:
+ result = self.client.toolingexecute(
+ f"query/?q=SELECT+Description+FROM+FieldDefinition+WHERE+"
+ f"EntityDefinition.QualifiedApiName='{table_name}'"
+ )
+ all_column_description = result["records"]
+ except KeyError as err:
+ logger.warning(
+ "Unable to get required key from Tooling API response for "
+ f"table [{table_name}]: {err}"
+ )
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(
+ f"Unable to get column description with Tooling API for table [{table_name}]: {exc}"
+ )
+ return all_column_description
+
def yield_table(
self, table_name_and_type: Tuple[str, TableType]
) -> Iterable[Either[CreateTableRequest]]:
@@ -245,7 +268,7 @@ def yield_table(
f"sobjects/{table_name}/describe/",
params=None,
)
- columns = self.get_columns(salesforce_objects.get("fields", []))
+ columns = self.get_columns(table_name, salesforce_objects.get("fields", []))
table_request = CreateTableRequest(
name=EntityName(table_name),
tableType=table_type,
@@ -278,12 +301,26 @@ def yield_table(
)
)
- def get_columns(self, salesforce_fields):
+ def get_columns(self, table_name: str, salesforce_fields: List):
"""
Method to handle column details
"""
row_order = 1
columns = []
+ column_description_mapping = {}
+ all_column_description = self.get_table_column_description(table_name)
+ if all_column_description:
+ for item in all_column_description:
+ try:
+ if item.get("Description") is not None:
+ column_name = item["attributes"]["url"].split(".")[-1]
+ column_description_mapping.update(
+ {column_name: item["Description"]}
+ )
+ except Exception as ex:
+ logger.debug(
+ f"Error creating column description mapping: {str(ex)}"
+ )
for column in salesforce_fields:
col_constraint = None
if column["nillable"]:
@@ -292,11 +329,15 @@ def get_columns(self, salesforce_fields):
col_constraint = Constraint.NOT_NULL
if column["unique"]:
col_constraint = Constraint.UNIQUE
+ if column_description_mapping.get(column["name"]):
+ column_description = column_description_mapping[column["name"]]
+ else:
+ column_description = column["label"]
columns.append(
Column(
name=column["name"],
- description=column["label"],
+ description=column_description,
dataType=self.column_type(column["type"].upper()),
dataTypeDisplay=column["type"],
constraint=col_constraint,
diff --git a/ingestion/src/metadata/ingestion/source/database/sample_data.py b/ingestion/src/metadata/ingestion/source/database/sample_data.py
index f809f045206b..69cdb59897e0 100644
--- a/ingestion/src/metadata/ingestion/source/database/sample_data.py
+++ b/ingestion/src/metadata/ingestion/source/database/sample_data.py
@@ -1548,9 +1548,7 @@ def ingest_test_suite(self) -> Iterable[Either[OMetaTestSuiteSample]]:
test_suite=CreateTestSuiteRequest(
name=test_suite["testSuiteName"],
description=test_suite["testSuiteDescription"],
- executableEntityReference=test_suite[
- "executableEntityReference"
- ],
+ basicEntityReference=test_suite["executableEntityReference"],
)
)
)
diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/connection.py b/ingestion/src/metadata/ingestion/source/database/snowflake/connection.py
index e32479fe3987..6c98a201b319 100644
--- a/ingestion/src/metadata/ingestion/source/database/snowflake/connection.py
+++ b/ingestion/src/metadata/ingestion/source/database/snowflake/connection.py
@@ -193,10 +193,18 @@ def test_connection(
engine_wrapper=engine_wrapper,
),
"GetQueries": partial(
- test_query, statement=SNOWFLAKE_TEST_GET_QUERIES, engine=engine
+ test_query,
+ statement=SNOWFLAKE_TEST_GET_QUERIES.format(
+ account_usage=service_connection.accountUsageSchema
+ ),
+ engine=engine,
),
"GetTags": partial(
- test_query, statement=SNOWFLAKE_TEST_FETCH_TAG, engine=engine
+ test_query,
+ statement=SNOWFLAKE_TEST_FETCH_TAG.format(
+ account_usage=service_connection.accountUsageSchema
+ ),
+ engine=engine,
),
}
diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/lineage.py b/ingestion/src/metadata/ingestion/source/database/snowflake/lineage.py
index 2d85ed8772e2..1337635fe14f 100644
--- a/ingestion/src/metadata/ingestion/source/database/snowflake/lineage.py
+++ b/ingestion/src/metadata/ingestion/source/database/snowflake/lineage.py
@@ -47,6 +47,8 @@ class SnowflakeLineageSource(
AND (
QUERY_TYPE IN ('MERGE', 'UPDATE','CREATE_TABLE_AS_SELECT')
OR (QUERY_TYPE = 'INSERT' and query_text ILIKE '%%insert%%into%%select%%')
+ OR (QUERY_TYPE = 'ALTER' and query_text ILIKE '%%alter%%table%%swap%%')
+ OR (QUERY_TYPE = 'CREATE_TABLE' and query_text ILIKE '%%clone%%')
)
"""
@@ -60,6 +62,7 @@ def get_stored_procedure_queries_dict(self) -> Dict[str, List[QueryByProcedure]]
start, _ = get_start_and_end(self.source_config.queryLogDuration)
query = self.stored_procedure_query.format(
start_date=start,
+ account_usage=self.service_connection.accountUsageSchema,
)
queries_dict = self.procedure_queries_dict(
query=query,
diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/metadata.py b/ingestion/src/metadata/ingestion/source/database/snowflake/metadata.py
index da5a7034a26d..f6f026d39137 100644
--- a/ingestion/src/metadata/ingestion/source/database/snowflake/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/database/snowflake/metadata.py
@@ -418,6 +418,7 @@ def yield_tag(
SNOWFLAKE_FETCH_ALL_TAGS.format(
database_name=self.context.get().database,
schema_name=schema_name,
+ account_usage=self.service_connection.accountUsageSchema,
)
)
@@ -431,6 +432,7 @@ def yield_tag(
SNOWFLAKE_FETCH_ALL_TAGS.format(
database_name=f'"{self.context.get().database}"',
schema_name=f'"{self.context.get().database_schema}"',
+ account_usage=self.service_connection.accountUsageSchema,
)
)
except Exception as inner_exc:
@@ -635,6 +637,7 @@ def _get_stored_procedures_internal(
query.format(
database_name=self.context.get().database,
schema_name=self.context.get().database_schema,
+ account_usage=self.service_connection.accountUsageSchema,
)
).all()
for row in results:
diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py
index 671e2ebfa8a4..55b2bf909c4a 100644
--- a/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py
+++ b/ingestion/src/metadata/ingestion/source/database/snowflake/queries.py
@@ -25,7 +25,7 @@
start_time "start_time",
end_time "end_time",
total_elapsed_time "duration"
- from snowflake.account_usage.query_history
+ from {account_usage}.query_history
WHERE query_text NOT LIKE '/* {{"app": "OpenMetadata", %%}} */%%'
AND query_text NOT LIKE '/* {{"app": "dbt", %%}} */%%'
AND start_time between to_timestamp_ltz('{start_time}') and to_timestamp_ltz('{end_time}')
@@ -39,7 +39,7 @@
SNOWFLAKE_FETCH_ALL_TAGS = textwrap.dedent(
"""
select TAG_NAME, TAG_VALUE, OBJECT_DATABASE, OBJECT_SCHEMA, OBJECT_NAME, COLUMN_NAME
- from snowflake.account_usage.tag_references
+ from {account_usage}.tag_references
where OBJECT_DATABASE = '{database_name}'
and OBJECT_SCHEMA = '{schema_name}'
"""
@@ -234,11 +234,11 @@
"""
SNOWFLAKE_TEST_FETCH_TAG = """
-select TAG_NAME from snowflake.account_usage.tag_references limit 1
+select TAG_NAME from {account_usage}.tag_references limit 1
"""
SNOWFLAKE_TEST_GET_QUERIES = """
-SELECT query_text from snowflake.account_usage.query_history limit 1
+SELECT query_text from {account_usage}.query_history limit 1
"""
SNOWFLAKE_TEST_GET_TABLES = """
@@ -296,9 +296,10 @@
ARGUMENT_SIGNATURE AS signature,
COMMENT as comment,
'StoredProcedure' as procedure_type
-FROM INFORMATION_SCHEMA.PROCEDURES
+FROM {account_usage}.PROCEDURES
WHERE PROCEDURE_CATALOG = '{database_name}'
AND PROCEDURE_SCHEMA = '{schema_name}'
+ AND DELETED IS NULL
"""
)
@@ -312,9 +313,10 @@
ARGUMENT_SIGNATURE AS signature,
COMMENT as comment,
'UDF' as procedure_type
-FROM INFORMATION_SCHEMA.FUNCTIONS
+FROM {account_usage}.FUNCTIONS
WHERE FUNCTION_CATALOG = '{database_name}'
AND FUNCTION_SCHEMA = '{schema_name}'
+ AND DELETED IS NULL
"""
)
@@ -334,9 +336,11 @@
SESSION_ID,
START_TIME,
END_TIME
- FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY SP
+ FROM {account_usage}.QUERY_HISTORY SP
WHERE QUERY_TYPE = 'CALL'
AND START_TIME >= '{start_date}'
+ AND QUERY_TEXT <> ''
+ AND QUERY_TEXT IS NOT NULL
),
Q_HISTORY AS (
SELECT
@@ -349,11 +353,15 @@
USER_NAME,
SCHEMA_NAME,
DATABASE_NAME
- FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY SP
+ FROM {account_usage}.QUERY_HISTORY SP
WHERE QUERY_TYPE <> 'CALL'
AND QUERY_TEXT NOT LIKE '/* {{"app": "OpenMetadata", %%}} */%%'
AND QUERY_TEXT NOT LIKE '/* {{"app": "dbt", %%}} */%%'
AND START_TIME >= '{start_date}'
+ AND (
+ QUERY_TYPE IN ('MERGE', 'UPDATE','CREATE_TABLE_AS_SELECT')
+ OR (QUERY_TYPE = 'INSERT' and query_text ILIKE '%%insert%%into%%select%%')
+ )
)
SELECT
Q.QUERY_TYPE AS QUERY_TYPE,
diff --git a/ingestion/src/metadata/ingestion/source/database/snowflake/query_parser.py b/ingestion/src/metadata/ingestion/source/database/snowflake/query_parser.py
index bbc528fc4c44..363495bd0f87 100644
--- a/ingestion/src/metadata/ingestion/source/database/snowflake/query_parser.py
+++ b/ingestion/src/metadata/ingestion/source/database/snowflake/query_parser.py
@@ -60,6 +60,7 @@ def get_sql_statement(self, start_time: datetime, end_time: datetime) -> str:
end_time=end_time,
result_limit=self.config.sourceConfig.config.resultLimit,
filters=self.get_filters(),
+ account_usage=self.service_connection.accountUsageSchema,
)
def check_life_cycle_query(
diff --git a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py
index 0f1ef012f9a3..9b65620d3402 100644
--- a/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py
+++ b/ingestion/src/metadata/ingestion/source/database/sql_column_handler.py
@@ -94,6 +94,12 @@ def _process_col_type(self, column: dict, schema: str) -> Tuple:
parsed_string["name"] = column["name"]
else:
col_type = ColumnTypeParser.get_column_type(column["type"])
+ # For arrays, we'll get the item type if possible, or parse the string representation of the column
+ # if SQLAlchemy does not provide any further information
+ if col_type == "ARRAY" and getattr(column["type"], "item_type"):
+ arr_data_type = ColumnTypeParser.get_column_type(
+ column["type"].item_type
+ )
if col_type == "ARRAY" and re.match(
r"(?:\w*)(?:\()(\w*)(?:.*)", str(column["type"])
):
@@ -196,26 +202,6 @@ def _process_complex_col_type(self, parsed_string: dict, column: dict) -> Column
]
return Column(**parsed_string)
- @calculate_execution_time()
- def process_json_type_column_fields( # pylint: disable=too-many-locals
- self, schema_name: str, table_name: str, column_name: str, inspector: Inspector
- ) -> Optional[List[Column]]:
- """
- Parse fields column with json data types
- """
- try:
- if hasattr(inspector, "get_json_fields_and_type"):
- result = inspector.get_json_fields_and_type(
- table_name, column_name, schema_name
- )
- return result
-
- except NotImplementedError:
- logger.debug(
- "Cannot parse json fields for table column [{schema_name}.{table_name}.{col_name}]: NotImplementedError"
- )
- return None
-
@calculate_execution_time()
def get_columns_and_constraints( # pylint: disable=too-many-locals
self, schema_name: str, table_name: str, db_name: str, inspector: Inspector
@@ -240,12 +226,16 @@ def get_columns_and_constraints( # pylint: disable=too-many-locals
if len(col) == 1:
column_level_unique_constraints.add(col[0])
else:
- table_constraints.append(
- TableConstraint(
- constraintType=ConstraintType.UNIQUE,
- columns=col,
+ if not any(
+ tc.constraintType == ConstraintType.UNIQUE and tc.columns == col
+ for tc in table_constraints
+ ):
+ table_constraints.append(
+ TableConstraint(
+ constraintType=ConstraintType.UNIQUE,
+ columns=col,
+ )
)
- )
if len(pk_columns) > 1:
table_constraints.append(
TableConstraint(
@@ -292,11 +282,6 @@ def get_columns_and_constraints( # pylint: disable=too-many-locals
)
col_data_length = 1 if col_data_length is None else col_data_length
- if col_type == "JSON":
- children = self.process_json_type_column_fields(
- schema_name, table_name, column.get("name"), inspector
- )
-
om_column = Column(
name=ColumnName(
root=column["name"]
diff --git a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py
index 5473f8b17009..7dba89daefd6 100644
--- a/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py
+++ b/ingestion/src/metadata/ingestion/source/database/stored_procedures_mixin.py
@@ -38,10 +38,11 @@
from metadata.ingestion.api.status import Status
from metadata.ingestion.lineage.models import ConnectionTypeDialectMapper
from metadata.ingestion.lineage.sql_lineage import get_lineage_by_query
+from metadata.ingestion.models.topology import Queue
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.utils.logger import ingestion_logger
from metadata.utils.stored_procedures import get_procedure_name_from_call
-from metadata.utils.time_utils import convert_timestamp_to_milliseconds
+from metadata.utils.time_utils import datetime_to_timestamp
logger = ingestion_logger()
@@ -176,8 +177,6 @@ def _yield_procedure_lineage(
timeout_seconds=self.source_config.parsingTimeoutLimit,
lineage_source=LineageSource.QueryLineage,
):
- print("&& " * 100)
- print(either_lineage)
if (
either_lineage.left is None
and either_lineage.right.edge.lineageDetails
@@ -200,8 +199,8 @@ def yield_procedure_query(
query_type=query_by_procedure.query_type,
duration=query_by_procedure.query_duration,
queryDate=Timestamp(
- root=convert_timestamp_to_milliseconds(
- int(query_by_procedure.query_start_time.timestamp())
+ root=datetime_to_timestamp(
+ query_by_procedure.query_start_time, True
)
),
triggeredBy=EntityReference(
@@ -214,37 +213,49 @@ def yield_procedure_query(
)
def procedure_lineage_processor(
- self, procedure_and_query: ProcedureAndQuery
+ self, procedure_and_queries: List[ProcedureAndQuery], queue: Queue
) -> Iterable[Either[Union[AddLineageRequest, CreateQueryRequest]]]:
-
- try:
- yield from self._yield_procedure_lineage(
- query_by_procedure=procedure_and_query.query_by_procedure,
- procedure=procedure_and_query.procedure,
- )
- except Exception as exc:
- logger.debug(traceback.format_exc())
- logger.warning(
- f"Could not get lineage for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]."
- )
- try:
- yield from self.yield_procedure_query(
- query_by_procedure=procedure_and_query.query_by_procedure,
- procedure=procedure_and_query.procedure,
- )
- except Exception as exc:
- logger.debug(traceback.format_exc())
- logger.warning(
- f"Could not get query for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]."
- )
+ for procedure_and_query in procedure_and_queries:
+ try:
+ for lineage in self._yield_procedure_lineage(
+ query_by_procedure=procedure_and_query.query_by_procedure,
+ procedure=procedure_and_query.procedure,
+ ):
+ queue.put(lineage)
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(
+ f"Could not get lineage for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]."
+ )
+ try:
+ for lineage in self.yield_procedure_query(
+ query_by_procedure=procedure_and_query.query_by_procedure,
+ procedure=procedure_and_query.procedure,
+ ):
+ queue.put(lineage)
+ except Exception as exc:
+ logger.debug(traceback.format_exc())
+ logger.warning(
+ f"Could not get query for store procedure '{procedure_and_query.procedure.fullyQualifiedName}' due to [{exc}]."
+ )
def procedure_lineage_generator(self) -> Iterable[ProcedureAndQuery]:
query = {
"query": {
"bool": {
"must": [
- {"term": {"service.name.keyword": self.service_name}},
- {"term": {"deleted": False}},
+ {
+ "bool": {
+ "should": [
+ {
+ "term": {
+ "service.name.keyword": self.service_name
+ }
+ }
+ ]
+ }
+ },
+ {"bool": {"should": [{"term": {"deleted": False}}]}},
]
}
}
@@ -256,7 +267,9 @@ def procedure_lineage_generator(self) -> Iterable[ProcedureAndQuery]:
queries_dict = self.get_stored_procedure_queries_dict()
# Then for each procedure, iterate over all its queries
for procedure in (
- self.metadata.paginate_es(entity=StoredProcedure, query_filter=query_filter)
+ self.metadata.paginate_es(
+ entity=StoredProcedure, query_filter=query_filter, size=10
+ )
or []
):
if procedure:
diff --git a/ingestion/src/metadata/ingestion/source/database/trino/connection.py b/ingestion/src/metadata/ingestion/source/database/trino/connection.py
index 6dbae4ac9c39..a87dd792b6bd 100644
--- a/ingestion/src/metadata/ingestion/source/database/trino/connection.py
+++ b/ingestion/src/metadata/ingestion/source/database/trino/connection.py
@@ -39,6 +39,7 @@
create_generic_db_connection,
get_connection_args_common,
init_empty_connection_arguments,
+ init_empty_connection_options,
)
from metadata.ingestion.connections.secrets import connection_with_options_secrets
from metadata.ingestion.connections.test_connections import (
@@ -135,6 +136,10 @@ def get_connection(connection: TrinoConnection) -> Engine:
# here we are creating a copy of connection, because we need to dynamically
# add auth params to connectionArguments, which we do no intend to store
# in original connection object and in OpenMetadata database
+ from trino.sqlalchemy.dialect import TrinoDialect
+
+ TrinoDialect.is_disconnect = _is_disconnect
+
connection_copy = deepcopy(connection)
if connection_copy.verify:
connection_copy.connectionArguments = (
@@ -183,3 +188,11 @@ def test_connection(
queries=queries,
timeout_seconds=timeout_seconds,
)
+
+
+# pylint: disable=unused-argument
+def _is_disconnect(self, e, connection, cursor):
+ """is_disconnect method for the Databricks dialect"""
+ if "JWT expired" in str(e):
+ return True
+ return False
diff --git a/ingestion/src/metadata/ingestion/source/database/unitycatalog/lineage.py b/ingestion/src/metadata/ingestion/source/database/unitycatalog/lineage.py
index 3189102eec3e..7ba075113605 100644
--- a/ingestion/src/metadata/ingestion/source/database/unitycatalog/lineage.py
+++ b/ingestion/src/metadata/ingestion/source/database/unitycatalog/lineage.py
@@ -11,6 +11,7 @@
"""
Databricks Unity Catalog Lineage Source Module
"""
+import traceback
from typing import Iterable, Optional
from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest
@@ -27,6 +28,7 @@
EntitiesEdge,
LineageDetails,
)
+from metadata.generated.schema.type.entityLineage import Source as LineageSource
from metadata.generated.schema.type.entityReference import EntityReference
from metadata.ingestion.api.models import Either
from metadata.ingestion.api.steps import InvalidSourceException, Source
@@ -109,9 +111,59 @@ def _get_lineage_details(
)
)
if col_lineage:
- return LineageDetails(columnsLineage=col_lineage)
+ return LineageDetails(
+ columnsLineage=col_lineage, source=LineageSource.QueryLineage
+ )
return None
+ def _handle_upstream_table(
+ self,
+ table_streams: LineageTableStreams,
+ table: Table,
+ databricks_table_fqn: str,
+ ) -> Iterable[Either[AddLineageRequest]]:
+ for upstream_table in table_streams.upstream_tables:
+ try:
+ if not upstream_table.name:
+ continue
+ from_entity_fqn = fqn.build(
+ metadata=self.metadata,
+ entity_type=Table,
+ database_name=upstream_table.catalog_name,
+ schema_name=upstream_table.schema_name,
+ table_name=upstream_table.name,
+ service_name=self.config.serviceName,
+ )
+
+ from_entity = self.metadata.get_by_name(
+ entity=Table, fqn=from_entity_fqn
+ )
+ if from_entity:
+ lineage_details = self._get_lineage_details(
+ from_table=from_entity,
+ to_table=table,
+ databricks_table_fqn=databricks_table_fqn,
+ )
+ yield Either(
+ left=None,
+ right=AddLineageRequest(
+ edge=EntitiesEdge(
+ toEntity=EntityReference(id=table.id, type="table"),
+ fromEntity=EntityReference(
+ id=from_entity.id, type="table"
+ ),
+ lineageDetails=lineage_details,
+ )
+ ),
+ )
+ except Exception:
+ logger.debug(
+ "Error while processing lineage for "
+ f"{upstream_table.catalog_name}.{upstream_table.schema_name}.{upstream_table.name}"
+ f" -> {databricks_table_fqn}"
+ )
+ logger.debug(traceback.format_exc())
+
def _iter(self, *_, **__) -> Iterable[Either[AddLineageRequest]]:
"""
Based on the query logs, prepare the lineage
@@ -128,37 +180,9 @@ def _iter(self, *_, **__) -> Iterable[Either[AddLineageRequest]]:
table_streams: LineageTableStreams = self.client.get_table_lineage(
databricks_table_fqn
)
- for upstream_table in table_streams.upstream_tables:
- from_entity_fqn = fqn.build(
- metadata=self.metadata,
- entity_type=Table,
- database_name=upstream_table.catalog_name,
- schema_name=upstream_table.schema_name,
- table_name=upstream_table.name,
- service_name=self.config.serviceName,
- )
-
- from_entity = self.metadata.get_by_name(
- entity=Table, fqn=from_entity_fqn
- )
- if from_entity:
- lineage_details = self._get_lineage_details(
- from_table=from_entity,
- to_table=table,
- databricks_table_fqn=databricks_table_fqn,
- )
- yield Either(
- left=None,
- right=AddLineageRequest(
- edge=EntitiesEdge(
- toEntity=EntityReference(id=table.id, type="table"),
- fromEntity=EntityReference(
- id=from_entity.id, type="table"
- ),
- lineageDetails=lineage_details,
- )
- ),
- )
+ yield from self._handle_upstream_table(
+ table_streams, table, databricks_table_fqn
+ )
def test_connection(self) -> None:
test_connection_fn = get_test_connection_fn(self.service_connection)
diff --git a/ingestion/src/metadata/ingestion/source/database/unitycatalog/query_parser.py b/ingestion/src/metadata/ingestion/source/database/unitycatalog/query_parser.py
index 5a6b7933a28d..f2ac03b99f98 100644
--- a/ingestion/src/metadata/ingestion/source/database/unitycatalog/query_parser.py
+++ b/ingestion/src/metadata/ingestion/source/database/unitycatalog/query_parser.py
@@ -44,6 +44,13 @@ class UnityCatalogQueryParserSource(
filters: str
+ def _init_super(
+ self,
+ config: WorkflowSource,
+ metadata: OpenMetadata,
+ ):
+ super().__init__(config, metadata, False)
+
# pylint: disable=super-init-not-called
def __init__(self, config: WorkflowSource, metadata: OpenMetadata):
self._init_super(config=config, metadata=metadata)
diff --git a/ingestion/src/metadata/ingestion/source/database/unitycatalog/service_spec.py b/ingestion/src/metadata/ingestion/source/database/unitycatalog/service_spec.py
index ca9f17b254e5..892f88ef7b74 100644
--- a/ingestion/src/metadata/ingestion/source/database/unitycatalog/service_spec.py
+++ b/ingestion/src/metadata/ingestion/source/database/unitycatalog/service_spec.py
@@ -11,6 +11,9 @@
from metadata.profiler.interface.sqlalchemy.unity_catalog.profiler_interface import (
UnityCatalogProfilerInterface,
)
+from metadata.profiler.interface.sqlalchemy.unity_catalog.sampler_interface import (
+ UnityCatalogSamplerInterface,
+)
from metadata.utils.service_spec.default import DefaultDatabaseSpec
ServiceSpec = DefaultDatabaseSpec(
@@ -19,4 +22,5 @@
usage_source_class=UnitycatalogUsageSource,
profiler_class=UnityCatalogProfilerInterface,
test_suite_class=UnityCatalogTestSuiteInterface,
+ sampler_class=UnityCatalogSamplerInterface,
)
diff --git a/ingestion/src/metadata/ingestion/source/database/unitycatalog/usage.py b/ingestion/src/metadata/ingestion/source/database/unitycatalog/usage.py
index 7a310f736a3f..c533454be8cb 100644
--- a/ingestion/src/metadata/ingestion/source/database/unitycatalog/usage.py
+++ b/ingestion/src/metadata/ingestion/source/database/unitycatalog/usage.py
@@ -11,17 +11,22 @@
"""
unity catalog usage module
"""
+import traceback
+from datetime import datetime
+from typing import Iterable
-from metadata.ingestion.source.database.databricks.usage import DatabricksUsageSource
+from metadata.generated.schema.type.basic import DateTime
+from metadata.generated.schema.type.tableQuery import TableQueries, TableQuery
from metadata.ingestion.source.database.unitycatalog.query_parser import (
UnityCatalogQueryParserSource,
)
+from metadata.ingestion.source.database.usage_source import UsageSource
from metadata.utils.logger import ingestion_logger
logger = ingestion_logger()
-class UnitycatalogUsageSource(UnityCatalogQueryParserSource, DatabricksUsageSource):
+class UnitycatalogUsageSource(UnityCatalogQueryParserSource, UsageSource):
"""
UnityCatalog Usage Source
@@ -29,3 +34,37 @@ class UnitycatalogUsageSource(UnityCatalogQueryParserSource, DatabricksUsageSour
DatabricksUsageSource as both the sources would call
the same API for fetching Usage Queries
"""
+
+ def yield_table_queries(self) -> Iterable[TableQuery]:
+ """
+ Method to yield TableQueries
+ """
+ queries = []
+ data = self.client.list_query_history(
+ start_date=self.start,
+ end_date=self.end,
+ )
+ for row in data or []:
+ try:
+ if self.client.is_query_valid(row):
+ queries.append(
+ TableQuery(
+ dialect=self.dialect.value,
+ query=row.get("query_text"),
+ userName=row.get("user_name"),
+ startTime=str(row.get("query_start_time_ms")),
+ endTime=str(row.get("execution_end_time_ms")),
+ analysisDate=DateTime(datetime.now()),
+ serviceName=self.config.serviceName,
+ duration=row.get("duration")
+ if row.get("duration")
+ else None,
+ )
+ )
+ except Exception as err:
+ logger.debug(traceback.format_exc())
+ logger.warning(
+ f"Failed to process query {row.get('query_text')} due to: {err}"
+ )
+
+ yield TableQueries(queries=queries)
diff --git a/ingestion/src/metadata/ingestion/source/database/usage_source.py b/ingestion/src/metadata/ingestion/source/database/usage_source.py
index 6209d601364f..65d5f2635876 100644
--- a/ingestion/src/metadata/ingestion/source/database/usage_source.py
+++ b/ingestion/src/metadata/ingestion/source/database/usage_source.py
@@ -153,7 +153,7 @@ def yield_table_queries(self) -> Iterable[TableQuery]:
if query:
logger.debug(
(
- f"###### USAGE QUERY #######\n{mask_query(query, self.dialect.value)}"
+ f"###### USAGE QUERY #######\n{mask_query(query, self.dialect.value) or query}"
"\n##########################"
)
)
diff --git a/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py b/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py
index f6f11d070fd9..72873ace2dcf 100644
--- a/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/messaging/kafka/metadata.py
@@ -22,21 +22,19 @@
from metadata.ingestion.api.steps import InvalidSourceException
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.messaging.common_broker_source import CommonBrokerSource
-from metadata.utils.ssl_manager import SSLManager
+from metadata.utils.ssl_manager import SSLManager, check_ssl_and_init
class KafkaSource(CommonBrokerSource):
def __init__(self, config: WorkflowSource, metadata: OpenMetadata):
self.ssl_manager = None
- service_connection = cast(KafkaConnection, config.serviceConnection.root.config)
- if service_connection.schemaRegistrySSL:
- self.ssl_manager = SSLManager(
- ca=service_connection.schemaRegistrySSL.root.caCertificate,
- key=service_connection.schemaRegistrySSL.root.sslKey,
- cert=service_connection.schemaRegistrySSL.root.sslCertificate,
- )
- service_connection = self.ssl_manager.setup_ssl(
- config.serviceConnection.root.config
+ self.service_connection = cast(
+ KafkaConnection, config.serviceConnection.root.config
+ )
+ self.ssl_manager: SSLManager = check_ssl_and_init(self.service_connection)
+ if self.ssl_manager:
+ self.service_connection = self.ssl_manager.setup_ssl(
+ self.service_connection
)
super().__init__(config, metadata)
diff --git a/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py b/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py
index 63a6817cf84a..ab342831deaf 100644
--- a/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py
+++ b/ingestion/src/metadata/ingestion/source/pipeline/airbyte/client.py
@@ -20,6 +20,7 @@
from metadata.ingestion.ometa.client import REST, APIError, ClientConfig
from metadata.utils.constants import AUTHORIZATION_HEADER, NO_ACCESS_TOKEN
from metadata.utils.credentials import generate_http_basic_token
+from metadata.utils.helpers import clean_uri
class AirbyteClient:
@@ -30,8 +31,8 @@ class AirbyteClient:
def __init__(self, config: AirbyteConnection):
self.config = config
client_config: ClientConfig = ClientConfig(
- base_url=str(self.config.hostPort),
- api_version="api/v1",
+ base_url=clean_uri(self.config.hostPort),
+ api_version=self.config.apiVersion,
auth_header=AUTHORIZATION_HEADER,
auth_token=lambda: (NO_ACCESS_TOKEN, 0),
)
diff --git a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py
index f0df49e3123a..6420276e73ec 100644
--- a/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/pipeline/dbtcloud/metadata.py
@@ -276,8 +276,16 @@ def yield_pipeline_status(
Get Pipeline Status
"""
try:
- task_status = [
- TaskStatus(
+
+ pipeline_fqn = fqn.build(
+ metadata=self.metadata,
+ entity_type=Pipeline,
+ service_name=self.context.get().pipeline_service,
+ pipeline_name=self.context.get().pipeline,
+ )
+
+ for task in self.client.get_runs(job_id=int(pipeline_details.id)) or []:
+ task_status = TaskStatus(
name=str(task.id),
executionStatus=STATUS_MAP.get(task.state, StatusType.Pending),
startTime=(
@@ -303,37 +311,21 @@ def yield_pipeline_status(
else None
),
)
- for task in self.client.get_runs(job_id=int(pipeline_details.id)) or []
- ]
- pipeline_status = PipelineStatus(
- executionStatus=STATUS_MAP.get(
- pipeline_details.state, StatusType.Pending
- ),
- taskStatus=task_status,
- timestamp=Timestamp(
- datetime_to_ts(
- datetime.strptime(
- pipeline_details.created_at, "%Y-%m-%d %H:%M:%S.%f%z"
- )
- if pipeline_details.created_at
- else None
- )
- ),
- )
+ pipeline_status = PipelineStatus(
+ executionStatus=task_status.executionStatus,
+ taskStatus=[task_status],
+ timestamp=task_status.endTime
+ if task_status.endTime
+ else task_status.startTime,
+ )
- pipeline_fqn = fqn.build(
- metadata=self.metadata,
- entity_type=Pipeline,
- service_name=self.context.get().pipeline_service,
- pipeline_name=self.context.get().pipeline,
- )
- yield Either(
- right=OMetaPipelineStatus(
- pipeline_fqn=pipeline_fqn,
- pipeline_status=pipeline_status,
+ yield Either(
+ right=OMetaPipelineStatus(
+ pipeline_fqn=pipeline_fqn,
+ pipeline_status=pipeline_status,
+ )
)
- )
except Exception as exc:
yield Either(
diff --git a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py
index 620f2416033e..ce37ca3f285c 100644
--- a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/metadata.py
@@ -95,7 +95,7 @@ def yield_pipeline(
sourceUrl=connection_url,
tasks=[
Task(
- name=task.id,
+ name=str(task.id),
)
for task in pipeline_details.tasks or []
],
@@ -205,7 +205,7 @@ def yield_pipeline_lineage_details(
metadata=self.metadata,
entity_type=Topic,
service_name=self.service_connection.messagingServiceName,
- topic_name=topic.name,
+ topic_name=str(topic.name),
)
topic_entity = self.metadata.get_by_name(entity=Topic, fqn=topic_fqn)
@@ -279,7 +279,7 @@ def yield_pipeline_status(
try:
task_status = [
TaskStatus(
- name=task.id,
+ name=str(task.id),
executionStatus=STATUS_MAP.get(task.state, StatusType.Pending),
)
for task in pipeline_details.tasks or []
diff --git a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py
index 55109d02d712..9de270f18a2c 100644
--- a/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py
+++ b/ingestion/src/metadata/ingestion/source/pipeline/kafkaconnect/models.py
@@ -27,7 +27,7 @@ class KafkaConnectTasks(BaseModel):
default="UNASSIGNED", description="State of the task (e.g., RUNNING, STOPPED)"
)
worker_id: Optional[str] = Field(
- ..., description="ID of the worker running the task"
+ default=None, description="ID of the worker running the task"
)
@@ -43,15 +43,15 @@ class KafkaConnectPipelineDetails(BaseModel):
default="UNASSIGNED",
description="State of the connector (e.g., RUNNING, STOPPED)",
)
- tasks: Optional[List[KafkaConnectTasks]]
- topics: Optional[List[KafkaConnectTopics]]
- conn_type: Optional[str] = Field(..., alias="type")
+ tasks: Optional[List[KafkaConnectTasks]] = []
+ topics: Optional[List[KafkaConnectTopics]] = []
+ conn_type: Optional[str] = Field(default="UNKNOWN", alias="type")
class KafkaConnectDatasetDetails(BaseModel):
- table: Optional[str]
- database: Optional[str]
- container_name: Optional[str]
+ table: Optional[str] = None
+ database: Optional[str] = None
+ container_name: Optional[str] = None
@property
def dataset_type(self):
diff --git a/ingestion/src/metadata/ingestion/source/pipeline/nifi/connection.py b/ingestion/src/metadata/ingestion/source/pipeline/nifi/connection.py
index 097b8cc1dbb4..680a8cff54c5 100644
--- a/ingestion/src/metadata/ingestion/source/pipeline/nifi/connection.py
+++ b/ingestion/src/metadata/ingestion/source/pipeline/nifi/connection.py
@@ -17,8 +17,10 @@
from metadata.generated.schema.entity.automations.workflow import (
Workflow as AutomationWorkflow,
)
+from metadata.generated.schema.entity.services.connections.pipeline.nifi.basicAuth import (
+ NifiBasicAuth,
+)
from metadata.generated.schema.entity.services.connections.pipeline.nifiConnection import (
- BasicAuthentication,
NifiConnection,
)
from metadata.generated.schema.entity.services.connections.testConnectionResult import (
@@ -34,7 +36,7 @@ def get_connection(connection: NifiConnection) -> NifiClient:
"""
Create connection
"""
- if isinstance(connection.nifiConfig, BasicAuthentication):
+ if isinstance(connection.nifiConfig, NifiBasicAuth):
return NifiClient(
host_port=connection.hostPort,
username=connection.nifiConfig.username,
diff --git a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py
index 777944bc204b..6220433d72b5 100644
--- a/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py
+++ b/ingestion/src/metadata/ingestion/source/storage/s3/metadata.py
@@ -12,6 +12,7 @@
import json
import secrets
import traceback
+from copy import deepcopy
from datetime import datetime, timedelta
from enum import Enum
from typing import Dict, Iterable, List, Optional, Tuple
@@ -293,15 +294,26 @@ def _generate_container_details(
)
# if we have a sample file to fetch a schema from
if sample_key:
- columns = self._get_columns(
- container_name=bucket_name,
- sample_key=sample_key,
- metadata_entry=metadata_entry,
- config_source=S3Config(
- securityConfig=self.service_connection.awsConfig
- ),
- client=self.s3_client,
- )
+ try:
+ columns = self._get_columns(
+ container_name=bucket_name,
+ sample_key=sample_key,
+ metadata_entry=metadata_entry,
+ config_source=S3Config(
+ securityConfig=self.service_connection.awsConfig
+ ),
+ client=self.s3_client,
+ )
+ except Exception as err:
+ logger.warning()
+ self.status.failed(
+ error=StackTraceError(
+ name=f"{bucket_name}/{sample_key}",
+ error=f"Error extracting columns from [{bucket_name}/{sample_key}] due to: [{err}]",
+ stackTrace=traceback.format_exc(),
+ )
+ )
+ return None
if columns:
prefix = (
f"{KEY_SEPARATOR}{metadata_entry.dataPath.strip(KEY_SEPARATOR)}"
@@ -325,6 +337,45 @@ def _generate_container_details(
)
return None
+ def _generate_structured_containers_by_depth(
+ self,
+ bucket_response: S3BucketResponse,
+ metadata_entry: MetadataEntry,
+ parent: Optional[EntityReference] = None,
+ ) -> Iterable[S3ContainerDetails]:
+ try:
+ prefix = self._get_sample_file_prefix(metadata_entry=metadata_entry)
+ if prefix:
+ response = self.s3_client.list_objects_v2(
+ Bucket=bucket_response.name, Prefix=prefix
+ )
+ # total depth is depth of prefix + depth of the metadata entry
+ total_depth = metadata_entry.depth + len(prefix[:-1].split("/"))
+ candidate_keys = {
+ "/".join(entry.get("Key").split("/")[:total_depth]) + "/"
+ for entry in response[S3_CLIENT_ROOT_RESPONSE]
+ if entry
+ and entry.get("Key")
+ and len(entry.get("Key").split("/")) > total_depth
+ }
+ for key in candidate_keys:
+ metadata_entry_copy = deepcopy(metadata_entry)
+ metadata_entry_copy.dataPath = key.strip(KEY_SEPARATOR)
+ structured_container: Optional[
+ S3ContainerDetails
+ ] = self._generate_container_details(
+ bucket_response=bucket_response,
+ metadata_entry=metadata_entry_copy,
+ parent=parent,
+ )
+ if structured_container:
+ yield structured_container
+ except Exception as err:
+ logger.warning(
+ f"Error while generating structured containers by depth for {metadata_entry.dataPath} - {err}"
+ )
+ logger.debug(traceback.format_exc())
+
def _generate_structured_containers(
self,
bucket_response: S3BucketResponse,
@@ -336,15 +387,22 @@ def _generate_structured_containers(
f"Extracting metadata from path {metadata_entry.dataPath.strip(KEY_SEPARATOR)} "
f"and generating structured container"
)
- structured_container: Optional[
- S3ContainerDetails
- ] = self._generate_container_details(
- bucket_response=bucket_response,
- metadata_entry=metadata_entry,
- parent=parent,
- )
- if structured_container:
- yield structured_container
+ if metadata_entry.depth == 0:
+ structured_container: Optional[
+ S3ContainerDetails
+ ] = self._generate_container_details(
+ bucket_response=bucket_response,
+ metadata_entry=metadata_entry,
+ parent=parent,
+ )
+ if structured_container:
+ yield structured_container
+ else:
+ yield from self._generate_structured_containers_by_depth(
+ bucket_response=bucket_response,
+ metadata_entry=metadata_entry,
+ parent=parent,
+ )
def is_valid_unstructured_file(self, accepted_extensions: List, key: str) -> bool:
# Split the string into a list of values
@@ -413,7 +471,7 @@ def _yield_nested_unstructured_containers(
candidate_keys = [
entry["Key"]
for entry in response[S3_CLIENT_ROOT_RESPONSE]
- if entry and entry.get("Key")
+ if entry and entry.get("Key") and not entry.get("Key").endswith("/")
]
for key in candidate_keys:
if self.is_valid_unstructured_file(metadata_entry.unstructuredFormats, key):
@@ -622,7 +680,7 @@ def _get_sample_file_path(
candidate_keys = [
entry["Key"]
for entry in response[S3_CLIENT_ROOT_RESPONSE]
- if entry and entry.get("Key")
+ if entry and entry.get("Key") and not entry.get("Key").endswith("/")
]
# pick a random key out of the candidates if any were returned
if candidate_keys:
diff --git a/ingestion/src/metadata/profiler/interface/sqlalchemy/unity_catalog/sampler_interface.py b/ingestion/src/metadata/profiler/interface/sqlalchemy/unity_catalog/sampler_interface.py
new file mode 100644
index 000000000000..12a4ae3eaacb
--- /dev/null
+++ b/ingestion/src/metadata/profiler/interface/sqlalchemy/unity_catalog/sampler_interface.py
@@ -0,0 +1,29 @@
+# Copyright 2021 Collate
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# 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.
+
+"""
+Interfaces with database for all database engine
+supporting sqlalchemy abstraction layer
+"""
+from metadata.ingestion.source.database.databricks.connection import (
+ get_connection as databricks_get_connection,
+)
+from metadata.sampler.sqlalchemy.sampler import SQASampler
+
+
+class UnityCatalogSamplerInterface(SQASampler):
+ def get_client(self):
+ """client is the session for SQA"""
+ self.connection = databricks_get_connection(self.service_connection_config)
+ self.client = super().get_client()
+ self.set_catalog(self.client)
+
+ return self.client
diff --git a/ingestion/src/metadata/profiler/metrics/window/first_quartile.py b/ingestion/src/metadata/profiler/metrics/window/first_quartile.py
index e24e94ec75ce..2b20bd3af6ec 100644
--- a/ingestion/src/metadata/profiler/metrics/window/first_quartile.py
+++ b/ingestion/src/metadata/profiler/metrics/window/first_quartile.py
@@ -56,14 +56,14 @@ def fn(self):
# col fullname is only needed for MySQL and SQLite
return self._compute_sqa_fn(
column(self.col.name, self.col.type),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.25,
)
if is_concatenable(self.col.type):
return self._compute_sqa_fn(
LenFn(column(self.col.name, self.col.type)),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.25,
)
diff --git a/ingestion/src/metadata/profiler/metrics/window/median.py b/ingestion/src/metadata/profiler/metrics/window/median.py
index 8e2d1dcffe07..c12878de9184 100644
--- a/ingestion/src/metadata/profiler/metrics/window/median.py
+++ b/ingestion/src/metadata/profiler/metrics/window/median.py
@@ -56,14 +56,14 @@ def fn(self):
# col fullname is only needed for MySQL and SQLite
return self._compute_sqa_fn(
column(self.col.name, self.col.type),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.5,
)
if is_concatenable(self.col.type):
return self._compute_sqa_fn(
LenFn(column(self.col.name, self.col.type)),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.5,
)
diff --git a/ingestion/src/metadata/profiler/metrics/window/third_quartile.py b/ingestion/src/metadata/profiler/metrics/window/third_quartile.py
index 86ebf26bf83d..8f0479a097c5 100644
--- a/ingestion/src/metadata/profiler/metrics/window/third_quartile.py
+++ b/ingestion/src/metadata/profiler/metrics/window/third_quartile.py
@@ -56,14 +56,14 @@ def fn(self):
# col fullname is only needed for MySQL and SQLite
return self._compute_sqa_fn(
column(self.col.name, self.col.type),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.75,
)
if is_concatenable(self.col.type):
return self._compute_sqa_fn(
LenFn(column(self.col.name, self.col.type)),
- self.col.table.fullname if self.col.table is not None else None,
+ self.col.table.name if self.col.table is not None else None,
0.75,
)
diff --git a/ingestion/src/metadata/profiler/orm/converter/common.py b/ingestion/src/metadata/profiler/orm/converter/common.py
index 2d1e41e13dc4..4c2f1d0997a7 100644
--- a/ingestion/src/metadata/profiler/orm/converter/common.py
+++ b/ingestion/src/metadata/profiler/orm/converter/common.py
@@ -76,7 +76,9 @@ def map_types(self, col: Column, table_service_type):
"""returns an ORM type"""
if col.arrayDataType:
- return self._TYPE_MAP.get(col.dataType)(item_type=col.arrayDataType)
+ return self._TYPE_MAP.get(col.dataType)(
+ item_type=self._TYPE_MAP.get(col.arrayDataType)
+ )
return self.return_custom_type(col, table_service_type)
def return_custom_type(self, col: Column, _):
diff --git a/ingestion/src/metadata/profiler/processor/core.py b/ingestion/src/metadata/profiler/processor/core.py
index 289b2aa8f09c..64b268f71e91 100644
--- a/ingestion/src/metadata/profiler/processor/core.py
+++ b/ingestion/src/metadata/profiler/processor/core.py
@@ -550,12 +550,12 @@ def get_profile(self) -> CreateTableProfileRequest:
createDateTime=raw_create_date,
sizeInByte=self._table_results.get("sizeInBytes"),
profileSample=(
- self.profiler_interface.sampler.sample_config.profile_sample
+ self.profiler_interface.sampler.sample_config.profileSample
if self.profiler_interface.sampler.sample_config
else None
),
profileSampleType=(
- self.profiler_interface.sampler.sample_config.profile_sample_type
+ self.profiler_interface.sampler.sample_config.profileSampleType
if self.profiler_interface.sampler.sample_config
else None
),
diff --git a/ingestion/src/metadata/profiler/source/database/base/profiler_source.py b/ingestion/src/metadata/profiler/source/database/base/profiler_source.py
index f14dfb206a46..c9bec0aa2170 100644
--- a/ingestion/src/metadata/profiler/source/database/base/profiler_source.py
+++ b/ingestion/src/metadata/profiler/source/database/base/profiler_source.py
@@ -150,9 +150,9 @@ def create_profiler_interface(
database_entity=database_entity,
table_config=config,
default_sample_config=SampleConfig(
- profile_sample=self.source_config.profileSample,
- profile_sample_type=self.source_config.profileSampleType,
- sampling_method_type=self.source_config.samplingMethodType,
+ profileSample=self.source_config.profileSample,
+ profileSampleType=self.source_config.profileSampleType,
+ samplingMethodType=self.source_config.samplingMethodType,
),
)
diff --git a/ingestion/src/metadata/profiler/source/fetcher/config.py b/ingestion/src/metadata/profiler/source/fetcher/config.py
index ef44e9eca1e2..006f1d5181c3 100644
--- a/ingestion/src/metadata/profiler/source/fetcher/config.py
+++ b/ingestion/src/metadata/profiler/source/fetcher/config.py
@@ -42,3 +42,7 @@ def tableFilterPattern(self) -> Optional[FilterPattern]:
@property
def useFqnForFiltering(self) -> Optional[bool]:
...
+
+ @property
+ def includeViews(self) -> Optional[bool]:
+ ...
diff --git a/ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py b/ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py
index 0fd55f84ccdc..60c04e3a8b81 100644
--- a/ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py
+++ b/ingestion/src/metadata/profiler/source/fetcher/fetcher_strategy.py
@@ -17,6 +17,7 @@
from typing import Iterable, Iterator, Optional, cast
from metadata.generated.schema.entity.data.database import Database
+from metadata.generated.schema.entity.data.table import TableType
from metadata.generated.schema.entity.services.ingestionPipelines.status import (
StackTraceError,
)
@@ -115,13 +116,13 @@ def __init__(
super().__init__(config, metadata, global_profiler_config, status)
self.source_config = cast(
EntityFilterConfigInterface, self.source_config
- ) # Satisfy typchecker
+ ) # Satisfy typechecker
def _filter_databases(self, databases: Iterable[Database]) -> Iterable[Database]:
"""Filter databases based on the filter pattern
Args:
- database (Database): Database to filter
+ databases (Database): Database to filter
Returns:
bool
@@ -192,6 +193,21 @@ def _filter_tables(self, table: Table) -> bool:
return False
+ def _filter_views(self, table: Table) -> bool:
+ """Filter the tables based on include views configuration"""
+ # If we include views, nothing to filter
+ if self.source_config.includeViews:
+ return False
+
+ # Otherwise, filter out views
+ if table.tableType == TableType.View:
+ self.status.filter(
+ table.name.root, f"We are not including views {table.name.root}"
+ )
+ return True
+
+ return False
+
def _filter_column_metrics_computation(self):
"""Filter"""
@@ -242,6 +258,7 @@ def _filter_entities(self, tables: Iterable[Table]) -> Iterable[Table]:
not self.source_config.classificationFilterPattern
or not self.filter_classifications(table)
)
+ and not self._filter_views(table)
]
return tables
diff --git a/ingestion/src/metadata/sampler/config.py b/ingestion/src/metadata/sampler/config.py
index e5c5239f6ae6..69b9dc75e554 100644
--- a/ingestion/src/metadata/sampler/config.py
+++ b/ingestion/src/metadata/sampler/config.py
@@ -119,9 +119,9 @@ def get_profile_sample_config(
try:
if config and config.profileSample:
return SampleConfig(
- profile_sample=config.profileSample,
- profile_sample_type=config.profileSampleType,
- sampling_method_type=config.samplingMethodType,
+ profileSample=config.profileSample,
+ profileSampleType=config.profileSampleType,
+ samplingMethodType=config.samplingMethodType,
)
except AttributeError:
pass
diff --git a/ingestion/src/metadata/sampler/models.py b/ingestion/src/metadata/sampler/models.py
index 26aa4d9ac95f..e76aa40f58bb 100644
--- a/ingestion/src/metadata/sampler/models.py
+++ b/ingestion/src/metadata/sampler/models.py
@@ -101,6 +101,6 @@ def __str__(self):
class SampleConfig(ConfigModel):
"""Profile Sample Config"""
- profile_sample: Optional[Union[float, int]] = None
- profile_sample_type: Optional[ProfileSampleType] = ProfileSampleType.PERCENTAGE
- sampling_method_type: Optional[SamplingMethodType] = None
+ profileSample: Optional[Union[float, int]] = None
+ profileSampleType: Optional[ProfileSampleType] = ProfileSampleType.PERCENTAGE
+ samplingMethodType: Optional[SamplingMethodType] = None
diff --git a/ingestion/src/metadata/sampler/nosql/sampler.py b/ingestion/src/metadata/sampler/nosql/sampler.py
index dc873c5c10d5..16ed1229f62b 100644
--- a/ingestion/src/metadata/sampler/nosql/sampler.py
+++ b/ingestion/src/metadata/sampler/nosql/sampler.py
@@ -82,10 +82,10 @@ def _fetch_sample_data(self, columns: List[SQALikeColumn]) -> TableData:
def _get_limit(self) -> Optional[int]:
num_rows = self.client.item_count(self.raw_dataset)
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
- limit = num_rows * (self.sample_config.profile_sample / 100)
- elif self.sample_config.profile_sample_type == ProfileSampleType.ROWS:
- limit = self.sample_config.profile_sample
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
+ limit = num_rows * (self.sample_config.profileSample or 100 / 100)
+ elif self.sample_config.profileSampleType == ProfileSampleType.ROWS:
+ limit = self.sample_config.profileSample
else:
limit = SAMPLE_DATA_DEFAULT_COUNT
return limit
diff --git a/ingestion/src/metadata/sampler/pandas/sampler.py b/ingestion/src/metadata/sampler/pandas/sampler.py
index 11f1095e9578..b221837daed5 100644
--- a/ingestion/src/metadata/sampler/pandas/sampler.py
+++ b/ingestion/src/metadata/sampler/pandas/sampler.py
@@ -123,13 +123,13 @@ def _get_sampled_dataframe(self):
"""
random.shuffle(self.raw_dataset) # we'll shuffle the list of dataframes
# sampling data based on profiler config (if any)
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
try:
- profile_sample = (self.sample_config.profile_sample or 100) / 100
+ profile_sample = (self.sample_config.profileSample or 100) / 100
except TypeError:
# if the profile sample is not a number or is None
# we'll set it to 100
- profile_sample = self.sample_config.profile_sample = 100
+ profile_sample = self.sample_config.profileSample = 100
return [
df.sample(
frac=profile_sample,
@@ -141,7 +141,7 @@ def _get_sampled_dataframe(self):
# we'll distribute the sample size equally among the dataframes
sample_rows_per_chunk: int = math.floor(
- (self.sample_config.profile_sample or 100) / len(self.raw_dataset)
+ (self.sample_config.profileSample or 100) / len(self.raw_dataset)
)
num_rows = sum(len(df) for df in self.raw_dataset)
@@ -187,9 +187,9 @@ def get_dataset(self, **__):
if self.partition_details:
self._table = self._partitioned_table()
- if not self.sample_config.profile_sample or (
- self.sample_config.profile_sample == 100
- and self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE
+ if not self.sample_config.profileSample or (
+ self.sample_config.profileSample == 100
+ and self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE
):
return self.raw_dataset
return self._get_sampled_dataframe()
diff --git a/ingestion/src/metadata/sampler/partition.py b/ingestion/src/metadata/sampler/partition.py
index e62ad94a0496..c2eb76f9833e 100644
--- a/ingestion/src/metadata/sampler/partition.py
+++ b/ingestion/src/metadata/sampler/partition.py
@@ -49,7 +49,7 @@ def validate_athena_injected_partitioning(
column_partitions: Optional[List[PartitionColumnDetails]] = table_partitions.columns
if not column_partitions:
- raise RuntimeError("Table parition is set but no columns are defined.")
+ raise RuntimeError("Table partition is set but no columns are defined.")
for column_partition in column_partitions:
if column_partition.intervalType == PartitionIntervalTypes.INJECTED:
@@ -163,6 +163,7 @@ def _handle_bigquery_partition(
partitionIntegerRangeStart=1,
partitionIntegerRangeEnd=10000,
)
+ # TODO: Allow External Hive Partitioning for profiler
raise TypeError(
f"Unsupported partition type {partition.intervalType}. Skipping table"
)
diff --git a/ingestion/src/metadata/sampler/sampler_interface.py b/ingestion/src/metadata/sampler/sampler_interface.py
index 03a3d1d6dc98..fe363816d01c 100644
--- a/ingestion/src/metadata/sampler/sampler_interface.py
+++ b/ingestion/src/metadata/sampler/sampler_interface.py
@@ -76,9 +76,6 @@ def __init__(
self._columns: Optional[List[SQALikeColumn]] = None
self.sample_config = sample_config
- if not self.sample_config.profile_sample:
- self.sample_config.profile_sample = 100
-
self.entity = entity
self.include_columns = include_columns
self.exclude_columns = exclude_columns
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/bigquery/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/bigquery/sampler.py
index 502143c11468..cd82565506b6 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/bigquery/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/bigquery/sampler.py
@@ -54,7 +54,6 @@ def __init__(
sample_query: Optional[str] = None,
storage_config: DataStorageConfig = None,
sample_data_count: Optional[int] = SAMPLE_DATA_DEFAULT_COUNT,
- table_type: TableType = None,
**kwargs,
):
super().__init__(
@@ -68,7 +67,7 @@ def __init__(
sample_data_count=sample_data_count,
**kwargs,
)
- self.raw_dataset_type: TableType = table_type
+ self.raw_dataset_type: Optional[TableType] = entity.tableType
def set_tablesample(self, selectable: SqaTable):
"""Set the TABLESAMPLE clause for BigQuery
@@ -76,11 +75,11 @@ def set_tablesample(self, selectable: SqaTable):
selectable (Table): Table object
"""
if (
- self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE
+ self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE
and self.raw_dataset_type != TableType.View
):
return selectable.tablesample(
- text(f"{self.sample_config.profile_sample or 100} PERCENT")
+ text(f"{self.sample_config.profileSample or 100} PERCENT")
)
return selectable
@@ -116,7 +115,7 @@ def get_sample_query(self, *, column=None) -> Query:
"""get query for sample data"""
# TABLESAMPLE SYSTEM is not supported for views
if (
- self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE
+ self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE
and self.raw_dataset_type != TableType.View
):
return self._base_sample_query(column).cte(
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/mssql/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/mssql/sampler.py
index 0d5b7dc5b53a..637cd35e7bae 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/mssql/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/mssql/sampler.py
@@ -17,7 +17,7 @@
from sqlalchemy import Table, text
from sqlalchemy.sql.selectable import CTE
-from metadata.generated.schema.entity.data.table import ProfileSampleType
+from metadata.generated.schema.entity.data.table import ProfileSampleType, TableType
from metadata.sampler.sqlalchemy.sampler import SQASampler
@@ -32,14 +32,16 @@ def set_tablesample(self, selectable: Table):
Args:
selectable (Table): _description_
"""
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.entity.tableType != TableType.View:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
+ return selectable.tablesample(
+ text(f"{self.sample_config.profileSample or 100} PERCENT")
+ )
+
return selectable.tablesample(
- text(f"{self.sample_config.profile_sample or 100} PERCENT")
+ text(f"{int(self.sample_config.profileSample or 100)} ROWS")
)
-
- return selectable.tablesample(
- text(f"{int(self.sample_config.profile_sample or 100)} ROWS")
- )
+ return selectable
def get_sample_query(self, *, column=None) -> CTE:
"""get query for sample data"""
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/postgres/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/postgres/sampler.py
index e2f15f1e59a9..1bdbe08e6e75 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/postgres/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/postgres/sampler.py
@@ -66,7 +66,7 @@ def __init__(
self.sampling_method_type = SamplingMethodType.BERNOULLI
if (
sample_config
- and sample_config.sampling_method_type == SamplingMethodType.SYSTEM
+ and sample_config.samplingMethodType == SamplingMethodType.SYSTEM
):
self.sampling_fn = func.system
@@ -75,15 +75,15 @@ def set_tablesample(self, selectable: SqaTable):
Args:
selectable (Table): _description_
"""
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
return selectable.tablesample(
- self.sampling_fn(self.sample_config.profile_sample or 100)
+ self.sampling_fn(self.sample_config.profileSample or 100)
)
return selectable
def get_sample_query(self, *, column=None) -> Query:
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
return self._base_sample_query(column).cte(
f"{self.raw_dataset.__tablename__}_rnd"
)
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/sampler.py
index cf5113208dd0..4d28f29f4a95 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/sampler.py
@@ -12,6 +12,7 @@
Helper module to handle data sampling
for the profiler
"""
+import hashlib
import traceback
from typing import List, Optional, Union, cast
@@ -32,6 +33,7 @@
from metadata.profiler.orm.functions.random_num import RandomNumFn
from metadata.profiler.processor.handle_partition import build_partition_predicate
from metadata.sampler.sampler_interface import SamplerInterface
+from metadata.utils.constants import UTF_8
from metadata.utils.helpers import is_safe_sql_query
from metadata.utils.logger import profiler_interface_registry_logger
@@ -109,17 +111,28 @@ def _base_sample_query(self, column: Optional[Column], label=None):
query = self.get_partitioned_query(query)
return query
+ def get_sampler_table_name(self) -> str:
+ """Get the base name of the SQA table for sampling.
+ We use MD5 as a hashing algorithm to generate a unique name for the table
+ keeping its length controlled. Otherwise, we ended up having issues
+ with names getting truncated when we add the suffixes to the identifiers
+ such as _sample, or _rnd.
+ """
+ encoded_name = self.raw_dataset.__tablename__.encode(UTF_8)
+ hash_object = hashlib.md5(encoded_name)
+ return hash_object.hexdigest()
+
def get_sample_query(self, *, column=None) -> Query:
"""get query for sample data"""
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
rnd = self._base_sample_query(
column,
(ModuloFn(RandomNumFn(), 100)).label(RANDOM_LABEL),
- ).cte(f"{self.raw_dataset.__tablename__}_rnd")
+ ).cte(f"{self.get_sampler_table_name()}_rnd")
session_query = self.client.query(rnd)
return session_query.where(
- rnd.c.random <= self.sample_config.profile_sample
- ).cte(f"{self.raw_dataset.__tablename__}_sample")
+ rnd.c.random <= self.sample_config.profileSample
+ ).cte(f"{self.get_sampler_table_name()}_sample")
table_query = self.client.query(self.raw_dataset)
session_query = self._base_sample_query(
@@ -128,8 +141,8 @@ def get_sample_query(self, *, column=None) -> Query:
)
return (
session_query.order_by(RANDOM_LABEL)
- .limit(self.sample_config.profile_sample)
- .cte(f"{self.raw_dataset.__tablename__}_rnd")
+ .limit(self.sample_config.profileSample)
+ .cte(f"{self.get_sampler_table_name()}_rnd")
)
def get_dataset(self, column=None, **__) -> Union[DeclarativeMeta, AliasedClass]:
@@ -140,13 +153,10 @@ def get_dataset(self, column=None, **__) -> Union[DeclarativeMeta, AliasedClass]
if self.sample_query:
return self._rdn_sample_from_user_query()
- if not self.sample_config.profile_sample or (
- int(self.sample_config.profile_sample) == 100
- and self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE
- ):
+ if not self.sample_config.profileSample:
if self.partition_details:
partitioned = self._partitioned_table()
- return partitioned.cte(f"{self.raw_dataset.__tablename__}_partitioned")
+ return partitioned.cte(f"{self.get_sampler_table_name()}_partitioned")
return self.raw_dataset
@@ -165,23 +175,23 @@ def fetch_sample_data(self, columns: Optional[List[Column]] = None) -> TableData
return self._fetch_sample_data_from_user_query()
# Add new RandomNumFn column
- rnd = self.get_sample_query()
+ ds = self.get_dataset()
if not columns:
- sqa_columns = [col for col in inspect(rnd).c if col.name != RANDOM_LABEL]
+ sqa_columns = [col for col in inspect(ds).c if col.name != RANDOM_LABEL]
else:
# we can't directly use columns as it is bound to self.raw_dataset and not the rnd table.
# If we use it, it will result in a cross join between self.raw_dataset and rnd table
names = [col.name for col in columns]
sqa_columns = [
col
- for col in inspect(rnd).c
+ for col in inspect(ds).c
if col.name != RANDOM_LABEL and col.name in names
]
try:
sqa_sample = (
self.client.query(*sqa_columns)
- .select_from(rnd)
+ .select_from(ds)
.limit(self.sample_limit)
.all()
)
@@ -227,7 +237,7 @@ def _rdn_sample_from_user_query(self) -> Query:
stmt = stmt.columns(*list(inspect(self.raw_dataset).c))
return self.client.query(stmt.subquery()).cte(
- f"{self.raw_dataset.__tablename__}_user_sampled"
+ f"{self.get_sampler_table_name()}_user_sampled"
)
def _partitioned_table(self) -> Query:
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/snowflake/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/snowflake/sampler.py
index 714d507c7296..f4dbaa45896e 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/snowflake/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/snowflake/sampler.py
@@ -68,7 +68,7 @@ def __init__(
self.sampling_method_type = func.bernoulli
if (
sample_config
- and sample_config.sampling_method_type == SamplingMethodType.SYSTEM
+ and sample_config.samplingMethodType == SamplingMethodType.SYSTEM
):
self.sampling_method_type = func.system
@@ -77,13 +77,13 @@ def set_tablesample(self, selectable: Table):
Args:
selectable (Table): _description_
"""
- if self.sample_config.profile_sample_type == ProfileSampleType.PERCENTAGE:
+ if self.sample_config.profileSampleType == ProfileSampleType.PERCENTAGE:
return selectable.tablesample(
- self.sampling_method_type(self.sample_config.profile_sample or 100)
+ self.sampling_method_type(self.sample_config.profileSample or 100)
)
return selectable.tablesample(
- func.ROW(text(f"{self.sample_config.profile_sample or 100} ROWS"))
+ func.ROW(text(f"{self.sample_config.profileSample or 100} ROWS"))
)
def get_sample_query(self, *, column=None) -> CTE:
diff --git a/ingestion/src/metadata/sampler/sqlalchemy/trino/sampler.py b/ingestion/src/metadata/sampler/sqlalchemy/trino/sampler.py
index 333e0bf01aa7..4eb6cc40c518 100644
--- a/ingestion/src/metadata/sampler/sqlalchemy/trino/sampler.py
+++ b/ingestion/src/metadata/sampler/sqlalchemy/trino/sampler.py
@@ -41,7 +41,7 @@ def _base_sample_query(self, column, label=None):
return self.client.query(entity, label).where(
or_(
*[
- text(f"is_nan({cols.name}) = False")
+ text(f'is_nan("{cols.name}") = False')
for cols in sqa_columns
if type(cols.type) in FLOAT_SET
]
diff --git a/ingestion/src/metadata/utils/constants.py b/ingestion/src/metadata/utils/constants.py
index 49a0e7c5eef5..62655909f832 100644
--- a/ingestion/src/metadata/utils/constants.py
+++ b/ingestion/src/metadata/utils/constants.py
@@ -12,12 +12,17 @@
"""
Define constants useful for the metadata ingestion
"""
+from metadata.generated.schema.entity.data.apiCollection import APICollection
+from metadata.generated.schema.entity.data.apiEndpoint import APIEndpoint
from metadata.generated.schema.entity.data.chart import Chart
from metadata.generated.schema.entity.data.container import Container
from metadata.generated.schema.entity.data.dashboard import Dashboard
from metadata.generated.schema.entity.data.dashboardDataModel import DashboardDataModel
from metadata.generated.schema.entity.data.database import Database
from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema
+from metadata.generated.schema.entity.data.glossary import Glossary
+from metadata.generated.schema.entity.data.glossaryTerm import GlossaryTerm
+from metadata.generated.schema.entity.data.metric import Metric
from metadata.generated.schema.entity.data.mlmodel import MlModel
from metadata.generated.schema.entity.data.pipeline import Pipeline
from metadata.generated.schema.entity.data.searchIndex import SearchIndex
@@ -42,12 +47,6 @@
from metadata.generated.schema.entity.services.connections.database.domoDatabaseConnection import (
DomoDatabaseType,
)
-from metadata.generated.schema.entity.services.connections.database.dorisConnection import (
- DorisType,
-)
-from metadata.generated.schema.entity.services.connections.database.druidConnection import (
- DruidType,
-)
from metadata.generated.schema.entity.services.connections.database.dynamoDBConnection import (
DynamoDBType,
)
@@ -69,9 +68,6 @@
from metadata.generated.schema.entity.services.connections.database.sasConnection import (
SasType,
)
-from metadata.generated.schema.entity.services.connections.database.unityCatalogConnection import (
- DatabricksType,
-)
from metadata.generated.schema.entity.services.dashboardService import DashboardService
from metadata.generated.schema.entity.services.databaseService import DatabaseService
from metadata.generated.schema.entity.services.messagingService import MessagingService
@@ -131,6 +127,8 @@
"metadataService": MetadataService,
"searchService": SearchService,
# Data Asset Entities
+ "apiCollection": APICollection,
+ "apiEndpoint": APIEndpoint,
"table": Table,
"storedProcedure": StoredProcedure,
"database": Database,
@@ -149,6 +147,10 @@
# Domain
"domain": Domain,
"dataProduct": DataProduct,
+ # Governance
+ "metric": Metric,
+ "glossary": Glossary,
+ "glossaryTerm": GlossaryTerm,
}
ENTITY_REFERENCE_TYPE_MAP = {
@@ -161,11 +163,8 @@
DatalakeType.Datalake.value,
BigtableType.BigTable.value,
CouchbaseType.Couchbase.value,
- DatabricksType.UnityCatalog.value,
DeltaLakeType.DeltaLake.value,
DomoDatabaseType.DomoDatabase.value,
- DorisType.Doris.value,
- DruidType.Druid.value,
DynamoDBType.DynamoDB.value,
GlueType.Glue.value,
IcebergType.Iceberg.value,
diff --git a/ingestion/src/metadata/utils/db_utils.py b/ingestion/src/metadata/utils/db_utils.py
index ef3a7c8d6088..afd1e4832940 100644
--- a/ingestion/src/metadata/utils/db_utils.py
+++ b/ingestion/src/metadata/utils/db_utils.py
@@ -69,6 +69,10 @@ def get_view_lineage(
fqn=table_fqn,
)
+ if not view_definition:
+ logger.warning(f"View definition for view {table_fqn} not available")
+ return
+
try:
connection_type = str(connection_type)
dialect = ConnectionTypeDialectMapper.dialect_of(connection_type)
diff --git a/ingestion/src/metadata/utils/elasticsearch.py b/ingestion/src/metadata/utils/elasticsearch.py
index 0f0ab014b057..8490309ec18f 100644
--- a/ingestion/src/metadata/utils/elasticsearch.py
+++ b/ingestion/src/metadata/utils/elasticsearch.py
@@ -18,6 +18,8 @@
from metadata.generated.schema.analytics.reportData import ReportData
from metadata.generated.schema.entity.classification.tag import Tag
+from metadata.generated.schema.entity.data.apiCollection import APICollection
+from metadata.generated.schema.entity.data.apiEndpoint import APIEndpoint
from metadata.generated.schema.entity.data.chart import Chart
from metadata.generated.schema.entity.data.container import Container
from metadata.generated.schema.entity.data.dashboard import Dashboard
@@ -26,6 +28,7 @@
from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema
from metadata.generated.schema.entity.data.glossary import Glossary
from metadata.generated.schema.entity.data.glossaryTerm import GlossaryTerm
+from metadata.generated.schema.entity.data.metric import Metric
from metadata.generated.schema.entity.data.mlmodel import MlModel
from metadata.generated.schema.entity.data.pipeline import Pipeline
from metadata.generated.schema.entity.data.query import Query
@@ -33,6 +36,7 @@
from metadata.generated.schema.entity.data.storedProcedure import StoredProcedure
from metadata.generated.schema.entity.data.table import Table
from metadata.generated.schema.entity.data.topic import Topic
+from metadata.generated.schema.entity.services.apiService import ApiService
from metadata.generated.schema.entity.services.databaseService import DatabaseService
from metadata.generated.schema.entity.teams.team import Team
from metadata.generated.schema.entity.teams.user import User
@@ -42,6 +46,9 @@
T = TypeVar("T", bound=BaseModel)
ES_INDEX_MAP = {
+ ApiService.__name__: "api_service_search_index",
+ APICollection.__name__: "api_collection_search_index",
+ APIEndpoint.__name__: "api_endpoint_search_index",
Table.__name__: "table_search_index",
Database.__name__: "database_search_index",
DatabaseSchema.__name__: "database_schema_search_index",
@@ -56,12 +63,13 @@
Topic.__name__: "topic_search_index",
Pipeline.__name__: "pipeline_search_index",
Glossary.__name__: "glossary_search_index",
- GlossaryTerm.__name__: "glossary_search_index",
+ GlossaryTerm.__name__: "glossary_term_search_index",
MlModel.__name__: "mlmodel_search_index",
Tag.__name__: "tag_search_index",
Container.__name__: "container_search_index",
Query.__name__: "query_search_index",
ReportData.__name__: "entity_report_data_index",
+ Metric.__name__: "metric_search_index",
"web_analytic_user_activity_report": "web_analytic_user_activity_report_data_index",
"web_analytic_entity_view_report": "web_analytic_entity_view_report_data_index",
}
diff --git a/ingestion/src/metadata/utils/logger.py b/ingestion/src/metadata/utils/logger.py
index f437bb4d3fcd..08d648d8c2fd 100644
--- a/ingestion/src/metadata/utils/logger.py
+++ b/ingestion/src/metadata/utils/logger.py
@@ -28,7 +28,6 @@
from metadata.generated.schema.type.queryParserData import QueryParserData
from metadata.generated.schema.type.tableQuery import TableQueries
from metadata.ingestion.api.models import Entity
-from metadata.ingestion.lineage.masker import mask_query
from metadata.ingestion.models.delete_entity import DeleteEntity
from metadata.ingestion.models.life_cycle import OMetaLifeCycleData
from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification
@@ -284,19 +283,13 @@ def _(record: PatchRequest) -> str:
@get_log_name.register
def _(record: TableQueries) -> str:
"""Get the log of the TableQuery"""
- queries = "\n------\n".join(
- mask_query(query.query, query.dialect) for query in record.queries
- )
- return f"Table Queries [{queries}]"
+ return f"Table Queries [{len(record.queries)}]"
@get_log_name.register
def _(record: QueryParserData) -> str:
"""Get the log of the ParsedData"""
- queries = "\n------\n".join(
- mask_query(query.sql, query.dialect) for query in record.parsedData
- )
- return f"Usage ParsedData [{queries}]"
+ return f"Usage ParsedData [{len(record.parsedData)}]"
def redacted_config(config: Dict[str, Union[str, dict]]) -> Dict[str, Union[str, dict]]:
diff --git a/ingestion/src/metadata/utils/ssl_manager.py b/ingestion/src/metadata/utils/ssl_manager.py
index 1e6d7a0af5ec..01e7d591a61a 100644
--- a/ingestion/src/metadata/utils/ssl_manager.py
+++ b/ingestion/src/metadata/utils/ssl_manager.py
@@ -17,7 +17,7 @@
import tempfile
import traceback
from functools import singledispatch, singledispatchmethod
-from typing import Optional, Union, cast
+from typing import List, Optional, Union, cast
from pydantic import SecretStr
@@ -66,7 +66,9 @@
class SSLManager:
"SSL Manager to manage SSL certificates for service connections"
- def __init__(self, ca=None, key=None, cert=None):
+ def __init__(
+ self, ca=None, key=None, cert=None, *args, **kwargs
+ ): # pylint: disable=keyword-arg-before-vararg
self.temp_files = []
self.ca_file_path = None
self.cert_file_path = None
@@ -77,6 +79,14 @@ def __init__(self, ca=None, key=None, cert=None):
self.cert_file_path = self.create_temp_file(cert)
if key:
self.key_file_path = self.create_temp_file(key)
+ if args:
+ for arg in args:
+ if arg:
+ setattr(self, f"{arg}", self.create_temp_file(arg))
+ if kwargs:
+ for dict_key, value in kwargs.items():
+ if value:
+ setattr(self, f"{dict_key}", self.create_temp_file(value))
def create_temp_file(self, content: SecretStr):
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
@@ -197,8 +207,15 @@ def _(self, connection: MongoDBConnection):
return connection
@setup_ssl.register(KafkaConnection)
- def _(self, connection):
+ def _(self, connection) -> KafkaConnection:
connection = cast(KafkaConnection, connection)
+ if connection.consumerConfigSSL:
+ connection.consumerConfig = {
+ **connection.consumerConfig,
+ "ssl.ca.location": getattr(self, "ca_consumer_config", None),
+ "ssl.key.location": getattr(self, "key_consumer_config", None),
+ "ssl.certificate.location": getattr(self, "cert_consumer_config", None),
+ }
connection.schemaRegistryConfig["ssl.ca.location"] = self.ca_file_path
connection.schemaRegistryConfig["ssl.key.location"] = self.key_file_path
connection.schemaRegistryConfig[
@@ -208,7 +225,9 @@ def _(self, connection):
@singledispatch
-def check_ssl_and_init(_) -> Optional[SSLManager]:
+def check_ssl_and_init(
+ _, *args, **kwargs # pylint: disable=unused-argument
+) -> Optional[Union[SSLManager, List[SSLManager]]]:
return None
@@ -274,6 +293,38 @@ def _(connection):
return None
+@check_ssl_and_init.register(KafkaConnection)
+def _(connection, *args, **kwargs):
+
+ service_connection: KafkaConnection = cast(KafkaConnection, connection)
+ ssl_consumer_config: Optional[
+ verifySSLConfig.SslConfig
+ ] = service_connection.consumerConfigSSL
+ ssl_schema_registry: Optional[
+ verifySSLConfig.SslConfig
+ ] = service_connection.schemaRegistrySSL
+
+ ssl_consumer_config_dict = {}
+
+ if ssl_consumer_config:
+ ssl_consumer_config_dict = {
+ "ca_consumer_config": ssl_consumer_config.root.caCertificate,
+ "cert_consumer_config": ssl_consumer_config.root.sslCertificate,
+ "key_consumer_config": ssl_consumer_config.root.sslKey,
+ }
+ ssl_schema_registry_dict = {}
+
+ if ssl_schema_registry:
+ ssl_schema_registry_dict = {
+ "ca_schema_registry": ssl_schema_registry.root.caCertificate,
+ "cert_schema_registry": ssl_schema_registry.root.sslCertificate,
+ "key_schema_registry": ssl_schema_registry.root.sslKey,
+ }
+ if ssl_consumer_config_dict or ssl_schema_registry_dict:
+ return SSLManager(**ssl_consumer_config_dict, **ssl_schema_registry_dict)
+ return None
+
+
@check_ssl_and_init.register(PostgresConnection)
@check_ssl_and_init.register(RedshiftConnection)
@check_ssl_and_init.register(GreenplumConnection)
diff --git a/ingestion/src/metadata/workflow/data_quality.py b/ingestion/src/metadata/workflow/data_quality.py
index b2b5b878b3b9..ea08be6107c4 100644
--- a/ingestion/src/metadata/workflow/data_quality.py
+++ b/ingestion/src/metadata/workflow/data_quality.py
@@ -11,15 +11,10 @@
"""
Workflow definition for the Data Quality
"""
-import traceback
from typing import Optional
from metadata.data_quality.processor.test_case_runner import TestCaseRunner
from metadata.data_quality.source.test_suite import TestSuiteSource
-from metadata.generated.schema.entity.services.connections.serviceConnection import (
- ServiceConnection,
-)
-from metadata.generated.schema.entity.services.databaseService import DatabaseService
from metadata.generated.schema.tests.testSuite import ServiceType, TestSuite
from metadata.ingestion.api.steps import Processor, Sink
from metadata.ingestion.ometa.utils import model_str
@@ -63,44 +58,11 @@ def _get_sink(self) -> Sink:
def _get_test_runner_processor(self) -> Processor:
return TestCaseRunner.create(self.config.model_dump(), self.metadata)
- def _retrieve_service_connection_if_needed(self, service_type: ServiceType) -> None:
- """Get service object from source config `entityFullyQualifiedName`"""
- if (
- not self.config.source.serviceConnection
- and not self.metadata.config.forceEntityOverwriting
- ):
- fully_qualified_name = (
- self.config.source.sourceConfig.config.entityFullyQualifiedName.root
- )
- try:
- service_name = fqn.split(fully_qualified_name)[0]
- except IndexError as exc:
- logger.debug(traceback.format_exc())
- raise IndexError(
- f"Could not retrieve service name from entity fully qualified name {fully_qualified_name}: {exc}"
- )
- try:
- service: DatabaseService = self.metadata.get_by_name(
- DatabaseService, service_name
- )
- if not service:
- raise ConnectionError(
- f"Could not retrieve service with name `{service_name}`. "
- "Typically caused by the `entityFullyQualifiedName` does not exists in OpenMetadata "
- "or the JWT Token is invalid."
- )
-
- self.config.source.serviceConnection = ServiceConnection(
- service.connection
- )
-
- except Exception as exc:
- logger.debug(traceback.format_exc())
- logger.error(
- f"Error getting service connection for service name [{service_name}]"
- f" using the secrets manager provider [{self.metadata.config.secretsManagerProvider}]: {exc}"
- )
- raise exc
+ def _retrieve_service_connection_if_needed(self, _: ServiceType) -> None:
+ """A test suite might require multiple connections (e.g., for logical test suites)
+ We'll skip this step and get the connections at runtime if they are not informed
+ in the YAML already.
+ """
def _get_ingestion_pipeline_service(self) -> Optional[T]:
"""
diff --git a/ingestion/tests/integration/datalake/conftest.py b/ingestion/tests/integration/datalake/conftest.py
index febbc3fbb1f6..491606a58718 100644
--- a/ingestion/tests/integration/datalake/conftest.py
+++ b/ingestion/tests/integration/datalake/conftest.py
@@ -209,8 +209,11 @@ def run_ingestion(metadata, ingestion_config):
@pytest.fixture(scope="class")
def run_test_suite_workflow(run_ingestion, ingestion_config):
workflow_config = deepcopy(DATA_QUALITY_CONFIG)
- workflow_config["source"]["serviceConnection"] = ingestion_config["source"][
- "serviceConnection"
+ workflow_config["source"]["sourceConfig"]["config"]["serviceConnections"] = [
+ {
+ "serviceName": ingestion_config["source"]["serviceName"],
+ "serviceConnection": ingestion_config["source"]["serviceConnection"],
+ }
]
ingestion_workflow = TestSuiteWorkflow.create(workflow_config)
ingestion_workflow.execute()
@@ -229,8 +232,11 @@ def run_sampled_test_suite_workflow(metadata, run_ingestion, ingestion_config):
),
)
workflow_config = deepcopy(DATA_QUALITY_CONFIG)
- workflow_config["source"]["serviceConnection"] = ingestion_config["source"][
- "serviceConnection"
+ workflow_config["source"]["sourceConfig"]["config"]["serviceConnections"] = [
+ {
+ "serviceName": ingestion_config["source"]["serviceName"],
+ "serviceConnection": ingestion_config["source"]["serviceConnection"],
+ }
]
ingestion_workflow = TestSuiteWorkflow.create(workflow_config)
ingestion_workflow.execute()
@@ -259,8 +265,11 @@ def run_partitioned_test_suite_workflow(metadata, run_ingestion, ingestion_confi
),
)
workflow_config = deepcopy(DATA_QUALITY_CONFIG)
- workflow_config["source"]["serviceConnection"] = ingestion_config["source"][
- "serviceConnection"
+ workflow_config["source"]["sourceConfig"]["config"]["serviceConnections"] = [
+ {
+ "serviceName": ingestion_config["source"]["serviceName"],
+ "serviceConnection": ingestion_config["source"]["serviceConnection"],
+ }
]
ingestion_workflow = TestSuiteWorkflow.create(workflow_config)
ingestion_workflow.execute()
diff --git a/ingestion/tests/integration/integration_base.py b/ingestion/tests/integration/integration_base.py
index 11fe6ee449aa..ab65a16089ae 100644
--- a/ingestion/tests/integration/integration_base.py
+++ b/ingestion/tests/integration/integration_base.py
@@ -162,7 +162,7 @@
"serviceConnection": {{
"config": {service_config}
}},
- "sourceConfig": {{"config": {{"type":"Profiler"}}}}
+ "sourceConfig": {{"config": {{"type":"Profiler", "profileSample": 100}}}}
}},
"processor": {{"type": "orm-profiler", "config": {{}}}},
"sink": {{"type": "metadata-rest", "config": {{}}}},
@@ -371,7 +371,7 @@ def get_create_test_suite(
return CreateTestSuiteRequest(
name=TestSuiteEntityName(name),
description=Markdown(description),
- executableEntityReference=FullyQualifiedEntityName(executable_entity_reference),
+ basicEntityReference=FullyQualifiedEntityName(executable_entity_reference),
)
diff --git a/ingestion/tests/integration/ometa/test_ometa_custom_properties_api.py b/ingestion/tests/integration/ometa/test_ometa_custom_properties_api.py
index 960a15adae92..cd8e7515fa6b 100644
--- a/ingestion/tests/integration/ometa/test_ometa_custom_properties_api.py
+++ b/ingestion/tests/integration/ometa/test_ometa_custom_properties_api.py
@@ -93,7 +93,7 @@
"description": "Rating of a table",
"propertyType": {"name": "enum"},
"customPropertyConfig": {
- "config": {"values": ["Good", "Average", "Bad"], "multiSelect": False},
+ "config": {"values": ["Average", "Bad", "Good"], "multiSelect": False},
},
},
{
diff --git a/ingestion/tests/integration/ometa/test_ometa_test_suite.py b/ingestion/tests/integration/ometa/test_ometa_test_suite.py
index c9391066a7bb..5e4f7711cad3 100644
--- a/ingestion/tests/integration/ometa/test_ometa_test_suite.py
+++ b/ingestion/tests/integration/ometa/test_ometa_test_suite.py
@@ -100,7 +100,7 @@ def setUpClass(cls) -> None:
description=Markdown(
root="This is a test suite for the integration tests"
),
- executableEntityReference=FullyQualifiedEntityName(
+ basicEntityReference=FullyQualifiedEntityName(
"sample_data.ecommerce_db.shopify.dim_address"
),
)
diff --git a/ingestion/tests/integration/ometa/test_ometa_user_api.py b/ingestion/tests/integration/ometa/test_ometa_user_api.py
index fa716b9efbbe..e746a7d65e10 100644
--- a/ingestion/tests/integration/ometa/test_ometa_user_api.py
+++ b/ingestion/tests/integration/ometa/test_ometa_user_api.py
@@ -62,7 +62,9 @@ def setUpClass(cls) -> None:
cls.user_1: User = cls.metadata.create_or_update(
data=CreateUserRequest(
- name="random.user.es", email="random.user.es@getcollate.io"
+ name="random.user.es",
+ email="random.user.es@getcollate.io",
+ description="test",
),
)
@@ -198,3 +200,8 @@ def test_es_search_from_name(self):
self.assertIsNone(
self.metadata.get_reference_by_name(name="Organization", is_owner=True)
)
+
+ # description should not affect in search
+ self.assertIsNone(
+ self.metadata.get_reference_by_name(name="test", is_owner=True)
+ )
diff --git a/ingestion/tests/integration/orm_profiler/test_orm_profiler_e2e.py b/ingestion/tests/integration/orm_profiler/test_orm_profiler_e2e.py
index 2d1d976ec72e..d6ca5e8a7e0e 100644
--- a/ingestion/tests/integration/orm_profiler/test_orm_profiler_e2e.py
+++ b/ingestion/tests/integration/orm_profiler/test_orm_profiler_e2e.py
@@ -549,8 +549,7 @@ def test_workflow_values_partition(ingest, metadata, service_name):
profile = metadata.get_latest_table_profile(table.fullyQualifiedName).profile
assert profile.rowCount == 4.0
- # If we don't have any sample, default to 100
- assert profile.profileSample == 100.0
+ assert profile.profileSample == None
workflow_config["processor"] = {
"type": "orm-profiler",
diff --git a/ingestion/tests/integration/postgres/test_data_quality.py b/ingestion/tests/integration/postgres/test_data_quality.py
index f37c9560ae75..36b1cb9a0ccc 100644
--- a/ingestion/tests/integration/postgres/test_data_quality.py
+++ b/ingestion/tests/integration/postgres/test_data_quality.py
@@ -9,6 +9,7 @@
from metadata.data_quality.api.models import TestCaseDefinition
from metadata.generated.schema.entity.services.databaseService import DatabaseService
from metadata.generated.schema.metadataIngestion.testSuitePipeline import (
+ ServiceConnections,
TestSuiteConfigType,
TestSuitePipeline,
)
@@ -55,9 +56,14 @@ def run_data_quality_workflow(
config=TestSuitePipeline(
type=TestSuiteConfigType.TestSuite,
entityFullyQualifiedName=f"{db_service.fullyQualifiedName.root}.dvdrental.public.customer",
+ serviceConnections=[
+ ServiceConnections(
+ serviceName=db_service.name.root,
+ serviceConnection=db_service.connection,
+ )
+ ],
)
),
- serviceConnection=db_service.connection,
),
processor=Processor(
type="orm-test-runner",
diff --git a/ingestion/tests/integration/test_suite/test_workflow.py b/ingestion/tests/integration/test_suite/test_workflow.py
index 240493bb104d..42e45887e3cc 100644
--- a/ingestion/tests/integration/test_suite/test_workflow.py
+++ b/ingestion/tests/integration/test_suite/test_workflow.py
@@ -134,7 +134,7 @@ def setUpClass(cls) -> None:
cls.test_suite = cls.metadata.create_or_update_executable_test_suite(
data=CreateTestSuiteRequest(
name="test-suite",
- executableEntityReference=cls.table_with_suite.fullyQualifiedName.root,
+ basicEntityReference=cls.table_with_suite.fullyQualifiedName.root,
)
)
diff --git a/ingestion/tests/unit/data_quality/source/test_test_suite.py b/ingestion/tests/unit/data_quality/source/test_test_suite.py
index 9359a6e3b1e0..b153162e689d 100644
--- a/ingestion/tests/unit/data_quality/source/test_test_suite.py
+++ b/ingestion/tests/unit/data_quality/source/test_test_suite.py
@@ -5,7 +5,11 @@
from metadata.data_quality.source.test_suite import TestSuiteSource
from metadata.generated.schema.entity.data.table import Table
+from metadata.generated.schema.entity.services.connections.database.postgresConnection import (
+ PostgresConnection,
+)
from metadata.generated.schema.entity.services.databaseService import (
+ DatabaseConnection,
DatabaseServiceType,
)
from metadata.generated.schema.metadataIngestion.workflow import (
@@ -72,6 +76,19 @@ def test_source_config(parameters, expected, monkeypatch):
},
}
monkeypatch.setattr(TestSuiteSource, "test_connection", Mock())
+ monkeypatch.setattr(
+ TestSuiteSource,
+ "_get_table_service_connection",
+ Mock(
+ return_value=DatabaseConnection(
+ config=PostgresConnection(
+ username="foo",
+ hostPort="localhost:5432",
+ database="postgres",
+ )
+ )
+ ),
+ )
mock_metadata = Mock(spec=OpenMetadata)
mock_metadata.get_by_name.return_value = Table(
@@ -98,7 +115,7 @@ def test_source_config(parameters, expected, monkeypatch):
),
]
mock_metadata.get_by_id.return_value = TestSuite(
- name="test_suite", executable=True, id=UUID(int=0)
+ name="test_suite", basic=True, id=UUID(int=0)
)
source = TestSuiteSource(
diff --git a/ingestion/tests/unit/metadata/utils/test_entity_link.py b/ingestion/tests/unit/metadata/utils/test_entity_link.py
index 840b22f56c3f..bd0bb95a9437 100644
--- a/ingestion/tests/unit/metadata/utils/test_entity_link.py
+++ b/ingestion/tests/unit/metadata/utils/test_entity_link.py
@@ -16,7 +16,11 @@
import pytest
from antlr4.error.Errors import ParseCancellationException
-from metadata.utils.entity_link import get_decoded_column, get_table_or_column_fqn
+from metadata.utils.entity_link import (
+ get_decoded_column,
+ get_table_fqn,
+ get_table_or_column_fqn,
+)
@pytest.mark.parametrize(
@@ -154,3 +158,16 @@ def test_invalid_get_table_or_column_fqn(entity_link, error):
"""
with pytest.raises(error):
get_table_or_column_fqn(entity_link)
+
+
+@pytest.mark.parametrize(
+ "entity_link,expected",
+ [
+ (
+ "<#E::table::red.dev.dbt_jaffle.customers::columns::a>",
+ "red.dev.dbt_jaffle.customers",
+ ),
+ ],
+)
+def test_get_table_fqn(entity_link, expected):
+ assert get_table_fqn(entity_link) == expected
diff --git a/ingestion/tests/unit/profiler/pandas/test_sample.py b/ingestion/tests/unit/profiler/pandas/test_sample.py
index 731acfa2f08d..c25c79f980c2 100644
--- a/ingestion/tests/unit/profiler/pandas/test_sample.py
+++ b/ingestion/tests/unit/profiler/pandas/test_sample.py
@@ -166,7 +166,7 @@ def setUpClass(cls, mock_get_connection, mock_sample_get_connection) -> None:
service_connection_config=DatalakeConnection(configSource={}),
ometa_client=None,
entity=cls.table_entity,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
cls.datalake_profiler_interface = PandasProfilerInterface(
service_connection_config=DatalakeConnection(configSource={}),
@@ -198,7 +198,7 @@ def test_random_sampler(self, _):
service_connection_config=DatalakeConnection(configSource={}),
ometa_client=None,
entity=self.table_entity,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
random_sample = sampler.get_dataset()
res = sum(len(r) for r in random_sample)
@@ -232,7 +232,7 @@ def test_sample_property(self, *_):
service_connection_config=DatalakeConnection(configSource={}),
ometa_client=None,
entity=self.table_entity,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
datalake_profiler_interface = PandasProfilerInterface(
service_connection_config=DatalakeConnection(configSource={}),
@@ -309,7 +309,7 @@ def test_sample_data(self, *_):
service_connection_config=DatalakeConnection(configSource={}),
ometa_client=None,
entity=self.table_entity,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
sample_data = sampler.fetch_sample_data()
@@ -337,7 +337,7 @@ def test_sample_from_user_query(self, *_):
service_connection_config=DatalakeConnection(configSource={}),
ometa_client=None,
entity=self.table_entity,
- default_sample_config=SampleConfig(profile_sample=50.0),
+ default_sample_config=SampleConfig(profileSample=50.0),
sample_query="`age` > 30",
)
sample_data = sampler.fetch_sample_data()
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/bigquery/test_bigquery_sampling.py b/ingestion/tests/unit/profiler/sqlalchemy/bigquery/test_bigquery_sampling.py
index 03152e3ee958..5c719d7fc753 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/bigquery/test_bigquery_sampling.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/bigquery/test_bigquery_sampling.py
@@ -110,7 +110,7 @@ def test_sampling(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
table_type=TableType.Regular,
)
@@ -127,20 +127,31 @@ def test_sampling_for_views(self, sampler_mock):
"""
Test view sampling
"""
+ view_entity = Table(
+ id=uuid4(),
+ name="user",
+ columns=[
+ EntityColumn(
+ name=ColumnName("id"),
+ dataType=DataType.INT,
+ ),
+ ],
+ tableType=TableType.View,
+ )
+
sampler = BigQuerySampler(
service_connection_config=self.bq_conn,
ometa_client=None,
- entity=self.table_entity,
+ entity=view_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
- table_type=TableType.View,
)
query: CTE = sampler.get_sample_query()
expected_query = (
- "WITH users_rnd AS \n(SELECT users.id AS id, ABS(RANDOM()) * 100 %% 100 AS random \n"
- "FROM users)\n SELECT users_rnd.id, users_rnd.random \n"
- "FROM users_rnd \nWHERE users_rnd.random <= 50.0"
+ 'WITH "9bc65c2abec141778ffaa729489f3e87_rnd" AS \n(SELECT users.id AS id, ABS(RANDOM()) * 100 %% 100 AS random \n'
+ 'FROM users)\n SELECT "9bc65c2abec141778ffaa729489f3e87_rnd".id, "9bc65c2abec141778ffaa729489f3e87_rnd".random \n'
+ 'FROM "9bc65c2abec141778ffaa729489f3e87_rnd" \nWHERE "9bc65c2abec141778ffaa729489f3e87_rnd".random <= 50.0'
)
assert (
expected_query.casefold()
@@ -151,12 +162,24 @@ def test_sampling_view_with_partition(self, sampler_mock):
"""
Test view sampling with partition
"""
+ view_entity = Table(
+ id=uuid4(),
+ name="user",
+ columns=[
+ EntityColumn(
+ name=ColumnName("id"),
+ dataType=DataType.INT,
+ ),
+ ],
+ tableType=TableType.View,
+ )
+
sampler = BigQuerySampler(
service_connection_config=self.bq_conn,
ometa_client=None,
- entity=self.table_entity,
+ entity=view_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
partition_details=PartitionProfilerConfig(
enablePartitioning=True,
@@ -168,9 +191,9 @@ def test_sampling_view_with_partition(self, sampler_mock):
)
query: CTE = sampler.get_sample_query()
expected_query = (
- "WITH users_rnd AS \n(SELECT users.id AS id, ABS(RANDOM()) * 100 %% 100 AS random \n"
- "FROM users \nWHERE id in ('1', '2'))\n SELECT users_rnd.id, users_rnd.random \n"
- "FROM users_rnd \nWHERE users_rnd.random <= 50.0"
+ 'WITH "9bc65c2abec141778ffaa729489f3e87_rnd" AS \n(SELECT users.id AS id, ABS(RANDOM()) * 100 %% 100 AS random \n'
+ "FROM users \nWHERE id in ('1', '2'))\n SELECT \"9bc65c2abec141778ffaa729489f3e87_rnd\".id, \"9bc65c2abec141778ffaa729489f3e87_rnd\".random \n"
+ 'FROM "9bc65c2abec141778ffaa729489f3e87_rnd" \nWHERE "9bc65c2abec141778ffaa729489f3e87_rnd".random <= 50.0'
)
assert (
expected_query.casefold()
@@ -186,7 +209,7 @@ def test_sampling_with_partition(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
partition_details=PartitionProfilerConfig(
enablePartitioning=True,
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/mssql/test_mssql_sampling.py b/ingestion/tests/unit/profiler/sqlalchemy/mssql/test_mssql_sampling.py
index feab9863468b..fb03b6d7c530 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/mssql/test_mssql_sampling.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/mssql/test_mssql_sampling.py
@@ -82,7 +82,7 @@ def test_omit_sampling_method_type(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
)
query: CTE = sampler.get_sample_query()
@@ -105,7 +105,7 @@ def test_row_sampling(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.ROWS, profile_sample=50
+ profileSampleType=ProfileSampleType.ROWS, profileSample=50
),
)
query: CTE = sampler.get_sample_query()
@@ -128,8 +128,8 @@ def test_sampling_with_partition(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE,
- profile_sample=50.0,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
+ profileSample=50.0,
),
partition_details=PartitionProfilerConfig(
enablePartitioning=True,
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/postgres/test_postgres_sampling.py b/ingestion/tests/unit/profiler/sqlalchemy/postgres/test_postgres_sampling.py
index f6c9a421a0d2..c603d060283a 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/postgres/test_postgres_sampling.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/postgres/test_postgres_sampling.py
@@ -81,8 +81,8 @@ def test_sampling_default(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE,
- profile_sample=50.0,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
+ profileSample=50.0,
),
)
query: CTE = sampler.get_sample_query()
@@ -107,9 +107,9 @@ def test_sampling(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE,
- profile_sample=50.0,
- sampling_method_type=sampling_method_type,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
+ profileSample=50.0,
+ samplingMethodType=sampling_method_type,
),
)
query: CTE = sampler.get_sample_query()
@@ -128,7 +128,7 @@ def test_sampling_with_partition(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
partition_details=PartitionProfilerConfig(
enablePartitioning=True,
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/snowflake/test_snowflake_sampling.py b/ingestion/tests/unit/profiler/sqlalchemy/snowflake/test_snowflake_sampling.py
index bdee9a860646..dc95877e72bc 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/snowflake/test_snowflake_sampling.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/snowflake/test_snowflake_sampling.py
@@ -80,7 +80,7 @@ def test_omit_sampling_method_type(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE, profile_sample=50.0
+ profileSampleType=ProfileSampleType.PERCENTAGE, profileSample=50.0
),
)
query: CTE = sampler.get_sample_query()
@@ -107,9 +107,9 @@ def test_specify_sampling_method_type(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE,
- profile_sample=50.0,
- sampling_method_type=sampling_method_type,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
+ profileSample=50.0,
+ samplingMethodType=sampling_method_type,
),
)
query: CTE = sampler.get_sample_query()
@@ -132,7 +132,7 @@ def test_row_sampling(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.ROWS, profile_sample=50
+ profileSampleType=ProfileSampleType.ROWS, profileSample=50
),
)
query: CTE = sampler.get_sample_query()
@@ -155,8 +155,8 @@ def test_sampling_with_partition(self, sampler_mock):
ometa_client=None,
entity=self.table_entity,
sample_config=SampleConfig(
- profile_sample_type=ProfileSampleType.PERCENTAGE,
- profile_sample=50.0,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
+ profileSample=50.0,
),
partition_details=PartitionProfilerConfig(
enablePartitioning=True,
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/test_runner.py b/ingestion/tests/unit/profiler/sqlalchemy/test_runner.py
index 880cc6ed94fe..117916a9a583 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/test_runner.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/test_runner.py
@@ -90,7 +90,7 @@ def setUpClass(cls) -> None:
service_connection_config=Mock(),
ometa_client=None,
entity=None,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
cls.dataset = sampler.get_dataset()
diff --git a/ingestion/tests/unit/profiler/sqlalchemy/test_sample.py b/ingestion/tests/unit/profiler/sqlalchemy/test_sample.py
index 71f5c0aa453e..3deabc317a67 100644
--- a/ingestion/tests/unit/profiler/sqlalchemy/test_sample.py
+++ b/ingestion/tests/unit/profiler/sqlalchemy/test_sample.py
@@ -104,7 +104,7 @@ def setUpClass(cls, sampler_mock) -> None:
service_connection_config=cls.sqlite_conn,
ometa_client=None,
entity=None,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
)
cls.dataset = cls.sampler.get_dataset()
cls.sqa_profiler_interface = SQAProfilerInterface(
@@ -361,7 +361,7 @@ def test_sample_from_user_query(self, sampler_mock):
service_connection_config=self.sqlite_conn,
ometa_client=None,
entity=None,
- sample_config=SampleConfig(profile_sample=50.0),
+ sample_config=SampleConfig(profileSample=50.0),
sample_query=stmt,
)
sample_data = sampler.fetch_sample_data()
diff --git a/ingestion/tests/unit/profiler/test_entity_fetcher.py b/ingestion/tests/unit/profiler/test_entity_fetcher.py
new file mode 100644
index 000000000000..9e1d475cd661
--- /dev/null
+++ b/ingestion/tests/unit/profiler/test_entity_fetcher.py
@@ -0,0 +1,88 @@
+# Copyright 2021 Collate
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# http://www.apache.org/licenses/LICENSE-2.0
+# 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.
+
+"""
+Validate entity fetcher filtering strategies
+"""
+import uuid
+
+from metadata.generated.schema.entity.data.table import Table, TableType
+from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import (
+ OpenMetadataConnection,
+)
+from metadata.generated.schema.metadataIngestion.databaseServiceAutoClassificationPipeline import (
+ DatabaseServiceAutoClassificationPipeline,
+)
+from metadata.generated.schema.metadataIngestion.workflow import (
+ OpenMetadataWorkflowConfig,
+ Source,
+ SourceConfig,
+ WorkflowConfig,
+)
+from metadata.ingestion.api.status import Status
+from metadata.profiler.source.fetcher.fetcher_strategy import DatabaseFetcherStrategy
+
+VIEW = Table(
+ id=uuid.uuid4(),
+ name="view",
+ columns=[],
+ tableType=TableType.View,
+)
+
+TABLE = Table(
+ id=uuid.uuid4(),
+ name="table",
+ columns=[],
+ tableType=TableType.Regular,
+)
+
+
+def get_db_fetcher(source_config):
+ """Fetch database"""
+ workflow_config = OpenMetadataWorkflowConfig(
+ source=Source(
+ type="mysql",
+ serviceName="mysql",
+ sourceConfig=SourceConfig(
+ config=source_config,
+ ),
+ ),
+ workflowConfig=WorkflowConfig(
+ openMetadataServerConfig=OpenMetadataConnection(
+ hostPort="localhost:8585/api",
+ )
+ ),
+ )
+ return DatabaseFetcherStrategy(
+ config=workflow_config,
+ metadata=...,
+ global_profiler_config=...,
+ status=Status(),
+ )
+
+
+def test_include_views():
+ """Validate we can include/exclude views"""
+ config = DatabaseServiceAutoClassificationPipeline(
+ includeViews=False,
+ )
+ fetcher = get_db_fetcher(config)
+
+ assert fetcher._filter_views(VIEW)
+ assert not fetcher._filter_views(TABLE)
+
+ config = DatabaseServiceAutoClassificationPipeline(
+ includeViews=True,
+ )
+ fetcher = get_db_fetcher(config)
+
+ assert not fetcher._filter_views(VIEW)
+ assert not fetcher._filter_views(TABLE)
diff --git a/ingestion/tests/unit/profiler/test_profiler_interface.py b/ingestion/tests/unit/profiler/test_profiler_interface.py
index a0336bcd31ee..ee4d3c566ff2 100644
--- a/ingestion/tests/unit/profiler/test_profiler_interface.py
+++ b/ingestion/tests/unit/profiler/test_profiler_interface.py
@@ -159,8 +159,8 @@ def test_get_profile_sample_configs(self):
source_config = DatabaseServiceProfilerPipeline()
expected = SampleConfig(
- profile_sample=11,
- profile_sample_type=ProfileSampleType.PERCENTAGE,
+ profileSample=11,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
)
actual = get_profile_sample_config(
entity=self.table,
@@ -168,9 +168,9 @@ def test_get_profile_sample_configs(self):
database_entity=self.database_entity,
entity_config=None,
default_sample_config=SampleConfig(
- profile_sample=source_config.profileSample,
- profile_sample_type=source_config.profileSampleType,
- sampling_method_type=source_config.samplingMethodType,
+ profileSample=source_config.profileSample,
+ profileSampleType=source_config.profileSampleType,
+ samplingMethodType=source_config.samplingMethodType,
),
)
self.assertEqual(expected, actual)
@@ -181,8 +181,8 @@ def test_get_profile_sample_configs(self):
fullyQualifiedName="demo",
)
expected = SampleConfig(
- profile_sample=11,
- profile_sample_type=ProfileSampleType.PERCENTAGE,
+ profileSample=11,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
)
actual = get_profile_sample_config(
entity=self.table,
@@ -190,17 +190,17 @@ def test_get_profile_sample_configs(self):
database_entity=self.database_entity,
entity_config=profiler,
default_sample_config=SampleConfig(
- profile_sample=source_config.profileSample,
- profile_sample_type=source_config.profileSampleType,
- sampling_method_type=source_config.samplingMethodType,
+ profileSample=source_config.profileSample,
+ profileSampleType=source_config.profileSampleType,
+ samplingMethodType=source_config.samplingMethodType,
),
)
self.assertEqual(expected, actual)
profiler = None
expected = SampleConfig(
- profile_sample=22,
- profile_sample_type=ProfileSampleType.PERCENTAGE,
+ profileSample=22,
+ profileSampleType=ProfileSampleType.PERCENTAGE,
)
table_copy = deepcopy(self.table)
table_copy.tableProfilerConfig = None
@@ -210,9 +210,9 @@ def test_get_profile_sample_configs(self):
database_entity=self.database_entity,
entity_config=profiler,
default_sample_config=SampleConfig(
- profile_sample=source_config.profileSample,
- profile_sample_type=source_config.profileSampleType,
- sampling_method_type=source_config.samplingMethodType,
+ profileSample=source_config.profileSample,
+ profileSampleType=source_config.profileSampleType,
+ samplingMethodType=source_config.samplingMethodType,
),
)
self.assertEqual(expected, actual)
diff --git a/ingestion/tests/unit/test_databricks_lineage.py b/ingestion/tests/unit/test_databricks_lineage.py
index 3f710316b669..8647f63f3603 100644
--- a/ingestion/tests/unit/test_databricks_lineage.py
+++ b/ingestion/tests/unit/test_databricks_lineage.py
@@ -128,23 +128,10 @@ def __init__(self, methodName) -> None:
super().__init__(methodName)
config = OpenMetadataWorkflowConfig.model_validate(mock_databricks_config)
- self.databricks = DatabricksLineageSource.create(
- mock_databricks_config["source"],
- config.workflowConfig.openMetadataServerConfig,
- )
-
- @patch(
- "metadata.ingestion.source.database.databricks.client.DatabricksClient.list_query_history"
- )
- def test_get_table_query(self, list_query_history):
- list_query_history.return_value = mock_data
- results = self.databricks.get_table_query()
- query_list = []
- for result in results:
- if isinstance(result, TableQuery):
- query_list.append(result)
- for _, (expected, original) in enumerate(
- zip(EXPECTED_DATABRICKS_DETAILS, query_list)
+ with patch(
+ "metadata.ingestion.source.database.databricks.lineage.DatabricksLineageSource.test_connection"
):
- expected.analysisDate = original.analysisDate = datetime.now()
- self.assertEqual(expected, original)
+ self.databricks = DatabricksLineageSource.create(
+ mock_databricks_config["source"],
+ config.workflowConfig.openMetadataServerConfig,
+ )
diff --git a/ingestion/tests/unit/test_databricks_schema.py b/ingestion/tests/unit/test_databricks_schema.py
deleted file mode 100644
index a5d9ed6b1122..000000000000
--- a/ingestion/tests/unit/test_databricks_schema.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import pytest
-
-from metadata.ingestion.source.database.databricks.metadata import format_schema_name
-
-
-@pytest.mark.parametrize(
- "input_schema, expected_schema",
- [
- ("test_schema-name", "`test_schema-name`"),
- ("test_schema_name", "test_schema_name"),
- ("schema-with-hyphen", "`schema-with-hyphen`"),
- ("schema_with_underscore", "schema_with_underscore"),
- ("validSchema", "validSchema"),
- ],
-)
-def test_schema_name_sanitization(input_schema, expected_schema):
- """
- Test sanitization of schema names by adding backticks only around hyphenated names.
- """
- sanitized_schema = format_schema_name(input_schema)
- assert sanitized_schema == expected_schema
diff --git a/ingestion/tests/unit/test_dbt.py b/ingestion/tests/unit/test_dbt.py
index 1c2db6edc0a2..46022a87bd59 100644
--- a/ingestion/tests/unit/test_dbt.py
+++ b/ingestion/tests/unit/test_dbt.py
@@ -8,7 +8,11 @@
from unittest import TestCase
from unittest.mock import MagicMock, patch
-from dbt_artifacts_parser.parser import parse_catalog, parse_manifest, parse_run_results
+from collate_dbt_artifacts_parser.parser import (
+ parse_catalog,
+ parse_manifest,
+ parse_run_results,
+)
from pydantic import AnyUrl
from metadata.generated.schema.entity.data.table import Column, DataModel, Table
@@ -53,6 +57,7 @@
"dbtRunResultsFilePath": "sample/dbt_files/run_results.json",
"dbtSourcesFilePath": "sample/dbt_files/sources.json",
},
+ "dbtUpdateOwners": True,
}
},
},
@@ -675,6 +680,7 @@ def check_yield_datamodel(self, dbt_objects, expected_data_models):
data_model_link.right.table_entity.fullyQualifiedName.root,
EXPECTED_DATA_MODEL_FQNS,
)
+ self.check_process_dbt_owners(data_model_link.right)
data_model_list.append(data_model_link.right.datamodel)
for _, (expected, original) in enumerate(
@@ -682,6 +688,12 @@ def check_yield_datamodel(self, dbt_objects, expected_data_models):
):
self.assertEqual(expected, original)
+ def check_process_dbt_owners(self, data_model_link):
+ process_dbt_owners = self.dbt_source_obj.process_dbt_owners(data_model_link)
+ for entity in process_dbt_owners:
+ entity_owner = entity.right.new_entity.owners
+ self.assertEqual(entity_owner, MOCK_OWNER)
+
@patch("metadata.ingestion.ometa.mixins.es_mixin.ESMixin.es_search_from_fqn")
def test_upstream_nodes_for_lineage(self, es_search_from_fqn):
expected_upstream_nodes = [
diff --git a/ingestion/tests/unit/test_sql_lineage.py b/ingestion/tests/unit/test_sql_lineage.py
index 4199cf84a5f8..ffe07a0557a1 100644
--- a/ingestion/tests/unit/test_sql_lineage.py
+++ b/ingestion/tests/unit/test_sql_lineage.py
@@ -229,14 +229,39 @@ def test_table_name_from_query(self):
def test_query_masker(self):
query_list = [
- """SELECT * FROM user WHERE id=1234 AND name='Alice' AND birthdate=DATE '2023-01-01';""",
- """insert into user values ('mayur',123,'my random address 1'), ('mayur',123,'my random address 1');""",
- """SELECT * FROM user WHERE address = '5th street' and name = 'john';""",
- """INSERT INTO user VALUE ('John', '19', '5TH Street');""",
- """SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user;""",
- """with test as (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user) select * from test;""",
- """select * from (select * from (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user));""",
- """select * from users where id > 2 and name <> 'pere';""",
+ (
+ """SELECT * FROM user WHERE id=1234 AND name='Alice' AND birthdate=DATE '2023-01-01';""",
+ Dialect.MYSQL.value,
+ ),
+ (
+ """insert into user values ('mayur',123,'my random address 1'), ('mayur',123,'my random address 1');""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """SELECT * FROM user WHERE address = '5th street' and name = 'john';""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """INSERT INTO user VALUE ('John', '19', '5TH Street');""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user;""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """with test as (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user) select * from test;""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """select * from (select * from (SELECT CASE address WHEN '5th Street' THEN 'CEO' ELSE 'Unknown' END AS person FROM user));""",
+ Dialect.ANSI.value,
+ ),
+ (
+ """select * from users where id > 2 and name <> 'pere';""",
+ Dialect.ANSI.value,
+ ),
+ ("""select * from users where id > 2 and name <> 'pere';""", "random"),
]
expected_query_list = [
@@ -248,7 +273,8 @@ def test_query_masker(self):
"""with test as (SELECT CASE address WHEN ? THEN ? ELSE ? END AS person FROM user) select * from test;""",
"""select * from (select * from (SELECT CASE address WHEN ? THEN ? ELSE ? END AS person FROM user));""",
"""select * from users where id > ? and name <> ?;""",
+ """select * from users where id > ? and name <> ?;""",
]
for i, query in enumerate(query_list):
- self.assertEqual(mask_query(query), expected_query_list[i])
+ self.assertEqual(mask_query(query[0], query[1]), expected_query_list[i])
diff --git a/ingestion/tests/unit/topology/dashboard/test_looker.py b/ingestion/tests/unit/topology/dashboard/test_looker.py
index b5a2b2b0ab46..5f0ca6b485b4 100644
--- a/ingestion/tests/unit/topology/dashboard/test_looker.py
+++ b/ingestion/tests/unit/topology/dashboard/test_looker.py
@@ -47,6 +47,7 @@
from metadata.generated.schema.type.usageDetails import UsageDetails, UsageStats
from metadata.generated.schema.type.usageRequest import UsageRequest
from metadata.ingestion.api.steps import InvalidSourceException
+from metadata.ingestion.lineage.models import Dialect
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.dashboard.dashboard_service import DashboardUsage
from metadata.ingestion.source.dashboard.looker.metadata import LookerSource
@@ -124,6 +125,13 @@
serviceType=DashboardServiceType.Looker,
)
+EXPECTED_PARSED_VIEWS = {
+ "v1": "table1",
+ "v2": "select * from v2",
+ "v3": "select * from (select * from v2)",
+ "v4": "select * from (select * from (select * from v2)) inner join (table1)",
+}
+
class LookerUnitTest(TestCase):
"""
@@ -292,13 +300,33 @@ def test_clean_table_name(self):
"""
Check table cleaning
"""
- self.assertEqual(self.looker._clean_table_name("MY_TABLE"), "my_table")
+ self.assertEqual(
+ self.looker._clean_table_name("MY_TABLE", Dialect.MYSQL), "my_table"
+ )
- self.assertEqual(self.looker._clean_table_name(" MY_TABLE "), "my_table")
+ self.assertEqual(
+ self.looker._clean_table_name(" MY_TABLE ", Dialect.REDSHIFT), "my_table"
+ )
- self.assertEqual(self.looker._clean_table_name(" my_table"), "my_table")
+ self.assertEqual(
+ self.looker._clean_table_name(" my_table", Dialect.SNOWFLAKE), "my_table"
+ )
+
+ self.assertEqual(
+ self.looker._clean_table_name("TABLE AS ALIAS", Dialect.BIGQUERY), "table"
+ )
+
+ self.assertEqual(
+ self.looker._clean_table_name(
+ "`project_id.dataset_id.table_id` AS ALIAS", Dialect.BIGQUERY
+ ),
+ "project_id.dataset_id.table_id",
+ )
- self.assertEqual(self.looker._clean_table_name("TABLE AS ALIAS"), "table")
+ self.assertEqual(
+ self.looker._clean_table_name("`db.schema.table`", Dialect.POSTGRES),
+ "`db.schema.table`",
+ )
def test_render_table_name(self):
"""
@@ -539,3 +567,33 @@ def test_yield_dashboard_usage(self):
self.assertIsNotNone(
list(self.looker.yield_dashboard_usage(MOCK_LOOKER_DASHBOARD))[0].left
)
+
+ def test_derived_view_references(self):
+ """
+ Validate if we can find derived references in a SQL query
+ and replace them with their actual values
+ """
+ # pylint: disable=protected-access
+ self.looker._parsed_views.update(
+ {
+ "v1": "table1",
+ "v2": "select * from v2",
+ }
+ )
+ self.looker._unparsed_views.update(
+ {
+ "v3": "select * from ${v2.SQL_TABLE_NAME}",
+ "v4": "select * from ${v3.SQL_TABLE_NAME} inner join ${v1.SQL_TABLE_NAME}",
+ }
+ )
+ self.looker._derived_dependencies.add_edges_from(
+ [
+ ("v3", "v2"),
+ ("v4", "v3"),
+ ("v4", "v1"),
+ ]
+ )
+ list(self.looker.build_lineage_for_unparsed_views())
+
+ self.assertEqual(self.looker._parsed_views, EXPECTED_PARSED_VIEWS)
+ self.assertEqual(self.looker._unparsed_views, {})
diff --git a/ingestion/tests/unit/topology/database/test_postgres.py b/ingestion/tests/unit/topology/database/test_postgres.py
index 86da89043473..8848cc55960d 100644
--- a/ingestion/tests/unit/topology/database/test_postgres.py
+++ b/ingestion/tests/unit/topology/database/test_postgres.py
@@ -320,14 +320,15 @@ def test_datatype(self):
@patch("sqlalchemy.engine.base.Engine")
def test_get_version_info(self, engine):
- engine.execute.return_value = [["15.3 (Debian 15.3-1.pgdg110+1)"]]
- self.assertEqual("15.3", get_postgres_version(engine))
+ # outdated with a switch to get_server_version_num instead of get_+server_version
+ # engine.execute.return_value = [["15.3 (Debian 15.3-1.pgdg110+1)"]]
+ # self.assertEqual("15.3", get_postgres_version(engine))
- engine.execute.return_value = [["11.16"]]
- self.assertEqual("11.16", get_postgres_version(engine))
+ engine.execute.return_value = [["110016"]]
+ self.assertEqual("110016", get_postgres_version(engine))
- engine.execute.return_value = [["9.6.24"]]
- self.assertEqual("9.6.24", get_postgres_version(engine))
+ engine.execute.return_value = [["90624"]]
+ self.assertEqual("90624", get_postgres_version(engine))
engine.execute.return_value = [[]]
self.assertIsNone(get_postgres_version(engine))
diff --git a/ingestion/tests/unit/topology/database/test_salesforce.py b/ingestion/tests/unit/topology/database/test_salesforce.py
index 8c2ceb780f39..34dc639a7618 100644
--- a/ingestion/tests/unit/topology/database/test_salesforce.py
+++ b/ingestion/tests/unit/topology/database/test_salesforce.py
@@ -117,7 +117,7 @@
dataTypeDisplay="textarea",
description="Contact Description",
fullyQualifiedName=None,
- tags=None,
+ tags=[],
constraint=Constraint.NULL,
ordinalPosition=1,
jsonSchema=None,
@@ -136,7 +136,7 @@
dataTypeDisplay="reference",
description="Owner ID",
fullyQualifiedName=None,
- tags=None,
+ tags=[],
constraint=Constraint.NOT_NULL,
ordinalPosition=2,
jsonSchema=None,
@@ -155,7 +155,7 @@
dataTypeDisplay="phone",
description="Phone",
fullyQualifiedName=None,
- tags=None,
+ tags=[],
constraint=Constraint.NOT_NULL,
ordinalPosition=3,
jsonSchema=None,
@@ -174,7 +174,7 @@
dataTypeDisplay="anytype",
description="Created By ID",
fullyQualifiedName=None,
- tags=None,
+ tags=[],
constraint=Constraint.NOT_NULL,
ordinalPosition=4,
jsonSchema=None,
@@ -451,8 +451,12 @@ def __init__(self, methodName, salesforce, test_connection) -> None:
"database_schema"
] = MOCK_DATABASE_SCHEMA
- def test_table_column(self):
- result = self.salesforce_source.get_columns(SALESFORCE_FIELDS)
+ @patch(
+ "metadata.ingestion.source.database.salesforce.metadata.SalesforceSource.get_table_column_description"
+ )
+ def test_table_column(self, get_table_column_description):
+ get_table_column_description.return_value = None
+ result = self.salesforce_source.get_columns("TEST_TABLE", SALESFORCE_FIELDS)
assert EXPECTED_COLUMN_VALUE == result
def test_column_type(self):
diff --git a/ingestion/tests/utils/sqa.py b/ingestion/tests/utils/sqa.py
index 19770c3f2969..73e370b7173c 100644
--- a/ingestion/tests/utils/sqa.py
+++ b/ingestion/tests/utils/sqa.py
@@ -18,6 +18,15 @@ class User(Base):
age = Column(Integer)
+class UserWithLongName(Base):
+ __tablename__ = "u" * 63 # Keep a max length name of 63 chars (max for Postgres)
+ id = Column(Integer, primary_key=True)
+ name = Column(String(256))
+ fullname = Column(String(256))
+ nickname = Column(String(256))
+ age = Column(Integer)
+
+
class SQATestUtils:
def __init__(self, connection_url: str):
self.connection_url = connection_url
@@ -34,14 +43,16 @@ def load_data(self, data: Sequence[DeclarativeMeta]):
self.session.commit()
def load_user_data(self):
- data = [
- User(name="John", fullname="John Doe", nickname="johnny b goode", age=30), # type: ignore
- User(name="Jane", fullname="Jone Doe", nickname=None, age=31), # type: ignore
- ] * 20
- self.load_data(data)
+ for clz in (User, UserWithLongName):
+ data = [
+ clz(name="John", fullname="John Doe", nickname="johnny b goode", age=30), # type: ignore
+ clz(name="Jane", fullname="Jone Doe", nickname=None, age=31), # type: ignore
+ ] * 20
+ self.load_data(data)
def create_user_table(self):
User.__table__.create(bind=self.session.get_bind())
+ UserWithLongName.__table__.create(bind=self.session.get_bind())
def close(self):
self.session.close()
diff --git a/openmetadata-airflow-apis/pyproject.toml b/openmetadata-airflow-apis/pyproject.toml
index 349b97021ce2..ae221348681f 100644
--- a/openmetadata-airflow-apis/pyproject.toml
+++ b/openmetadata-airflow-apis/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
# since it helps us organize and isolate version management
[project]
name = "openmetadata_managed_apis"
-version = "1.6.0.0.dev0"
+version = "1.6.4.0"
readme = "README.md"
authors = [
{name = "OpenMetadata Committers"}
diff --git a/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_deploy.py b/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_deploy.py
index 57d14b33c91f..329e7b3cf936 100644
--- a/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_deploy.py
+++ b/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_deploy.py
@@ -15,8 +15,6 @@
import uuid
from unittest.mock import patch
-from openmetadata_managed_apis.operations.deploy import dump_with_safe_jwt
-
from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import (
AuthProvider,
OpenMetadataConnection,
@@ -55,9 +53,11 @@
)
-@patch.dict(os.environ, {"AWS_DEFAULT_REGION": "us-east-2"})
+@patch.dict(os.environ, {"AWS_DEFAULT_REGION": "us-east-2", "AIRFLOW_HOME": "/tmp"})
def test_deploy_ingestion_pipeline():
"""We can dump an ingestion pipeline to a file without exposing secrets"""
+ from openmetadata_managed_apis.operations.deploy import dump_with_safe_jwt
+
# Instantiate the Secrets Manager
SecretsManagerFactory.clear_all()
with patch.object(AWSSecretsManager, "get_string_value", return_value=SECRET_VALUE):
diff --git a/openmetadata-clients/openmetadata-java-client/pom.xml b/openmetadata-clients/openmetadata-java-client/pom.xml
index c60dfde3b786..ec3160813f02 100644
--- a/openmetadata-clients/openmetadata-java-client/pom.xml
+++ b/openmetadata-clients/openmetadata-java-client/pom.xml
@@ -5,7 +5,7 @@
openmetadata-clients
org.open-metadata
- 1.6.0-SNAPSHOT
+ 1.6.4
4.0.0
diff --git a/openmetadata-clients/pom.xml b/openmetadata-clients/pom.xml
index 90b7fa975e8c..aaafa098df5a 100644
--- a/openmetadata-clients/pom.xml
+++ b/openmetadata-clients/pom.xml
@@ -5,7 +5,7 @@
platform
org.open-metadata
- 1.6.0-SNAPSHOT
+ 1.6.4
4.0.0
diff --git a/openmetadata-dist/pom.xml b/openmetadata-dist/pom.xml
index 0a24ef385cd3..845272aae7e8 100644
--- a/openmetadata-dist/pom.xml
+++ b/openmetadata-dist/pom.xml
@@ -20,7 +20,7 @@
platform
org.open-metadata
- 1.6.0-SNAPSHOT
+ 1.6.4
openmetadata-dist
diff --git a/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md b/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md
index be18c834c6d9..a1f243dd6707 100644
--- a/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md
+++ b/openmetadata-docs/content/partials/v1.4/connectors/storage/manifest.md
@@ -57,6 +57,56 @@ Again, this information will be added on top of the inferred schema from the dat
{% /codeInfo %}
+{% codeInfo srNumber=7 %}
+
+**Automated Container Ingestion**: Registering all the data paths one by one can be a time consuming job,
+to make the automated structure container ingestion you can provide the depth at which all the data is available.
+
+Let us understand this with the example, suppose following is the file hierarchy within my bucket.
+
+```
+
+# prefix/depth1/depth2/depth3
+athena_service/my_database_a/my_schema_a/table_a/date=01-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_a/date=02-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_b/date=01-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_b/date=02-01-2025/data.parquet
+
+athena_service/my_database_b/my_schema_a/table_a/date=01-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_a/date=02-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_b/date=01-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_b/date=02-01-2025/data.parquet
+
+```
+
+all my tables folders which contains the actual data are available at depth 3, hence when you specify the `depth: 3` in
+manifest entry all following path will get registered as container in OpenMetadata with this single entry
+
+```
+athena_service/my_database_a/my_schema_a/table_a
+athena_service/my_database_a/my_schema_a/table_b
+athena_service/my_database_b/my_schema_a/table_a
+athena_service/my_database_b/my_schema_a/table_b
+```
+
+saving efforts to add 4 individual entries compared to 1
+
+{% /codeInfo %}
+
+
+{% codeInfo srNumber=6 %}
+
+**Unstructured Container**: OpenMetadata supports ingesting unstructured files like images, pdf's etc. We support fetching the file names, size and tags associates to such files.
+
+In case you want to ingest a single unstructured file, then just specifying the full path of the unstructured file in `datapath` would be enough for ingestion.
+
+In case you want to ingest all unstructured files with a specific extension for example `pdf` & `png` then you can provide the folder name containing such files in `dataPath` and list of extensions in the `unstructuredFormats` field.
+
+In case you want to ingest all unstructured files with irrespective of their file type or extension then you can provide the folder name containing such files in `dataPath` and `["*"]` in the `unstructuredFormats` field.
+
+{% /codeInfo %}
+
+
{% /codeInfoContainer %}
{% codeBlock fileName="openmetadata.json" %}
@@ -110,6 +160,27 @@ Again, this information will be added on top of the inferred schema from the dat
}
]
}
+```
+```json {% srNumber=7 %}
+ {
+ "dataPath": "athena_service",
+ "structureFormat": "parquet",
+ "isPartitioned": true,
+ "depth": 2
+ }
+```
+```json {% srNumber=6 %}
+ {
+ "dataPath": "path/to/solution.pdf",
+ },
+ {
+ "dataPath": "path/to/unstructured_folder_png_pdf",
+ "unstructuredFormats": ["png","pdf"]
+ },
+ {
+ "dataPath": "path/to/unstructured_folder_all",
+ "unstructuredFormats": ["*"]
+ }
]
}
```
diff --git a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md
index 1938909593e3..eb3c11a332e9 100644
--- a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md
+++ b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config-def.md
@@ -4,6 +4,8 @@
**dbtUpdateDescriptions**: Configuration to update the description from dbt or not. If set to true descriptions from dbt will override the already present descriptions on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-descriptions)
+**dbtUpdateOwners**: Configuration to update the owner from dbt or not. If set to true owners from dbt will override the already present owners on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-owner)
+
**includeTags**: true or false, to ingest tags from dbt. Default is true.
**dbtClassificationName**: Custom OpenMetadata Classification name for dbt tags.
diff --git a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md
index a697981792aa..8f7662283dcf 100644
--- a/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md
+++ b/openmetadata-docs/content/partials/v1.4/connectors/yaml/dbt/source-config.md
@@ -1,5 +1,6 @@
```yaml {% srNumber=120 %}
# dbtUpdateDescriptions: true or false
+ # dbtUpdateOwners: true or false
# includeTags: true or false
# dbtClassificationName: dbtTags
# databaseFilterPattern:
diff --git a/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md b/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md
index 0f165a9a57fa..aae373ab44bb 100644
--- a/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md
+++ b/openmetadata-docs/content/partials/v1.6/connectors/storage/manifest.md
@@ -57,6 +57,43 @@ Again, this information will be added on top of the inferred schema from the dat
{% /codeInfo %}
+{% codeInfo srNumber=7 %}
+
+**Automated Container Ingestion**: Registering all the data paths one by one can be a time consuming job,
+to make the automated structure container ingestion you can provide the depth at which all the data is available.
+
+Let us understand this with the example, suppose following is the file hierarchy within my bucket.
+
+```
+
+# prefix/depth1/depth2/depth3
+athena_service/my_database_a/my_schema_a/table_a/date=01-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_a/date=02-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_b/date=01-01-2025/data.parquet
+athena_service/my_database_a/my_schema_a/table_b/date=02-01-2025/data.parquet
+
+athena_service/my_database_b/my_schema_a/table_a/date=01-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_a/date=02-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_b/date=01-01-2025/data.parquet
+athena_service/my_database_b/my_schema_a/table_b/date=02-01-2025/data.parquet
+
+```
+
+all my tables folders which contains the actual data are available at depth 3, hence when you specify the `depth: 3` in
+manifest entry all following path will get registered as container in OpenMetadata with this single entry
+
+```
+athena_service/my_database_a/my_schema_a/table_a
+athena_service/my_database_a/my_schema_a/table_b
+athena_service/my_database_b/my_schema_a/table_a
+athena_service/my_database_b/my_schema_a/table_b
+```
+
+saving efforts to add 4 individual entries compared to 1
+
+{% /codeInfo %}
+
+
{% codeInfo srNumber=6 %}
**Unstructured Container**: OpenMetadata supports ingesting unstructured files like images, pdf's etc. We support fetching the file names, size and tags associates to such files.
@@ -124,6 +161,14 @@ In case you want to ingest all unstructured files with irrespective of their fil
]
}
```
+```json {% srNumber=7 %}
+ {
+ "dataPath": "athena_service",
+ "structureFormat": "parquet",
+ "isPartitioned": true,
+ "depth": 2
+ }
+```
```json {% srNumber=6 %}
{
"dataPath": "path/to/solution.pdf",
diff --git a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md
index 1938909593e3..eb3c11a332e9 100644
--- a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md
+++ b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config-def.md
@@ -4,6 +4,8 @@
**dbtUpdateDescriptions**: Configuration to update the description from dbt or not. If set to true descriptions from dbt will override the already present descriptions on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-descriptions)
+**dbtUpdateOwners**: Configuration to update the owner from dbt or not. If set to true owners from dbt will override the already present owners on the entity. For more details visit [here](/connectors/ingestion/workflows/dbt/ingest-dbt-owner)
+
**includeTags**: true or false, to ingest tags from dbt. Default is true.
**dbtClassificationName**: Custom OpenMetadata Classification name for dbt tags.
diff --git a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md
index a697981792aa..8f7662283dcf 100644
--- a/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md
+++ b/openmetadata-docs/content/partials/v1.6/connectors/yaml/dbt/source-config.md
@@ -1,5 +1,6 @@
```yaml {% srNumber=120 %}
# dbtUpdateDescriptions: true or false
+ # dbtUpdateOwners: true or false
# includeTags: true or false
# dbtClassificationName: dbtTags
# databaseFilterPattern:
diff --git a/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md b/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md
index 3004c74156d7..3852522895b8 100644
--- a/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md
+++ b/openmetadata-docs/content/partials/v1.6/deployment/upgrade/upgrade-prerequisites.md
@@ -86,6 +86,134 @@ After the migration is finished, you can revert this changes.
# Backward Incompatible Changes
+## 1.6.4
+
+### Airflow 2.9.3
+
+We are upgrading the Ingestion Airflow version to 2.9.3.
+
+The upgrade from the existing 2.9.1 -> 2.9.3 should happen transparently. The only thing to note is that there's
+an ongoing issue with Airflow migrations and the `pymysql` driver, which we used before. If you are specifying
+on your end the `DB_SCHEME` environment variable in the ingestion image, make sure it now is set to `mysql+mysqldb`.
+
+We have updated the default values accordingly.
+
+## 1.6.2
+
+### Executable Logical Test Suites
+
+We are introducing a new feature that allows users to execute logical test suites. This feature will allow users to run
+groups of Data Quality tests, even if they belong to different tables (or even services!). Note that before, you could
+only schedule and execute the tests for each of the tables.
+
+From the UI, you can now create a new Test Suite, add any tests you want and create and schedule the run.
+
+This change, however, requires some adjustments if you are directly interacting with the OpenMetadata API or if you
+are running the ingestions externally:
+
+#### `/executable` endpoints Changes
+
+CRUD operations around "executable" Test Suites - the ones directly related to a single table - were managed by the
+`/executable` endpoints, e.g., `POST /v1/dataQuality/testSuites/executable`. We'll keep this endpoints until the next release,
+but users should update their operations to use the new `/base` endpoints, e.g., `POST /v1/dataQuality/testSuites/base`.
+
+This is to adjust the naming convention since all Test Suites are executable, so we're differentiating between "base" and
+"logical" Test Suites.
+
+In the meantime, you can use the `/executable` endpoints to create and manage the Test Suites, but you'll get deprecation
+headers in the response. We recommend migrating to the new endpoints as soon as possible to avoid any issues when the `/executable`
+endpoints get completely removed.
+
+#### YAML Changes
+
+If you're running the DQ Workflows externally AND YOU ARE NOT STORING THE SERVICE INFORMATION IN OPENMETADATA, this is how they'll change:
+
+A YAML file for 1.5.x would look like this:
+
+```yaml
+source:
+ type: testsuite
+ serviceName: red # Test Suite Name
+ serviceConnection:
+ config:
+ hostPort:
+ username:
+ password:
+ database:
+ type: Redshift
+ sourceConfig:
+ config:
+ type: TestSuite
+ entityFullyQualifiedName: red.dev.dbt_jaffle.customers
+ profileSampleType: PERCENTAGE
+processor:
+ type: "orm-test-runner"
+ config: {}
+sink:
+ type: metadata-rest
+ config: {}
+workflowConfig:
+ openMetadataServerConfig:
+ hostPort: http://localhost:8585/api
+ authProvider: openmetadata
+ securityConfig:
+ jwtToken: "..."
+```
+
+Basically, if you are not storing the service connection in OpenMetadata, you could leverage the `source.serviceConnection`
+entry to pass that information.
+
+However, with the ability to execute Logical Test Suites, you can now have multiple tests from different services! This means,
+that the connection information needs to be placed differently. The new YAML file would look like this:
+
+```yaml
+source:
+ type: testsuite
+ serviceName: Logical # Test Suite Name
+ sourceConfig:
+ config:
+ type: TestSuite
+ serviceConnections:
+ - serviceName: red
+ serviceConnection:
+ config:
+ hostPort:
+ username:
+ password:
+ database:
+ type: Redshift
+ - serviceName: snowflake
+ serviceConnection:
+ config:
+ hostPort:
+ username:
+ password:
+ database:
+ type: Snowflake
+processor:
+ type: "orm-test-runner"
+ config: {}
+sink:
+ type: metadata-rest
+ config: {}
+workflowConfig:
+ openMetadataServerConfig:
+ hostPort: http://localhost:8585/api
+ authProvider: openmetadata
+ securityConfig:
+ jwtToken: "..."
+```
+
+As you can see, you can pass multiple `serviceConnections` to the `sourceConfig` entry, each one with the connection information
+and the `serviceName` they are linked to.
+
+{% note noteType="Warning" %}
+
+If you are already storing the service connection information in OpenMetadata (e.g., because you have created the services via the UI),
+there's nothing you need to do. The ingestion will automatically pick up the connection information from the service.
+
+{% /note %}
+
## 1.6.0
### Ingestion Workflow Status
diff --git a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md
index 92f5e3369403..57ebb385e1e1 100644
--- a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md
+++ b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/index.md
@@ -59,13 +59,18 @@ GRANT SELECT ON ALL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL EXTERNAL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL VIEWS IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL DYNAMIC TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
+
+-- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role,
+-- optional but required for usage, lineage and stored procedure ingestion
+GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE;
```
{% note %}
If running any of:
- Incremental Extraction
- Ingesting Tags
- - Usage Workflow
+ - Ingesting Stored Procedures
+ - Lineage & Usage Workflow
The following Grant is needed
{% /note %}
@@ -74,24 +79,12 @@ The following Grant is needed
- **Ingesting Tags**: Openmetadata fetches the information by querying `snowflake.account_usage.tag_references`.
-- **Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`.
-
-In order to be able to query those tables, the user should be either granted the `ACCOUNTADMIN` role or a role with the `IMPORTED PRIVILEGES` grant on the `SNOWFLAKE` database:
-
-```sql
--- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role
-GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE;
-```
+- **Lineage & Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`.
You can find more information about the `account_usage` schema [here](https://docs.snowflake.com/en/sql-reference/account-usage).
-Regarding Stored Procedures:
-1. Snowflake only allows the grant of `USAGE` or `OWNERSHIP`
-2. A user can only see the definition of the procedure in 2 situations:
- 1. If it has the `OWNERSHIP` grant,
- 2. If it has the `USAGE` grant and the procedure is created with `EXECUTE AS CALLER`.
+- **Ingesting Stored Procedures**: Openmetadata fetches the information by querying `snowflake.account_usage.procedures` & `snowflake.account_usage.functions`.
-Make sure to add the `GRANT ON PROCEDURE () to NEW_ROLE`, e.g., `GRANT USAGE ON PROCEDURE CLEAN_DATA(varchar, varchar) to NEW_ROLE`.
## Metadata Ingestion
@@ -122,6 +115,9 @@ Make sure to add the `GRANT ON PROCEDURE () t
- **Include Temporary and Transient Tables**:
Optional configuration for ingestion of `TRANSIENT` and `TEMPORARY` tables, By default, it will skip the `TRANSIENT` and `TEMPORARY` tables.
- **Client Session Keep Alive**: Optional Configuration to keep the session active in case the ingestion job runs for longer duration.
+- **Account Usage Schema Name**: Full name of account usage schema, used in case your used do not have direct access to `SNOWFLAKE.ACCOUNT_USAGE` schema. In such case you can replicate tables `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS` to a custom schema let's say `CUSTOM_DB.CUSTOM_SCHEMA` and provide the same name in this field.
+
+When using this field make sure you have all these tables available within your custom schema `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS`.
{% partial file="/v1.4/connectors/database/advanced-configuration.md" /%}
diff --git a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/yaml.md b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/yaml.md
index 750c0f00ff9f..57eea486da9b 100644
--- a/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/yaml.md
+++ b/openmetadata-docs/content/v1.4.x/connectors/database/snowflake/yaml.md
@@ -150,6 +150,14 @@ This is a sample config for Snowflake:
{% /codeInfo %}
+{% codeInfo srNumber=40 %}
+
+**accountUsageSchema**: Full name of account usage schema, used in case your used do not have direct access to `SNOWFLAKE.ACCOUNT_USAGE` schema. In such case you can replicate tables `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS` to a custom schema let's say `CUSTOM_DB.CUSTOM_SCHEMA` and provide the same name in this field.
+
+When using this field make sure you have all these tables available within your custom schema `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS`.
+
+{% /codeInfo %}
+
{% codeInfo srNumber=6 %}
**includeTransientTables**: Optional configuration for ingestion of TRANSIENT and TEMPORARY tables, By default, it will skip the TRANSIENT and TEMPORARY tables.
@@ -231,6 +239,9 @@ source:
```yaml {% srNumber=5 %}
# database:
```
+```yaml {% srNumber=40 %}
+ # accountUsageSchema: SNOWFLAKE.ACCOUNT_USAGE
+```
```yaml {% srNumber=6 %}
includeTransientTables: false
```
diff --git a/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md b/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
index 2a246172fa5f..f32625e71410 100644
--- a/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
+++ b/openmetadata-docs/content/v1.4.x/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
@@ -129,8 +129,16 @@ After running the ingestion workflow with dbt you can see the created user or te
-{% note %}
+## Overriding the existing table Owners
-If a table already has a owner linked to it, owner from the dbt will not update the current owner.
+To establish a unified and reliable system for owners, a single source of truth is necessary. It either is directly OpenMetadata, if individuals want to go there and keep updating, or if they prefer to keep it centralized in dbt, then we can always rely on that directly.
-{% /note %}
+When the `Update Owners` toggle is enabled during the configuration of dbt ingestion, existing owners of tables will be overwritten with the dbt owners.
+
+If toggle is disabled during the configuration of dbt ingestion, dbt owners will only be updated for tables in OpenMetadata that currently have no owners. Existing owners will remain unchanged and will not be overwritten with dbt owners.
+
+{% image
+ src="/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp"
+ alt="update-dbt-owners"
+ caption="Update dbt Owners"
+ /%}
diff --git a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md
index 50d6fb14ad30..3af86179b6c7 100644
--- a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md
+++ b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/index.md
@@ -38,6 +38,12 @@ Configure and schedule Airbyte metadata and profiler workflows from the OpenMeta
- **Host and Port**: Pipeline Service Management UI URL
+- **Username**: Username to connect to Airbyte.
+
+- **Password**: Password to connect to Airbyte.
+
+- **API Version**: Version of the Airbyte REST API by default `api/v1`.
+
{% /extraContent %}
{% partial file="/v1.4/connectors/test-connection.md" /%}
diff --git a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md
index 31fb1b194052..3aeed4bbee23 100644
--- a/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md
+++ b/openmetadata-docs/content/v1.4.x/connectors/pipeline/airbyte/yaml.md
@@ -59,6 +59,23 @@ This is a sample config for Airbyte:
**hostPort**: Pipeline Service Management UI URL
+{% /codeInfo %}
+
+{% codeInfo srNumber=2 %}
+
+**username**: Username to connect to Airbyte.
+
+{% /codeInfo %}
+
+{% codeInfo srNumber=3 %}
+
+**password**: Password to connect to Airbyte.
+
+{% /codeInfo %}
+
+{% codeInfo srNumber=4 %}
+
+**apiVersion**: Version of the Airbyte REST API by default `api/v1`.
{% /codeInfo %}
@@ -86,6 +103,15 @@ source:
```yaml {% srNumber=1 %}
hostPort: http://localhost:8000
```
+```yaml {% srNumber=2 %}
+ username:
+```
+```yaml {% srNumber=3 %}
+ password:
+```
+```yaml {% srNumber=4 %}
+ apiVersion: api/v1
+```
{% partial file="/v1.4/connectors/yaml/pipeline/source-config.md" /%}
diff --git a/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md
index 2f55f76168c8..14dd7b0d831d 100644
--- a/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md
+++ b/openmetadata-docs/content/v1.4.x/how-to-guides/data-discovery/advanced.md
@@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search"
For example, we can set up a complex query as follows:
- Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them.
+{% note noteType="Tip" %}
+### Note on Custom Properties in Elasticsearch Search
+
+Elasticsearch does not support searching for custom properties with the following formats:
+
+- **Time**
+- **DateTime**
+- Any date formats other than `yyyy-MM-dd`
+
+Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality.
+
+{% /note %}
+
{% image
src="/images/v1.4/how-to-guides/discovery/adv3.png"
alt="Grouped Condition based on the Owner of the Data Assets"
diff --git a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
index 842dfc102670..ab655037461f 100644
--- a/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
+++ b/openmetadata-docs/content/v1.4.x/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
@@ -11,7 +11,9 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline
- **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`.
- **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files.
+- **`searchAcrossDatabases`** *(boolean)*: Optional configuration to search across databases for tables or not. Default: `False`.
- **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`.
+- **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owner from DBT or not. Default: `False`.
- **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`.
- **`dbtClassificationName`** *(string)*: Custom OpenMetadata Classification name for dbt tags. Default: `dbtTags`.
- **`schemaFilterPattern`**: Regex to only fetch tables or databases that matches the pattern. Refer to *../type/filterPattern.json#/definitions/filterPattern*.
diff --git a/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md
index 3a8b3d04db5d..6139eeb42d9b 100644
--- a/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md
+++ b/openmetadata-docs/content/v1.5.x/how-to-guides/data-discovery/advanced.md
@@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search"
For example, we can set up a complex query as follows:
- Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them.
+{% note noteType="Tip" %}
+### Note on Custom Properties in Elasticsearch Search
+
+Elasticsearch does not support searching for custom properties with the following formats:
+
+- **Time**
+- **DateTime**
+- Any date formats other than `yyyy-MM-dd`
+
+Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality.
+
+{% /note %}
+
{% image
src="/images/v1.5/how-to-guides/discovery/adv3.png"
alt="Grouped Condition based on the Owner of the Data Assets"
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md
index a4c5d452b58d..408b6b0b12ef 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/index.md
@@ -59,13 +59,18 @@ GRANT SELECT ON ALL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL EXTERNAL TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL VIEWS IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
GRANT SELECT ON ALL DYNAMIC TABLES IN SCHEMA TEST_SCHEMA TO ROLE NEW_ROLE;
+
+-- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role,
+-- optional but required for usage, lineage and stored procedure ingestion
+GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE;
```
{% note %}
If running any of:
- Incremental Extraction
- Ingesting Tags
- - Usage Workflow
+ - Ingesting Stored Procedures
+ - Lineage & Usage Workflow
The following Grant is needed
{% /note %}
@@ -74,24 +79,11 @@ The following Grant is needed
- **Ingesting Tags**: Openmetadata fetches the information by querying `snowflake.account_usage.tag_references`.
-- **Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`.
-
-In order to be able to query those tables, the user should be either granted the `ACCOUNTADMIN` role or a role with the `IMPORTED PRIVILEGES` grant on the `SNOWFLAKE` database:
-
-```sql
--- Grant IMPORTED PRIVILEGES on all Schemas of SNOWFLAKE DB to New Role
-GRANT IMPORTED PRIVILEGES ON ALL SCHEMAS IN DATABASE SNOWFLAKE TO ROLE NEW_ROLE;
-```
+- **Lineage & Usage Workflow**: Openmetadata fetches the query logs by querying `snowflake.account_usage.query_history` table. For this the snowflake user should be granted the `ACCOUNTADMIN` role or a role granted IMPORTED PRIVILEGES on the database `SNOWFLAKE`.
You can find more information about the `account_usage` schema [here](https://docs.snowflake.com/en/sql-reference/account-usage).
-Regarding Stored Procedures:
-1. Snowflake only allows the grant of `USAGE` or `OWNERSHIP`
-2. A user can only see the definition of the procedure in 2 situations:
- 1. If it has the `OWNERSHIP` grant,
- 2. If it has the `USAGE` grant and the procedure is created with `EXECUTE AS CALLER`.
-
-Make sure to add the `GRANT ON PROCEDURE () to NEW_ROLE`, e.g., `GRANT USAGE ON PROCEDURE CLEAN_DATA(varchar, varchar) to NEW_ROLE`.
+- **Ingesting Stored Procedures**: Openmetadata fetches the information by querying `snowflake.account_usage.procedures` & `snowflake.account_usage.functions`.
## Metadata Ingestion
@@ -122,6 +114,9 @@ Make sure to add the `GRANT ON PROCEDURE () t
- **Include Temporary and Transient Tables**:
Optional configuration for ingestion of `TRANSIENT` and `TEMPORARY` tables, By default, it will skip the `TRANSIENT` and `TEMPORARY` tables.
- **Client Session Keep Alive**: Optional Configuration to keep the session active in case the ingestion job runs for longer duration.
+- **Account Usage Schema Name**: Full name of account usage schema, used in case your used do not have direct access to `SNOWFLAKE.ACCOUNT_USAGE` schema. In such case you can replicate tables `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS` to a custom schema let's say `CUSTOM_DB.CUSTOM_SCHEMA` and provide the same name in this field.
+
+When using this field make sure you have all these tables available within your custom schema `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS`.
{% partial file="/v1.6/connectors/database/advanced-configuration.md" /%}
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/yaml.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/yaml.md
index 8a824e07928b..bd10edc7097b 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/yaml.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/database/snowflake/yaml.md
@@ -150,6 +150,14 @@ This is a sample config for Snowflake:
{% /codeInfo %}
+{% codeInfo srNumber=40 %}
+
+**accountUsageSchema**: Full name of account usage schema, used in case your used do not have direct access to `SNOWFLAKE.ACCOUNT_USAGE` schema. In such case you can replicate tables `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS` to a custom schema let's say `CUSTOM_DB.CUSTOM_SCHEMA` and provide the same name in this field.
+
+When using this field make sure you have all these tables available within your custom schema `QUERY_HISTORY`, `TAG_REFERENCES`, `PROCEDURES`, `FUNCTIONS`.
+
+{% /codeInfo %}
+
{% codeInfo srNumber=6 %}
**includeTransientTables**: Optional configuration for ingestion of TRANSIENT and TEMPORARY tables, By default, it will skip the TRANSIENT and TEMPORARY tables.
@@ -231,6 +239,9 @@ source:
```yaml {% srNumber=5 %}
# database:
```
+```yaml {% srNumber=40 %}
+ # accountUsageSchema: SNOWFLAKE.ACCOUNT_USAGE
+```
```yaml {% srNumber=6 %}
includeTransientTables: false
```
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
index b85174d24ad0..0fbdbc7a7dea 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/ingestion/workflows/dbt/ingest-dbt-owner.md
@@ -138,8 +138,16 @@ After running the ingestion workflow with dbt you can see the created user or te
-{% note %}
+## Overriding the existing table Owners
-If a table already has a owner linked to it, owner from the dbt will not update the current owner.
+To establish a unified and reliable system for owners, a single source of truth is necessary. It either is directly OpenMetadata, if individuals want to go there and keep updating, or if they prefer to keep it centralized in dbt, then we can always rely on that directly.
-{% /note %}
+When the `Update Owners` toggle is enabled during the configuration of dbt ingestion, existing owners of tables will be overwritten with the dbt owners.
+
+If toggle is disabled during the configuration of dbt ingestion, dbt owners will only be updated for tables in OpenMetadata that currently have no owners. Existing owners will remain unchanged and will not be overwritten with dbt owners.
+
+{% image
+ src="/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp"
+ alt="update-dbt-owners"
+ caption="Update dbt Owners"
+ /%}
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md
index 9fb026318f51..4c0abb7b60a9 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/index.md
@@ -38,6 +38,12 @@ Configure and schedule Airbyte metadata and profiler workflows from the OpenMeta
- **Host and Port**: Pipeline Service Management UI URL
+- **Username**: Username to connect to Airbyte.
+
+- **Password**: Password to connect to Airbyte.
+
+- **API Version**: Version of the Airbyte REST API by default `api/v1`.
+
{% /extraContent %}
{% partial file="/v1.6/connectors/test-connection.md" /%}
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md
index 4337cf7db36f..94c2e71439ce 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/connectors/pipeline/airbyte/yaml.md
@@ -59,6 +59,23 @@ This is a sample config for Airbyte:
**hostPort**: Pipeline Service Management UI URL
+{% /codeInfo %}
+
+{% codeInfo srNumber=2 %}
+
+**username**: Username to connect to Airbyte.
+
+{% /codeInfo %}
+
+{% codeInfo srNumber=3 %}
+
+**password**: Password to connect to Airbyte.
+
+{% /codeInfo %}
+
+{% codeInfo srNumber=4 %}
+
+**apiVersion**: Version of the Airbyte REST API by default `api/v1`.
{% /codeInfo %}
@@ -86,6 +103,15 @@ source:
```yaml {% srNumber=1 %}
hostPort: http://localhost:8000
```
+```yaml {% srNumber=2 %}
+ username:
+```
+```yaml {% srNumber=3 %}
+ password:
+```
+```yaml {% srNumber=4 %}
+ apiVersion: api/v1
+```
{% partial file="/v1.6/connectors/yaml/pipeline/source-config.md" /%}
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md
index 291d69b4556f..400ab4c6f301 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/how-to-guides/data-discovery/advanced.md
@@ -33,6 +33,19 @@ caption="Add Complex Queries using Advanced Search"
For example, we can set up a complex query as follows:
- Group one set of conditions together by defining the `Owner`. You can add multiple conditions to define different owners and use the `OR` condition to ensure that the owner is any one among them.
+{% note noteType="Tip" %}
+### Note on Custom Properties in Elasticsearch Search
+
+Elasticsearch does not support searching for custom properties with the following formats:
+
+- **Time**
+- **DateTime**
+- Any date formats other than `yyyy-MM-dd`
+
+Please ensure that custom properties adhere to these constraints for compatibility with Elasticsearch search functionality.
+
+{% /note %}
+
{% image
src="/images/v1.6/how-to-guides/discovery/adv3.png"
alt="Grouped Condition based on the Owner of the Data Assets"
diff --git a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
index 842dfc102670..d98baf90b002 100644
--- a/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
+++ b/openmetadata-docs/content/v1.6.x-SNAPSHOT/main-concepts/metadata-standard/schemas/metadataIngestion/dbtPipeline.md
@@ -11,7 +11,9 @@ slug: /main-concepts/metadata-standard/schemas/metadataingestion/dbtpipeline
- **`type`**: Pipeline type. Refer to *#/definitions/dbtConfigType*. Default: `DBT`.
- **`dbtConfigSource`**: Available sources to fetch DBT catalog and manifest files.
+- **`searchAcrossDatabases`** *(boolean)*: Optional configuration to search across databases for tables or not. Default: `False`.
- **`dbtUpdateDescriptions`** *(boolean)*: Optional configuration to update the description from DBT or not. Default: `False`.
+- **`dbtUpdateOwners`** *(boolean)*: Optional configuration to update the owners from DBT or not. Default: `False`.
- **`includeTags`** *(boolean)*: Optional configuration to toggle the tags ingestion. Default: `True`.
- **`dbtClassificationName`** *(string)*: Custom OpenMetadata Classification name for dbt tags. Default: `dbtTags`.
- **`schemaFilterPattern`**: Regex to only fetch tables or databases that matches the pattern. Refer to *../type/filterPattern.json#/definitions/filterPattern*.
diff --git a/openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp b/openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp
new file mode 100644
index 000000000000..b9e0bd91c42b
Binary files /dev/null and b/openmetadata-docs/images/v1.6/features/ingestion/workflows/dbt/dbt-features/dbt-update-owners.webp differ
diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml
index 541bb6a41160..e6f9fee14064 100644
--- a/openmetadata-service/pom.xml
+++ b/openmetadata-service/pom.xml
@@ -5,7 +5,7 @@
platform
org.open-metadata
- 1.6.0-SNAPSHOT
+ 1.6.4
4.0.0
openmetadata-service
@@ -16,7 +16,7 @@
${project.basedir}/target/site/jacoco-aggregate/jacoco.xml
${project.basedir}/src/test/java
1.20.3
- 2.29.15
+ 2.30.19
1.14.0
4.9.0
1.0.0
@@ -28,6 +28,7 @@
3.6.0
3.3.1
2.1.1
+ 2.5.2
@@ -89,7 +90,7 @@
net.minidev
json-smart
- 2.5.1
+ ${json-smart.version}
org.open-metadata
@@ -147,6 +148,24 @@
ru.vyarus.guicey
guicey-jdbi3
5.9.2
+
+
+ ch.qos.logback
+ logback-core
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ ch.qos.logback
+ logback-access
+
+
+ org.slf4j
+ log4j-over-slf4j
+
+
com.smoketurner
@@ -182,10 +201,46 @@
io.github.maksymdolgykh.dropwizard
dropwizard-micrometer-core
+
+
+ ch.qos.logback
+ logback-core
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ ch.qos.logback
+ logback-access
+
+
+ org.slf4j
+ log4j-over-slf4j
+
+
io.github.maksymdolgykh.dropwizard
dropwizard-micrometer-jdbi
+
+
+ ch.qos.logback
+ logback-core
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ ch.qos.logback
+ logback-access
+
+
+ org.slf4j
+ log4j-over-slf4j
+
+
io.dropwizard
@@ -264,6 +319,10 @@
ch.qos.logback
logback-classic
+
+ ch.qos.logback
+ logback-access
+
com.jayway.jsonpath
json-path
diff --git a/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java b/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java
index 0121dbdc6d07..f13ae7b53a5c 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java
@@ -50,6 +50,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
+import java.util.function.Function;
import javax.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.csv.CSVFormat;
@@ -193,7 +194,11 @@ public final void addRecord(CsvFile csvFile, List recordList) {
}
/** Owner field is in entityType:entityName format */
- public List getOwners(CSVPrinter printer, CSVRecord csvRecord, int fieldNumber)
+ public List getOwners(
+ CSVPrinter printer,
+ CSVRecord csvRecord,
+ int fieldNumber,
+ Function invalidMessageCreator)
throws IOException {
if (!processRecord) {
return null;
@@ -207,7 +212,7 @@ public List getOwners(CSVPrinter printer, CSVRecord csvRecord,
for (String owner : owners) {
List ownerTypes = listOrEmpty(CsvUtil.fieldToEntities(owner));
if (ownerTypes.size() != 2) {
- importFailure(printer, invalidOwner(fieldNumber), csvRecord);
+ importFailure(printer, invalidMessageCreator.apply(fieldNumber), csvRecord);
return Collections.emptyList();
}
EntityReference ownerRef =
@@ -219,6 +224,16 @@ public List getOwners(CSVPrinter printer, CSVRecord csvRecord,
return refs.isEmpty() ? null : refs;
}
+ public List getOwners(CSVPrinter printer, CSVRecord csvRecord, int fieldNumber)
+ throws IOException {
+ return getOwners(printer, csvRecord, fieldNumber, EntityCsv::invalidOwner);
+ }
+
+ public List getReviewers(
+ CSVPrinter printer, CSVRecord csvRecord, int fieldNumber) throws IOException {
+ return getOwners(printer, csvRecord, fieldNumber, EntityCsv::invalidReviewer);
+ }
+
/** Owner field is in entityName format */
public EntityReference getOwnerAsUser(CSVPrinter printer, CSVRecord csvRecord, int fieldNumber)
throws IOException {
@@ -868,6 +883,11 @@ public static String invalidOwner(int field) {
return String.format(FIELD_ERROR_MSG, CsvErrorType.INVALID_FIELD, field + 1, error);
}
+ public static String invalidReviewer(int field) {
+ String error = "Reviewer should be of format user:userName or team:teamName";
+ return String.format(FIELD_ERROR_MSG, CsvErrorType.INVALID_FIELD, field + 1, error);
+ }
+
public static String invalidExtension(int field, String key, String value) {
String error =
"Invalid key-value pair in extension string: Key = "
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
index 9ee396193102..5b3d52970f0b 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
@@ -67,6 +67,7 @@
import org.openmetadata.service.jdbi3.SystemRepository;
import org.openmetadata.service.jdbi3.TokenRepository;
import org.openmetadata.service.jdbi3.UsageRepository;
+import org.openmetadata.service.jobs.JobDAO;
import org.openmetadata.service.resources.feeds.MessageParser.EntityLink;
import org.openmetadata.service.search.SearchRepository;
import org.openmetadata.service.search.indexes.SearchIndex;
@@ -77,6 +78,7 @@
public final class Entity {
private static volatile boolean initializedRepositories = false;
@Getter @Setter private static CollectionDAO collectionDAO;
+ @Getter @Setter private static JobDAO jobDAO;
@Getter @Setter private static Jdbi jdbi;
public static final String SEPARATOR = "."; // Fully qualified name separator
@@ -248,6 +250,8 @@ public final class Entity {
public static final String DOCUMENT = "document";
// ServiceType - Service Entity name map
static final Map SERVICE_TYPE_ENTITY_MAP = new EnumMap<>(ServiceType.class);
+ // entity type to service entity name map
+ static final Map ENTITY_SERVICE_TYPE_MAP = new HashMap<>();
public static final List PARENT_ENTITY_TYPES = new ArrayList<>();
static {
@@ -260,6 +264,24 @@ public final class Entity {
SERVICE_TYPE_ENTITY_MAP.put(ServiceType.STORAGE, STORAGE_SERVICE);
SERVICE_TYPE_ENTITY_MAP.put(ServiceType.SEARCH, SEARCH_SERVICE);
SERVICE_TYPE_ENTITY_MAP.put(ServiceType.API, API_SERVICE);
+
+ ENTITY_SERVICE_TYPE_MAP.put(DATABASE, DATABASE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(DATABASE_SCHEMA, DATABASE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(TABLE, DATABASE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(STORED_PROCEDURE, DATABASE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(QUERY, DATABASE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(DASHBOARD, DASHBOARD_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(DASHBOARD_DATA_MODEL, DASHBOARD_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(CHART, DASHBOARD_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(PIPELINE, PIPELINE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(MLMODEL, MLMODEL_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(TOPIC, MESSAGING_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(API, API_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(API_COLLCECTION, API_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(API_ENDPOINT, API_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(CONTAINER, STORAGE_SERVICE);
+ ENTITY_SERVICE_TYPE_MAP.put(SEARCH_INDEX, SEARCH_SERVICE);
+
PARENT_ENTITY_TYPES.addAll(
listOf(
DATABASE_SERVICE,
@@ -314,6 +336,7 @@ public static void initializeRepositories(OpenMetadataApplicationConfig config,
public static void cleanup() {
initializedRepositories = false;
collectionDAO = null;
+ jobDAO = null;
searchRepository = null;
ENTITY_REPOSITORY_MAP.clear();
}
@@ -636,4 +659,11 @@ public static T getDao() {
public static T getSearchRepo() {
return (T) searchRepository;
}
+
+ public static String getServiceType(String entityType) {
+ if (ENTITY_SERVICE_TYPE_MAP.containsKey(entityType)) {
+ return ENTITY_SERVICE_TYPE_MAP.get(entityType);
+ }
+ return entityType;
+ }
}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java
index 9fedc0c89534..900b34fcf0dd 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java
@@ -73,17 +73,22 @@
import org.openmetadata.service.events.EventFilter;
import org.openmetadata.service.events.EventPubSub;
import org.openmetadata.service.events.scheduled.EventSubscriptionScheduler;
-import org.openmetadata.service.events.scheduled.PipelineServiceStatusJobHandler;
+import org.openmetadata.service.events.scheduled.ServicesStatusJobHandler;
import org.openmetadata.service.exception.CatalogGenericExceptionMapper;
import org.openmetadata.service.exception.ConstraintViolationExceptionMapper;
import org.openmetadata.service.exception.JsonMappingExceptionMapper;
import org.openmetadata.service.exception.OMErrorPageHandler;
import org.openmetadata.service.fernet.Fernet;
+import org.openmetadata.service.governance.workflows.WorkflowHandler;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.EntityRepository;
import org.openmetadata.service.jdbi3.MigrationDAO;
import org.openmetadata.service.jdbi3.locator.ConnectionAwareAnnotationSqlLocator;
import org.openmetadata.service.jdbi3.locator.ConnectionType;
+import org.openmetadata.service.jobs.EnumCleanupHandler;
+import org.openmetadata.service.jobs.GenericBackgroundWorker;
+import org.openmetadata.service.jobs.JobDAO;
+import org.openmetadata.service.jobs.JobHandlerRegistry;
import org.openmetadata.service.limits.DefaultLimits;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.migration.Migration;
@@ -162,6 +167,7 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ
jdbi = createAndSetupJDBI(environment, catalogConfig.getDataSourceFactory());
Entity.setCollectionDAO(getDao(jdbi));
+ Entity.setJobDAO(jdbi.onDemand(JobDAO.class));
Entity.setJdbi(jdbi);
initializeSearchRepository(catalogConfig.getElasticSearchConfiguration());
@@ -173,6 +179,9 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ
// Configure the Fernet instance
Fernet.getInstance().setFernetKey(catalogConfig);
+ // Initialize Workflow Handler
+ WorkflowHandler.initialize(catalogConfig);
+
// Init Settings Cache after repositories
SettingsCache.initialize(catalogConfig);
@@ -186,7 +195,10 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ
EntityMaskerFactory.createEntityMasker();
// Instantiate JWT Token Generator
- JWTTokenGenerator.getInstance().init(catalogConfig.getJwtTokenConfiguration());
+ JWTTokenGenerator.getInstance()
+ .init(
+ catalogConfig.getAuthenticationConfiguration().getTokenValidationAlgorithm(),
+ catalogConfig.getJwtTokenConfiguration());
// Set the Database type for choosing correct queries from annotations
jdbi.getConfig(SqlObjects.class)
@@ -227,6 +239,13 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ
// Register Event Handler
registerEventFilter(catalogConfig, environment);
environment.lifecycle().manage(new ManagedShutdown());
+
+ JobHandlerRegistry registry = new JobHandlerRegistry();
+ registry.register("EnumCleanupHandler", new EnumCleanupHandler(getDao(jdbi)));
+ environment
+ .lifecycle()
+ .manage(new GenericBackgroundWorker(jdbi.onDemand(JobDAO.class), registry));
+
// Register Event publishers
registerEventPublisher(catalogConfig);
@@ -245,16 +264,23 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ
// Asset Servlet Registration
registerAssetServlet(catalogConfig.getWebConfiguration(), environment);
- // Handle Pipeline Service Client Status job
- PipelineServiceStatusJobHandler pipelineServiceStatusJobHandler =
- PipelineServiceStatusJobHandler.create(
- catalogConfig.getPipelineServiceClientConfiguration(), catalogConfig.getClusterName());
- pipelineServiceStatusJobHandler.addPipelineServiceStatusJob();
+ // Handle Services Jobs
+ registerHealthCheckJobs(catalogConfig);
// Register Auth Handlers
registerAuthServlets(catalogConfig, environment);
}
+ private void registerHealthCheckJobs(OpenMetadataApplicationConfig catalogConfig) {
+ ServicesStatusJobHandler healthCheckStatusHandler =
+ ServicesStatusJobHandler.create(
+ catalogConfig.getEventMonitorConfiguration(),
+ catalogConfig.getPipelineServiceClientConfiguration(),
+ catalogConfig.getClusterName());
+ healthCheckStatusHandler.addPipelineServiceStatusJob();
+ healthCheckStatusHandler.addDatabaseAndSearchStatusJobs();
+ }
+
private void registerAuthServlets(OpenMetadataApplicationConfig config, Environment environment) {
if (config.getAuthenticationConfiguration() != null
&& config
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java
index a294a79cc51b..78c26a5668c5 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java
@@ -14,15 +14,22 @@
package org.openmetadata.service;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
+import static org.openmetadata.service.Entity.ADMIN_USER_NAME;
+import static org.openmetadata.service.resources.types.TypeResource.PROPERTIES_FIELD;
import com.networknt.schema.JsonSchema;
+import java.util.List;
import java.util.Map;
+import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.entity.Type;
+import org.openmetadata.schema.entity.type.Category;
import org.openmetadata.schema.entity.type.CustomProperty;
import org.openmetadata.service.exception.CatalogExceptionMessage;
import org.openmetadata.service.exception.EntityNotFoundException;
+import org.openmetadata.service.jdbi3.TypeRepository;
+import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
@@ -49,6 +56,35 @@ public static TypeRegistry instance() {
return INSTANCE;
}
+ public final void initialize(TypeRepository repository) {
+ // Load types defined in OpenMetadata schemas
+ long now = System.currentTimeMillis();
+ List types = JsonUtils.getTypes();
+ types.forEach(
+ type -> {
+ type.withId(UUID.randomUUID()).withUpdatedBy(ADMIN_USER_NAME).withUpdatedAt(now);
+ LOG.debug("Loading type {}", type.getName());
+ try {
+ EntityUtil.Fields fields = repository.getFields(PROPERTIES_FIELD);
+ try {
+ Type storedType = repository.getByName(null, type.getName(), fields);
+ type.setId(storedType.getId());
+ // If entity type already exists, then carry forward custom properties
+ if (storedType.getCategory().equals(Category.Entity)) {
+ type.setCustomProperties(storedType.getCustomProperties());
+ }
+ } catch (Exception e) {
+ LOG.debug(
+ "Type '{}' not found. Proceeding to add new type entity in database.",
+ type.getName());
+ }
+ repository.addToRegistry(type);
+ } catch (Exception e) {
+ LOG.error("Error loading type {}", type.getName(), e);
+ }
+ });
+ }
+
public void addType(Type type) {
TYPES.put(type.getName(), type);
@@ -111,34 +147,25 @@ public static String getPropertyName(String propertyFQN) {
}
public static String getCustomPropertyType(String entityType, String propertyName) {
- Type type = TypeRegistry.TYPES.get(entityType);
- if (type != null && type.getCustomProperties() != null) {
- for (CustomProperty property : type.getCustomProperties()) {
- if (property.getName().equals(propertyName)) {
- return property.getPropertyType().getName();
- }
- }
+ String fqn = getCustomPropertyFQN(entityType, propertyName);
+ CustomProperty property = CUSTOM_PROPERTIES.get(fqn);
+ if (property == null) {
+ throw EntityNotFoundException.byMessage(
+ CatalogExceptionMessage.entityNotFound(propertyName, entityType));
}
- throw EntityNotFoundException.byMessage(
- CatalogExceptionMessage.entityNotFound(Entity.TYPE, String.valueOf(type)));
+ return property.getPropertyType().getName();
}
public static String getCustomPropertyConfig(String entityType, String propertyName) {
- Type type = TypeRegistry.TYPES.get(entityType);
- if (type != null && type.getCustomProperties() != null) {
- for (CustomProperty property : type.getCustomProperties()) {
- if (property.getName().equals(propertyName)
- && property.getCustomPropertyConfig() != null
- && property.getCustomPropertyConfig().getConfig() != null) {
- Object config = property.getCustomPropertyConfig().getConfig();
- if (config instanceof String || config instanceof Integer) {
- return config.toString(); // for simple type config return as string
- } else {
- return JsonUtils.pojoToJson(
- config); // for complex object in config return as JSON string
- }
- }
- }
+ String fqn = getCustomPropertyFQN(entityType, propertyName);
+ CustomProperty property = CUSTOM_PROPERTIES.get(fqn);
+ if (property != null
+ && property.getCustomPropertyConfig() != null
+ && property.getCustomPropertyConfig().getConfig() != null) {
+ Object config = property.getCustomPropertyConfig().getConfig();
+ return (config instanceof String || config instanceof Integer)
+ ? config.toString() // for simple type config return as string
+ : JsonUtils.pojoToJson(config); // for complex object in config return as JSON string
}
return null;
}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java
index a0a11f031205..7db4c4e4f89c 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java
@@ -32,10 +32,12 @@
import org.openmetadata.schema.entity.events.EventSubscriptionOffset;
import org.openmetadata.schema.entity.events.FailedEvent;
import org.openmetadata.schema.entity.events.SubscriptionDestination;
+import org.openmetadata.schema.system.EntityError;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.service.Entity;
import org.openmetadata.service.events.errors.EventPublisherException;
import org.openmetadata.service.util.JsonUtils;
+import org.openmetadata.service.util.ResultList;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobDetail;
@@ -70,8 +72,9 @@ private void init(JobExecutionContext context) {
(EventSubscription) context.getJobDetail().getJobDataMap().get(ALERT_INFO_KEY);
this.jobDetail = context.getJobDetail();
this.eventSubscription = sub;
- this.offset = loadInitialOffset(context).getCurrentOffset();
- this.startingOffset = loadInitialOffset(context).getStartingOffset();
+ EventSubscriptionOffset eventSubscriptionOffset = loadInitialOffset(context);
+ this.offset = eventSubscriptionOffset.getCurrentOffset();
+ this.startingOffset = eventSubscriptionOffset.getStartingOffset();
this.alertMetrics = loadInitialMetrics();
this.destinationMap = loadDestinationsMap(context);
this.doInit(context);
@@ -240,34 +243,46 @@ public void commit(JobExecutionContext jobExecutionContext) {
}
@Override
- public List pollEvents(long offset, long batchSize) {
- // Read from Change Event Table
+ public ResultList pollEvents(long offset, long batchSize) {
List eventJson = Entity.getCollectionDAO().changeEventDAO().list(batchSize, offset);
-
List changeEvents = new ArrayList<>();
+ List errorEvents = new ArrayList<>();
for (String json : eventJson) {
- ChangeEvent event = JsonUtils.readValue(json, ChangeEvent.class);
- changeEvents.add(event);
+ try {
+ ChangeEvent event = JsonUtils.readValue(json, ChangeEvent.class);
+ changeEvents.add(event);
+ } catch (Exception ex) {
+ errorEvents.add(new EntityError().withMessage(ex.getMessage()).withEntity(json));
+ LOG.error("Error in Parsing Change Event : {} , Message: {} ", json, ex.getMessage(), ex);
+ }
}
- return changeEvents;
+ return new ResultList<>(changeEvents, errorEvents, null, null, eventJson.size());
}
@Override
public void execute(JobExecutionContext jobExecutionContext) {
// Must Have , Before Execute the Init, Quartz Requires a Non-Arg Constructor
this.init(jobExecutionContext);
- // Poll Events from Change Event Table
- List batch = pollEvents(offset, eventSubscription.getBatchSize());
- int batchSize = batch.size();
- Map> eventsWithReceivers = createEventsWithReceivers(batch);
+ long batchSize = 0;
+ Map> eventsWithReceivers = new HashMap<>();
try {
+ // Poll Events from Change Event Table
+ ResultList batch = pollEvents(offset, eventSubscription.getBatchSize());
+ batchSize = batch.getPaging().getTotal();
+ eventsWithReceivers.putAll(createEventsWithReceivers(batch.getData()));
// Publish Events
if (!eventsWithReceivers.isEmpty()) {
alertMetrics.withTotalEvents(alertMetrics.getTotalEvents() + eventsWithReceivers.size());
publishEvents(eventsWithReceivers);
}
} catch (Exception e) {
- LOG.error("Error in executing the Job : {} ", e.getMessage());
+ LOG.error(
+ "Error in polling events for alert : {} , Offset : {} , Batch Size : {} ",
+ e.getMessage(),
+ offset,
+ batchSize,
+ e);
+
} finally {
if (!eventsWithReceivers.isEmpty()) {
// Commit the Offset
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/Consumer.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/Consumer.java
index 9f1c8482062a..0d1be623ce08 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/Consumer.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/Consumer.java
@@ -13,16 +13,16 @@
package org.openmetadata.service.apps.bundles.changeEvent;
-import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.service.events.errors.EventPublisherException;
+import org.openmetadata.service.util.ResultList;
import org.quartz.JobExecutionContext;
public interface Consumer {
- List pollEvents(long offset, long batchSize);
+ ResultList pollEvents(long offset, long batchSize);
void publishEvents(Map> events);
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java
new file mode 100644
index 000000000000..214807bd10ba
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/dataRetention/DataRetention.java
@@ -0,0 +1,100 @@
+package org.openmetadata.service.apps.bundles.dataRetention;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Supplier;
+import lombok.extern.slf4j.Slf4j;
+import org.jdbi.v3.sqlobject.transaction.Transaction;
+import org.openmetadata.common.utils.CommonUtil;
+import org.openmetadata.schema.entity.app.App;
+import org.openmetadata.schema.entity.applications.configuration.internal.DataRetentionConfiguration;
+import org.openmetadata.service.apps.AbstractNativeApplication;
+import org.openmetadata.service.jdbi3.CollectionDAO;
+import org.openmetadata.service.search.SearchRepository;
+import org.openmetadata.service.util.JsonUtils;
+import org.quartz.JobExecutionContext;
+
+@Slf4j
+public class DataRetention extends AbstractNativeApplication {
+ private static final int BATCH_SIZE = 10_000;
+ private DataRetentionConfiguration dataRetentionConfiguration;
+ private final CollectionDAO.EventSubscriptionDAO eventSubscriptionDAO;
+
+ public DataRetention(CollectionDAO collectionDAO, SearchRepository searchRepository) {
+ super(collectionDAO, searchRepository);
+ this.eventSubscriptionDAO = collectionDAO.eventSubscriptionDAO();
+ }
+
+ @Override
+ public void init(App app) {
+ super.init(app);
+ this.dataRetentionConfiguration =
+ JsonUtils.convertValue(app.getAppConfiguration(), DataRetentionConfiguration.class);
+ if (CommonUtil.nullOrEmpty(this.dataRetentionConfiguration)) {
+ LOG.warn("No retention policy configuration provided. Cleanup tasks will not run.");
+ }
+ }
+
+ @Override
+ public void startApp(JobExecutionContext jobExecutionContext) {
+ executeCleanup(dataRetentionConfiguration);
+ }
+
+ public void executeCleanup(DataRetentionConfiguration config) {
+ if (CommonUtil.nullOrEmpty(config)) {
+ return;
+ }
+
+ cleanChangeEvents(config.getChangeEventRetentionPeriod());
+ }
+
+ @Transaction
+ private void cleanChangeEvents(int retentionPeriod) {
+ LOG.info(
+ "Initiating change events cleanup: Deleting records with a retention period of {} days.",
+ retentionPeriod);
+ long cutoffMillis = getRetentionCutoffMillis(retentionPeriod);
+
+ int totalDeletedSuccessfulEvents =
+ batchDelete(
+ () ->
+ eventSubscriptionDAO.deleteSuccessfulSentChangeEventsInBatches(
+ cutoffMillis, BATCH_SIZE));
+
+ int totalDeletedChangeEvents =
+ batchDelete(
+ () -> eventSubscriptionDAO.deleteChangeEventsInBatches(cutoffMillis, BATCH_SIZE));
+
+ int totalDeletedDlq =
+ batchDelete(
+ () -> eventSubscriptionDAO.deleteConsumersDlqInBatches(cutoffMillis, BATCH_SIZE));
+
+ LOG.info(
+ "Change events cleanup completed: {} successful_sent_change_events, {} change_events, and {} consumers_dlq records deleted (retention period: {} days).",
+ totalDeletedSuccessfulEvents,
+ totalDeletedChangeEvents,
+ totalDeletedDlq,
+ retentionPeriod);
+ }
+
+ private long getRetentionCutoffMillis(int retentionPeriodInDays) {
+ return Instant.now()
+ .minusMillis(Duration.ofDays(retentionPeriodInDays).toMillis())
+ .toEpochMilli();
+ }
+
+ /**
+ * Runs a batch delete operation in a loop until fewer than BATCH_SIZE records are deleted in a single iteration.
+ */
+ private int batchDelete(Supplier deleteFunction) {
+ var totalDeleted = 0;
+ while (true) {
+ var deletedCount = deleteFunction.get();
+ totalDeleted += deletedCount;
+ if (deletedCount < BATCH_SIZE) {
+ break;
+ }
+ }
+ return totalDeleted;
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java
index d0b5faf7b6ed..d46f625b6bbd 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/DataInsightsApp.java
@@ -146,11 +146,19 @@ private void deleteDataQualityDataIndex() {
private void createDataAssetsDataStream() {
DataInsightsSearchInterface searchInterface = getSearchInterface();
+ ElasticSearchConfiguration config = searchRepository.getElasticSearchConfiguration();
+ String language =
+ config != null && config.getSearchIndexMappingLanguage() != null
+ ? config.getSearchIndexMappingLanguage().value()
+ : "en";
+
try {
for (String dataAssetType : dataAssetTypes) {
+ IndexMapping dataAssetIndex = searchRepository.getIndexMapping(dataAssetType);
String dataStreamName = getDataStreamName(dataAssetType);
if (!searchInterface.dataAssetDataStreamExists(dataStreamName)) {
- searchInterface.createDataAssetsDataStream(dataStreamName);
+ searchInterface.createDataAssetsDataStream(
+ dataStreamName, dataAssetType, dataAssetIndex, language);
}
}
} catch (IOException ex) {
@@ -312,7 +320,13 @@ private WorkflowStats processCostAnalysis() {
private WorkflowStats processDataAssets() {
DataAssetsWorkflow workflow =
new DataAssetsWorkflow(
- timestamp, batchSize, backfill, dataAssetTypes, collectionDAO, searchRepository);
+ timestamp,
+ batchSize,
+ backfill,
+ dataAssetTypes,
+ collectionDAO,
+ searchRepository,
+ getSearchInterface());
WorkflowStats workflowStats = workflow.getWorkflowStats();
try {
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java
new file mode 100644
index 000000000000..d9fc4dc1672f
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchConfiguration.java
@@ -0,0 +1,10 @@
+package org.openmetadata.service.apps.bundles.insights.search;
+
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+
+@Getter
+public class DataInsightsSearchConfiguration {
+ private Map> mappingFields;
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java
index 0d06b3e03be2..3cba896e8ee5 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/DataInsightsSearchInterface.java
@@ -2,9 +2,13 @@
import java.io.IOException;
import java.io.InputStream;
+import java.util.List;
import org.openmetadata.service.exception.UnhandledServerException;
+import org.openmetadata.service.search.models.IndexMapping;
+import org.openmetadata.service.util.JsonUtils;
public interface DataInsightsSearchInterface {
+ String DATA_INSIGHTS_SEARCH_CONFIG_PATH = "/dataInsights/config.json";
void createLifecyclePolicy(String name, String policy) throws IOException;
@@ -23,7 +27,63 @@ default String readResource(String resourceFile) {
}
}
- void createDataAssetsDataStream(String name) throws IOException;
+ default String buildMapping(
+ String entityType,
+ IndexMapping entityIndexMapping,
+ String language,
+ String indexMappingTemplateStr) {
+ IndexMappingTemplate indexMappingTemplate =
+ JsonUtils.readOrConvertValue(indexMappingTemplateStr, IndexMappingTemplate.class);
+ EntityIndexMap entityIndexMap =
+ JsonUtils.readOrConvertValue(
+ readResource(
+ String.format(entityIndexMapping.getIndexMappingFile(), language.toLowerCase())),
+ EntityIndexMap.class);
+
+ DataInsightsSearchConfiguration dataInsightsSearchConfiguration =
+ readDataInsightsSearchConfiguration();
+ List entityAttributeFields =
+ getEntityAttributeFields(dataInsightsSearchConfiguration, entityType);
+
+ indexMappingTemplate
+ .getTemplate()
+ .getSettings()
+ .put("analysis", entityIndexMap.getSettings().get("analysis"));
+
+ for (String attribute : entityAttributeFields) {
+ if (!indexMappingTemplate
+ .getTemplate()
+ .getMappings()
+ .getProperties()
+ .containsKey(attribute)) {
+ Object value = entityIndexMap.getMappings().getProperties().get(attribute);
+ if (value != null) {
+ indexMappingTemplate.getTemplate().getMappings().getProperties().put(attribute, value);
+ }
+ }
+ }
+
+ return JsonUtils.pojoToJson(indexMappingTemplate);
+ }
+
+ default DataInsightsSearchConfiguration readDataInsightsSearchConfiguration() {
+ return JsonUtils.readOrConvertValue(
+ readResource(DATA_INSIGHTS_SEARCH_CONFIG_PATH), DataInsightsSearchConfiguration.class);
+ }
+
+ default List getEntityAttributeFields(
+ DataInsightsSearchConfiguration dataInsightsSearchConfiguration, String entityType) {
+ List entityAttributeFields =
+ dataInsightsSearchConfiguration.getMappingFields().get("common");
+ entityAttributeFields.addAll(
+ dataInsightsSearchConfiguration.getMappingFields().get(entityType));
+
+ return entityAttributeFields;
+ }
+
+ void createDataAssetsDataStream(
+ String name, String entityType, IndexMapping entityIndexMapping, String language)
+ throws IOException;
void deleteDataAssetDataStream(String name) throws IOException;
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java
new file mode 100644
index 000000000000..8a5e9b64dbbf
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/EntityIndexMap.java
@@ -0,0 +1,18 @@
+package org.openmetadata.service.apps.bundles.insights.search;
+
+import java.util.Map;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class EntityIndexMap {
+ private Map settings;
+ private Mappings mappings;
+
+ @Getter
+ @Setter
+ public static class Mappings {
+ private Map properties;
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java
new file mode 100644
index 000000000000..c418ab63b757
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/IndexMappingTemplate.java
@@ -0,0 +1,8 @@
+package org.openmetadata.service.apps.bundles.insights.search;
+
+import lombok.Getter;
+
+@Getter
+public class IndexMappingTemplate {
+ private EntityIndexMap template;
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java
index 150fc48c33cc..d5a1bbab55cd 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/elasticsearch/ElasticSearchDataInsightsClient.java
@@ -5,6 +5,7 @@
import es.org.elasticsearch.client.RestClient;
import java.io.IOException;
import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface;
+import org.openmetadata.service.search.models.IndexMapping;
public class ElasticSearchDataInsightsClient implements DataInsightsSearchInterface {
private final RestClient client;
@@ -52,7 +53,9 @@ public Boolean dataAssetDataStreamExists(String name) throws IOException {
}
@Override
- public void createDataAssetsDataStream(String name) throws IOException {
+ public void createDataAssetsDataStream(
+ String name, String entityType, IndexMapping entityIndexMapping, String language)
+ throws IOException {
String resourcePath = "/dataInsights/elasticsearch";
createLifecyclePolicy(
"di-data-assets-lifecycle",
@@ -62,7 +65,11 @@ public void createDataAssetsDataStream(String name) throws IOException {
readResource(String.format("%s/indexSettingsTemplate.json", resourcePath)));
createComponentTemplate(
"di-data-assets-mapping",
- readResource(String.format("%s/indexMappingsTemplate.json", resourcePath)));
+ buildMapping(
+ entityType,
+ entityIndexMapping,
+ language,
+ readResource(String.format("%s/indexMappingsTemplate.json", resourcePath))));
createIndexTemplate(
"di-data-assets", readResource(String.format("%s/indexTemplate.json", resourcePath)));
createDataStream(name);
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java
index da94394a3a5f..8d8096a8adfa 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/search/opensearch/OpenSearchDataInsightsClient.java
@@ -2,6 +2,7 @@
import java.io.IOException;
import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface;
+import org.openmetadata.service.search.models.IndexMapping;
import os.org.opensearch.client.Request;
import os.org.opensearch.client.Response;
import os.org.opensearch.client.ResponseException;
@@ -63,14 +64,20 @@ public Boolean dataAssetDataStreamExists(String name) throws IOException {
}
@Override
- public void createDataAssetsDataStream(String name) throws IOException {
+ public void createDataAssetsDataStream(
+ String name, String entityType, IndexMapping entityIndexMapping, String language)
+ throws IOException {
String resourcePath = "/dataInsights/opensearch";
createLifecyclePolicy(
"di-data-assets-lifecycle",
readResource(String.format("%s/indexLifecyclePolicy.json", resourcePath)));
createComponentTemplate(
"di-data-assets-mapping",
- readResource(String.format("%s/indexMappingsTemplate.json", resourcePath)));
+ buildMapping(
+ entityType,
+ entityIndexMapping,
+ language,
+ readResource(String.format("%s/indexMappingsTemplate.json", resourcePath))));
createIndexTemplate(
"di-data-assets", readResource(String.format("%s/indexTemplate.json", resourcePath)));
createDataStream(name);
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java
index 2e30832ea5d8..938cba712766 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/DataAssetsWorkflow.java
@@ -23,6 +23,8 @@
import org.openmetadata.schema.system.StepStats;
import org.openmetadata.schema.type.Include;
import org.openmetadata.service.apps.bundles.insights.DataInsightsApp;
+import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchConfiguration;
+import org.openmetadata.service.apps.bundles.insights.search.DataInsightsSearchInterface;
import org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils;
import org.openmetadata.service.apps.bundles.insights.workflows.WorkflowStats;
import org.openmetadata.service.apps.bundles.insights.workflows.dataAssets.processors.DataInsightsElasticSearchProcessor;
@@ -43,6 +45,7 @@
@Slf4j
public class DataAssetsWorkflow {
public static final String DATA_STREAM_KEY = "DataStreamKey";
+ public static final String ENTITY_TYPE_FIELDS_KEY = "EnityTypeFields";
private final int retentionDays = 30;
private final Long startTimestamp;
private final Long endTimestamp;
@@ -51,6 +54,8 @@ public class DataAssetsWorkflow {
private final CollectionDAO collectionDAO;
private final List sources = new ArrayList<>();
private final Set entityTypes;
+ private final DataInsightsSearchConfiguration dataInsightsSearchConfiguration;
+ private final DataInsightsSearchInterface searchInterface;
private DataInsightsEntityEnricherProcessor entityEnricher;
private Processor entityProcessor;
@@ -63,7 +68,8 @@ public DataAssetsWorkflow(
Optional backfill,
Set entityTypes,
CollectionDAO collectionDAO,
- SearchRepository searchRepository) {
+ SearchRepository searchRepository,
+ DataInsightsSearchInterface searchInterface) {
if (backfill.isPresent()) {
Long oldestPossibleTimestamp =
TimestampUtils.getStartOfDayTimestamp(
@@ -95,6 +101,8 @@ public DataAssetsWorkflow(
this.searchRepository = searchRepository;
this.collectionDAO = collectionDAO;
this.entityTypes = entityTypes;
+ this.searchInterface = searchInterface;
+ this.dataInsightsSearchConfiguration = searchInterface.readDataInsightsSearchConfiguration();
}
private void initialize() {
@@ -146,6 +154,10 @@ public void process() throws SearchIndexException {
deleteDataBeforeInserting(getDataStreamName(source.getEntityType()));
contextData.put(DATA_STREAM_KEY, getDataStreamName(source.getEntityType()));
contextData.put(ENTITY_TYPE_KEY, source.getEntityType());
+ contextData.put(
+ ENTITY_TYPE_FIELDS_KEY,
+ searchInterface.getEntityAttributeFields(
+ dataInsightsSearchConfiguration, source.getEntityType()));
while (!source.isDone().get()) {
try {
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java
index a1714dc07eb3..210aaf38bdbe 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/insights/workflows/dataAssets/processors/DataInsightsEntityEnricherProcessor.java
@@ -3,6 +3,8 @@
import static org.openmetadata.schema.EntityInterface.ENTITY_TYPE_TO_CLASS_MAP;
import static org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils.END_TIMESTAMP_KEY;
import static org.openmetadata.service.apps.bundles.insights.utils.TimestampUtils.START_TIMESTAMP_KEY;
+import static org.openmetadata.service.apps.bundles.insights.workflows.dataAssets.DataAssetsWorkflow.ENTITY_TYPE_FIELDS_KEY;
+import static org.openmetadata.service.search.SearchIndexUtils.parseFollowers;
import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.ENTITY_TYPE_KEY;
import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.TIMESTAMP_KEY;
import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.getUpdatedStats;
@@ -141,6 +143,8 @@ private Map enrichEntity(
Long endTimestamp = (Long) entityVersionMap.get("endTimestamp");
Map entityMap = JsonUtils.getMap(entity);
+ entityMap.keySet().retainAll((List) contextData.get(ENTITY_TYPE_FIELDS_KEY));
+
String entityType = (String) contextData.get(ENTITY_TYPE_KEY);
List> interfaces = List.of(entity.getClass().getInterfaces());
@@ -224,14 +228,12 @@ private Map enrichEntity(
}
// Modify Custom Property key
- Optional