From cff32e9c742f9bff2db686445e3f9cddaa6caf38 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Sat, 9 Dec 2023 05:37:00 +0530 Subject: [PATCH 001/540] fix(ingest/transformer): correct registration (#9418) --- metadata-ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index dac865d2dac37e..e894cbf043338d 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -650,7 +650,7 @@ "simple_add_dataset_properties = datahub.ingestion.transformer.add_dataset_properties:SimpleAddDatasetProperties", "pattern_add_dataset_schema_terms = datahub.ingestion.transformer.add_dataset_schema_terms:PatternAddDatasetSchemaTerms", "pattern_add_dataset_schema_tags = datahub.ingestion.transformer.add_dataset_schema_tags:PatternAddDatasetSchemaTags", - "extract_owners_from_tags = datahub.ingestion.transformer.extract_ownership_from_tags:ExtractOwnersFromTagsTransformer", + "extract_ownership_from_tags = datahub.ingestion.transformer.extract_ownership_from_tags:ExtractOwnersFromTagsTransformer", ], "datahub.ingestion.sink.plugins": [ "file = datahub.ingestion.sink.file:FileSink", From e4d8dcbc02d2dae73b7054813b900af239795485 Mon Sep 17 00:00:00 2001 From: Andrew Sikowitz Date: Mon, 11 Dec 2023 09:43:23 -0500 Subject: [PATCH 002/540] docs(ingest/sql-queries): Rearrange sections (#9426) --- .../sql-queries/{sql-queries.md => sql-queries_pre.md} | 5 +++-- .../src/datahub/ingestion/source/sql_queries.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) rename metadata-ingestion/docs/sources/sql-queries/{sql-queries.md => sql-queries_pre.md} (67%) diff --git a/metadata-ingestion/docs/sources/sql-queries/sql-queries.md b/metadata-ingestion/docs/sources/sql-queries/sql-queries_pre.md similarity index 67% rename from metadata-ingestion/docs/sources/sql-queries/sql-queries.md rename to metadata-ingestion/docs/sources/sql-queries/sql-queries_pre.md index e829b4366bb847..2d915f0bcf84db 100644 --- a/metadata-ingestion/docs/sources/sql-queries/sql-queries.md +++ b/metadata-ingestion/docs/sources/sql-queries/sql-queries_pre.md @@ -1,8 +1,9 @@ -### Example Queries File +#### Example Queries File ```json {"query": "SELECT x FROM my_table", "timestamp": 1689232738.051, "user": "user_a", "downstream_tables": [], "upstream_tables": ["my_database.my_schema.my_table"]} {"query": "INSERT INTO my_table VALUES (1, 'a')", "timestamp": 1689232737.669, "user": "user_b", "downstream_tables": ["my_database.my_schema.my_table"], "upstream_tables": []} ``` -Note that this is not a valid standard JSON file, but rather a file containing one JSON object per line. +Note that this file does not represent a single JSON object, but instead newline-delimited JSON, in which +each line is a separate JSON object. diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql_queries.py b/metadata-ingestion/src/datahub/ingestion/source/sql_queries.py index 58e9682df935e3..c3d6657c81fa70 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql_queries.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql_queries.py @@ -93,8 +93,9 @@ def compute_stats(self) -> None: @capability(SourceCapability.LINEAGE_FINE, "Parsed from SQL queries") class SqlQueriesSource(Source): """ - This source reads a specifically-formatted JSON file containing SQL queries and parses them to generate lineage. + This source reads a newline-delimited JSON file containing SQL queries and parses them to generate lineage. + ### Query File Format This file should contain one JSON object per line, with the following fields: - query: string - The SQL query to parse. - timestamp (optional): number - The timestamp of the query, in seconds since the epoch. From 5ac854dcb1f1516a5325ef5bbac466d08c016fcb Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Mon, 11 Dec 2023 22:43:58 +0530 Subject: [PATCH 003/540] fix(ui): Adjusting the view of the Column Stats (#9430) --- .../shared/tabs/Dataset/Stats/snapshot/ColumnStats.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Stats/snapshot/ColumnStats.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Stats/snapshot/ColumnStats.tsx index 080fba66199774..0cbb79dde49cde 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Stats/snapshot/ColumnStats.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Stats/snapshot/ColumnStats.tsx @@ -14,6 +14,8 @@ type Props = { const StatSection = styled.div` padding: 20px 20px; overflow: auto; + display: flex; + flex-direction: column; `; const NameText = styled(Typography.Text)` @@ -162,7 +164,12 @@ export default function ColumnStats({ columnStats }: Props) { return ( Column Stats - + ); } From 8a1122049c02c4929d8029c25dac517e5fdafc48 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Mon, 11 Dec 2023 14:25:43 -0800 Subject: [PATCH 004/540] feat(patch): support fine grained lineage patches (#9408) Co-authored-by: Harshal Sheth --- .../dataset/UpstreamLineageTemplate.java | 271 ++++++++++++- .../registry/UpstreamLineageTemplateTest.java | 359 ++++++++++++++++++ .../java/com/linkedin/metadata/Constants.java | 5 + .../src/datahub/specific/dataset.py | 107 +++++- .../unit/patch/complex_dataset_patch.json | 45 ++- .../tests/unit/patch/test_patch_builder.py | 16 + .../dataset/UpstreamLineagePatchBuilder.java | 231 ++++++++++- .../java/datahub/client/patch/PatchTest.java | 24 +- 8 files changed, 1023 insertions(+), 35 deletions(-) create mode 100644 entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java index 35816895669beb..81a4065dedb1a2 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java @@ -1,20 +1,41 @@ package com.linkedin.metadata.models.registry.template.dataset; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; +import static com.linkedin.metadata.Constants.*; + import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.Streams; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; import com.linkedin.dataset.FineGrainedLineageArray; import com.linkedin.dataset.UpstreamArray; import com.linkedin.dataset.UpstreamLineage; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.models.registry.template.CompoundKeyTemplate; import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import javax.annotation.Nonnull; +import javax.annotation.Nullable; -public class UpstreamLineageTemplate implements ArrayMergingTemplate { +public class UpstreamLineageTemplate extends CompoundKeyTemplate { + // Fields private static final String UPSTREAMS_FIELD_NAME = "upstreams"; private static final String DATASET_FIELD_NAME = "dataset"; + private static final String FINE_GRAINED_LINEAGES_FIELD_NAME = "fineGrainedLineages"; + private static final String FINE_GRAINED_UPSTREAM_TYPE = "upstreamType"; + private static final String FINE_GRAINED_UPSTREAMS = "upstreams"; + private static final String FINE_GRAINED_DOWNSTREAM_TYPE = "downstreamType"; + private static final String FINE_GRAINED_DOWNSTREAMS = "downstreams"; + private static final String FINE_GRAINED_TRANSFORMATION_OPERATION = "transformOperation"; + private static final String FINE_GRAINED_CONFIDENCE_SCORE = "confidenceScore"; - // TODO: Fine Grained Lineages not patchable at this time, they don't have a well established key + // Template support + private static final String NONE_TRANSFORMATION_TYPE = "NONE"; + private static final Float DEFAULT_CONFIDENCE_SCORE = 1.0f; @Override public UpstreamLineage getSubtype(RecordTemplate recordTemplate) throws ClassCastException { @@ -42,14 +63,250 @@ public UpstreamLineage getDefault() { @Nonnull @Override public JsonNode transformFields(JsonNode baseNode) { - return arrayFieldToMap( - baseNode, UPSTREAMS_FIELD_NAME, Collections.singletonList(DATASET_FIELD_NAME)); + JsonNode transformedNode = + arrayFieldToMap( + baseNode, UPSTREAMS_FIELD_NAME, Collections.singletonList(DATASET_FIELD_NAME)); + ((ObjectNode) transformedNode) + .set( + FINE_GRAINED_LINEAGES_FIELD_NAME, + combineAndTransformFineGrainedLineages( + transformedNode.get(FINE_GRAINED_LINEAGES_FIELD_NAME))); + + return transformedNode; } @Nonnull @Override public JsonNode rebaseFields(JsonNode patched) { - return transformedMapToArray( - patched, UPSTREAMS_FIELD_NAME, Collections.singletonList(DATASET_FIELD_NAME)); + JsonNode rebasedNode = + transformedMapToArray( + patched, UPSTREAMS_FIELD_NAME, Collections.singletonList(DATASET_FIELD_NAME)); + ((ObjectNode) rebasedNode) + .set( + FINE_GRAINED_LINEAGES_FIELD_NAME, + reconstructFineGrainedLineages(rebasedNode.get(FINE_GRAINED_LINEAGES_FIELD_NAME))); + return rebasedNode; + } + + /** + * Combines fine grained lineage array into a map using upstream and downstream types as keys, + * defaulting when not present. Due to this construction, patches will look like: path: + * /fineGrainedLineages/TRANSFORMATION_OPERATION/(upstreamType || downstreamType)/TYPE/FIELD_URN, + * op: ADD/REMOVE, value: float (confidenceScore) Due to the way FineGrainedLineage was designed + * it doesn't necessarily have a consistent key we can reference, so this specialized method + * mimics the arrayFieldToMap of the super class with the specialization that it does not put the + * full value of the aspect at the end of the key, just the particular array. This prevents + * unintended overwrites through improper MCP construction that is technically allowed by the + * schema when combining under fields that form the natural key. + * + * @param fineGrainedLineages the fine grained lineage array node + * @return the modified {@link JsonNode} with array fields transformed to maps + */ + private JsonNode combineAndTransformFineGrainedLineages(@Nullable JsonNode fineGrainedLineages) { + ObjectNode mapNode = instance.objectNode(); + if (!(fineGrainedLineages instanceof ArrayNode) || fineGrainedLineages.isEmpty()) { + return mapNode; + } + JsonNode lineageCopy = fineGrainedLineages.deepCopy(); + + lineageCopy + .elements() + .forEachRemaining( + node -> { + JsonNode nodeClone = node.deepCopy(); + String transformationOperation = + nodeClone.has(FINE_GRAINED_TRANSFORMATION_OPERATION) + ? nodeClone.get(FINE_GRAINED_TRANSFORMATION_OPERATION).asText() + : NONE_TRANSFORMATION_TYPE; + + if (!mapNode.has(transformationOperation)) { + mapNode.set(transformationOperation, instance.objectNode()); + } + ObjectNode transformationOperationNode = + (ObjectNode) mapNode.get(transformationOperation); + + Float confidenceScore = + nodeClone.has(FINE_GRAINED_CONFIDENCE_SCORE) + ? nodeClone.get(FINE_GRAINED_CONFIDENCE_SCORE).floatValue() + : DEFAULT_CONFIDENCE_SCORE; + + String upstreamType = + nodeClone.has(FINE_GRAINED_UPSTREAM_TYPE) + ? nodeClone.get(FINE_GRAINED_UPSTREAM_TYPE).asText() + : null; + String downstreamType = + nodeClone.has(FINE_GRAINED_DOWNSTREAM_TYPE) + ? nodeClone.get(FINE_GRAINED_DOWNSTREAM_TYPE).asText() + : null; + ArrayNode upstreams = + nodeClone.has(FINE_GRAINED_UPSTREAMS) + ? (ArrayNode) nodeClone.get(FINE_GRAINED_UPSTREAMS) + : null; + ArrayNode downstreams = + nodeClone.has(FINE_GRAINED_DOWNSTREAMS) + ? (ArrayNode) nodeClone.get(FINE_GRAINED_DOWNSTREAMS) + : null; + + // Handle upstreams + if (upstreamType == null) { + // Determine default type + Urn upstreamUrn = + upstreams != null ? UrnUtils.getUrn(upstreams.get(0).asText()) : null; + if (upstreamUrn != null + && SCHEMA_FIELD_ENTITY_NAME.equals(upstreamUrn.getEntityType())) { + upstreamType = FINE_GRAINED_LINEAGE_FIELD_SET_TYPE; + } else { + upstreamType = FINE_GRAINED_LINEAGE_DATASET_TYPE; + } + } + if (!transformationOperationNode.has(FINE_GRAINED_UPSTREAM_TYPE)) { + transformationOperationNode.set(FINE_GRAINED_UPSTREAM_TYPE, instance.objectNode()); + } + ObjectNode upstreamTypeNode = + (ObjectNode) transformationOperationNode.get(FINE_GRAINED_UPSTREAM_TYPE); + if (!upstreamTypeNode.has(upstreamType)) { + upstreamTypeNode.set(upstreamType, instance.objectNode()); + } + if (upstreams != null) { + addUrnsToSubType(upstreamTypeNode, upstreams, upstreamType, confidenceScore); + } + + // Handle downstreams + if (downstreamType == null) { + // Determine default type + if (downstreams != null && downstreams.size() > 1) { + downstreamType = FINE_GRAINED_LINEAGE_FIELD_SET_TYPE; + } else { + downstreamType = FINE_GRAINED_LINEAGE_FIELD_TYPE; + } + } + if (!transformationOperationNode.has(FINE_GRAINED_DOWNSTREAM_TYPE)) { + transformationOperationNode.set( + FINE_GRAINED_DOWNSTREAM_TYPE, instance.objectNode()); + } + ObjectNode downstreamTypeNode = + (ObjectNode) transformationOperationNode.get(FINE_GRAINED_DOWNSTREAM_TYPE); + if (!downstreamTypeNode.has(downstreamType)) { + downstreamTypeNode.set(downstreamType, instance.objectNode()); + } + if (downstreams != null) { + addUrnsToSubType(downstreamTypeNode, downstreams, downstreamType, confidenceScore); + } + }); + return mapNode; + } + + private void addUrnsToSubType( + JsonNode superType, ArrayNode urnsList, String subType, Float confidenceScore) { + ObjectNode upstreamSubTypeNode = (ObjectNode) superType.get(subType); + // Will overwrite repeat urns with different confidence scores with the most recently seen + upstreamSubTypeNode.setAll( + Streams.stream(urnsList.elements()) + .map(JsonNode::asText) + .distinct() + .collect(Collectors.toMap(urn -> urn, urn -> instance.numberNode(confidenceScore)))); + } + + /** + * Takes the transformed fine grained lineages map from pre-processing and reconstructs an array + * of FineGrainedLineages Avoids producing side effects by copying nodes, use resulting node and + * not the original + * + * @param transformedFineGrainedLineages the transformed fine grained lineage map + * @return the modified {@link JsonNode} formatted consistent with the original schema + */ + private ArrayNode reconstructFineGrainedLineages(JsonNode transformedFineGrainedLineages) { + if (transformedFineGrainedLineages instanceof ArrayNode) { + // We already have an ArrayNode, no need to transform. This happens during `replace` + // operations + return (ArrayNode) transformedFineGrainedLineages; + } + ObjectNode mapNode = (ObjectNode) transformedFineGrainedLineages; + ArrayNode arrayNode = instance.arrayNode(); + + mapNode + .fieldNames() + .forEachRemaining( + transformationOperation -> { + final ObjectNode transformationOperationNode = + (ObjectNode) mapNode.get(transformationOperation); + final ObjectNode upstreamType = + transformationOperationNode.has(FINE_GRAINED_UPSTREAM_TYPE) + ? (ObjectNode) transformationOperationNode.get(FINE_GRAINED_UPSTREAM_TYPE) + : instance.objectNode(); + final ObjectNode downstreamType = + transformationOperationNode.has(FINE_GRAINED_DOWNSTREAM_TYPE) + ? (ObjectNode) transformationOperationNode.get(FINE_GRAINED_DOWNSTREAM_TYPE) + : instance.objectNode(); + + // Handle upstreams + if (!upstreamType.isEmpty()) { + populateTypeNode( + upstreamType, + transformationOperation, + FINE_GRAINED_UPSTREAM_TYPE, + FINE_GRAINED_UPSTREAMS, + FINE_GRAINED_DOWNSTREAM_TYPE, + arrayNode); + } + + // Handle downstreams + if (!downstreamType.isEmpty()) { + populateTypeNode( + downstreamType, + transformationOperation, + FINE_GRAINED_DOWNSTREAM_TYPE, + FINE_GRAINED_DOWNSTREAMS, + FINE_GRAINED_UPSTREAM_TYPE, + arrayNode); + } + }); + + return arrayNode; + } + + private void populateTypeNode( + JsonNode typeNode, + String transformationOperation, + String typeName, + String arrayTypeName, + String defaultTypeName, + ArrayNode arrayNode) { + typeNode + .fieldNames() + .forEachRemaining( + subTypeName -> { + ObjectNode subType = (ObjectNode) typeNode.get(subTypeName); + if (!subType.isEmpty()) { + ObjectNode fineGrainedLineage = instance.objectNode(); + AtomicReference minimumConfidenceScore = new AtomicReference<>(1.0f); + + fineGrainedLineage.put(typeName, subTypeName); + fineGrainedLineage.put( + FINE_GRAINED_TRANSFORMATION_OPERATION, transformationOperation); + // Array to actually be filled out + fineGrainedLineage.set(arrayTypeName, instance.arrayNode()); + // Added to pass model validation, because we have no way of appropriately pairing + // upstreams and downstreams + // within fine grained lineages consistently due to being able to have multiple + // downstream types paired with a single + // transform operation, we just set a default type because it's a required property + fineGrainedLineage.put(defaultTypeName, FINE_GRAINED_LINEAGE_FIELD_SET_TYPE); + subType + .fieldNames() + .forEachRemaining( + subTypeKey -> { + ((ArrayNode) fineGrainedLineage.get(arrayTypeName)).add(subTypeKey); + Float scoreValue = subType.get(subTypeKey).floatValue(); + if (scoreValue <= minimumConfidenceScore.get()) { + minimumConfidenceScore.set(scoreValue); + fineGrainedLineage.set( + FINE_GRAINED_CONFIDENCE_SCORE, + instance.numberNode(minimumConfidenceScore.get())); + } + }); + arrayNode.add(fineGrainedLineage); + } + }); } } diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java new file mode 100644 index 00000000000000..07982a87be56cb --- /dev/null +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java @@ -0,0 +1,359 @@ +package com.linkedin.metadata.models.registry; + +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; + +import com.fasterxml.jackson.databind.node.NumericNode; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonpatch.AddOperation; +import com.github.fge.jsonpatch.JsonPatch; +import com.github.fge.jsonpatch.JsonPatchOperation; +import com.github.fge.jsonpatch.RemoveOperation; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.DataMap; +import com.linkedin.dataset.FineGrainedLineage; +import com.linkedin.dataset.FineGrainedLineageDownstreamType; +import com.linkedin.dataset.FineGrainedLineageUpstreamType; +import com.linkedin.dataset.UpstreamLineage; +import com.linkedin.metadata.models.registry.template.dataset.UpstreamLineageTemplate; +import java.util.ArrayList; +import java.util.List; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class UpstreamLineageTemplateTest { + @Test + public void testPatchUpstream() throws Exception { + UpstreamLineageTemplate upstreamLineageTemplate = new UpstreamLineageTemplate(); + UpstreamLineage upstreamLineage = upstreamLineageTemplate.getDefault(); + List patchOperations = new ArrayList<>(); + NumericNode upstreamConfidenceScore = instance.numberNode(1.0f); + JsonPatchOperation operation = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"), + upstreamConfidenceScore); + patchOperations.add(operation); + JsonPatch jsonPatch = new JsonPatch(patchOperations); + + // Initial population test + UpstreamLineage result = upstreamLineageTemplate.applyPatch(upstreamLineage, jsonPatch); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap = new DataMap(); + dataMap.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage = new FineGrainedLineage(dataMap); + UrnArray urns = new UrnArray(); + Urn urn1 = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"); + urns.add(urn1); + fineGrainedLineage.setUpstreams(urns); + fineGrainedLineage.setTransformOperation("CREATE"); + fineGrainedLineage.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + fineGrainedLineage.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + Assert.assertEquals(result.getFineGrainedLineages().get(0), fineGrainedLineage); + + // Test non-overwrite upstreams and correct confidence score + JsonPatchOperation operation2 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"), + upstreamConfidenceScore); + NumericNode upstreamConfidenceScore2 = instance.numberNode(0.1f); + JsonPatchOperation operation3 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"), + upstreamConfidenceScore2); + List patchOperations2 = new ArrayList<>(); + patchOperations2.add(operation2); + patchOperations2.add(operation3); + JsonPatch jsonPatch2 = new JsonPatch(patchOperations2); + UpstreamLineage result2 = upstreamLineageTemplate.applyPatch(result, jsonPatch2); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap2 = new DataMap(); + dataMap2.put("confidenceScore", 0.1); + FineGrainedLineage fineGrainedLineage2 = new FineGrainedLineage(dataMap2); + UrnArray urns2 = new UrnArray(); + Urn urn2 = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"); + urns2.add(urn1); + urns2.add(urn2); + fineGrainedLineage2.setUpstreams(urns2); + fineGrainedLineage2.setTransformOperation("CREATE"); + fineGrainedLineage2.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + fineGrainedLineage2.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + Assert.assertEquals(result2.getFineGrainedLineages().get(0), fineGrainedLineage2); + + // Check different upstream types + JsonPatchOperation operation4 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/DATASET/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)"), + upstreamConfidenceScore); + List patchOperations3 = new ArrayList<>(); + patchOperations3.add(operation4); + JsonPatch jsonPatch3 = new JsonPatch(patchOperations3); + UpstreamLineage result3 = upstreamLineageTemplate.applyPatch(result2, jsonPatch3); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap3 = new DataMap(); + dataMap3.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage3 = new FineGrainedLineage(dataMap3); + UrnArray urns3 = new UrnArray(); + Urn urn3 = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)"); + urns3.add(urn3); + fineGrainedLineage3.setUpstreams(urns3); + fineGrainedLineage3.setTransformOperation("CREATE"); + fineGrainedLineage3.setUpstreamType(FineGrainedLineageUpstreamType.DATASET); + fineGrainedLineage3.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + // Splits into two for different types + Assert.assertEquals(result3.getFineGrainedLineages().get(1), fineGrainedLineage3); + + // Check different transform types + JsonPatchOperation operation5 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/TRANSFORM/upstreamType/DATASET/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)"), + upstreamConfidenceScore); + List patchOperations4 = new ArrayList<>(); + patchOperations4.add(operation5); + JsonPatch jsonPatch4 = new JsonPatch(patchOperations4); + UpstreamLineage result4 = upstreamLineageTemplate.applyPatch(result3, jsonPatch4); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap4 = new DataMap(); + dataMap4.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage4 = new FineGrainedLineage(dataMap4); + UrnArray urns4 = new UrnArray(); + Urn urn4 = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)"); + urns4.add(urn4); + fineGrainedLineage4.setUpstreams(urns4); + fineGrainedLineage4.setTransformOperation("TRANSFORM"); + fineGrainedLineage4.setUpstreamType(FineGrainedLineageUpstreamType.DATASET); + fineGrainedLineage4.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + // New entry in array because of new transformation type + Assert.assertEquals(result4.getFineGrainedLineages().get(2), fineGrainedLineage4); + + // Remove + JsonPatchOperation removeOperation = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)")); + JsonPatchOperation removeOperation2 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)")); + JsonPatchOperation removeOperation3 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/DATASET/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)")); + JsonPatchOperation removeOperation4 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/TRANSFORM/upstreamType/DATASET/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)")); + + List removeOperations = new ArrayList<>(); + removeOperations.add(removeOperation); + removeOperations.add(removeOperation2); + removeOperations.add(removeOperation3); + removeOperations.add(removeOperation4); + JsonPatch removePatch = new JsonPatch(removeOperations); + UpstreamLineage finalResult = upstreamLineageTemplate.applyPatch(result4, removePatch); + Assert.assertEquals(upstreamLineageTemplate.getDefault(), finalResult); + } + + @Test + public void testPatchDownstream() throws Exception { + UpstreamLineageTemplate upstreamLineageTemplate = new UpstreamLineageTemplate(); + UpstreamLineage upstreamLineage = upstreamLineageTemplate.getDefault(); + List patchOperations = new ArrayList<>(); + NumericNode downstreamConfidenceScore = instance.numberNode(1.0f); + JsonPatchOperation operation = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"), + downstreamConfidenceScore); + patchOperations.add(operation); + JsonPatch jsonPatch = new JsonPatch(patchOperations); + + // Initial population test + UpstreamLineage result = upstreamLineageTemplate.applyPatch(upstreamLineage, jsonPatch); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap = new DataMap(); + dataMap.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage = new FineGrainedLineage(dataMap); + UrnArray urns = new UrnArray(); + Urn urn1 = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"); + urns.add(urn1); + fineGrainedLineage.setDownstreams(urns); + fineGrainedLineage.setTransformOperation("CREATE"); + fineGrainedLineage.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + fineGrainedLineage.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + Assert.assertEquals(result.getFineGrainedLineages().get(0), fineGrainedLineage); + + // Test non-overwrite downstreams and correct confidence score + JsonPatchOperation operation2 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"), + downstreamConfidenceScore); + NumericNode downstreamConfidenceScore2 = instance.numberNode(0.1f); + JsonPatchOperation operation3 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"), + downstreamConfidenceScore2); + List patchOperations2 = new ArrayList<>(); + patchOperations2.add(operation2); + patchOperations2.add(operation3); + JsonPatch jsonPatch2 = new JsonPatch(patchOperations2); + UpstreamLineage result2 = upstreamLineageTemplate.applyPatch(result, jsonPatch2); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap2 = new DataMap(); + dataMap2.put("confidenceScore", 0.1); + FineGrainedLineage fineGrainedLineage2 = new FineGrainedLineage(dataMap2); + UrnArray urns2 = new UrnArray(); + Urn urn2 = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)"); + urns2.add(urn1); + urns2.add(urn2); + fineGrainedLineage2.setDownstreams(urns2); + fineGrainedLineage2.setTransformOperation("CREATE"); + fineGrainedLineage2.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + fineGrainedLineage2.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + Assert.assertEquals(result2.getFineGrainedLineages().get(0), fineGrainedLineage2); + + // Check different downstream types + JsonPatchOperation operation4 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)"), + downstreamConfidenceScore); + List patchOperations3 = new ArrayList<>(); + patchOperations3.add(operation4); + JsonPatch jsonPatch3 = new JsonPatch(patchOperations3); + UpstreamLineage result3 = upstreamLineageTemplate.applyPatch(result2, jsonPatch3); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap3 = new DataMap(); + dataMap3.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage3 = new FineGrainedLineage(dataMap3); + UrnArray urns3 = new UrnArray(); + Urn urn3 = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)"); + urns3.add(urn3); + fineGrainedLineage3.setDownstreams(urns3); + fineGrainedLineage3.setTransformOperation("CREATE"); + fineGrainedLineage3.setDownstreamType(FineGrainedLineageDownstreamType.FIELD); + fineGrainedLineage3.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + // Splits into two for different types + Assert.assertEquals(result3.getFineGrainedLineages().get(1), fineGrainedLineage3); + + // Check different transform types + JsonPatchOperation operation5 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/TRANSFORM/downstreamType/FIELD/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)"), + downstreamConfidenceScore); + List patchOperations4 = new ArrayList<>(); + patchOperations4.add(operation5); + JsonPatch jsonPatch4 = new JsonPatch(patchOperations4); + UpstreamLineage result4 = upstreamLineageTemplate.applyPatch(result3, jsonPatch4); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap4 = new DataMap(); + dataMap4.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage4 = new FineGrainedLineage(dataMap4); + UrnArray urns4 = new UrnArray(); + Urn urn4 = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)"); + urns4.add(urn4); + fineGrainedLineage4.setDownstreams(urns4); + fineGrainedLineage4.setTransformOperation("TRANSFORM"); + fineGrainedLineage4.setDownstreamType(FineGrainedLineageDownstreamType.FIELD); + fineGrainedLineage4.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + // New entry in array because of new transformation type + Assert.assertEquals(result4.getFineGrainedLineages().get(2), fineGrainedLineage4); + + // Remove + JsonPatchOperation removeOperation = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)")); + JsonPatchOperation removeOperation2 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c2)")); + JsonPatchOperation removeOperation3 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD)")); + JsonPatchOperation removeOperation4 = + new RemoveOperation( + new JsonPointer( + "/fineGrainedLineages/TRANSFORM/downstreamType/FIELD/urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_2,PROD)")); + + List removeOperations = new ArrayList<>(); + removeOperations.add(removeOperation); + removeOperations.add(removeOperation2); + removeOperations.add(removeOperation3); + removeOperations.add(removeOperation4); + JsonPatch removePatch = new JsonPatch(removeOperations); + UpstreamLineage finalResult = upstreamLineageTemplate.applyPatch(result4, removePatch); + Assert.assertEquals(upstreamLineageTemplate.getDefault(), finalResult); + } + + @Test + public void testUpAndDown() throws Exception { + UpstreamLineageTemplate upstreamLineageTemplate = new UpstreamLineageTemplate(); + UpstreamLineage upstreamLineage = upstreamLineageTemplate.getDefault(); + List patchOperations = new ArrayList<>(); + NumericNode downstreamConfidenceScore = instance.numberNode(1.0f); + JsonPatchOperation operation = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/downstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"), + downstreamConfidenceScore); + patchOperations.add(operation); + NumericNode upstreamConfidenceScore = instance.numberNode(1.0f); + JsonPatchOperation operation2 = + new AddOperation( + new JsonPointer( + "/fineGrainedLineages/CREATE/upstreamType/FIELD_SET/urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"), + upstreamConfidenceScore); + patchOperations.add(operation2); + JsonPatch jsonPatch = new JsonPatch(patchOperations); + + // Initial population test + UpstreamLineage result = upstreamLineageTemplate.applyPatch(upstreamLineage, jsonPatch); + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap = new DataMap(); + dataMap.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage = new FineGrainedLineage(dataMap); + UrnArray urns = new UrnArray(); + Urn urn1 = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:bigquery,upstream_table_1,PROD),c1)"); + urns.add(urn1); + fineGrainedLineage.setTransformOperation("CREATE"); + fineGrainedLineage.setUpstreams(urns); + fineGrainedLineage.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + fineGrainedLineage.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + fineGrainedLineage.setDownstreams(urns); + + // Hack because Jackson parses values to doubles instead of floats + DataMap dataMap2 = new DataMap(); + dataMap2.put("confidenceScore", 1.0); + FineGrainedLineage fineGrainedLineage2 = new FineGrainedLineage(dataMap2); + fineGrainedLineage2.setTransformOperation("CREATE"); + fineGrainedLineage2.setUpstreamType(FineGrainedLineageUpstreamType.FIELD_SET); + fineGrainedLineage2.setDownstreamType(FineGrainedLineageDownstreamType.FIELD_SET); + fineGrainedLineage2.setDownstreams(urns); + + Assert.assertEquals(result.getFineGrainedLineages().get(1), fineGrainedLineage2); + } +} diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index f5a3c9c12ff70e..3d9b533dc8f720 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -125,6 +125,11 @@ public class Constants { public static final String VIEW_PROPERTIES_ASPECT_NAME = "viewProperties"; public static final String DATASET_PROFILE_ASPECT_NAME = "datasetProfile"; + // Aspect support + public static final String FINE_GRAINED_LINEAGE_DATASET_TYPE = "DATASET"; + public static final String FINE_GRAINED_LINEAGE_FIELD_SET_TYPE = "FIELD_SET"; + public static final String FINE_GRAINED_LINEAGE_FIELD_TYPE = "FIELD"; + // Chart public static final String CHART_KEY_ASPECT_NAME = "chartKey"; public static final String CHART_INFO_ASPECT_NAME = "chartInfo"; diff --git a/metadata-ingestion/src/datahub/specific/dataset.py b/metadata-ingestion/src/datahub/specific/dataset.py index fcfe049fb15cf9..294a80572669b8 100644 --- a/metadata-ingestion/src/datahub/specific/dataset.py +++ b/metadata-ingestion/src/datahub/specific/dataset.py @@ -1,4 +1,4 @@ -from typing import Dict, Generic, List, Optional, TypeVar, Union +from typing import Dict, Generic, List, Optional, Tuple, TypeVar, Union from urllib.parse import quote from datahub.emitter.mcp_patch_builder import MetadataPatchProposal @@ -6,6 +6,9 @@ DatasetPropertiesClass as DatasetProperties, EditableDatasetPropertiesClass as EditableDatasetProperties, EditableSchemaMetadataClass as EditableSchemaMetadata, + FineGrainedLineageClass as FineGrainedLineage, + FineGrainedLineageDownstreamTypeClass as FineGrainedLineageDownstreamType, + FineGrainedLineageUpstreamTypeClass as FineGrainedLineageUpstreamType, GlobalTagsClass as GlobalTags, GlossaryTermAssociationClass as Term, GlossaryTermsClass as GlossaryTerms, @@ -144,6 +147,108 @@ def set_upstream_lineages(self, upstreams: List[Upstream]) -> "DatasetPatchBuild ) return self + def add_fine_grained_upstream_lineage( + self, fine_grained_lineage: FineGrainedLineage + ) -> "DatasetPatchBuilder": + ( + transform_op, + upstream_type, + downstream_type, + ) = DatasetPatchBuilder.get_fine_grained_key(fine_grained_lineage) + for upstream_urn in fine_grained_lineage.upstreams or []: + self._add_patch( + UpstreamLineage.ASPECT_NAME, + "add", + path=DatasetPatchBuilder.quote_fine_grained_upstream_path( + transform_op, upstream_type, upstream_urn + ), + value=fine_grained_lineage.confidenceScore, + ) + for downstream_urn in fine_grained_lineage.downstreams or []: + self._add_patch( + UpstreamLineage.ASPECT_NAME, + "add", + path=DatasetPatchBuilder.quote_fine_grained_downstream_path( + transform_op, downstream_type, downstream_urn + ), + value=fine_grained_lineage.confidenceScore, + ) + return self + + @staticmethod + def get_fine_grained_key( + fine_grained_lineage: FineGrainedLineage, + ) -> Tuple[str, str, str]: + transform_op = fine_grained_lineage.transformOperation or "NONE" + upstream_type = ( + fine_grained_lineage.upstreamType + if isinstance(fine_grained_lineage.upstreamType, str) + else FineGrainedLineageUpstreamType.FIELD_SET + ) + downstream_type = ( + fine_grained_lineage.downstreamType + if isinstance(fine_grained_lineage.downstreamType, str) + else FineGrainedLineageDownstreamType.FIELD_SET + ) + return transform_op, upstream_type, downstream_type + + @staticmethod + def quote_fine_grained_downstream_path( + transform_op: str, downstream_type: str, downstream_urn: str + ) -> str: + return ( + f"/fineGrainedLineages/{quote(transform_op, safe='')}/downstreamType/" + f"{quote(downstream_type, safe='')}/{quote(downstream_urn, safe='')}" + ) + + @staticmethod + def quote_fine_grained_upstream_path( + transform_op: str, upstream_type: str, upstream_urn: str + ) -> str: + return ( + f"/fineGrainedLineages/{quote(transform_op, safe='')}/upstreamType/" + f"{quote(upstream_type, safe='')}/{quote(upstream_urn, safe='')}" + ) + + def remove_fine_grained_upstream_lineage( + self, fine_grained_lineage: FineGrainedLineage + ) -> "DatasetPatchBuilder": + ( + transform_op, + upstream_type, + downstream_type, + ) = DatasetPatchBuilder.get_fine_grained_key(fine_grained_lineage) + for upstream_urn in fine_grained_lineage.upstreams or []: + self._add_patch( + UpstreamLineage.ASPECT_NAME, + "remove", + path=DatasetPatchBuilder.quote_fine_grained_upstream_path( + transform_op, upstream_type, upstream_urn + ), + value={}, + ) + for downstream_urn in fine_grained_lineage.downstreams or []: + self._add_patch( + UpstreamLineage.ASPECT_NAME, + "remove", + path=DatasetPatchBuilder.quote_fine_grained_downstream_path( + transform_op, downstream_type, downstream_urn + ), + value={}, + ) + return self + + def set_fine_grained_upstream_lineages( + self, fine_grained_lineages: List[FineGrainedLineage] + ) -> "DatasetPatchBuilder": + self._add_patch( + UpstreamLineage.ASPECT_NAME, + "add", + path="/fineGrainedLineages", + value=fine_grained_lineages, + ) + return self + def add_tag(self, tag: Tag) -> "DatasetPatchBuilder": self._add_patch( GlobalTags.ASPECT_NAME, "add", path=f"/tags/{tag.tag}", value=tag diff --git a/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json b/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json index d5dfe125942fba..ed5a7723ac2bf1 100644 --- a/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json +++ b/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json @@ -42,26 +42,31 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD)", - "changeType": "PATCH", - "aspectName": "upstreamLineage", - "aspect": { - "json": [ - { - "op": "add", - "path": "/upstreams/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2Cfct_users_created_upstream%2CPROD%29", - "value": { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created_upstream,PROD)", - "type": "TRANSFORMED" - } - } - ] - } + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD)", + "changeType": "PATCH", + "aspectName": "upstreamLineage", + "aspect": { + "json": [ + { + "op": "add", + "path": "/upstreams/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2Cfct_users_created_upstream%2CPROD%29", + "value": { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created_upstream,PROD)", + "type": "TRANSFORMED" + } + }, + { + "op": "add", + "path": "/fineGrainedLineages/TRANSFORM/upstreamType/DATASET/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2Cfct_users_created_upstream%2CPROD%29", + "value": 1.0 + } + ] + } }, { "entityType": "dataset", diff --git a/metadata-ingestion/tests/unit/patch/test_patch_builder.py b/metadata-ingestion/tests/unit/patch/test_patch_builder.py index 0701b3d6968959..f05c4978f8644e 100644 --- a/metadata-ingestion/tests/unit/patch/test_patch_builder.py +++ b/metadata-ingestion/tests/unit/patch/test_patch_builder.py @@ -7,6 +7,9 @@ from datahub.ingestion.sink.file import write_metadata_file from datahub.metadata.schema_classes import ( DatasetLineageTypeClass, + FineGrainedLineageClass, + FineGrainedLineageDownstreamTypeClass, + FineGrainedLineageUpstreamTypeClass, GenericAspectClass, MetadataChangeProposalClass, TagAssociationClass, @@ -53,6 +56,19 @@ def test_complex_dataset_patch( type=DatasetLineageTypeClass.TRANSFORMED, ) ) + .add_fine_grained_upstream_lineage( + fine_grained_lineage=FineGrainedLineageClass( + upstreamType=FineGrainedLineageUpstreamTypeClass.DATASET, + upstreams=[ + make_dataset_urn( + platform="hive", name="fct_users_created_upstream", env="PROD" + ) + ], + downstreamType=FineGrainedLineageDownstreamTypeClass.FIELD_SET, + transformOperation="TRANSFORM", + confidenceScore=1.0, + ) + ) ) patcher.for_field("field1").add_tag(TagAssociationClass(tag=make_tag_urn("tag1"))) diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java index 6ded8a25b4e22c..9db2ebc522e093 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java +++ b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java @@ -5,10 +5,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.urn.DatasetUrn; +import com.linkedin.common.urn.Urn; import com.linkedin.dataset.DatasetLineageType; +import com.linkedin.dataset.FineGrainedLineageDownstreamType; +import com.linkedin.dataset.FineGrainedLineageUpstreamType; import datahub.client.patch.AbstractMultiFieldPatchBuilder; import datahub.client.patch.PatchOperationType; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.ToString; import org.apache.commons.lang3.tuple.ImmutableTriple; @@ -16,7 +20,8 @@ public class UpstreamLineagePatchBuilder extends AbstractMultiFieldPatchBuilder { - private static final String PATH_START = "/upstreams/"; + private static final String UPSTREAMS_PATH_START = "/upstreams/"; + private static final String FINE_GRAINED_PATH_START = "/fineGrainedLineages/"; private static final String DATASET_KEY = "dataset"; private static final String AUDIT_STAMP_KEY = "auditStamp"; private static final String TIME_KEY = "time"; @@ -34,13 +39,233 @@ public UpstreamLineagePatchBuilder addUpstream( .set(AUDIT_STAMP_KEY, auditStamp); pathValues.add( - ImmutableTriple.of(PatchOperationType.ADD.getValue(), PATH_START + datasetUrn, value)); + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), UPSTREAMS_PATH_START + datasetUrn, value)); return this; } public UpstreamLineagePatchBuilder removeUpstream(@Nonnull DatasetUrn datasetUrn) { pathValues.add( - ImmutableTriple.of(PatchOperationType.REMOVE.getValue(), PATH_START + datasetUrn, null)); + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), UPSTREAMS_PATH_START + datasetUrn, null)); + return this; + } + + /** + * Method for adding an upstream FineGrained Dataset + * + * @param datasetUrn dataset to be set as upstream + * @param confidenceScore optional, confidence score for the lineage edge. Defaults to 1.0 for + * full confidence + * @param transformationOperation string operation type that describes the transformation + * operation happening in the lineage edge + * @return this builder + */ + public UpstreamLineagePatchBuilder addFineGrainedUpstreamDataset( + @Nonnull DatasetUrn datasetUrn, + @Nullable Float confidenceScore, + @Nonnull String transformationOperation) { + Float finalConfidenceScore = getConfidenceScoreOrDefault(confidenceScore); + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "upstreamType" + + "/" + + "DATASET" + + "/" + + datasetUrn, + instance.numberNode(finalConfidenceScore))); + return this; + } + + /** + * Adds a field as a fine grained upstream + * + * @param schemaFieldUrn a schema field to be marked as upstream, format: + * urn:li:schemaField(DATASET_URN, COLUMN NAME) + * @param confidenceScore optional, confidence score for the lineage edge. Defaults to 1.0 for + * full confidence + * @param transformationOperation string operation type that describes the transformation + * operation happening in the lineage edge + * @param type the upstream lineage type, either Field or Field Set + * @return this builder + */ + public UpstreamLineagePatchBuilder addFineGrainedUpstreamField( + @Nonnull Urn schemaFieldUrn, + @Nullable Float confidenceScore, + @Nonnull String transformationOperation, + @Nullable FineGrainedLineageUpstreamType type) { + Float finalConfidenceScore = getConfidenceScoreOrDefault(confidenceScore); + String finalType; + if (type == null) { + // Default to set of fields if not explicitly a single field + finalType = FineGrainedLineageUpstreamType.FIELD_SET.toString(); + } else { + finalType = type.toString(); + } + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "upstreamType" + + "/" + + finalType + + "/" + + schemaFieldUrn, + instance.numberNode(finalConfidenceScore))); + + return this; + } + + /** + * Adds a field as a fine grained downstream + * + * @param schemaFieldUrn a schema field to be marked as downstream, format: + * urn:li:schemaField(DATASET_URN, COLUMN NAME) + * @param confidenceScore optional, confidence score for the lineage edge. Defaults to 1.0 for + * full confidence + * @param transformationOperation string operation type that describes the transformation + * operation happening in the lineage edge + * @param type the downstream lineage type, either Field or Field Set + * @return this builder + */ + public UpstreamLineagePatchBuilder addFineGrainedDownstreamField( + @Nonnull Urn schemaFieldUrn, + @Nullable Float confidenceScore, + @Nonnull String transformationOperation, + @Nullable FineGrainedLineageDownstreamType type) { + Float finalConfidenceScore = getConfidenceScoreOrDefault(confidenceScore); + String finalType; + if (type == null) { + // Default to set of fields if not explicitly a single field + finalType = FineGrainedLineageDownstreamType.FIELD_SET.toString(); + } else { + finalType = type.toString(); + } + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "downstreamType" + + "/" + + finalType + + "/" + + schemaFieldUrn, + instance.numberNode(finalConfidenceScore))); + return this; + } + + private Float getConfidenceScoreOrDefault(@Nullable Float confidenceScore) { + float finalConfidenceScore; + if (confidenceScore != null && confidenceScore > 0 && confidenceScore <= 1.0f) { + finalConfidenceScore = confidenceScore; + } else { + finalConfidenceScore = 1.0f; + } + + return finalConfidenceScore; + } + + /** + * Removes a field as a fine grained upstream + * + * @param schemaFieldUrn a schema field to be marked as upstream, format: + * urn:li:schemaField(DATASET_URN, COLUMN NAME) + * @param transformationOperation string operation type that describes the transformation + * operation happening in the lineage edge + * @param type the upstream lineage type, either Field or Field Set + * @return this builder + */ + public UpstreamLineagePatchBuilder removeFineGrainedUpstreamField( + @Nonnull Urn schemaFieldUrn, + @Nonnull String transformationOperation, + @Nullable FineGrainedLineageUpstreamType type) { + String finalType; + if (type == null) { + // Default to set of fields if not explicitly a single field + finalType = FineGrainedLineageUpstreamType.FIELD_SET.toString(); + } else { + finalType = type.toString(); + } + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "upstreamType" + + "/" + + finalType + + "/" + + schemaFieldUrn, + null)); + + return this; + } + + public UpstreamLineagePatchBuilder removeFineGrainedUpstreamDataset( + @Nonnull DatasetUrn datasetUrn, @Nonnull String transformationOperation) { + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "upstreamType" + + "/" + + "DATASET" + + "/" + + datasetUrn, + null)); + return this; + } + + /** + * Adds a field as a fine grained downstream + * + * @param schemaFieldUrn a schema field to be marked as downstream, format: + * urn:li:schemaField(DATASET_URN, COLUMN NAME) + * @param transformationOperation string operation type that describes the transformation + * operation happening in the lineage edge + * @param type the downstream lineage type, either Field or Field Set + * @return this builder + */ + public UpstreamLineagePatchBuilder removeFineGrainedDownstreamField( + @Nonnull Urn schemaFieldUrn, + @Nonnull String transformationOperation, + @Nullable FineGrainedLineageDownstreamType type) { + String finalType; + if (type == null) { + // Default to set of fields if not explicitly a single field + finalType = FineGrainedLineageDownstreamType.FIELD_SET.toString(); + } else { + finalType = type.toString(); + } + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), + FINE_GRAINED_PATH_START + + transformationOperation + + "/" + + "downstreamType" + + "/" + + finalType + + "/" + + schemaFieldUrn, + null)); return this; } diff --git a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java index 1d387acb0ce12b..563742990f5468 100644 --- a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java +++ b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java @@ -14,6 +14,7 @@ import com.linkedin.common.urn.DatasetUrn; import com.linkedin.common.urn.GlossaryTermUrn; import com.linkedin.common.urn.TagUrn; +import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.dataset.DatasetLineageType; import com.linkedin.metadata.graph.LineageDirection; @@ -49,15 +50,21 @@ public class PatchTest { public void testLocalUpstream() { RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); try { + DatasetUrn upstreamUrn = + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD)"); + Urn schemaFieldUrn = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD), foo)"); MetadataChangeProposal upstreamPatch = new UpstreamLineagePatchBuilder() .urn( UrnUtils.getUrn( "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)")) - .addUpstream( - DatasetUrn.createFromString( - "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD)"), - DatasetLineageType.TRANSFORMED) + .addUpstream(upstreamUrn, DatasetLineageType.TRANSFORMED) + .addFineGrainedUpstreamDataset(upstreamUrn, null, "TRANSFORM") + .addFineGrainedUpstreamField(schemaFieldUrn, null, "TRANSFORM", null) + .addFineGrainedDownstreamField(schemaFieldUrn, null, "TRANSFORM", null) .build(); Future response = restEmitter.emit(upstreamPatch); @@ -73,6 +80,12 @@ public void testLocalUpstream() { public void testLocalUpstreamRemove() { RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); try { + DatasetUrn upstreamUrn = + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD)"); + Urn schemaFieldUrn = + UrnUtils.getUrn( + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD), foo)"); MetadataChangeProposal upstreamPatch = new UpstreamLineagePatchBuilder() .urn( @@ -81,6 +94,9 @@ public void testLocalUpstreamRemove() { .removeUpstream( DatasetUrn.createFromString( "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset,PROD)")) + .removeFineGrainedUpstreamDataset(upstreamUrn, "TRANSFORM") + .removeFineGrainedUpstreamField(schemaFieldUrn, "TRANSFORM", null) + .removeFineGrainedDownstreamField(schemaFieldUrn, "TRANSFORM", null) .build(); Future response = restEmitter.emit(upstreamPatch); From 79ccbc57d1c3266025c8e52ce18fbfcff550c387 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Mon, 11 Dec 2023 14:41:23 -0800 Subject: [PATCH 005/540] fix(CVE-2023-6378): update logback classic (#9438) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f5e5403e822e77..b16e3ca169c717 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ buildscript { ext.playVersion = '2.8.18' ext.log4jVersion = '2.19.0' ext.slf4jVersion = '1.7.36' - ext.logbackClassic = '1.2.12' + ext.logbackClassic = '1.2.13' ext.hadoop3Version = '3.3.5' ext.kafkaVersion = '2.3.0' ext.hazelcastVersion = '5.3.6' From ee4e8dd74c569d0dfc98e8eb13034c91b0ad61a8 Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Tue, 12 Dec 2023 15:03:30 +0530 Subject: [PATCH 006/540] feat: allow the sidebar size to be draggable (#9401) --- .../src/app/search/SearchResults.tsx | 2 +- .../src/app/search/sidebar/BrowseSidebar.tsx | 51 ++++++++++++------- .../src/app/search/sidebar/EntityNode.tsx | 3 +- .../cypress/cypress/e2e/browse/browseV2.js | 10 ++-- 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/datahub-web-react/src/app/search/SearchResults.tsx b/datahub-web-react/src/app/search/SearchResults.tsx index 56e83e42350270..d7ad6d517d8fed 100644 --- a/datahub-web-react/src/app/search/SearchResults.tsx +++ b/datahub-web-react/src/app/search/SearchResults.tsx @@ -197,7 +197,7 @@ export const SearchResults = ({ {showBrowseV2 && ( - + )} diff --git a/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx b/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx index 822e75b65febc3..c16bcdcaf6c727 100644 --- a/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx +++ b/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Typography } from 'antd'; import EntityNode from './EntityNode'; @@ -7,10 +7,16 @@ import SidebarLoadingError from './SidebarLoadingError'; import { SEARCH_RESULTS_BROWSE_SIDEBAR_ID } from '../../onboarding/config/SearchOnboardingConfig'; import useSidebarEntities from './useSidebarEntities'; import { ANTD_GRAY_V2 } from '../../entity/shared/constants'; +import { ProfileSidebarResizer } from '../../entity/shared/containers/profile/sidebar/ProfileSidebarResizer'; -const Sidebar = styled.div<{ visible: boolean; width: number }>` + +export const MAX_BROWSER_WIDTH = 500; +export const MIN_BROWSWER_WIDTH = 200; + +export const SidebarWrapper = styled.div<{ visible: boolean; width: number }>` height: 100%; width: ${(props) => (props.visible ? `${props.width}px` : '0')}; + min-width: ${(props) => (props.visible ? `${props.width}px` : '0')}; transition: width 250ms ease-in-out; border-right: 1px solid ${(props) => props.theme.styles['border-color-base']}; background-color: ${ANTD_GRAY_V2[1]}; @@ -37,29 +43,38 @@ const SidebarBody = styled.div<{ visible: boolean }>` type Props = { visible: boolean; - width: number; }; -const BrowseSidebar = ({ visible, width }: Props) => { +const BrowseSidebar = ({ visible }: Props) => { const { error, entityAggregations, retry } = useSidebarEntities({ skip: !visible, }); + const [browserWidth, setBrowserWith] = useState(window.innerWidth * 0.2); return ( - - - Navigate - - - {entityAggregations && !entityAggregations.length &&
No results found
} - {entityAggregations?.map((entityAggregation) => ( - - - - ))} - {error && } -
-
+ <> + + + Navigate + + + {entityAggregations && !entityAggregations.length &&
No results found
} + {entityAggregations?.map((entityAggregation) => ( + + + + ))} + {error && } +
+
+ + setBrowserWith(Math.min(Math.max(widthProp, MIN_BROWSWER_WIDTH), MAX_BROWSER_WIDTH)) + } + initialSize={browserWidth} + isSidebarOnLeft + /> + ); }; diff --git a/datahub-web-react/src/app/search/sidebar/EntityNode.tsx b/datahub-web-react/src/app/search/sidebar/EntityNode.tsx index e04e4253dca134..627d19c4fb10c1 100644 --- a/datahub-web-react/src/app/search/sidebar/EntityNode.tsx +++ b/datahub-web-react/src/app/search/sidebar/EntityNode.tsx @@ -38,7 +38,8 @@ const EntityNode = () => { onToggle: (isNowOpen: boolean) => trackToggleNodeEvent(isNowOpen, 'entity'), }); - const onClickHeader = () => { + const onClickHeader = (e) => { + e.preventDefault(); if (count) toggle(); }; diff --git a/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js b/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js index a61b9030b13c6f..f45edc5fa04819 100644 --- a/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js +++ b/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js @@ -46,31 +46,31 @@ describe("search", () => { cy.get("[data-testid=browse-v2") .invoke("css", "width") - .should("match", /^\d\d\dpx$/); + .should("match", /\d\d\dpx$/); cy.get("[data-testid=browse-v2-toggle").click(); cy.get("[data-testid=browse-v2") .invoke("css", "width") - .should("match", /^\dpx$/); + .should("match", /\dpx$/); cy.reload(); cy.get("[data-testid=browse-v2") .invoke("css", "width") - .should("match", /^\dpx$/); + .should("match", /\dpx$/); cy.get("[data-testid=browse-v2-toggle").click(); cy.get("[data-testid=browse-v2") .invoke("css", "width") - .should("match", /^\d\d\dpx$/); + .should("match", /\d\d\dpx$/); cy.reload(); cy.get("[data-testid=browse-v2") .invoke("css", "width") - .should("match", /^\d\d\dpx$/); + .should("match", /\d\d\dpx$/); }); it("should take you to the old browse experience when clicking entity type on home page with the browse flag off", () => { From abbc4cdc577647d7b97a03117c4317805a3a8ce3 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Tue, 12 Dec 2023 17:26:29 +0530 Subject: [PATCH 007/540] fix(json-schema): do not send invalid URLs (#9417) --- .../ingestion/source/schema/json_schema.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/schema/json_schema.py b/metadata-ingestion/src/datahub/ingestion/source/schema/json_schema.py index f6e944f4fc3cb3..c7e8a15d8dfa48 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/schema/json_schema.py +++ b/metadata-ingestion/src/datahub/ingestion/source/schema/json_schema.py @@ -9,6 +9,7 @@ from os.path import basename, dirname from pathlib import Path from typing import Any, Iterable, List, Optional, Union +from urllib.parse import urlparse import jsonref from pydantic import AnyHttpUrl, DirectoryPath, FilePath, validator @@ -53,6 +54,16 @@ logger = logging.getLogger(__name__) +def is_url_valid(url: Optional[str]) -> bool: + if url is None: + return False + try: + result = urlparse(url) + return all([result.scheme, result.netloc]) + except Exception: + return False + + class URIReplacePattern(ConfigModel): match: str = Field( description="Pattern to match on uri-s as part of reference resolution. See replace field", @@ -281,12 +292,14 @@ def _load_one_file( entityUrn=dataset_urn, aspect=models.StatusClass(removed=False) ).as_workunit() + external_url = JsonSchemaTranslator._get_id_from_any_schema(schema_dict) + if not is_url_valid(external_url): + external_url = None + yield MetadataChangeProposalWrapper( entityUrn=dataset_urn, aspect=models.DatasetPropertiesClass( - externalUrl=JsonSchemaTranslator._get_id_from_any_schema( - schema_dict - ), + externalUrl=external_url, name=dataset_simple_name, description=JsonSchemaTranslator._get_description_from_any_schema( schema_dict From ffccc6556110ea197402ad1de72117ffd5509a8d Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Tue, 12 Dec 2023 18:31:58 +0100 Subject: [PATCH 008/540] fix(ingest/profiling) Fixing profile eligibility check (#9446) --- .../datahub/ingestion/source/sql/sql_generic_profiler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py index 844a458d9f1ab6..a2f91e5fae1a98 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py @@ -274,16 +274,16 @@ def is_dataset_eligible_for_profiling( return False if self.config.profiling.profile_table_size_limit is not None and ( - size_in_bytes is None - or size_in_bytes / (2**30) + size_in_bytes is not None + and size_in_bytes / (2**30) > self.config.profiling.profile_table_size_limit ): self.report.profiling_skipped_size_limit[schema_name] += 1 return False if self.config.profiling.profile_table_row_limit is not None and ( - rows_count is None - or rows_count > self.config.profiling.profile_table_row_limit + rows_count is not None + and rows_count > self.config.profiling.profile_table_row_limit ): self.report.profiling_skipped_row_limit[schema_name] += 1 return False From 66f90c7ffd483f397c99dbf494280d3cd9ef10dd Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 12 Dec 2023 12:32:59 -0500 Subject: [PATCH 009/540] fix(ingest): avoid git dependency in dbt (#9447) --- metadata-ingestion/src/datahub/configuration/git.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/metadata-ingestion/src/datahub/configuration/git.py b/metadata-ingestion/src/datahub/configuration/git.py index 9ea9007553839b..a5f88744661a4a 100644 --- a/metadata-ingestion/src/datahub/configuration/git.py +++ b/metadata-ingestion/src/datahub/configuration/git.py @@ -6,7 +6,6 @@ from datahub.configuration.common import ConfigModel from datahub.configuration.validate_field_rename import pydantic_renamed_field -from datahub.ingestion.source.git.git_import import GitClone _GITHUB_PREFIX = "https://github.com/" _GITLAB_PREFIX = "https://gitlab.com/" @@ -151,6 +150,9 @@ def clone( ) -> pathlib.Path: """Clones the repo into a temporary directory and returns the path to the checkout.""" + # We import this here to avoid a hard dependency on gitpython. + from datahub.ingestion.source.git.git_import import GitClone + assert self.repo_ssh_locator git_clone = GitClone(str(tmp_path)) From 02982ed88600f9b11c2387e540299c437ca21ed6 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 12 Dec 2023 12:38:21 -0500 Subject: [PATCH 010/540] feat(ingest): add retries for tableau (#9437) --- .../src/datahub/ingestion/source/tableau.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau.py index da44d09121c6c1..f870e99df27c5f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau.py @@ -21,7 +21,7 @@ import tableauserverclient as TSC from pydantic import root_validator, validator from pydantic.fields import Field -from requests.adapters import ConnectionError +from requests.adapters import ConnectionError, HTTPAdapter from tableauserverclient import ( PersonalAccessTokenAuth, Server, @@ -29,6 +29,7 @@ TableauAuth, ) from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError +from urllib3 import Retry import datahub.emitter.mce_builder as builder import datahub.utilities.sqlglot_lineage as sqlglot_l @@ -174,6 +175,7 @@ class TableauConnectionConfig(ConfigModel): description="Unique relationship between the Tableau Server and site", ) + max_retries: int = Field(3, description="Number of retries for failed requests.") ssl_verify: Union[bool, str] = Field( default=True, description="Whether to verify SSL certificates. If using self-signed certificates, set to false or provide the path to the .pem certificate bundle.", @@ -224,6 +226,17 @@ def make_tableau_client(self) -> Server: # From https://stackoverflow.com/a/50159273/5004662. server._session.trust_env = False + # Setup request retries. + adapter = HTTPAdapter( + max_retries=Retry( + total=self.max_retries, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + ) + server._session.mount("http://", adapter) + server._session.mount("https://", adapter) + server.auth.sign_in(authentication) return server except ServerResponseError as e: From 9899aca4995ec0bd5a7e3ccc6c7e1495b4ee78df Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:16:27 -0600 Subject: [PATCH 011/540] docs(updating-datahub): update docs for v0.12.1 (#9441) --- docs/how/updating-datahub.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index 94ab1b0611c339..36be572f2886e5 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -7,15 +7,26 @@ This file documents any backwards-incompatible changes in DataHub and assists pe ### Breaking Changes - Updating MySQL version for quickstarts to 8.2, may cause quickstart issues for existing instances. + +### Potential Downtime + +### Deprecations + +### Other Notable Changes + +## 0.12.1 + +### Breaking Changes + - #9244: The `redshift-legacy` and `redshift-legacy-usage` sources, which have been deprecated for >6 months, have been removed. The new `redshift` source is a superset of the functionality provided by those legacy sources. - `database_alias` config is no longer supported in SQL sources namely - Redshift, MySQL, Oracle, Postgres, Trino, Presto-on-hive. The config will automatically be ignored if it's present in your recipe. It has been deprecated since v0.9.6. - #9257: The Python SDK urn types are now autogenerated. The new classes are largely backwards compatible with the previous, manually written classes, but many older methods are now deprecated in favor of a more uniform interface. The only breaking change is that the signature for the director constructor e.g. `TagUrn("tag", ["tag_name"])` is no longer supported, and the simpler `TagUrn("tag_name")` should be used instead. The canonical place to import the urn classes from is `datahub.metadata.urns.*`. Other import paths, like `datahub.utilities.urns.corpuser_urn.CorpuserUrn` are retained for backwards compatibility, but are considered deprecated. - #9286: The `DataHubRestEmitter.emit` method no longer returns anything. It previously returned a tuple of timestamps. - #8951: A great expectations based profiler has been added for the Unity Catalog source. -To use the old profiler, set `method: analyze` under the `profiling` section in your recipe. -To use the new profiler, set `method: ge`. Profiling is disabled by default, so to enable it, -one of these methods must be specified. + To use the old profiler, set `method: analyze` under the `profiling` section in your recipe. + To use the new profiler, set `method: ge`. Profiling is disabled by default, so to enable it, + one of these methods must be specified. ### Potential Downtime From eb8cbd8b4150b31429cf09158cb1113f275ac544 Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Wed, 13 Dec 2023 12:19:49 +0530 Subject: [PATCH 012/540] feat: Allow specifying Data Product URN via UI (#9386) Co-authored-by: Aseem Bansal --- .../DataHubDataFetcherExceptionHandler.java | 40 +++++++---- .../CreateDataProductResolver.java | 1 + .../src/main/resources/entity.graphql | 4 ++ .../CreateDataProductModal.tsx | 5 +- .../DataProductAdvancedOption.tsx | 68 +++++++++++++++++++ .../DataProductBuilderForm.tsx | 11 ++- .../entity/domain/DataProductsTab/types.ts | 6 ++ .../metadata/service/DataProductService.java | 22 +++++- .../tests/privileges/test_privileges.py | 7 +- 9 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/exception/DataHubDataFetcherExceptionHandler.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/exception/DataHubDataFetcherExceptionHandler.java index 7c3ea1d581b6ed..746ce0cdc10fe1 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/exception/DataHubDataFetcherExceptionHandler.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/exception/DataHubDataFetcherExceptionHandler.java @@ -12,6 +12,8 @@ @Slf4j public class DataHubDataFetcherExceptionHandler implements DataFetcherExceptionHandler { + private static final String DEFAULT_ERROR_MESSAGE = "An unknown error occurred."; + @Override public DataFetcherExceptionHandlerResult onException( DataFetcherExceptionHandlerParameters handlerParameters) { @@ -19,28 +21,40 @@ public DataFetcherExceptionHandlerResult onException( SourceLocation sourceLocation = handlerParameters.getSourceLocation(); ResultPath path = handlerParameters.getPath(); - log.error("Failed to execute DataFetcher", exception); - DataHubGraphQLErrorCode errorCode = DataHubGraphQLErrorCode.SERVER_ERROR; - String message = "An unknown error occurred."; + String message = DEFAULT_ERROR_MESSAGE; - // note: make sure to access the true error message via `getCause()` - if (exception.getCause() instanceof IllegalArgumentException) { + IllegalArgumentException illException = + findFirstThrowableCauseOfClass(exception, IllegalArgumentException.class); + if (illException != null) { + log.error("Failed to execute", illException); errorCode = DataHubGraphQLErrorCode.BAD_REQUEST; - message = exception.getCause().getMessage(); + message = illException.getMessage(); } - if (exception instanceof DataHubGraphQLException) { - errorCode = ((DataHubGraphQLException) exception).errorCode(); - message = exception.getMessage(); + DataHubGraphQLException graphQLException = + findFirstThrowableCauseOfClass(exception, DataHubGraphQLException.class); + if (graphQLException != null) { + log.error("Failed to execute", graphQLException); + errorCode = graphQLException.errorCode(); + message = graphQLException.getMessage(); } - if (exception.getCause() instanceof DataHubGraphQLException) { - errorCode = ((DataHubGraphQLException) exception.getCause()).errorCode(); - message = exception.getCause().getMessage(); + if (illException == null && graphQLException == null) { + log.error("Failed to execute", exception); } - DataHubGraphQLError error = new DataHubGraphQLError(message, path, sourceLocation, errorCode); return DataFetcherExceptionHandlerResult.newResult().error(error).build(); } + + T findFirstThrowableCauseOfClass(Throwable throwable, Class clazz) { + while (throwable != null) { + if (clazz.isInstance(throwable)) { + return (T) throwable; + } else { + throwable = throwable.getCause(); + } + } + return null; + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/CreateDataProductResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/CreateDataProductResolver.java index 10c487a839f358..8ac7b2c3ce3754 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/CreateDataProductResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/CreateDataProductResolver.java @@ -47,6 +47,7 @@ public CompletableFuture get(final DataFetchingEnvironment environm try { final Urn dataProductUrn = _dataProductService.createDataProduct( + input.getId(), input.getProperties().getName(), input.getProperties().getDescription(), authentication); diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index feb344154d11e4..307c7f7b383e30 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -11055,6 +11055,10 @@ input CreateDataProductInput { The primary key of the Domain """ domainUrn: String! + """ + An optional id for the new data product + """ + id: String } """ diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx index 2d82521a90df58..0610fbfa7a7704 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/CreateDataProductModal.tsx @@ -32,6 +32,7 @@ export default function CreateDataProductModal({ domain, onCreateDataProduct, on variables: { input: { domainUrn: domain.urn, + id: builderState.id, properties: { name: builderState.name, description: builderState.description || undefined, @@ -49,10 +50,10 @@ export default function CreateDataProductModal({ domain, onCreateDataProduct, on onClose(); } }) - .catch(() => { + .catch(( error ) => { onClose(); message.destroy(); - message.error({ content: 'Failed to create Data Product. An unexpected error occurred' }); + message.error({ content: `Failed to create Data Product: ${error.message}.` }); }); } diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx new file mode 100644 index 00000000000000..a077a0308af1ff --- /dev/null +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductAdvancedOption.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Collapse, Form, Input, Typography } from "antd"; +import styled from "styled-components"; +import { validateCustomUrnId } from '../../../shared/textUtil'; +import { DataProductBuilderFormProps } from "./types"; + + +const FormItem = styled(Form.Item)` + .ant-form-item-label { + padding-bottom: 2px; + } +`; + +const FormItemWithMargin = styled(FormItem)` + margin-bottom: 16px; +`; + +const FormItemNoMargin = styled(FormItem)` + margin-bottom: 0; +`; + +const AdvancedLabel = styled(Typography.Text)` + color: #373d44; +`; + +export function DataProductAdvancedOption({builderState, updateBuilderState }: DataProductBuilderFormProps){ + + function updateDataProductId(id: string) { + updateBuilderState({ + ...builderState, + id, + }); + } + + return ( + + Advanced Options} key="1"> + Data Product Id} + help="By default, a random UUID will be generated to uniquely identify this data product. If + you'd like to provide a custom id instead to more easily keep track of this data product, + you may provide it here. Be careful, you cannot easily change the data product id after + creation." + > + ({ + validator(_, value) { + if (value && validateCustomUrnId(value)) { + return Promise.resolve(); + } + return Promise.reject(new Error('Please enter a valid Data product id')); + }, + }), + ]} + > + updateDataProductId(e.target.value)} + /> + + + + + ) +} \ No newline at end of file diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx index b5a27a6e1b8766..98bb09098a36ea 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/DataProductBuilderForm.tsx @@ -3,18 +3,14 @@ import React from 'react'; import styled from 'styled-components'; import { Editor as MarkdownEditor } from '../../shared/tabs/Documentation/components/editor/Editor'; import { ANTD_GRAY } from '../../shared/constants'; -import { DataProductBuilderState } from './types'; +import { DataProductBuilderFormProps } from './types'; +import { DataProductAdvancedOption } from './DataProductAdvancedOption'; const StyledEditor = styled(MarkdownEditor)` border: 1px solid ${ANTD_GRAY[4]}; `; -type Props = { - builderState: DataProductBuilderState; - updateBuilderState: (newState: DataProductBuilderState) => void; -}; - -export default function DataProductBuilderForm({ builderState, updateBuilderState }: Props) { +export default function DataProductBuilderForm({ builderState, updateBuilderState }: DataProductBuilderFormProps) { function updateName(name: string) { updateBuilderState({ ...builderState, @@ -47,6 +43,7 @@ export default function DataProductBuilderForm({ builderState, updateBuilderStat Description}> + ); } diff --git a/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts b/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts index 1ed3ede39cfbe4..fe22e3ed9a2a4b 100644 --- a/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts +++ b/datahub-web-react/src/app/entity/domain/DataProductsTab/types.ts @@ -1,4 +1,10 @@ export type DataProductBuilderState = { name: string; + id?: string; description?: string; }; + +export type DataProductBuilderFormProps = { + builderState: DataProductBuilderState; + updateBuilderState: (newState: DataProductBuilderState) => void; +}; \ No newline at end of file diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/DataProductService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/DataProductService.java index 10016ee89605b9..d60427a27a5c59 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/service/DataProductService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/DataProductService.java @@ -1,5 +1,7 @@ package com.linkedin.metadata.service; +import static com.linkedin.metadata.Constants.DATA_PRODUCT_ENTITY_NAME; + import com.datahub.authentication.Authentication; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -22,6 +24,7 @@ import com.linkedin.metadata.graph.GraphClient; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.utils.EntityKeyUtils; +import com.linkedin.r2.RemoteInvocationException; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -58,11 +61,26 @@ public DataProductService(@Nonnull EntityClient entityClient, @Nonnull GraphClie * @return the urn of the newly created DataProduct */ public Urn createDataProduct( - @Nullable String name, @Nullable String description, @Nonnull Authentication authentication) { + @Nullable String id, + @Nullable String name, + @Nullable String description, + @Nonnull Authentication authentication) { // 1. Generate a unique id for the new DataProduct. final DataProductKey key = new DataProductKey(); - key.setId(UUID.randomUUID().toString()); + if (id != null && !id.isBlank()) { + key.setId(id); + } else { + key.setId(UUID.randomUUID().toString()); + } + try { + if (_entityClient.exists( + EntityKeyUtils.convertEntityKeyToUrn(key, DATA_PRODUCT_ENTITY_NAME), authentication)) { + throw new IllegalArgumentException("This Data product already exists!"); + } + } catch (RemoteInvocationException e) { + throw new RuntimeException("Unable to check for existence of Data Product!"); + } // 2. Create a new instance of DataProductProperties final DataProductProperties properties = new DataProductProperties(); diff --git a/smoke-test/tests/privileges/test_privileges.py b/smoke-test/tests/privileges/test_privileges.py index aa54a50b04e7f8..75e2265f1f5551 100644 --- a/smoke-test/tests/privileges/test_privileges.py +++ b/smoke-test/tests/privileges/test_privileges.py @@ -63,7 +63,7 @@ def _ensure_cant_perform_action(session, json,assertion_key): action_response.raise_for_status() action_data = action_response.json() - assert action_data["errors"][0]["extensions"]["code"] == 403 + assert action_data["errors"][0]["extensions"]["code"] == 403, action_data["errors"][0] assert action_data["errors"][0]["extensions"]["type"] == "UNAUTHORIZED" assert action_data["data"][assertion_key] == None @@ -367,8 +367,9 @@ def test_privilege_to_create_and_manage_policies(): # Verify new user can't create a policy create_policy = { - "query": """mutation createPolicy($input: PolicyUpdateInput!) {\n - createPolicy(input: $input) }""", + "query": """mutation createPolicy($input: PolicyUpdateInput!) { + createPolicy(input: $input) + }""", "variables": { "input": { "type": "PLATFORM", From 5af799ee892a0a1f9655ff569c4da63ffa976e52 Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Wed, 13 Dec 2023 14:31:24 +0530 Subject: [PATCH 013/540] feat(ownership): add button to copy urn of an Ownership Type (#9452) --- .../entity/ownership/table/ActionsColumn.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx b/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx index 41e07520a0ece5..e08853ad150bfc 100644 --- a/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx +++ b/datahub-web-react/src/app/entity/ownership/table/ActionsColumn.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Dropdown, MenuProps, Popconfirm, Typography, message, notification } from 'antd'; -import { DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons'; +import { CopyOutlined, DeleteOutlined, EditOutlined, MoreOutlined } from '@ant-design/icons'; import styled from 'styled-components/macro'; import { OwnershipTypeEntity } from '../../../../types.generated'; import { useDeleteOwnershipTypeMutation } from '../../../../graphql/ownership.generated'; @@ -48,6 +48,10 @@ export const ActionsColumn = ({ ownershipType, setIsOpen, setOwnershipType, refe setOwnershipType(ownershipType); }; + const onCopy=() => { + navigator.clipboard.writeText(ownershipType.urn); + } + const [deleteOwnershipTypeMutation] = useDeleteOwnershipTypeMutation(); const onDelete = () => { @@ -106,6 +110,15 @@ export const ActionsColumn = ({ ownershipType, setIsOpen, setOwnershipType, refe ), }, + { + key: 'copy', + icon: ( + + + Copy Urn + + ), + }, ]; const onClick: MenuProps['onClick'] = (e) => { @@ -113,6 +126,9 @@ export const ActionsColumn = ({ ownershipType, setIsOpen, setOwnershipType, refe if (key === 'edit') { editOnClick(); } + else if( key === 'copy') { + onCopy(); + } }; const menuProps: MenuProps = { From a92230b32162dc26776210a3278eadaafaa6e08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B0=80=EC=9C=A4?= <60080153+KaYunKIM@users.noreply.github.com> Date: Thu, 14 Dec 2023 02:30:18 +0900 Subject: [PATCH 014/540] docs(ingest/tableau): add token to sink config in sample recipe (#9411) Co-authored-by: KaYunKIM Co-authored-by: Harshal Sheth --- metadata-ingestion/examples/recipes/tableau_to_datahub.dhub.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/metadata-ingestion/examples/recipes/tableau_to_datahub.dhub.yaml b/metadata-ingestion/examples/recipes/tableau_to_datahub.dhub.yaml index ed6567b5889df1..a9db27bb52a233 100644 --- a/metadata-ingestion/examples/recipes/tableau_to_datahub.dhub.yaml +++ b/metadata-ingestion/examples/recipes/tableau_to_datahub.dhub.yaml @@ -18,3 +18,4 @@ sink: type: "datahub-rest" config: server: "http://localhost:8080" + token: token_value # optional From 3cde9549a290d2560d9eebaa4fc5a3521266a841 Mon Sep 17 00:00:00 2001 From: allizex <150264485+allizex@users.noreply.github.com> Date: Wed, 13 Dec 2023 20:26:45 +0100 Subject: [PATCH 015/540] feat(glossary): add ability to clone glossary term(name and documentation) from term profile menu (#9445) Co-authored-by: Olga Dimova <38855943+olgadimova@users.noreply.github.com> --- .../glossaryTerm/GlossaryTermEntity.tsx | 7 +++- .../CreateGlossaryEntityModal.tsx | 34 ++++++++++++++++--- .../shared/EntityDropdown/EntityDropdown.tsx | 22 ++++++++++++ .../src/app/entity/shared/types.ts | 1 + 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx index 080ee5889aec92..a6f6d9b0e28671 100644 --- a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx +++ b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx @@ -65,7 +65,12 @@ export class GlossaryTermEntity implements Entity { useEntityQuery={useGetGlossaryTermQuery as any} headerActionItems={new Set([EntityActionItem.BATCH_ADD_GLOSSARY_TERM])} headerDropdownItems={ - new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.MOVE, EntityMenuItems.DELETE]) + new Set([ + EntityMenuItems.UPDATE_DEPRECATION, + EntityMenuItems.CLONE, + EntityMenuItems.MOVE, + EntityMenuItems.DELETE, + ]) } isNameEditable hideBrowseBar diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/CreateGlossaryEntityModal.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/CreateGlossaryEntityModal.tsx index 9788d36af2c65a..d60e86b0af8ca4 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/CreateGlossaryEntityModal.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/CreateGlossaryEntityModal.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components/macro'; import { EditOutlined } from '@ant-design/icons'; import { message, Button, Input, Modal, Typography, Form, Collapse } from 'antd'; import DOMPurify from 'dompurify'; +import { useHistory } from 'react-router'; import { useCreateGlossaryTermMutation, useCreateGlossaryNodeMutation, @@ -16,6 +17,7 @@ import DescriptionModal from '../components/legacy/DescriptionModal'; import { validateCustomUrnId } from '../../../shared/textUtil'; import { useGlossaryEntityData } from '../GlossaryEntityContext'; import { getGlossaryRootToUpdate, updateGlossarySidebar } from '../../../glossary/utils'; +import { getEntityPath } from '../containers/profile/utils'; const StyledItem = styled(Form.Item)` margin-bottom: 0; @@ -33,6 +35,7 @@ interface Props { entityType: EntityType; onClose: () => void; refetchData?: () => void; + isCloning?: boolean; } function CreateGlossaryEntityModal(props: Props) { @@ -43,15 +46,31 @@ function CreateGlossaryEntityModal(props: Props) { const entityRegistry = useEntityRegistry(); const [stagedId, setStagedId] = useState(undefined); const [stagedName, setStagedName] = useState(''); - const [selectedParentUrn, setSelectedParentUrn] = useState(entityData.urn); + const [selectedParentUrn, setSelectedParentUrn] = useState(props.isCloning ? '' : entityData.urn); const [documentation, setDocumentation] = useState(''); const [isDocumentationModalVisible, setIsDocumentationModalVisible] = useState(false); const [createButtonDisabled, setCreateButtonDisabled] = useState(true); const refetch = useRefetch(); + const history = useHistory(); const [createGlossaryTermMutation] = useCreateGlossaryTermMutation(); const [createGlossaryNodeMutation] = useCreateGlossaryNodeMutation(); + useEffect(() => { + if (props.isCloning && entityData.entityData) { + const { properties } = entityData.entityData; + + if (properties?.name) { + setStagedName(properties.name); + form.setFieldValue('name', properties.name); + } + + if (properties?.description) { + setDocumentation(properties.description); + } + } + }, [props.isCloning, entityData.entityData, form]); + function createGlossaryEntity() { const mutation = entityType === EntityType.GlossaryTerm ? createGlossaryTermMutation : createGlossaryNodeMutation; @@ -67,7 +86,7 @@ function CreateGlossaryEntityModal(props: Props) { }, }, }) - .then(() => { + .then((res) => { message.loading({ content: 'Updating...', duration: 2 }); setTimeout(() => { analytics.event({ @@ -82,12 +101,19 @@ function CreateGlossaryEntityModal(props: Props) { refetch(); if (isInGlossaryContext) { // either refresh this current glossary node or the root nodes or root terms - const nodeToUpdate = entityData?.urn || getGlossaryRootToUpdate(entityType); + const nodeToUpdate = selectedParentUrn || getGlossaryRootToUpdate(entityType); updateGlossarySidebar([nodeToUpdate], urnsToUpdate, setUrnsToUpdate); } if (refetchData) { refetchData(); } + if (props.isCloning) { + const redirectUrn = + entityType === EntityType.GlossaryTerm + ? res.data?.createGlossaryTerm + : res.data?.createGlossaryNode; + history.push(getEntityPath(entityType, redirectUrn, entityRegistry, false, false)); + } }, 2000); }) .catch((e) => { diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx index 5d4f9d9f875cfe..8d7f1cca9c1cbd 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx @@ -9,6 +9,7 @@ import { LinkOutlined, MoreOutlined, PlusOutlined, + CopyOutlined, } from '@ant-design/icons'; import { Redirect } from 'react-router'; import { EntityType } from '../../../../types.generated'; @@ -32,6 +33,7 @@ export enum EntityMenuItems { ADD_TERM_GROUP, DELETE, MOVE, + CLONE, } export const MenuIcon = styled(MoreOutlined)<{ fontSize?: number }>` @@ -107,6 +109,7 @@ function EntityDropdown(props: Props) { const [isCreateTermModalVisible, setIsCreateTermModalVisible] = useState(false); const [isCreateNodeModalVisible, setIsCreateNodeModalVisible] = useState(false); + const [isCloneEntityModalVisible, setIsCloneEntityModalVisible] = useState(false); const [isDeprecationModalVisible, setIsDeprecationModalVisible] = useState(false); const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); @@ -230,6 +233,17 @@ function EntityDropdown(props: Props) { )} + {menuItems.has(EntityMenuItems.CLONE) && ( + setIsCloneEntityModalVisible(true)} + > + +  Clone + + + )} } trigger={['click']} @@ -250,6 +264,14 @@ function EntityDropdown(props: Props) { refetchData={refetchForNodes} /> )} + {isCloneEntityModalVisible && ( + setIsCloneEntityModalVisible(false)} + refetchData={entityType === EntityType.GlossaryTerm ? refetchForTerms : refetchForNodes} + isCloning + /> + )} {isDeprecationModalVisible && ( ; properties?: Maybe<{ + name?: Maybe; description?: Maybe; qualifiedName?: Maybe; sourceUrl?: Maybe; From a495d652e0e08885ce35eb3110a27853c2c05071 Mon Sep 17 00:00:00 2001 From: skrydal Date: Wed, 13 Dec 2023 20:34:20 +0100 Subject: [PATCH 016/540] feat(ingestion): Add typeUrn handling to ownership transformers (#9370) --- .../docs/transformer/dataset_transformer.md | 32 +++++++------- .../src/datahub/emitter/mce_builder.py | 31 ++++++------- .../transformer/add_dataset_ownership.py | 34 +++++--------- .../tests/unit/test_pipeline.py | 5 ++- .../tests/unit/test_transform_dataset.py | 44 ++++++++++++++++++- 5 files changed, 86 insertions(+), 60 deletions(-) diff --git a/metadata-ingestion/docs/transformer/dataset_transformer.md b/metadata-ingestion/docs/transformer/dataset_transformer.md index d1a1555a3ca022..1c84a2759d23e6 100644 --- a/metadata-ingestion/docs/transformer/dataset_transformer.md +++ b/metadata-ingestion/docs/transformer/dataset_transformer.md @@ -55,12 +55,12 @@ transformers: ``` ## Simple Add Dataset ownership ### Config Details -| Field | Required | Type | Default | Description | -|-----------------------------|----------|--------------|---------------|------------------------------------------------------------------| -| `owner_urns` | ✅ | list[string] | | List of owner urns. | -| `ownership_type` | | string | `DATAOWNER` | ownership type of the owners. | -| `replace_existing` | | boolean | `false` | Whether to remove owners from entity sent by ingestion source. | -| `semantics` | | enum | `OVERWRITE` | Whether to OVERWRITE or PATCH the entity present on DataHub GMS. | +| Field | Required | Type | Default | Description | +|--------------------|----------|--------------|-------------|---------------------------------------------------------------------| +| `owner_urns` | ✅ | list[string] | | List of owner urns. | +| `ownership_type` | | string | "DATAOWNER" | ownership type of the owners (either as enum or ownership type urn) | +| `replace_existing` | | boolean | `false` | Whether to remove owners from entity sent by ingestion source. | +| `semantics` | | enum | `OVERWRITE` | Whether to OVERWRITE or PATCH the entity present on DataHub GMS. | For transformer behaviour on `replace_existing` and `semantics`, please refer section [Relationship Between replace_existing And semantics](#relationship-between-replace_existing-and-semantics). @@ -95,7 +95,7 @@ transformers: - "urn:li:corpuser:username1" - "urn:li:corpuser:username2" - "urn:li:corpGroup:groupname" - ownership_type: "PRODUCER" + ownership_type: "urn:li:ownershipType:__system__producer" ``` - Add owners, however overwrite the owners available for the dataset on DataHub GMS ```yaml @@ -107,7 +107,7 @@ transformers: - "urn:li:corpuser:username1" - "urn:li:corpuser:username2" - "urn:li:corpGroup:groupname" - ownership_type: "PRODUCER" + ownership_type: "urn:li:ownershipType:__system__producer" ``` - Add owners, however keep the owners available for the dataset on DataHub GMS ```yaml @@ -124,12 +124,12 @@ transformers: ## Pattern Add Dataset ownership ### Config Details -| Field | Required | Type | Default | Description | -|-----------------------------|--------- |-----------------------|------------------|-----------------------------------------------------------------------------------------| -| `owner_pattern` | ✅ | map[regx, list[urn]] | | entity urn with regular expression and list of owners urn apply to matching entity urn. | -| `ownership_type` | | string | `DATAOWNER` | ownership type of the owners. | -| `replace_existing` | | boolean | `false` | Whether to remove owners from entity sent by ingestion source. | -| `semantics` | | enum | `OVERWRITE` | Whether to OVERWRITE or PATCH the entity present on DataHub GMS. | +| Field | Required | Type | Default | Description | +|--------------------|----------|----------------------|-------------|-----------------------------------------------------------------------------------------| +| `owner_pattern` | ✅ | map[regx, list[urn]] | | entity urn with regular expression and list of owners urn apply to matching entity urn. | +| `ownership_type` | | string | "DATAOWNER" | ownership type of the owners (either as enum or ownership type urn) | +| `replace_existing` | | boolean | `false` | Whether to remove owners from entity sent by ingestion source. | +| `semantics` | | enum | `OVERWRITE` | Whether to OVERWRITE or PATCH the entity present on DataHub GMS. | let’s suppose we’d like to append a series of users who we know to own a different dataset from a data source but aren't detected during normal ingestion. To do so, we can use the `pattern_add_dataset_ownership` module that’s included in the ingestion framework. This will match the pattern to `urn` of the dataset and assign the respective owners. @@ -158,7 +158,7 @@ The config, which we’d append to our ingestion recipe YAML, would look like th rules: ".*example1.*": ["urn:li:corpuser:username1"] ".*example2.*": ["urn:li:corpuser:username2"] - ownership_type: "PRODUCER" + ownership_type: "urn:li:ownershipType:__system__producer" ``` - Add owner, however overwrite the owners available for the dataset on DataHub GMS ```yaml @@ -170,7 +170,7 @@ The config, which we’d append to our ingestion recipe YAML, would look like th rules: ".*example1.*": ["urn:li:corpuser:username1"] ".*example2.*": ["urn:li:corpuser:username2"] - ownership_type: "PRODUCER" + ownership_type: "urn:li:ownershipType:__system__producer" ``` - Add owner, however keep the owners available for the dataset on DataHub GMS ```yaml diff --git a/metadata-ingestion/src/datahub/emitter/mce_builder.py b/metadata-ingestion/src/datahub/emitter/mce_builder.py index 64c9ec1bb5704d..3b2c87ea25a314 100644 --- a/metadata-ingestion/src/datahub/emitter/mce_builder.py +++ b/metadata-ingestion/src/datahub/emitter/mce_builder.py @@ -9,12 +9,13 @@ from typing import ( TYPE_CHECKING, Any, + Iterable, List, Optional, + Tuple, Type, TypeVar, Union, - cast, get_type_hints, ) @@ -342,26 +343,20 @@ def make_ml_model_group_urn(platform: str, group_name: str, env: str) -> str: ) -def is_valid_ownership_type(ownership_type: Optional[str]) -> bool: - return ownership_type is not None and ownership_type in [ - OwnershipTypeClass.TECHNICAL_OWNER, - OwnershipTypeClass.BUSINESS_OWNER, - OwnershipTypeClass.DATA_STEWARD, - OwnershipTypeClass.NONE, - OwnershipTypeClass.DEVELOPER, - OwnershipTypeClass.DATAOWNER, - OwnershipTypeClass.DELEGATE, - OwnershipTypeClass.PRODUCER, - OwnershipTypeClass.CONSUMER, - OwnershipTypeClass.STAKEHOLDER, +def get_class_fields(_class: Type[object]) -> Iterable[str]: + return [ + f + for f in dir(_class) + if not callable(getattr(_class, f)) and not f.startswith("_") ] -def validate_ownership_type(ownership_type: Optional[str]) -> str: - if is_valid_ownership_type(ownership_type): - return cast(str, ownership_type) - else: - raise ValueError(f"Unexpected ownership type: {ownership_type}") +def validate_ownership_type(ownership_type: str) -> Tuple[str, Optional[str]]: + if ownership_type.startswith("urn:li:"): + return OwnershipTypeClass.CUSTOM, ownership_type + if ownership_type in get_class_fields(OwnershipTypeClass): + return ownership_type, None + raise ValueError(f"Unexpected ownership type: {ownership_type}") def make_lineage_mce( diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_ownership.py b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_ownership.py index 71cf6cfa7e92bf..73cb8e4d6739bd 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_ownership.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_ownership.py @@ -14,11 +14,8 @@ from datahub.ingestion.transformer.dataset_transformer import ( DatasetOwnershipTransformer, ) -from datahub.metadata.schema_classes import ( - OwnerClass, - OwnershipClass, - OwnershipTypeClass, -) +from datahub.metadata._schema_classes import OwnershipTypeClass +from datahub.metadata.schema_classes import OwnerClass, OwnershipClass class AddDatasetOwnershipConfig(TransformerSemanticsConfigModel): @@ -102,7 +99,7 @@ def transform_aspect( class DatasetOwnershipBaseConfig(TransformerSemanticsConfigModel): - ownership_type: Optional[str] = OwnershipTypeClass.DATAOWNER + ownership_type: str = OwnershipTypeClass.DATAOWNER class SimpleDatasetOwnershipConfig(DatasetOwnershipBaseConfig): @@ -114,11 +111,14 @@ class SimpleAddDatasetOwnership(AddDatasetOwnership): """Transformer that adds a specified set of owners to each dataset.""" def __init__(self, config: SimpleDatasetOwnershipConfig, ctx: PipelineContext): - ownership_type = builder.validate_ownership_type(config.ownership_type) + ownership_type, ownership_type_urn = builder.validate_ownership_type( + config.ownership_type + ) owners = [ OwnerClass( owner=owner, type=ownership_type, + typeUrn=ownership_type_urn, ) for owner in config.owner_urns ] @@ -147,29 +147,17 @@ class PatternDatasetOwnershipConfig(DatasetOwnershipBaseConfig): class PatternAddDatasetOwnership(AddDatasetOwnership): """Transformer that adds a specified set of owners to each dataset.""" - def getOwners( - self, - key: str, - owner_pattern: KeyValuePattern, - ownership_type: Optional[str] = None, - ) -> List[OwnerClass]: - owners = [ - OwnerClass( - owner=owner, - type=builder.validate_ownership_type(ownership_type), - ) - for owner in owner_pattern.value(key) - ] - return owners - def __init__(self, config: PatternDatasetOwnershipConfig, ctx: PipelineContext): - ownership_type = builder.validate_ownership_type(config.ownership_type) owner_pattern = config.owner_pattern + ownership_type, ownership_type_urn = builder.validate_ownership_type( + config.ownership_type + ) generic_config = AddDatasetOwnershipConfig( get_owners_to_add=lambda urn: [ OwnerClass( owner=owner, type=ownership_type, + typeUrn=ownership_type_urn, ) for owner in owner_pattern.value(urn) ], diff --git a/metadata-ingestion/tests/unit/test_pipeline.py b/metadata-ingestion/tests/unit/test_pipeline.py index 7ce78f0ab3e13a..0f3c984196a784 100644 --- a/metadata-ingestion/tests/unit/test_pipeline.py +++ b/metadata-ingestion/tests/unit/test_pipeline.py @@ -214,7 +214,10 @@ def test_run_including_registered_transformation(self): "transformers": [ { "type": "simple_add_dataset_ownership", - "config": {"owner_urns": ["urn:li:corpuser:foo"]}, + "config": { + "owner_urns": ["urn:li:corpuser:foo"], + "ownership_type": "urn:li:ownershipType:__system__technical_owner", + }, } ], "sink": {"type": "tests.test_helpers.sink_helpers.RecordingSink"}, diff --git a/metadata-ingestion/tests/unit/test_transform_dataset.py b/metadata-ingestion/tests/unit/test_transform_dataset.py index bc95451620d22f..8014df2f5c519d 100644 --- a/metadata-ingestion/tests/unit/test_transform_dataset.py +++ b/metadata-ingestion/tests/unit/test_transform_dataset.py @@ -234,7 +234,7 @@ def test_simple_dataset_ownership_transformation(mock_time): assert last_event.entityUrn == outputs[0].record.proposedSnapshot.urn assert all( [ - owner.type == models.OwnershipTypeClass.DATAOWNER + owner.type == models.OwnershipTypeClass.DATAOWNER and owner.typeUrn is None for owner in last_event.aspect.owners ] ) @@ -247,7 +247,7 @@ def test_simple_dataset_ownership_transformation(mock_time): assert len(second_ownership_aspect.owners) == 3 assert all( [ - owner.type == models.OwnershipTypeClass.DATAOWNER + owner.type == models.OwnershipTypeClass.DATAOWNER and owner.typeUrn is None for owner in second_ownership_aspect.owners ] ) @@ -293,6 +293,44 @@ def test_simple_dataset_ownership_with_type_transformation(mock_time): assert ownership_aspect.owners[0].type == models.OwnershipTypeClass.PRODUCER +def test_simple_dataset_ownership_with_type_urn_transformation(mock_time): + input = make_generic_dataset() + + transformer = SimpleAddDatasetOwnership.create( + { + "owner_urns": [ + builder.make_user_urn("person1"), + ], + "ownership_type": "urn:li:ownershipType:__system__technical_owner", + }, + PipelineContext(run_id="test"), + ) + + output = list( + transformer.transform( + [ + RecordEnvelope(input, metadata={}), + RecordEnvelope(EndOfStream(), metadata={}), + ] + ) + ) + + assert len(output) == 3 + + # original MCE is unchanged + assert input == output[0].record + + ownership_aspect = output[1].record.aspect + + assert isinstance(ownership_aspect, OwnershipClass) + assert len(ownership_aspect.owners) == 1 + assert ownership_aspect.owners[0].type == OwnershipTypeClass.CUSTOM + assert ( + ownership_aspect.owners[0].typeUrn + == "urn:li:ownershipType:__system__technical_owner" + ) + + def _test_extract_tags(in_urn: str, regex_str: str, out_tag: str) -> None: input = make_generic_dataset(entity_urn=in_urn) transformer = ExtractDatasetTags.create( @@ -883,6 +921,7 @@ def test_pattern_dataset_ownership_transformation(mock_time): ".*example2.*": [builder.make_user_urn("person2")], } }, + "ownership_type": "DATAOWNER", }, PipelineContext(run_id="test"), ) @@ -2233,6 +2272,7 @@ def fake_ownership_class(entity_urn: str) -> models.OwnershipClass: "replace_existing": False, "semantics": TransformerSemantics.PATCH, "owner_urns": [owner2], + "ownership_type": "DATAOWNER", }, pipeline_context=pipeline_context, ) From 32d237b56f54c83bd7b8d343b04d36f53ae72d0a Mon Sep 17 00:00:00 2001 From: Arun Vasudevan <12974850+arunvasudevan@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:02:21 -0600 Subject: [PATCH 017/540] fix(ingest): reduce GraphQL Logs to warning for circuit breaker (#9436) --- .../src/datahub/api/circuit_breaker/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/metadata-ingestion/src/datahub/api/circuit_breaker/__init__.py b/metadata-ingestion/src/datahub/api/circuit_breaker/__init__.py index 4dcf40454736b9..27317826264b85 100644 --- a/metadata-ingestion/src/datahub/api/circuit_breaker/__init__.py +++ b/metadata-ingestion/src/datahub/api/circuit_breaker/__init__.py @@ -1,3 +1,7 @@ +import logging + +from gql.transport.requests import log as requests_logger + from datahub.api.circuit_breaker.assertion_circuit_breaker import ( AssertionCircuitBreaker, AssertionCircuitBreakerConfig, @@ -6,3 +10,5 @@ OperationCircuitBreaker, OperationCircuitBreakerConfig, ) + +requests_logger.setLevel(logging.WARNING) From 288e458739ec15e0d294ed5c0eb54963fee01071 Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Thu, 14 Dec 2023 06:19:05 +0530 Subject: [PATCH 018/540] refactor(ui): support Apollo caching for settings / Policies (#9442) --- .../app/permissions/policy/ManagePolicies.tsx | 194 ++------------- .../policy/_tests_/policyUtils.test.tsx | 110 +++++++++ .../src/app/permissions/policy/policyUtils.ts | 98 ++++++++ .../src/app/permissions/policy/usePolicy.ts | 227 ++++++++++++++++++ 4 files changed, 460 insertions(+), 169 deletions(-) create mode 100644 datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx create mode 100644 datahub-web-react/src/app/permissions/policy/usePolicy.ts diff --git a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx index 2f0c284fc4e8f3..72c22f3bddc2cd 100644 --- a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx +++ b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Button, Empty, message, Modal, Pagination, Tag } from 'antd'; +import { Button, Empty, message, Pagination, Tag } from 'antd'; import styled from 'styled-components/macro'; import * as QueryString from 'query-string'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; @@ -7,26 +7,15 @@ import { useLocation } from 'react-router'; import PolicyBuilderModal from './PolicyBuilderModal'; import { Policy, - PolicyUpdateInput, PolicyState, - PolicyType, - Maybe, - ResourceFilterInput, - PolicyMatchFilter, - PolicyMatchFilterInput, - PolicyMatchCriterionInput, - EntityType, } from '../../../types.generated'; import { useAppConfig } from '../../useAppConfig'; import PolicyDetailsModal from './PolicyDetailsModal'; import { - useCreatePolicyMutation, - useDeletePolicyMutation, useListPoliciesQuery, - useUpdatePolicyMutation, } from '../../../graphql/policy.generated'; import { Message } from '../../shared/Message'; -import { EMPTY_POLICY } from './policyUtils'; +import { DEFAULT_PAGE_SIZE, EMPTY_POLICY } from './policyUtils'; import TabToolbar from '../../entity/shared/components/styled/TabToolbar'; import { StyledTable } from '../../entity/shared/components/styled/StyledTable'; import AvatarsGroup from '../AvatarsGroup'; @@ -37,6 +26,7 @@ import { scrollToTop } from '../../shared/searchUtils'; import analytics, { EventType } from '../../analytics'; import { POLICIES_CREATE_POLICY_ID, POLICIES_INTRO_ID } from '../../onboarding/config/PoliciesOnboardingConfig'; import { OnboardingTour } from '../../onboarding/OnboardingTour'; +import { usePolicy } from './usePolicy'; const SourceContainer = styled.div` overflow: auto; @@ -84,58 +74,6 @@ const PageContainer = styled.span` overflow: auto; `; -const DEFAULT_PAGE_SIZE = 10; - -type PrivilegeOptionType = { - type?: string; - name?: Maybe; -}; - -const toFilterInput = (filter: PolicyMatchFilter): PolicyMatchFilterInput => { - return { - criteria: filter.criteria?.map((criterion): PolicyMatchCriterionInput => { - return { - field: criterion.field, - values: criterion.values.map((criterionValue) => criterionValue.value), - condition: criterion.condition, - }; - }), - }; -}; - -const toPolicyInput = (policy: Omit): PolicyUpdateInput => { - let policyInput: PolicyUpdateInput = { - type: policy.type, - name: policy.name, - state: policy.state, - description: policy.description, - privileges: policy.privileges, - actors: { - users: policy.actors.users, - groups: policy.actors.groups, - allUsers: policy.actors.allUsers, - allGroups: policy.actors.allGroups, - resourceOwners: policy.actors.resourceOwners, - resourceOwnersTypes: policy.actors.resourceOwnersTypes, - }, - }; - if (policy.resources !== null && policy.resources !== undefined) { - let resourceFilter: ResourceFilterInput = { - type: policy.resources.type, - resources: policy.resources.resources, - allResources: policy.resources.allResources, - }; - if (policy.resources.filter) { - resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter) }; - } - // Add the resource filters. - policyInput = { - ...policyInput, - resources: resourceFilter, - }; - } - return policyInput; -}; // TODO: Cleanup the styling. export const ManagePolicies = () => { @@ -163,9 +101,7 @@ export const ManagePolicies = () => { const [focusPolicyUrn, setFocusPolicyUrn] = useState(undefined); const [focusPolicy, setFocusPolicy] = useState>(EMPTY_POLICY); - // Construct privileges - const platformPrivileges = policiesConfig?.platformPrivileges || []; - const resourcePrivileges = policiesConfig?.resourcePrivileges || []; + const { loading: policiesLoading, @@ -183,15 +119,6 @@ export const ManagePolicies = () => { fetchPolicy: (query?.length || 0) > 0 ? 'no-cache' : 'cache-first', }); - // Any time a policy is removed, edited, or created, refetch the list. - const [createPolicy, { error: createPolicyError }] = useCreatePolicyMutation(); - - const [updatePolicy, { error: updatePolicyError }] = useUpdatePolicyMutation(); - - const [deletePolicy, { error: deletePolicyError }] = useDeletePolicyMutation(); - - const updateError = createPolicyError || updatePolicyError || deletePolicyError; - const totalPolicies = policiesData?.listPolicies?.total || 0; const policies = useMemo(() => policiesData?.listPolicies?.policies || [], [policiesData]); @@ -212,28 +139,6 @@ export const ManagePolicies = () => { setShowPolicyBuilderModal(false); }; - const getPrivilegeNames = (policy: Omit) => { - let privileges: PrivilegeOptionType[] = []; - if (policy?.type === PolicyType.Platform) { - privileges = platformPrivileges - .filter((platformPrivilege) => policy.privileges.includes(platformPrivilege.type)) - .map((platformPrivilege) => { - return { type: platformPrivilege.type, name: platformPrivilege.displayName }; - }); - } else { - const allResourcePriviliges = resourcePrivileges.find( - (resourcePrivilege) => resourcePrivilege.resourceType === 'all', - ); - privileges = - allResourcePriviliges?.privileges - .filter((resourcePrivilege) => policy.privileges.includes(resourcePrivilege.type)) - .map((b) => { - return { type: b.type, name: b.displayName }; - }) || []; - } - return privileges; - }; - const onViewPolicy = (policy: Policy) => { setShowViewPolicyModal(true); setFocusPolicyUrn(policy?.urn); @@ -247,79 +152,30 @@ export const ManagePolicies = () => { }; const onEditPolicy = (policy: Policy) => { - setShowPolicyBuilderModal(true); - setFocusPolicyUrn(policy?.urn); - setFocusPolicy({ ...policy }); - }; - - // On Delete Policy handler - const onRemovePolicy = (policy: Policy) => { - Modal.confirm({ - title: `Delete ${policy?.name}`, - content: `Are you sure you want to remove policy?`, - onOk() { - deletePolicy({ variables: { urn: policy?.urn as string } }); // There must be a focus policy urn. - analytics.event({ - type: EventType.DeleteEntityEvent, - entityUrn: policy?.urn, - entityType: EntityType.DatahubPolicy, - }); - message.success('Successfully removed policy.'); - setTimeout(() => { - policiesRefetch(); - }, 3000); - onCancelViewPolicy(); - }, - onCancel() {}, - okText: 'Yes', - maskClosable: true, - closable: true, - }); + setShowPolicyBuilderModal(true); + setFocusPolicyUrn(policy?.urn); + setFocusPolicy({ ...policy }); }; - // On Activate and deactivate Policy handler - const onToggleActiveDuplicate = (policy: Policy) => { - const newState = policy?.state === PolicyState.Active ? PolicyState.Inactive : PolicyState.Active; - const newPolicy = { - ...policy, - state: newState, - }; - updatePolicy({ - variables: { - urn: policy?.urn as string, // There must be a focus policy urn. - input: toPolicyInput(newPolicy), - }, - }); - message.success(`Successfully ${newState === PolicyState.Active ? 'activated' : 'deactivated'} policy.`); - setTimeout(() => { - policiesRefetch(); - }, 3000); - setShowViewPolicyModal(false); - }; - - // On Add/Update Policy handler - const onSavePolicy = (savePolicy: Omit) => { - if (focusPolicyUrn) { - // If there's an URN associated with the focused policy, then we are editing an existing policy. - updatePolicy({ variables: { urn: focusPolicyUrn, input: toPolicyInput(savePolicy) } }); - analytics.event({ - type: EventType.UpdatePolicyEvent, - policyUrn: focusPolicyUrn, - }); - } else { - // If there's no URN associated with the focused policy, then we are creating. - createPolicy({ variables: { input: toPolicyInput(savePolicy) } }); - analytics.event({ - type: EventType.CreatePolicyEvent, - }); - } - message.success('Successfully saved policy.'); - setTimeout(() => { - policiesRefetch(); - }, 3000); - onClosePolicyBuilder(); - }; + const { + createPolicyError, + updatePolicyError, + deletePolicyError, + onSavePolicy, + onToggleActiveDuplicate, + onRemovePolicy, + getPrivilegeNames + } = usePolicy( + policiesConfig, + focusPolicyUrn, + policiesRefetch, + setShowViewPolicyModal, + onCancelViewPolicy, + onClosePolicyBuilder + ); + const updateError = createPolicyError || updatePolicyError || deletePolicyError; + const tableColumns = [ { title: 'Name', diff --git a/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx b/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx new file mode 100644 index 00000000000000..06d2e97255139e --- /dev/null +++ b/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx @@ -0,0 +1,110 @@ +import { + addOrUpdatePoliciesInList, + updateListPoliciesCache, + removeFromListPoliciesCache, + } from '../policyUtils'; + + // Mock the Apollo Client readQuery and writeQuery methods + const mockReadQuery = jest.fn(); + const mockWriteQuery = jest.fn(); + + jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useApolloClient: () => ({ + readQuery: mockReadQuery, + writeQuery: mockWriteQuery, + }), + })); + + describe('addOrUpdatePoliciesInList', () => { + it('should add a new policy to the list', () => { + const existingPolicies = [{ urn: 'existing-urn' }]; + const newPolicies = { urn: 'new-urn' }; + + const result = addOrUpdatePoliciesInList(existingPolicies, newPolicies); + + expect(result.length).toBe(existingPolicies.length + 1); + expect(result).toContain(newPolicies); + }); + + it('should update an existing policy in the list', () => { + const existingPolicies = [{ urn: 'existing-urn' }]; + const newPolicies = { urn: 'existing-urn', updatedField: 'new-value' }; + + const result = addOrUpdatePoliciesInList(existingPolicies, newPolicies); + + expect(result.length).toBe(existingPolicies.length); + expect(result).toContainEqual(newPolicies); + }); + }); + + describe('updateListPoliciesCache', () => { + // Mock client.readQuery response + const mockReadQueryResponse = { + listPolicies: { + start: 0, + count: 1, + total: 1, + policies: [{ urn: 'existing-urn' }], + }, + }; + + beforeEach(() => { + mockReadQuery.mockReturnValueOnce(mockReadQueryResponse); + }); + + it('should update the list policies cache with a new policy', () => { + const mockClient = { + readQuery: mockReadQuery, + writeQuery: mockWriteQuery, + }; + + const policiesToAdd = [{ urn: 'new-urn' }]; + const pageSize = 10; + + updateListPoliciesCache(mockClient, policiesToAdd, pageSize); + + // Ensure writeQuery is called with the expected data + expect(mockWriteQuery).toHaveBeenCalledWith({ + query: expect.any(Object), + variables: { input: { start: 0, count: pageSize, query: undefined } }, + data: expect.any(Object), + }); + }); + }); + + describe('removeFromListPoliciesCache', () => { + // Mock client.readQuery response + const mockReadQueryResponse = { + listPolicies: { + start: 0, + count: 1, + total: 1, + policies: [{ urn: 'existing-urn' }], + }, + }; + + beforeEach(() => { + mockReadQuery.mockReturnValueOnce(mockReadQueryResponse); + }); + + it('should remove a policy from the list policies cache', () => { + const mockClient = { + readQuery: mockReadQuery, + writeQuery: mockWriteQuery, + }; + + const urnToRemove = 'existing-urn'; + const pageSize = 10; + + removeFromListPoliciesCache(mockClient, urnToRemove, pageSize); + + // Ensure writeQuery is called with the expected data + expect(mockWriteQuery).toHaveBeenCalledWith({ + query: expect.any(Object), + variables: { input: { start: 0, count: pageSize } }, + data: expect.any(Object), + }); + }); + }); + \ No newline at end of file diff --git a/datahub-web-react/src/app/permissions/policy/policyUtils.ts b/datahub-web-react/src/app/permissions/policy/policyUtils.ts index 2f178fcdeb5c34..27aa8fcd351e9b 100644 --- a/datahub-web-react/src/app/permissions/policy/policyUtils.ts +++ b/datahub-web-react/src/app/permissions/policy/policyUtils.ts @@ -10,6 +10,9 @@ import { ResourceFilter, ResourcePrivileges, } from '../../../types.generated'; +import { ListPoliciesDocument, ListPoliciesQuery } from '../../../graphql/policy.generated'; + +export const DEFAULT_PAGE_SIZE = 10; export const EMPTY_POLICY = { type: PolicyType.Metadata, @@ -126,3 +129,98 @@ export const setFieldValues = ( } return { ...filter, criteria: [...restCriteria, createCriterion(resourceFieldType, fieldValues)] }; }; + +export const addOrUpdatePoliciesInList = (existingPolicies, newPolicies) => { + const policies = [...existingPolicies]; + let didUpdate = false; + const updatedPolicies = policies.map((policy) => { + if (policy.urn === newPolicies.urn) { + didUpdate = true; + return newPolicies; + } + return policy; + }); + return didUpdate ? updatedPolicies : [newPolicies, ...existingPolicies]; +}; + +/** + * Add an entry to the ListPolicies cache. + */ +export const updateListPoliciesCache = (client, policies, pageSize) => { + // Read the data from our cache for this query. + const currData: ListPoliciesQuery | null = client.readQuery({ + query: ListPoliciesDocument, + variables: { + input: { + start: 0, + count: pageSize, + query: undefined, + }, + }, + }); + + // Add our new policy into the existing list. + const existingPolicies = [...(currData?.listPolicies?.policies || [])]; + const newPolicies = addOrUpdatePoliciesInList(existingPolicies, policies); + const didAddTest = newPolicies.length > existingPolicies.length; + + // Write our data back to the cache. + client.writeQuery({ + query: ListPoliciesDocument, + variables: { + input: { + start: 0, + count: pageSize, + query: undefined, + }, + }, + data: { + + listPolicies: { + __typename: 'ListPoliciesResult', + start: 0, + count: didAddTest ? (currData?.listPolicies?.count || 0) + 1 : currData?.listPolicies?.count, + total: didAddTest ? (currData?.listPolicies?.total || 0) + 1 : currData?.listPolicies?.total, + policies: newPolicies, + }, + }, + }); +}; + +/** + * Remove an entry from the ListTests cache. + */ +export const removeFromListPoliciesCache = (client, urn, pageSize) => { + // Read the data from our cache for this query. + const currData: ListPoliciesQuery | null = client.readQuery({ + query: ListPoliciesDocument, + variables: { + input: { + start: 0, + count: pageSize, + }, + }, + }); + + // Remove the policy from the existing tests set. + const newPolicies = [...(currData?.listPolicies?.policies || []).filter((policy) => policy.urn !== urn)]; + + // Write our data back to the cache. + client.writeQuery({ + query: ListPoliciesDocument, + variables: { + input: { + start: 0, + count: pageSize, + }, + }, + data: { + listPolicies: { + start: currData?.listPolicies?.start || 0, + count: (currData?.listPolicies?.count || 1) - 1, + total: (currData?.listPolicies?.total || 1) - 1, + policies: newPolicies, + }, + }, + }); +}; diff --git a/datahub-web-react/src/app/permissions/policy/usePolicy.ts b/datahub-web-react/src/app/permissions/policy/usePolicy.ts new file mode 100644 index 00000000000000..6f359805e42db1 --- /dev/null +++ b/datahub-web-react/src/app/permissions/policy/usePolicy.ts @@ -0,0 +1,227 @@ +import { Modal, message } from 'antd'; +import { useApolloClient } from '@apollo/client'; +import { + EntityType, + Policy, + PolicyMatchCriterionInput, + PolicyMatchFilter, + PolicyMatchFilterInput, + PolicyState, + PolicyType, + Maybe, + PolicyUpdateInput, + ResourceFilterInput, +} from '../../../types.generated'; +import { useCreatePolicyMutation, useDeletePolicyMutation, useUpdatePolicyMutation } from '../../../graphql/policy.generated'; +import analytics, { EventType } from '../../analytics'; +import { DEFAULT_PAGE_SIZE, removeFromListPoliciesCache, updateListPoliciesCache } from './policyUtils'; + + +type PrivilegeOptionType = { + type?: string; + name?: Maybe; +}; + +export function usePolicy( + policiesConfig, + focusPolicyUrn, + policiesRefetch, + setShowViewPolicyModal, + onCancelViewPolicy, + onClosePolicyBuilder +){ + + const client = useApolloClient(); + + // Construct privileges + const platformPrivileges = policiesConfig?.platformPrivileges || []; + const resourcePrivileges = policiesConfig?.resourcePrivileges || []; + + // Any time a policy is removed, edited, or created, refetch the list. + const [createPolicy, { error: createPolicyError }] = useCreatePolicyMutation(); + + const [updatePolicy, { error: updatePolicyError }] = useUpdatePolicyMutation(); + + const [deletePolicy, { error: deletePolicyError }] = useDeletePolicyMutation(); + + const toFilterInput = (filter: PolicyMatchFilter): PolicyMatchFilterInput => { + return { + criteria: filter.criteria?.map((criterion): PolicyMatchCriterionInput => { + return { + field: criterion.field, + values: criterion.values.map((criterionValue) => criterionValue.value), + condition: criterion.condition, + }; + }), + }; + }; + + const toPolicyInput = (policy: Omit): PolicyUpdateInput => { + let policyInput: PolicyUpdateInput = { + type: policy.type, + name: policy.name, + state: policy.state, + description: policy.description, + privileges: policy.privileges, + actors: { + users: policy.actors.users, + groups: policy.actors.groups, + allUsers: policy.actors.allUsers, + allGroups: policy.actors.allGroups, + resourceOwners: policy.actors.resourceOwners, + resourceOwnersTypes: policy.actors.resourceOwnersTypes, + }, + }; + if (policy.resources !== null && policy.resources !== undefined) { + let resourceFilter: ResourceFilterInput = { + type: policy.resources.type, + resources: policy.resources.resources, + allResources: policy.resources.allResources, + }; + if (policy.resources.filter) { + resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter) }; + } + // Add the resource filters. + policyInput = { + ...policyInput, + resources: resourceFilter, + }; + } + return policyInput; + }; + + const getPrivilegeNames = (policy: Omit) => { + let privileges: PrivilegeOptionType[] = []; + if (policy?.type === PolicyType.Platform) { + privileges = platformPrivileges + .filter((platformPrivilege) => policy.privileges.includes(platformPrivilege.type)) + .map((platformPrivilege) => { + return { type: platformPrivilege.type, name: platformPrivilege.displayName }; + }); + } else { + const allResourcePriviliges = resourcePrivileges.find( + (resourcePrivilege) => resourcePrivilege.resourceType === 'all', + ); + privileges = + allResourcePriviliges?.privileges + .filter((resourcePrivilege) => policy.privileges.includes(resourcePrivilege.type)) + .map((b) => { + return { type: b.type, name: b.displayName }; + }) || []; + } + return privileges; + }; + + // On Delete Policy handler + const onRemovePolicy = (policy: Policy) => { + Modal.confirm({ + title: `Delete ${policy?.name}`, + content: `Are you sure you want to remove policy?`, + onOk() { + deletePolicy({ variables: { urn: policy?.urn as string } }) + .then(()=>{ + // There must be a focus policy urn. + analytics.event({ + type: EventType.DeleteEntityEvent, + entityUrn: policy?.urn, + entityType: EntityType.DatahubPolicy, + }); + message.success('Successfully removed policy.'); + removeFromListPoliciesCache(client,policy?.urn, DEFAULT_PAGE_SIZE); + setTimeout(() => { + policiesRefetch(); + }, 3000); + onCancelViewPolicy(); + }) + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + }; + + // On Activate and deactivate Policy handler + const onToggleActiveDuplicate = (policy: Policy) => { + const newState = policy?.state === PolicyState.Active ? PolicyState.Inactive : PolicyState.Active; + const newPolicy = { + ...policy, + state: newState, + }; + updatePolicy({ + variables: { + urn: policy?.urn as string, // There must be a focus policy urn. + input: toPolicyInput(newPolicy), + }, + }).then(()=>{ + const updatePolicies= { + ...newPolicy, + __typename: 'ListPoliciesResult', + } + updateListPoliciesCache(client,updatePolicies,DEFAULT_PAGE_SIZE); + message.success(`Successfully ${newState === PolicyState.Active ? 'activated' : 'deactivated'} policy.`); + setTimeout(() => { + policiesRefetch(); + }, 3000); + }) + + setShowViewPolicyModal(false); + }; + + // On Add/Update Policy handler + const onSavePolicy = (savePolicy: Omit) => { + if (focusPolicyUrn) { + // If there's an URN associated with the focused policy, then we are editing an existing policy. + updatePolicy({ variables: { urn: focusPolicyUrn, input: toPolicyInput(savePolicy) } }) + .then(()=>{ + const newPolicy = { + __typename: 'ListPoliciesResult', + urn: focusPolicyUrn, + ...savePolicy, + }; + analytics.event({ + type: EventType.UpdatePolicyEvent, + policyUrn: focusPolicyUrn, + }); + message.success('Successfully saved policy.'); + updateListPoliciesCache(client,newPolicy,DEFAULT_PAGE_SIZE); + setTimeout(() => { + policiesRefetch(); + }, 1000); + onClosePolicyBuilder(); + }) + } else { + // If there's no URN associated with the focused policy, then we are creating. + createPolicy({ variables: { input: toPolicyInput(savePolicy) } }) + .then((result)=>{ + const newPolicy = { + __typename: 'ListPoliciesResult', + urn: result?.data?.createPolicy, + ...savePolicy, + type: null, + actors: null, + resources: null, + }; + analytics.event({ + type: EventType.CreatePolicyEvent, + }); + message.success('Successfully saved policy.'); + setTimeout(() => { + policiesRefetch(); + }, 1000); + updateListPoliciesCache(client,newPolicy,DEFAULT_PAGE_SIZE); + onClosePolicyBuilder(); + }) + } + }; + + return{ + createPolicyError, + updatePolicyError, + deletePolicyError, + onSavePolicy, + onToggleActiveDuplicate, + onRemovePolicy, + getPrivilegeNames, + } +} \ No newline at end of file From b87f9774ae646180675023196871f5965a5d97c3 Mon Sep 17 00:00:00 2001 From: Sumit Patil <91715217+sumitappt@users.noreply.github.com> Date: Thu, 14 Dec 2023 06:41:30 +0530 Subject: [PATCH 019/540] =?UTF-8?q?refactor=20|=20PRD-785=20|=20datahub=20?= =?UTF-8?q?oss:=20migrate=20use=20of=20useGetAuthenticatedU=E2=80=A6=20(#9?= =?UTF-8?q?456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: John Joyce --- datahub-web-react/src/app/AdminConsole.tsx | 8 ++++---- datahub-web-react/src/app/embed/EmbeddedPage.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/datahub-web-react/src/app/AdminConsole.tsx b/datahub-web-react/src/app/AdminConsole.tsx index 8b14ca35763d10..f6395a3bd3cb8a 100644 --- a/datahub-web-react/src/app/AdminConsole.tsx +++ b/datahub-web-react/src/app/AdminConsole.tsx @@ -4,9 +4,9 @@ import { Menu } from 'antd'; import styled from 'styled-components'; import { BankOutlined, BarChartOutlined, MenuOutlined } from '@ant-design/icons'; import Sider from 'antd/lib/layout/Sider'; -import { useGetAuthenticatedUser } from './useGetAuthenticatedUser'; import { useAppConfig } from './useAppConfig'; import { ANTD_GRAY } from './entity/shared/constants'; +import { useUserContext } from './context/useUserContext'; const ToggleContainer = styled.div` background-color: ${ANTD_GRAY[4]}; @@ -32,7 +32,7 @@ const ControlSlideOut = styled(Sider)` * Container for all views behind an authentication wall. */ export const AdminConsole = (): JSX.Element => { - const me = useGetAuthenticatedUser(); + const me = useUserContext(); const [adminConsoleOpen, setAdminConsoleOpen] = useState(false); const { config } = useAppConfig(); @@ -40,8 +40,8 @@ export const AdminConsole = (): JSX.Element => { const isAnalyticsEnabled = config?.analyticsConfig.enabled; const isPoliciesEnabled = config?.policiesConfig.enabled; - const showAnalytics = (isAnalyticsEnabled && me && me.platformPrivileges.viewAnalytics) || false; - const showPolicyBuilder = (isPoliciesEnabled && me && me.platformPrivileges.managePolicies) || false; + const showAnalytics = (isAnalyticsEnabled && me && me?.platformPrivileges?.viewAnalytics) || false; + const showPolicyBuilder = (isPoliciesEnabled && me && me?.platformPrivileges?.managePolicies) || false; const showAdminConsole = showAnalytics || showPolicyBuilder; const onMenuItemClick = () => { diff --git a/datahub-web-react/src/app/embed/EmbeddedPage.tsx b/datahub-web-react/src/app/embed/EmbeddedPage.tsx index 429f83f34af6e8..603a72675c4337 100644 --- a/datahub-web-react/src/app/embed/EmbeddedPage.tsx +++ b/datahub-web-react/src/app/embed/EmbeddedPage.tsx @@ -8,9 +8,9 @@ import { VIEW_ENTITY_PAGE } from '../entity/shared/constants'; import { decodeUrn } from '../entity/shared/utils'; import CompactContext from '../shared/CompactContext'; import { useEntityRegistry } from '../useEntityRegistry'; -import { useGetAuthenticatedUserUrn } from '../useGetAuthenticatedUser'; import analytics from '../analytics/analytics'; import { EventType } from '../analytics'; +import { useUserContext } from '../context/useUserContext'; const EmbeddedPageWrapper = styled.div` max-height: 100%; @@ -39,11 +39,11 @@ export default function EmbeddedPage({ entityType }: Props) { }); }, [entityType, urn]); - const authenticatedUserUrn = useGetAuthenticatedUserUrn(); + const { urn : authenticatedUserUrn } = useUserContext(); const { data } = useGetGrantedPrivilegesQuery({ variables: { input: { - actorUrn: authenticatedUserUrn, + actorUrn: authenticatedUserUrn as string, resourceSpec: { resourceType: entityType, resourceUrn: urn }, }, }, From ff0570edacdd967d8fef23ac3333ccc93e50e406 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Wed, 13 Dec 2023 17:12:48 -0800 Subject: [PATCH 020/540] refactor(ui): Minor improvements & refactoring (#9420) --- .../search/EmbeddedListSearchResults.tsx | 6 +- .../src/app/lineage/LineageLoadingSection.tsx | 5 +- datahub-web-react/src/graphql/domain.graphql | 4 +- datahub-web-react/src/graphql/lineage.graphql | 167 ++++++++++++------ datahub-web-react/src/graphql/query.graphql | 10 ++ .../com/linkedin/query/QueryProperties.pdl | 7 +- 6 files changed, 139 insertions(+), 60 deletions(-) diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx index 1daf2a4c59b70f..80fc2aa223fdf5 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pagination, Typography } from 'antd'; +import { Pagination, Spin, Typography } from 'antd'; import { LoadingOutlined } from '@ant-design/icons'; import styled from 'styled-components'; import { FacetFilterInput, FacetMetadata, SearchResults as SearchResultType } from '../../../../../../types.generated'; @@ -61,7 +61,7 @@ const LoadingContainer = styled.div` `; const StyledLoading = styled(LoadingOutlined)` - font-size: 36px; + font-size: 32px; color: ${ANTD_GRAY[7]}; padding-bottom: 18px; ]`; @@ -128,7 +128,7 @@ export const EmbeddedListSearchResults = ({ {loading && ( - + } /> )} {!loading && ( diff --git a/datahub-web-react/src/app/lineage/LineageLoadingSection.tsx b/datahub-web-react/src/app/lineage/LineageLoadingSection.tsx index 9d84de0c211729..3b7f0e48ecdf4c 100644 --- a/datahub-web-react/src/app/lineage/LineageLoadingSection.tsx +++ b/datahub-web-react/src/app/lineage/LineageLoadingSection.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import styled from 'styled-components'; +import { Spin } from 'antd'; import { LoadingOutlined } from '@ant-design/icons'; import { ANTD_GRAY } from '../entity/shared/constants'; @@ -13,7 +14,7 @@ const Container = styled.div` `; const StyledLoading = styled(LoadingOutlined)` - font-size: 36px; + font-size: 32px; color: ${ANTD_GRAY[7]}; padding-bottom: 18px; ]`; @@ -21,7 +22,7 @@ const StyledLoading = styled(LoadingOutlined)` export default function LineageLoadingSection() { return ( - + } /> ); } diff --git a/datahub-web-react/src/graphql/domain.graphql b/datahub-web-react/src/graphql/domain.graphql index 951b93fcba9af1..170a5b5df476ba 100644 --- a/datahub-web-react/src/graphql/domain.graphql +++ b/datahub-web-react/src/graphql/domain.graphql @@ -27,9 +27,7 @@ query getDomain($urn: String!) { } } } - children: relationships(input: { types: ["IsPartOf"], direction: INCOMING, start: 0, count: 0 }) { - total - } + ...domainEntitiesFields } } diff --git a/datahub-web-react/src/graphql/lineage.graphql b/datahub-web-react/src/graphql/lineage.graphql index dc511ca411e8db..4e9b8aacfcfa15 100644 --- a/datahub-web-react/src/graphql/lineage.graphql +++ b/datahub-web-react/src/graphql/lineage.graphql @@ -164,6 +164,9 @@ fragment lineageNodeProperties on EntityWithRelationships { domain { ...entityDomain } + parentContainers { + ...parentContainersFields + } ...entityDataProduct status { removed @@ -188,6 +191,9 @@ fragment lineageNodeProperties on EntityWithRelationships { ownership { ...ownershipFields } + parentContainers { + ...parentContainersFields + } subTypes { typeNames } @@ -361,6 +367,60 @@ fragment partialLineageResults on EntityLineageResult { filtered } +fragment entityLineage on Entity { + urn + type + ...lineageNodeProperties + ...canEditLineageFragment + ... on Dataset { + schemaMetadata(version: 0) @include(if: $showColumns) { + ...schemaMetadataFields + } + siblings { + isPrimary + siblings { + urn + type + ... on Dataset { + exists + } + ...lineageNodeProperties + } + } + } + ... on Chart { + inputFields @include(if: $showColumns) { + ...inputFieldsFields + } + } + ... on EntityWithRelationships { + upstream: lineage( + input: { + direction: UPSTREAM + start: 0 + count: 100 + separateSiblings: $separateSiblings + startTimeMillis: $startTimeMillis + endTimeMillis: $endTimeMillis + } + ) @skip(if: $excludeUpstream) { + ...fullLineageResults + } + downstream: lineage( + input: { + direction: DOWNSTREAM + start: 0 + count: 100 + separateSiblings: $separateSiblings + startTimeMillis: $startTimeMillis + endTimeMillis: $endTimeMillis + } + ) @skip(if: $excludeDownstream) { + ...fullLineageResults + } + } +} + query getEntityLineage( $urn: String! $separateSiblings: Boolean @@ -371,57 +431,21 @@ query getEntityLineage( $excludeDownstream: Boolean = false ) { entity(urn: $urn) { - urn - type - ...lineageNodeProperties - ...canEditLineageFragment - ... on Dataset { - schemaMetadata(version: 0) @include(if: $showColumns) { - ...schemaMetadataFields - } - siblings { - isPrimary - siblings { - urn - type - ... on Dataset { - exists - } - ...lineageNodeProperties - } - } - } - ... on Chart { - inputFields @include(if: $showColumns) { - ...inputFieldsFields - } - } - ... on EntityWithRelationships { - upstream: lineage( - input: { - direction: UPSTREAM - start: 0 - count: 100 - separateSiblings: $separateSiblings - startTimeMillis: $startTimeMillis - endTimeMillis: $endTimeMillis - } - ) @skip(if: $excludeUpstream) { - ...fullLineageResults - } - downstream: lineage( - input: { - direction: DOWNSTREAM - start: 0 - count: 100 - separateSiblings: $separateSiblings - startTimeMillis: $startTimeMillis - endTimeMillis: $endTimeMillis - } - ) @skip(if: $excludeDownstream) { - ...fullLineageResults - } - } + ...entityLineage + } +} + +query getBulkEntityLineage( + $urns: [String!]!, + $separateSiblings: Boolean + $showColumns: Boolean! + $startTimeMillis: Long + $endTimeMillis: Long + $excludeUpstream: Boolean = false + $excludeDownstream: Boolean = false +) { + entities(urns: $urns) { + ...entityLineage } } @@ -489,3 +513,44 @@ query getLineageCounts( } } } + +query getSearchAcrossLineageCounts( + $urn: String! + $excludeUpstream: Boolean = false + $excludeDownstream: Boolean = false +) { + upstreams: searchAcrossLineage( + input: { + urn: $urn + query: "*" + start: 0 + count: 10000 + filters: [{ field: "degree", value: "1", values: ["1"] }] + direction: UPSTREAM + } + ) @skip(if: $excludeUpstream) { + start + count + total + facets { + ...facetFields + } + } + downstreams: searchAcrossLineage( + input: { + urn: $urn + query: "*" + start: 0 + count: 10000 + filters: [{ field: "degree", value: "1", values: ["1"] }] + direction: DOWNSTREAM + } + ) @skip(if: $excludeDownstream) { + start + count + total + facets { + ...facetFields + } + } +} \ No newline at end of file diff --git a/datahub-web-react/src/graphql/query.graphql b/datahub-web-react/src/graphql/query.graphql index 84908b24f9ae7f..e24c12a4448b11 100644 --- a/datahub-web-react/src/graphql/query.graphql +++ b/datahub-web-react/src/graphql/query.graphql @@ -1,3 +1,13 @@ +query getQuery($urn: String!) { + entity(urn: $urn) { + urn + type + ... on QueryEntity { + ...query + } + } +} + fragment query on QueryEntity { urn properties { diff --git a/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl index 3ba19d348913bf..9587775dbed3a8 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl @@ -1,6 +1,7 @@ namespace com.linkedin.query import com.linkedin.common.AuditStamp +import com.linkedin.common.Urn /** * Information about a Query against one or more data assets (e.g. Tables or Views). @@ -22,7 +23,11 @@ record QueryProperties { /** * The query was entered manually by a user (via the UI). */ - MANUAL + MANUAL, + /** + * The query was discovered by a crawler. + */ + SYSTEM } /** From 70e64e80786a2112b3c77d790d9634ee17dd1d34 Mon Sep 17 00:00:00 2001 From: Seokyun Ha Date: Thu, 14 Dec 2023 18:02:37 +0900 Subject: [PATCH 021/540] feat(ingest): add ingest `--no-progress` option (#9300) --- docs/cli.md | 1 + metadata-ingestion/src/datahub/cli/ingest_cli.py | 10 ++++++++++ .../src/datahub/ingestion/run/pipeline.py | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 8845ed5a6dac78..cb5077db429061 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -98,6 +98,7 @@ Command Options: --preview-workunits The number of workunits to produce for preview --strict-warnings If enabled, ingestion runs with warnings will yield a non-zero error code --test-source-connection When set, ingestion will only test the source connection details from the recipe + --no-progress If enabled, mute intermediate progress ingestion reports ``` #### ingest --dry-run diff --git a/metadata-ingestion/src/datahub/cli/ingest_cli.py b/metadata-ingestion/src/datahub/cli/ingest_cli.py index b7827ec9f050b4..569a836f3ef5c2 100644 --- a/metadata-ingestion/src/datahub/cli/ingest_cli.py +++ b/metadata-ingestion/src/datahub/cli/ingest_cli.py @@ -97,6 +97,13 @@ def ingest() -> None: @click.option( "--no-spinner", type=bool, is_flag=True, default=False, help="Turn off spinner" ) +@click.option( + "--no-progress", + type=bool, + is_flag=True, + default=False, + help="If enabled, mute intermediate progress ingestion reports", +) @telemetry.with_telemetry( capture_kwargs=[ "dry_run", @@ -105,6 +112,7 @@ def ingest() -> None: "test_source_connection", "no_default_report", "no_spinner", + "no_progress", ] ) def run( @@ -117,6 +125,7 @@ def run( report_to: str, no_default_report: bool, no_spinner: bool, + no_progress: bool, ) -> None: """Ingest metadata into DataHub.""" @@ -170,6 +179,7 @@ async def run_ingestion_and_check_upgrade() -> int: preview_workunits, report_to, no_default_report, + no_progress, raw_pipeline_config, ) diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py index f2735c24ca19dc..25e17d692109a5 100644 --- a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py +++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py @@ -173,6 +173,7 @@ def __init__( preview_workunits: int = 10, report_to: Optional[str] = None, no_default_report: bool = False, + no_progress: bool = False, ): self.config = config self.dry_run = dry_run @@ -180,6 +181,7 @@ def __init__( self.preview_workunits = preview_workunits self.report_to = report_to self.reporters: List[PipelineRunListener] = [] + self.no_progress = no_progress self.num_intermediate_workunits = 0 self.last_time_printed = int(time.time()) self.cli_report = CliReport() @@ -330,6 +332,7 @@ def create( preview_workunits: int = 10, report_to: Optional[str] = "datahub", no_default_report: bool = False, + no_progress: bool = False, raw_config: Optional[dict] = None, ) -> "Pipeline": config = PipelineConfig.from_dict(config_dict, raw_config) @@ -340,6 +343,7 @@ def create( preview_workunits=preview_workunits, report_to=report_to, no_default_report=no_default_report, + no_progress=no_progress, ) def _time_to_print(self) -> bool: @@ -379,7 +383,7 @@ def run(self) -> None: self.preview_workunits if self.preview_mode else None, ): try: - if self._time_to_print(): + if self._time_to_print() and not self.no_progress: self.pretty_print_summary(currently_running=True) except Exception as e: logger.warning(f"Failed to print summary {e}") From b0de1dc0ce7a2de221a27f12dfecea9924380ab2 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Thu, 14 Dec 2023 18:41:50 +0530 Subject: [PATCH 022/540] fix(powerbi): add access token refresh (#9405) Co-authored-by: elish7lapid Co-authored-by: treff7es --- .../ingestion/source/powerbi/config.py | 1 + .../powerbi/rest_api_wrapper/data_resolver.py | 15 +- .../tests/integration/powerbi/test_powerbi.py | 235 +++++++++++++++--- 3 files changed, 212 insertions(+), 39 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/powerbi/config.py b/metadata-ingestion/src/datahub/ingestion/source/powerbi/config.py index f71afac737ca61..70786efff79a4d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/powerbi/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/powerbi/config.py @@ -95,6 +95,7 @@ class Constant: TITLE = "title" EMBED_URL = "embedUrl" ACCESS_TOKEN = "access_token" + ACCESS_TOKEN_EXPIRY = "expires_in" IS_READ_ONLY = "isReadOnly" WEB_URL = "webUrl" ODATA_COUNT = "@odata.count" diff --git a/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/data_resolver.py b/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/data_resolver.py index c6314c212d104d..3aeffa60bc28e0 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/data_resolver.py +++ b/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/data_resolver.py @@ -1,6 +1,7 @@ import logging import math from abc import ABC, abstractmethod +from datetime import datetime, timedelta from time import sleep from typing import Any, Dict, List, Optional @@ -59,6 +60,7 @@ def __init__( tenant_id: str, ): self.__access_token: Optional[str] = None + self.__access_token_expiry_time: Optional[datetime] = None self.__tenant_id = tenant_id # Test connection by generating access token logger.info("Trying to connect to {}".format(self._get_authority_url())) @@ -128,7 +130,7 @@ def get_authorization_header(self): return {Constant.Authorization: self.get_access_token()} def get_access_token(self): - if self.__access_token is not None: + if self.__access_token is not None and not self._is_access_token_expired(): return self.__access_token logger.info("Generating PowerBi access token") @@ -150,11 +152,22 @@ def get_access_token(self): self.__access_token = "Bearer {}".format( auth_response.get(Constant.ACCESS_TOKEN) ) + safety_gap = 300 + self.__access_token_expiry_time = datetime.now() + timedelta( + seconds=( + max(auth_response.get(Constant.ACCESS_TOKEN_EXPIRY, 0) - safety_gap, 0) + ) + ) logger.debug(f"{Constant.PBIAccessToken}={self.__access_token}") return self.__access_token + def _is_access_token_expired(self) -> bool: + if not self.__access_token_expiry_time: + return True + return self.__access_token_expiry_time < datetime.now() + def get_dashboards(self, workspace: Workspace) -> List[Dashboard]: """ Get the list of dashboard from PowerBi for the given workspace identifier diff --git a/metadata-ingestion/tests/integration/powerbi/test_powerbi.py b/metadata-ingestion/tests/integration/powerbi/test_powerbi.py index c9b0ded4337491..b2cbccf983eb0c 100644 --- a/metadata-ingestion/tests/integration/powerbi/test_powerbi.py +++ b/metadata-ingestion/tests/integration/powerbi/test_powerbi.py @@ -1,8 +1,10 @@ +import datetime import logging import re import sys from typing import Any, Dict, List, cast from unittest import mock +from unittest.mock import MagicMock import pytest from freezegun import freeze_time @@ -31,13 +33,23 @@ def enable_logging(): logging.getLogger().setLevel(logging.DEBUG) -def mock_msal_cca(*args, **kwargs): - class MsalClient: - def acquire_token_for_client(self, *args, **kwargs): - return { - "access_token": "dummy", - } +class MsalClient: + call_num = 0 + token: Dict[str, Any] = { + "access_token": "dummy", + } + + @staticmethod + def acquire_token_for_client(*args, **kwargs): + MsalClient.call_num += 1 + return MsalClient.token + + @staticmethod + def reset(): + MsalClient.call_num = 0 + +def mock_msal_cca(*args, **kwargs): return MsalClient() @@ -627,7 +639,13 @@ def default_source_config(): @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration -def test_powerbi_ingest(mock_msal, pytestconfig, tmp_path, mock_time, requests_mock): +def test_powerbi_ingest( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -658,7 +676,7 @@ def test_powerbi_ingest(mock_msal, pytestconfig, tmp_path, mock_time, requests_m mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_mces.json", + output_path=f"{tmp_path}/powerbi_mces.json", golden_path=f"{test_resources_dir}/{golden_file}", ) @@ -667,8 +685,12 @@ def test_powerbi_ingest(mock_msal, pytestconfig, tmp_path, mock_time, requests_m @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_powerbi_platform_instance_ingest( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -711,8 +733,12 @@ def test_powerbi_platform_instance_ingest( @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_powerbi_ingest_urn_lower_case( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" register_mock_api(request_mock=requests_mock) @@ -752,8 +778,12 @@ def test_powerbi_ingest_urn_lower_case( @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_override_ownership( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" register_mock_api(request_mock=requests_mock) @@ -783,7 +813,7 @@ def test_override_ownership( mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_mces_disabled_ownership.json", + output_path=f"{tmp_path}/powerbi_mces_disabled_ownership.json", golden_path=f"{test_resources_dir}/{mce_out_file}", ) @@ -792,8 +822,13 @@ def test_override_ownership( @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_scan_all_workspaces( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: + test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" register_mock_api(request_mock=requests_mock) @@ -828,7 +863,7 @@ def test_scan_all_workspaces( mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_mces_scan_all_workspaces.json", + output_path=f"{tmp_path}/powerbi_mces_scan_all_workspaces.json", golden_path=f"{test_resources_dir}/{golden_file}", ) @@ -836,7 +871,14 @@ def test_scan_all_workspaces( @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration -def test_extract_reports(mock_msal, pytestconfig, tmp_path, mock_time, requests_mock): +def test_extract_reports( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: + enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -868,7 +910,7 @@ def test_extract_reports(mock_msal, pytestconfig, tmp_path, mock_time, requests_ mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_report_mces.json", + output_path=f"{tmp_path}/powerbi_report_mces.json", golden_path=f"{test_resources_dir}/{golden_file}", ) @@ -876,7 +918,13 @@ def test_extract_reports(mock_msal, pytestconfig, tmp_path, mock_time, requests_ @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration -def test_extract_lineage(mock_msal, pytestconfig, tmp_path, mock_time, requests_mock): +def test_extract_lineage( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -925,8 +973,12 @@ def test_extract_lineage(mock_msal, pytestconfig, tmp_path, mock_time, requests_ @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_extract_endorsements( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" register_mock_api(request_mock=requests_mock) @@ -957,7 +1009,7 @@ def test_extract_endorsements( mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_endorsement_mces.json", + output_path=f"{tmp_path}/powerbi_endorsement_mces.json", golden_path=f"{test_resources_dir}/{mce_out_file}", ) @@ -966,8 +1018,12 @@ def test_extract_endorsements( @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_admin_access_is_not_allowed( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -1024,8 +1080,12 @@ def test_admin_access_is_not_allowed( @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) def test_workspace_container( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: enable_logging() test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -1062,11 +1122,92 @@ def test_workspace_container( mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_container_mces.json", + output_path=f"{tmp_path}/powerbi_container_mces.json", golden_path=f"{test_resources_dir}/{mce_out_file}", ) +@mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) +def test_access_token_expiry_with_long_expiry( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: + enable_logging() + + register_mock_api(request_mock=requests_mock) + + pipeline = Pipeline.create( + { + "run_id": "powerbi-test", + "source": { + "type": "powerbi", + "config": { + **default_source_config(), + }, + }, + "sink": { + "type": "file", + "config": { + "filename": f"{tmp_path}/powerbi_access_token_mces.json", + }, + }, + } + ) + + # for long expiry, the token should only be requested once. + MsalClient.token = { + "access_token": "dummy2", + "expires_in": 3600, + } + + MsalClient.reset() + pipeline.run() + # We expect the token to be requested twice (once for AdminApiResolver and one for RegularApiResolver) + assert MsalClient.call_num == 2 + + +@mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) +def test_access_token_expiry_with_short_expiry( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: + enable_logging() + + register_mock_api(request_mock=requests_mock) + + pipeline = Pipeline.create( + { + "run_id": "powerbi-test", + "source": { + "type": "powerbi", + "config": { + **default_source_config(), + }, + }, + "sink": { + "type": "file", + "config": { + "filename": f"{tmp_path}/powerbi_access_token_mces.json", + }, + }, + } + ) + + # for short expiry, the token should be requested when expires. + MsalClient.token = { + "access_token": "dummy", + "expires_in": 0, + } + pipeline.run() + assert MsalClient.call_num > 2 + + def dataset_type_mapping_set_to_all_platform(pipeline: Pipeline) -> None: source_config: PowerBiDashboardSourceConfig = cast( PowerBiDashboardSource, pipeline.source @@ -1306,8 +1447,12 @@ def validate_pipeline(pipeline: Pipeline) -> None: @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration def test_reports_with_failed_page_request( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: """ Test that all reports are fetched even if a single page request fails """ @@ -1419,8 +1564,12 @@ def test_reports_with_failed_page_request( @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) def test_independent_datasets_extraction( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -1503,14 +1652,20 @@ def test_independent_datasets_extraction( mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_independent_mces.json", + output_path=f"{tmp_path}/powerbi_independent_mces.json", golden_path=f"{test_resources_dir}/{golden_file}", ) @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) -def test_cll_extraction(mock_msal, pytestconfig, tmp_path, mock_time, requests_mock): +def test_cll_extraction( + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: test_resources_dir = pytestconfig.rootpath / "tests/integration/powerbi" @@ -1553,7 +1708,7 @@ def test_cll_extraction(mock_msal, pytestconfig, tmp_path, mock_time, requests_m mce_helpers.check_golden_file( pytestconfig, - output_path=tmp_path / "powerbi_cll_mces.json", + output_path=f"{tmp_path}/powerbi_cll_mces.json", golden_path=f"{test_resources_dir}/{golden_file}", ) @@ -1561,8 +1716,12 @@ def test_cll_extraction(mock_msal, pytestconfig, tmp_path, mock_time, requests_m @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) def test_cll_extraction_flags( - mock_msal, pytestconfig, tmp_path, mock_time, requests_mock -): + mock_msal: MagicMock, + pytestconfig: pytest.Config, + tmp_path: str, + mock_time: datetime.datetime, + requests_mock: Any, +) -> None: register_mock_api( request_mock=requests_mock, From 9ecda6485202ce89291bd1485c861cf7be1b8741 Mon Sep 17 00:00:00 2001 From: Sumit Patil <91715217+sumitappt@users.noreply.github.com> Date: Thu, 14 Dec 2023 19:07:48 +0530 Subject: [PATCH 023/540] fix(analytics): do not ping the track endpoint before login (#9462) --- datahub-web-react/src/app/analytics/analytics.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/datahub-web-react/src/app/analytics/analytics.ts b/datahub-web-react/src/app/analytics/analytics.ts index a66d76a09cf4de..468164069cfd03 100644 --- a/datahub-web-react/src/app/analytics/analytics.ts +++ b/datahub-web-react/src/app/analytics/analytics.ts @@ -30,16 +30,17 @@ export function getMergedTrackingOptions(options?: any) { export default { page: (data?: PageData, options?: any, callback?: (...params: any[]) => any) => { + const actorUrn = Cookies.get(CLIENT_AUTH_COOKIE) || undefined; const modifiedData = { ...data, type: EventType[EventType.PageViewEvent], - actorUrn: Cookies.get(CLIENT_AUTH_COOKIE) || undefined, + actorUrn, timestamp: Date.now(), date: new Date().toString(), userAgent: navigator.userAgent, browserId: getBrowserId(), }; - if (NODE_ENV === 'test') { + if (NODE_ENV === 'test' || !actorUrn) { return null; } const trackingOptions = getMergedTrackingOptions(options); From aac1c55a14fdf65cb51f1fd0f441d93eb7757098 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Thu, 14 Dec 2023 21:05:06 +0530 Subject: [PATCH 024/540] feat(ingest/unity): enable hive metastore ingestion (#9416) --- metadata-ingestion/setup.py | 5 +- .../ingestion/source/bigquery_v2/bigquery.py | 4 + .../ingestion/source/source_registry.py | 9 + .../datahub/ingestion/source/unity/config.py | 51 +- .../source/unity/hive_metastore_proxy.py | 242 ++ .../datahub/ingestion/source/unity/proxy.py | 22 + .../ingestion/source/unity/proxy_types.py | 38 +- .../datahub/ingestion/source/unity/report.py | 4 +- .../datahub/ingestion/source/unity/source.py | 64 +- .../unity/test_unity_catalog_ingest.py | 77 +- .../unity/unity_catalog_mces_golden.json | 2509 +++++++++-------- .../tests/unit/test_unity_catalog_config.py | 65 +- 12 files changed, 1958 insertions(+), 1132 deletions(-) create mode 100644 metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index e894cbf043338d..5d15d7167b63e8 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -263,7 +263,8 @@ "pyspark~=3.3.0", "requests", # Version 2.4.0 includes sqlalchemy dialect, 2.8.0 includes some bug fixes - "databricks-sql-connector>=2.8.0", + # Version 3.0.0 required SQLAlchemy > 2.0.21 + "databricks-sql-connector>=2.8.0,<3.0.0", } mysql = sql_common | {"pymysql>=1.0.2"} @@ -395,6 +396,8 @@ "powerbi-report-server": powerbi_report_server, "vertica": sql_common | {"vertica-sqlalchemy-dialect[vertica-python]==0.0.8.1"}, "unity-catalog": databricks | sql_common | sqllineage_lib, + # databricks is alias for unity-catalog and needs to be kept in sync + "databricks": databricks | sql_common | sqllineage_lib, "fivetran": snowflake_common, } diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py index 6959a483130106..9813945683289c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py @@ -1031,6 +1031,10 @@ def gen_dataset_urn_from_ref(self, ref: BigQueryTableRef) -> str: def gen_schema_fields(self, columns: List[BigqueryColumn]) -> List[SchemaField]: schema_fields: List[SchemaField] = [] + # Below line affects HiveColumnToAvroConverter._STRUCT_TYPE_SEPARATOR in global scope + # TODO: Refractor this such that + # converter = HiveColumnToAvroConverter(struct_type_separator=" "); + # converter.get_schema_fields_for_hive_column(...) HiveColumnToAvroConverter._STRUCT_TYPE_SEPARATOR = " " _COMPLEX_TYPE = re.compile("^(struct|array)") last_id = -1 diff --git a/metadata-ingestion/src/datahub/ingestion/source/source_registry.py b/metadata-ingestion/src/datahub/ingestion/source/source_registry.py index c3fbab3f9a0122..e003c658f45e8d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/source_registry.py +++ b/metadata-ingestion/src/datahub/ingestion/source/source_registry.py @@ -14,3 +14,12 @@ "mssql-odbc", "mssql", ) + +# Use databricks as alias for unity-catalog ingestion source. +# As mentioned here - https://docs.databricks.com/en/data-governance/unity-catalog/enable-workspaces.html, +# Databricks is rolling out Unity Catalog gradually across accounts. +# TODO: Rename unity-catalog source to databricks source, once it is rolled out for all accounts +source_registry.register_alias( + "databricks", + "unity-catalog", +) diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py index 2c567120b4850e..96971faeea69f4 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py @@ -129,6 +129,14 @@ class UnityCatalogSourceConfig( workspace_url: str = pydantic.Field( description="Databricks workspace url. e.g. https://my-workspace.cloud.databricks.com" ) + warehouse_id: Optional[str] = pydantic.Field( + default=None, + description="SQL Warehouse id, for running queries. If not set, will use the default warehouse.", + ) + include_hive_metastore: bool = pydantic.Field( + default=False, + description="Whether to ingest legacy `hive_metastore` catalog. This requires executing queries on SQL warehouse.", + ) workspace_name: Optional[str] = pydantic.Field( default=None, description="Name of the workspace. Default to deployment name present in workspace_url", @@ -254,16 +262,17 @@ class UnityCatalogSourceConfig( scheme: str = DATABRICKS - def get_sql_alchemy_url(self): + def get_sql_alchemy_url(self, database: Optional[str] = None) -> str: + uri_opts = {"http_path": f"/sql/1.0/warehouses/{self.warehouse_id}"} + if database: + uri_opts["catalog"] = database return make_sqlalchemy_uri( scheme=self.scheme, username="token", password=self.token, at=urlparse(self.workspace_url).netloc, - db=None, - uri_opts={ - "http_path": f"/sql/1.0/warehouses/{self.profiling.warehouse_id}" - }, + db=database, + uri_opts=uri_opts, ) def is_profiling_enabled(self) -> bool: @@ -304,3 +313,35 @@ def include_metastore_warning(cls, v: bool) -> bool: logger.warning(msg) add_global_warning(msg) return v + + @pydantic.root_validator(skip_on_failure=True) + def set_warehouse_id_from_profiling(cls, values: Dict[str, Any]) -> Dict[str, Any]: + profiling: Optional[UnityCatalogProfilerConfig] = values.get("profiling") + if not values.get("warehouse_id") and profiling and profiling.warehouse_id: + values["warehouse_id"] = profiling.warehouse_id + if ( + values.get("warehouse_id") + and profiling + and profiling.warehouse_id + and values["warehouse_id"] != profiling.warehouse_id + ): + raise ValueError( + "When `warehouse_id` is set, it must match the `warehouse_id` in `profiling`." + ) + + if values.get("include_hive_metastore") and not values.get("warehouse_id"): + raise ValueError( + "When `include_hive_metastore` is set, `warehouse_id` must be set." + ) + + if values.get("warehouse_id") and profiling and not profiling.warehouse_id: + profiling.warehouse_id = values["warehouse_id"] + + return values + + @pydantic.validator("schema_pattern", always=True) + def schema_pattern_should__always_deny_information_schema( + cls, v: AllowDenyPattern + ) -> AllowDenyPattern: + v.deny.append(".*\\.information_schema") + return v diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py new file mode 100644 index 00000000000000..99b2ff998662cb --- /dev/null +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py @@ -0,0 +1,242 @@ +import logging +from datetime import datetime +from functools import lru_cache +from typing import Iterable, List, Optional + +from databricks.sdk.service.catalog import ColumnTypeName, DataSourceFormat +from databricks.sql.types import Row +from sqlalchemy import create_engine, inspect +from sqlalchemy.engine.reflection import Inspector + +from datahub.ingestion.api.closeable import Closeable +from datahub.ingestion.source.unity.proxy_types import ( + Catalog, + Column, + CustomCatalogType, + HiveTableType, + Metastore, + Schema, + Table, +) + +logger = logging.getLogger(__name__) +HIVE_METASTORE = "hive_metastore" + +type_map = { + "boolean": ColumnTypeName.BOOLEAN, + "tinyint": ColumnTypeName.INT, + "smallint": ColumnTypeName.INT, + "int": ColumnTypeName.INT, + "bigint": ColumnTypeName.LONG, + "float": ColumnTypeName.FLOAT, + "double": ColumnTypeName.DOUBLE, + "decimal": ColumnTypeName.DECIMAL, + "string": ColumnTypeName.STRING, + "varchar": ColumnTypeName.STRING, + "timestamp": ColumnTypeName.TIMESTAMP, + "date": ColumnTypeName.DATE, + "binary": ColumnTypeName.BINARY, +} + + +class HiveMetastoreProxy(Closeable): + # TODO: Support for view lineage using SQL parsing + # Why not use hive ingestion source directly here ? + # 1. hive ingestion source assumes 2-level namespace heirarchy and currently + # there is no other intermediate interface except sqlalchemy inspector + # that can be used to fetch hive metadata. + # 2. hive recipe for databricks (databricks+pyhive dialect) does not + # readily support SQL warehouse. Also this dialect is not actively maintained. + """ + Proxy to read metadata from hive_metastore databricks catalog. This is required + as unity catalog apis do not return details about this legacy metastore. + """ + + def __init__(self, sqlalchemy_url: str, options: dict) -> None: + try: + self.inspector = HiveMetastoreProxy.get_inspector(sqlalchemy_url, options) + except Exception: + # This means that there is no `hive_metastore` catalog in databricks workspace + # Not tested but seems like the logical conclusion. + raise + + @staticmethod + def get_inspector(sqlalchemy_url: str, options: dict) -> Inspector: + engine = create_engine(sqlalchemy_url, **options) + return inspect(engine.connect()) + + def hive_metastore_catalog(self, metastore: Optional[Metastore]) -> Catalog: + return Catalog( + id=HIVE_METASTORE, + name=HIVE_METASTORE, + comment=None, + metastore=metastore, + owner=None, + type=CustomCatalogType.HIVE_METASTORE_CATALOG, + ) + + def hive_metastore_schemas(self, catalog: Catalog) -> Iterable[Schema]: + for schema_name in self.inspector.get_schema_names(): + yield Schema( + name=schema_name, + id=f"{catalog.id}.{schema_name}", + catalog=catalog, + comment=None, + owner=None, + ) + + def hive_metastore_tables(self, schema: Schema) -> Iterable[Table]: + views = self.inspector.get_view_names(schema.name) + for table_name in views: + yield self._get_table(schema, table_name, True) + + for table_name in self.inspector.get_table_names(schema.name): + if table_name in views: + continue + yield self._get_table(schema, table_name, False) + + def _get_table(self, schema: Schema, table_name: str, is_view: bool) -> Table: + columns = self._get_columns(schema, table_name) + detailed_info = self._get_table_info(schema, table_name) + + comment = detailed_info.pop("Comment", None) + storage_location = detailed_info.pop("Location", None) + datasource_format = self._get_datasource_format( + detailed_info.pop("Provider", None) + ) + + created_at = self._get_created_at(detailed_info.pop("Created Time", None)) + + return Table( + name=table_name, + id=f"{schema.id}.{table_name}", + table_type=self._get_table_type(detailed_info.pop("Type", None)), + schema=schema, + columns=columns, + storage_location=storage_location, + data_source_format=datasource_format, + view_definition=self._get_view_definition(schema.name, table_name) + if is_view + else None, + properties=detailed_info, + owner=None, + generation=None, + created_at=created_at, + created_by=None, + updated_at=None, + updated_by=None, + table_id=f"{schema.id}.{table_name}", + comment=comment, + ) + + def _get_created_at(self, created_at: Optional[str]) -> Optional[datetime]: + return ( + datetime.strptime(created_at, "%a %b %d %H:%M:%S %Z %Y") + if created_at + else None + ) + + def _get_datasource_format( + self, provider: Optional[str] + ) -> Optional[DataSourceFormat]: + raw_format = provider + if raw_format: + try: + return DataSourceFormat(raw_format.upper()) + except Exception: + logger.debug(f"Unknown datasource format : {raw_format}") + pass + return None + + def _get_view_definition(self, schema_name: str, table_name: str) -> Optional[str]: + try: + rows = self._execute_sql( + f"SHOW CREATE TABLE `{schema_name}`.`{table_name}`" + ) + for row in rows: + return row[0] + except Exception: + logger.debug( + f"Failed to get view definition for {schema_name}.{table_name}" + ) + return None + + def _get_table_type(self, type: Optional[str]) -> HiveTableType: + if type == "EXTERNAL": + return HiveTableType.HIVE_EXTERNAL_TABLE + elif type == "MANAGED": + return HiveTableType.HIVE_MANAGED_TABLE + elif type == "VIEW": + return HiveTableType.HIVE_VIEW + else: + return HiveTableType.UNKNOWN + + def _get_table_info(self, schema: Schema, table_name: str) -> dict: + rows = self._describe_extended(schema.name, table_name) + + index = rows.index(("# Detailed Table Information", "", "")) + rows = rows[index + 1 :] + # Copied from https://github.com/acryldata/PyHive/blob/master/pyhive/sqlalchemy_hive.py#L375 + # Generate properties dictionary. + properties = {} + active_heading = None + for col_name, data_type, value in rows: + col_name = col_name.rstrip() + if col_name.startswith("# "): + continue + elif col_name == "" and data_type is None: + active_heading = None + continue + elif col_name != "" and data_type is None: + active_heading = col_name + elif col_name != "" and data_type is not None: + properties[col_name] = data_type.strip() + else: + # col_name == "", data_type is not None + prop_name = "{} {}".format(active_heading, data_type.rstrip()) + properties[prop_name] = value.rstrip() + + return properties + + def _get_columns(self, schema: Schema, table_name: str) -> List[Column]: + rows = self._describe_extended(schema.name, table_name) + + columns: List[Column] = [] + for i, row in enumerate(rows): + if i == 0 and row[0].strip() == "col_name": + continue # first row + if row[0].strip() in ( + "", + "# Partition Information", + "# Detailed Table Information", + ): + break + columns.append( + Column( + name=row[0].strip(), + id=f"{schema.id}.{table_name}.{row[0].strip()}", + type_text=row[1].strip(), + type_name=type_map.get(row[1].strip().lower()), + type_scale=None, + type_precision=None, + position=None, + nullable=None, + comment=row[2], + ) + ) + + return columns + + @lru_cache(maxsize=1) + def _describe_extended(self, schema_name: str, table_name: str) -> List[Row]: + """ + Rows are structured as shown in examples here + https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-aux-describe-table.html#examples + """ + return self._execute_sql(f"DESCRIBE EXTENDED `{schema_name}`.`{table_name}`") + + def _execute_sql(self, sql: str) -> List[Row]: + return self.inspector.bind.execute(sql).fetchall() + + def close(self): + self.inspector.bind.close() # type:ignore diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py index 375c76db8e9719..13baa8b57a639d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py @@ -26,6 +26,7 @@ from databricks.sdk.service.workspace import ObjectType import datahub +from datahub.ingestion.source.unity.hive_metastore_proxy import HiveMetastoreProxy from datahub.ingestion.source.unity.proxy_profiling import ( UnityCatalogProxyProfilingMixin, ) @@ -33,6 +34,7 @@ ALLOWED_STATEMENT_TYPES, Catalog, Column, + CustomCatalogType, ExternalTableReference, Metastore, Notebook, @@ -87,6 +89,7 @@ def __init__( personal_access_token: str, warehouse_id: Optional[str], report: UnityCatalogReport, + hive_metastore_proxy: Optional[HiveMetastoreProxy] = None, ): self._workspace_client = WorkspaceClient( host=workspace_url, @@ -96,6 +99,7 @@ def __init__( ) self.warehouse_id = warehouse_id or "" self.report = report + self.hive_metastore_proxy = hive_metastore_proxy def check_basic_connectivity(self) -> bool: return bool(self._workspace_client.catalogs.list()) @@ -105,6 +109,9 @@ def assigned_metastore(self) -> Metastore: return self._create_metastore(response) def catalogs(self, metastore: Optional[Metastore]) -> Iterable[Catalog]: + if self.hive_metastore_proxy: + yield self.hive_metastore_proxy.hive_metastore_catalog(metastore) + response = self._workspace_client.catalogs.list() if not response: logger.info("Catalogs not found") @@ -122,6 +129,12 @@ def catalog( return self._create_catalog(metastore, response) def schemas(self, catalog: Catalog) -> Iterable[Schema]: + if ( + self.hive_metastore_proxy + and catalog.type == CustomCatalogType.HIVE_METASTORE_CATALOG + ): + yield from self.hive_metastore_proxy.hive_metastore_schemas(catalog) + return response = self._workspace_client.schemas.list(catalog_name=catalog.name) if not response: logger.info(f"Schemas not found for catalog {catalog.id}") @@ -130,6 +143,12 @@ def schemas(self, catalog: Catalog) -> Iterable[Schema]: yield self._create_schema(catalog, schema) def tables(self, schema: Schema) -> Iterable[Table]: + if ( + self.hive_metastore_proxy + and schema.catalog.type == CustomCatalogType.HIVE_METASTORE_CATALOG + ): + yield from self.hive_metastore_proxy.hive_metastore_tables(schema) + return with patch("databricks.sdk.service.catalog.TableInfo", TableInfoWithGeneration): response = self._workspace_client.tables.list( catalog_name=schema.catalog.name, schema_name=schema.name @@ -244,6 +263,9 @@ def list_lineages_by_column(self, table_name: str, column_name: str) -> dict: ) def table_lineage(self, table: Table, include_entity_lineage: bool) -> None: + if table.schema.catalog.type == CustomCatalogType.HIVE_METASTORE_CATALOG: + # Lineage is not available for Hive Metastore Tables. + return None # Lineage endpoint doesn't exists on 2.1 version try: response: dict = self.list_lineages_by_table( diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py index 315c1c0d20186f..e5951cb0fa4ffc 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py @@ -4,7 +4,8 @@ import logging from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, FrozenSet, List, Optional, Set +from enum import Enum +from typing import Dict, FrozenSet, List, Optional, Set, Union from databricks.sdk.service.catalog import ( CatalogType, @@ -75,6 +76,17 @@ NotebookId = int +class CustomCatalogType(Enum): + HIVE_METASTORE_CATALOG = "HIVE_METASTORE_CATALOG" + + +class HiveTableType(Enum): + HIVE_MANAGED_TABLE = "HIVE_MANAGED_TABLE" + HIVE_EXTERNAL_TABLE = "HIVE_EXTERNAL_TABLE" + HIVE_VIEW = "HIVE_VIEW" + UNKNOWN = "UNKNOWN" + + @dataclass class CommonProperty: id: str @@ -95,7 +107,7 @@ class Metastore(CommonProperty): class Catalog(CommonProperty): metastore: Optional[Metastore] owner: Optional[str] - type: CatalogType + type: Union[CatalogType, CustomCatalogType] @dataclass @@ -107,11 +119,11 @@ class Schema(CommonProperty): @dataclass class Column(CommonProperty): type_text: str - type_name: ColumnTypeName - type_precision: int - type_scale: int - position: int - nullable: bool + type_name: Optional[ColumnTypeName] + type_precision: Optional[int] + type_scale: Optional[int] + position: Optional[int] + nullable: Optional[bool] comment: Optional[str] @@ -212,11 +224,11 @@ class Table(CommonProperty): columns: List[Column] storage_location: Optional[str] data_source_format: Optional[DataSourceFormat] - table_type: TableType + table_type: Union[TableType, HiveTableType] owner: Optional[str] generation: Optional[int] - created_at: datetime - created_by: str + created_at: Optional[datetime] + created_by: Optional[str] updated_at: Optional[datetime] updated_by: Optional[str] table_id: str @@ -231,7 +243,11 @@ class Table(CommonProperty): def __post_init__(self): self.ref = TableReference.create(self) - self.is_view = self.table_type in [TableType.VIEW, TableType.MATERIALIZED_VIEW] + self.is_view = self.table_type in [ + TableType.VIEW, + TableType.MATERIALIZED_VIEW, + HiveTableType.HIVE_VIEW, + ] @dataclass diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/report.py b/metadata-ingestion/src/datahub/ingestion/source/unity/report.py index 7f19b6e2103ea9..0770d9d27055c0 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/report.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Tuple +from typing import Optional, Tuple from datahub.ingestion.api.report import EntityFilterReport from datahub.ingestion.source.sql.sql_generic_profiler import ProfilingSqlReport @@ -16,6 +16,8 @@ class UnityCatalogReport(IngestionStageReport, ProfilingSqlReport): table_profiles: EntityFilterReport = EntityFilterReport.field(type="table profile") notebooks: EntityFilterReport = EntityFilterReport.field(type="notebook") + hive_metastore_catalog_found: Optional[bool] = None + num_column_lineage_skipped_column_count: int = 0 num_external_upstreams_lacking_permissions: int = 0 num_external_upstreams_unsupported: int = 0 diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py index d1940c1d576073..43c5e244393772 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py @@ -58,6 +58,10 @@ ) from datahub.ingestion.source.unity.connection_test import UnityCatalogConnectionTest from datahub.ingestion.source.unity.ge_profiler import UnityCatalogGEProfiler +from datahub.ingestion.source.unity.hive_metastore_proxy import ( + HIVE_METASTORE, + HiveMetastoreProxy, +) from datahub.ingestion.source.unity.proxy import UnityCatalogApiProxy from datahub.ingestion.source.unity.proxy_types import ( DATA_TYPE_REGISTRY, @@ -142,12 +146,17 @@ def __init__(self, ctx: PipelineContext, config: UnityCatalogSourceConfig): self.config = config self.report: UnityCatalogReport = UnityCatalogReport() + + self.init_hive_metastore_proxy() + self.unity_catalog_api_proxy = UnityCatalogApiProxy( config.workspace_url, config.token, - config.profiling.warehouse_id, + config.warehouse_id, report=self.report, + hive_metastore_proxy=self.hive_metastore_proxy, ) + self.external_url_base = urljoin(self.config.workspace_url, "/explore/data") # Determine the platform_instance_name @@ -174,6 +183,23 @@ def __init__(self, ctx: PipelineContext, config: UnityCatalogSourceConfig): # Global map of tables, for profiling self.tables: FileBackedDict[Table] = FileBackedDict() + def init_hive_metastore_proxy(self): + self.hive_metastore_proxy: Optional[HiveMetastoreProxy] = None + if self.config.include_hive_metastore: + try: + self.hive_metastore_proxy = HiveMetastoreProxy( + self.config.get_sql_alchemy_url(HIVE_METASTORE), self.config.options + ) + self.report.hive_metastore_catalog_found = True + except Exception as e: + logger.debug("Exception", exc_info=True) + self.warn( + logger, + HIVE_METASTORE, + f"Failed to connect to hive_metastore due to {e}", + ) + self.report.hive_metastore_catalog_found = False + @staticmethod def test_connection(config_dict: dict) -> TestConnectionReport: return UnityCatalogConnectionTest(config_dict).get_connection_test() @@ -194,7 +220,7 @@ def get_workunit_processors(self) -> List[Optional[MetadataWorkUnitProcessor]]: def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: self.report.report_ingestion_stage_start("Ingestion Setup") wait_on_warehouse = None - if self.config.is_profiling_enabled(): + if self.config.is_profiling_enabled() or self.config.include_hive_metastore: self.report.report_ingestion_stage_start("Start warehouse") # Can take several minutes, so start now and wait later wait_on_warehouse = self.unity_catalog_api_proxy.start_warehouse() @@ -204,6 +230,9 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: f"SQL warehouse {self.config.profiling.warehouse_id} not found", ) return + else: + # wait until warehouse is started + wait_on_warehouse.result() if self.config.include_ownership: self.report.report_ingestion_stage_start("Ingest service principals") @@ -678,18 +707,25 @@ def _create_table_property_aspect(self, table: Table) -> DatasetPropertiesClass: custom_properties["table_type"] = table.table_type.value - custom_properties["created_by"] = table.created_by - custom_properties["created_at"] = str(table.created_at) + if table.created_by: + custom_properties["created_by"] = table.created_by if table.properties: custom_properties.update({k: str(v) for k, v in table.properties.items()}) custom_properties["table_id"] = table.table_id - custom_properties["owner"] = table.owner - custom_properties["updated_by"] = table.updated_by - custom_properties["updated_at"] = str(table.updated_at) - - created = TimeStampClass( - int(table.created_at.timestamp() * 1000), make_user_urn(table.created_by) - ) + if table.owner: + custom_properties["owner"] = table.owner + if table.updated_by: + custom_properties["updated_by"] = table.updated_by + if table.updated_at: + custom_properties["updated_at"] = str(table.updated_at) + + created: Optional[TimeStampClass] = None + if table.created_at: + custom_properties["created_at"] = str(table.created_at) + created = TimeStampClass( + int(table.created_at.timestamp() * 1000), + make_user_urn(table.created_by) if table.created_by else None, + ) last_modified = created if table.updated_at: last_modified = TimeStampClass( @@ -780,3 +816,9 @@ def _create_schema_field(column: Column) -> List[SchemaFieldClass]: description=column.comment, ) ] + + def close(self): + if self.hive_metastore_proxy: + self.hive_metastore_proxy.close() + + super().close() diff --git a/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py b/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py index c43ba7eee58478..aab7630d57f460 100644 --- a/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py +++ b/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py @@ -3,6 +3,7 @@ from unittest.mock import patch import databricks +import pytest from databricks.sdk.service.catalog import ( CatalogInfo, GetMetastoreSummaryResponse, @@ -12,12 +13,15 @@ from freezegun import freeze_time from datahub.ingestion.run.pipeline import Pipeline +from datahub.ingestion.source.unity.hive_metastore_proxy import HiveMetastoreProxy from tests.test_helpers import mce_helpers FROZEN_TIME = "2021-12-07 07:00:00" SERVICE_PRINCIPAL_ID_1 = str(uuid.uuid4()) SERVICE_PRINCIPAL_ID_2 = str(uuid.uuid4()) +pytestmark = pytest.mark.integration_batch_1 + def register_mock_api(request_mock): api_vs_response = { @@ -215,6 +219,65 @@ def register_mock_data(workspace_client): ] +def mock_hive_sql(query): + if query == "DESCRIBE EXTENDED `bronze_kambi`.`bet`": + return [ + ("betStatusId", "bigint", None), + ("channelId", "bigint", None), + ( + "combination", + "struct>,eventId:bigint,eventName:string,eventStartDate:string,live:boolean,odds:double,outcomeIds:array,outcomeLabel:string,sportId:string,status:string,voidReason:string>>,payout:double,rewardExtraPayout:double,stake:double>", + None, + ), + ("", "", ""), + ("# Detailed Table Information", "", ""), + ("Catalog", "hive_metastore", ""), + ("Database", "bronze_kambi", ""), + ("Table", "bet", ""), + ("Created Time", "Wed Jun 22 05:14:56 UTC 2022", ""), + ("Last Access", "UNKNOWN", ""), + ("Created By", "Spark 3.2.1", ""), + ("Type", "MANAGED", ""), + ("Location", "dbfs:/user/hive/warehouse/bronze_kambi.db/bet", ""), + ("Provider", "delta", ""), + ("Owner", "root", ""), + ("Is_managed_location", "true", ""), + ( + "Table Properties", + "[delta.autoOptimize.autoCompact=true,delta.autoOptimize.optimizeWrite=true,delta.minReaderVersion=1,delta.minWriterVersion=2]", + "", + ), + ] + elif query == "DESCRIBE EXTENDED `bronze_kambi`.`view1`": + return [ + ("betStatusId", "bigint", None), + ("channelId", "bigint", None), + ( + "combination", + "struct>,eventId:bigint,eventName:string,eventStartDate:string,live:boolean,odds:double,outcomeIds:array,outcomeLabel:string,sportId:string,status:string,voidReason:string>>,payout:double,rewardExtraPayout:double,stake:double>", + None, + ), + ("", "", ""), + ("# Detailed Table Information", "", ""), + ("Catalog", "hive_metastore", ""), + ("Database", "bronze_kambi", ""), + ("Table", "view1", ""), + ("Created Time", "Wed Jun 22 05:14:56 UTC 2022", ""), + ("Last Access", "UNKNOWN", ""), + ("Created By", "Spark 3.2.1", ""), + ("Type", "VIEW", ""), + ("Owner", "root", ""), + ] + elif query == "SHOW CREATE TABLE `bronze_kambi`.`view1`": + return [ + ( + "CREATE VIEW `hive_metastore`.`bronze_kambi`.`view1` AS SELECT * FROM `hive_metastore`.`bronze_kambi`.`bet`", + ) + ] + + return [] + + @freeze_time(FROZEN_TIME) def test_ingestion(pytestconfig, tmp_path, requests_mock): test_resources_dir = pytestconfig.rootpath / "tests/integration/unity" @@ -223,11 +286,21 @@ def test_ingestion(pytestconfig, tmp_path, requests_mock): output_file_name = "unity_catalog_mcps.json" - with patch("databricks.sdk.WorkspaceClient") as WorkspaceClient: + with patch("databricks.sdk.WorkspaceClient") as WorkspaceClient, patch.object( + HiveMetastoreProxy, "get_inspector" + ) as get_inspector, patch.object(HiveMetastoreProxy, "_execute_sql") as execute_sql: workspace_client: mock.MagicMock = mock.MagicMock() WorkspaceClient.return_value = workspace_client register_mock_data(workspace_client) + inspector = mock.MagicMock() + inspector.get_schema_names.return_value = ["bronze_kambi"] + inspector.get_view_names.return_value = ["view1"] + inspector.get_table_names.return_value = ["bet", "view1"] + get_inspector.return_value = inspector + + execute_sql.side_effect = mock_hive_sql + config_dict: dict = { "run_id": "unity-catalog-test", "pipeline_name": "unity-catalog-test-pipeline", @@ -237,6 +310,8 @@ def test_ingestion(pytestconfig, tmp_path, requests_mock): "workspace_url": "https://dummy.cloud.databricks.com", "token": "fake", "include_ownership": True, + "include_hive_metastore": True, + "warehouse_id": "test", }, }, "sink": { diff --git a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json index d25c86a3a1f9a3..98a6615dd2b52c 100644 --- a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json +++ b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json @@ -114,7 +114,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -123,11 +123,10 @@ "platform": "databricks", "env": "PROD", "metastore": "acryl metastore", - "catalog": "main" + "catalog": "hive_metastore" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main", - "name": "main", - "description": "Main catalog (auto-created)" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore", + "name": "hive_metastore" } }, "systemMetadata": { @@ -138,7 +137,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -156,10 +155,18 @@ "entityType": "container", "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "containerProperties", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "customProperties": { + "platform": "databricks", + "env": "PROD", + "metastore": "acryl metastore", + "catalog": "main" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main", + "name": "main", + "description": "Main catalog (auto-created)" } }, "systemMetadata": { @@ -170,7 +177,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -188,21 +195,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "container", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" } }, "systemMetadata": { @@ -213,12 +211,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -229,7 +227,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -250,32 +248,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main", - "unity_schema": "default" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default", - "name": "default", - "description": "Default schema (auto-created)" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -291,7 +264,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -307,13 +280,13 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Schema" + "Catalog" ] } }, @@ -325,14 +298,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:abc@acryl.io", + "owner": "urn:li:corpuser:account users", "type": "DATAOWNER" } ], @@ -350,12 +323,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" } }, "systemMetadata": { @@ -366,21 +339,20 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "containerProperties", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - } - ] + "customProperties": { + "platform": "databricks", + "env": "PROD", + "metastore": "acryl metastore", + "catalog": "hive_metastore", + "unity_schema": "bronze_kambi" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi", + "name": "bronze_kambi" } }, "systemMetadata": { @@ -390,13 +362,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "status", "aspect": { "json": { - "container": "urn:li:container:5ada0a9773235325e506410c512feabb" + "removed": false } }, "systemMetadata": { @@ -406,40 +378,18 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "browsePathsV2", "aspect": { "json": { - "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "main.default.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "tags": [] + "path": [ + { + "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + } + ] } }, "systemMetadata": { @@ -449,14 +399,14 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Table" + "Schema" ] } }, @@ -467,55 +417,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", - "aspectName": "schemaMetadata", + "aspectName": "container", "aspect": { "json": { - "schemaName": "acryl_metastore.main.default.quickstart_table", - "platform": "urn:li:dataPlatform:databricks", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.schema.MySqlDDL": { - "tableSchema": "" - } - }, - "fields": [ - { - "fieldPath": "columnA", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "int", - "recursive": false, - "isPartOfKey": false - }, - { - "fieldPath": "columnB", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.StringType": {} - } - }, - "nativeDataType": "string", - "recursive": false, - "isPartOfKey": false - } - ] + "container": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" } }, "systemMetadata": { @@ -525,22 +433,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -551,7 +450,23 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -562,12 +477,8 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - }, - { - "id": "urn:li:container:5ada0a9773235325e506410c512feabb", - "urn": "urn:li:container:5ada0a9773235325e506410c512feabb" + "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" } ] } @@ -579,22 +490,33 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "datasetProperties", "aspect": { "json": { "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main", - "unity_schema": "information_schema" + "table_type": "HIVE_VIEW", + "Catalog": "hive_metastore", + "Database": "bronze_kambi", + "Table": "view1", + "Last Access": "UNKNOWN", + "Created By": "Spark 3.2.1", + "Owner": "root", + "table_id": "hive_metastore.bronze_kambi.view1", + "created_at": "2022-06-22 05:14:56" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/view1", + "name": "view1", + "qualifiedName": "hive_metastore.bronze_kambi.view1", + "created": { + "time": 1655874896000 }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/information_schema", - "name": "information_schema", - "description": "Information schema (auto-created)" + "lastModified": { + "time": 1655874896000 + }, + "tags": [] } }, "systemMetadata": { @@ -604,13 +526,15 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "viewProperties", "aspect": { "json": { - "removed": false + "materialized": false, + "viewLogic": "CREATE VIEW `hive_metastore`.`bronze_kambi`.`view1` AS SELECT * FROM `hive_metastore`.`bronze_kambi`.`bet`", + "viewLanguage": "SQL" } }, "systemMetadata": { @@ -621,13 +545,22 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "containerProperties", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" - } + "customProperties": { + "platform": "databricks", + "env": "PROD", + "metastore": "acryl metastore", + "catalog": "main", + "unity_schema": "default" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default", + "name": "default", + "description": "Default schema (auto-created)" + } }, "systemMetadata": { "lastObserved": 1638860400000, @@ -636,14 +569,14 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Schema" + "View" ] } }, @@ -654,49 +587,8 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:Service Principal 1", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -707,8 +599,12 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" + }, + { + "id": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", + "urn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" } ] } @@ -720,74 +616,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "datasetProperties", - "aspect": { - "json": { - "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/information_schema/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "main.information_schema.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "tags": [] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "status", "aspect": { "json": { - "typeNames": [ - "Table" - ] + "removed": false } }, "systemMetadata": { @@ -798,12 +633,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.main.information_schema.quickstart_table", + "schemaName": "hive_metastore.bronze_kambi.view1", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -822,144 +657,409 @@ }, "fields": [ { - "fieldPath": "columnA", - "nullable": true, + "fieldPath": "betStatusId", + "nullable": false, "type": { "type": { "com.linkedin.schema.NumberType": {} } }, - "nativeDataType": "int", + "nativeDataType": "bigint", "recursive": false, "isPartOfKey": false }, { - "fieldPath": "columnB", - "nullable": true, + "fieldPath": "channelId", + "nullable": false, "type": { "type": { - "com.linkedin.schema.StringType": {} + "com.linkedin.schema.NumberType": {} } }, - "nativeDataType": "string", + "nativeDataType": "bigint", "recursive": false, "isPartOfKey": false - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ + }, { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.RecordType": {} + } + }, + "nativeDataType": "struct>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>,payout:double,rewardextrapayout:double,stake:double>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"struct>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>,payout:double,rewardextrapayout:double,stake:double>\"}" + }, { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=long].combinationref", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" }, { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].currentodds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" }, { - "id": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6", - "urn": "urn:li:container:0e09e6ec299ef004941e25221d3ef6b6" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main", - "unity_schema": "quickstart_schema" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema", - "name": "quickstart_schema", - "description": "A new Unity Catalog schema called quickstart_schema" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" - } - }, - "systemMetadata": { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=boolean].eachway", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=boolean].livebetting", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].odds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "record" + ] + } + } + }, + "nativeDataType": "array>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].betoffertypeid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].criterionid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].criterionname", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=double].currentodds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].eventgroupid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "record" + ] + } + } + }, + "nativeDataType": "array>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array>\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath.[type=long].id", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath.[type=string].name", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].eventid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].eventname", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].eventstartdate", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=boolean].live", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=double].odds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=long].outcomeids", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "long" + ] + } + } + }, + "nativeDataType": "array", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].outcomelabel", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].sportid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].status", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].voidreason", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].payout", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].rewardextrapayout", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].stake", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + } + ] + } + }, + "systemMetadata": { "lastObserved": 1638860400000, "runId": "unity-catalog-test", "lastRunId": "no-run-id-provided" @@ -967,7 +1067,23 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "changeType": "UPSERT", + "aspectName": "dataPlatformInstance", + "aspect": { + "json": { + "platform": "urn:li:dataPlatform:databricks" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -985,14 +1101,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:account users", + "owner": "urn:li:corpuser:abc@acryl.io", "type": "DATAOWNER" } ], @@ -1009,13 +1125,13 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "container": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" } }, "systemMetadata": { @@ -1026,37 +1142,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:481380c5a355638fc626eca8380cdda9" + "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" } }, "systemMetadata": { @@ -1067,37 +1158,34 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { "json": { "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "storage_location": "dbfs:/user/hive/warehouse/bronze_kambi.db/bet", "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "main.quickstart_schema.quickstart_table", + "table_type": "HIVE_MANAGED_TABLE", + "Catalog": "hive_metastore", + "Database": "bronze_kambi", + "Table": "bet", + "Last Access": "UNKNOWN", + "Created By": "Spark 3.2.1", + "Owner": "root", + "Is_managed_location": "true", + "Table Properties": "[delta.autoOptimize.autoCompact=true,delta.autoOptimize.optimizeWrite=true,delta.minReaderVersion=1,delta.minWriterVersion=2]", + "table_id": "hive_metastore.bronze_kambi.bet", + "created_at": "2022-06-22 05:14:56" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/bet", + "name": "bet", + "qualifiedName": "hive_metastore.bronze_kambi.bet", "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" + "time": 1655874896000 }, "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" + "time": 1655874896000 }, "tags": [] } @@ -1110,7 +1198,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -1127,53 +1215,20 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", "changeType": "UPSERT", - "aspectName": "schemaMetadata", + "aspectName": "browsePathsV2", "aspect": { "json": { - "schemaName": "acryl_metastore.main.quickstart_schema.quickstart_table", - "platform": "urn:li:dataPlatform:databricks", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.schema.MySqlDDL": { - "tableSchema": "" - } - }, - "fields": [ + "path": [ { - "fieldPath": "columnA", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "int", - "recursive": false, - "isPartOfKey": false + "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "fieldPath": "columnB", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.StringType": {} - } - }, - "nativeDataType": "string", - "recursive": false, - "isPartOfKey": false + "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" } ] } @@ -1186,32 +1241,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -1222,12 +1252,12 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" }, { - "id": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "urn": "urn:li:container:481380c5a355638fc626eca8380cdda9" + "id": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", + "urn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" } ] } @@ -1239,272 +1269,429 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "schemaMetadata", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "quickstart_catalog" + "schemaName": "hive_metastore.bronze_kambi.bet", + "platform": "urn:li:dataPlatform:databricks", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog", - "name": "quickstart_catalog", - "description": "" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Catalog" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], "lastModified": { "time": 0, "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "quickstart_catalog", - "unity_schema": "default" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default", - "name": "default", - "description": "Default schema (auto-created)" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Schema" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ + "hash": "", + "platformSchema": { + "com.linkedin.schema.MySqlDDL": { + "tableSchema": "" + } + }, + "fields": [ + { + "fieldPath": "betStatusId", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false + }, + { + "fieldPath": "channelId", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.RecordType": {} + } + }, + "nativeDataType": "struct>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>,payout:double,rewardextrapayout:double,stake:double>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"struct>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>,payout:double,rewardextrapayout:double,stake:double>\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=long].combinationref", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].currentodds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=boolean].eachway", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=boolean].livebetting", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].odds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "record" + ] + } + } + }, + "nativeDataType": "array>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array>,eventid:bigint,eventname:string,eventstartdate:string,live:boolean,odds:double,outcomeids:array,outcomelabel:string,sportid:string,status:string,voidreason:string>>\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].betoffertypeid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].criterionid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].criterionname", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=double].currentodds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].eventgroupid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "record" + ] + } + } + }, + "nativeDataType": "array>", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array>\"}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath.[type=long].id", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=struct].eventgrouppath.[type=string].name", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=long].eventid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "bigint", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"bigint\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].eventname", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].eventstartdate", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=boolean].live", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.BooleanType": {} + } + }, + "nativeDataType": "boolean", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"boolean\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=double].odds", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=array].[type=long].outcomeids", + "nullable": false, + "type": { + "type": { + "com.linkedin.schema.ArrayType": { + "nestedType": [ + "long" + ] + } + } + }, + "nativeDataType": "array", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"array\"}" + }, { - "owner": "urn:li:corpuser:abc@acryl.io", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].outcomelabel", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].sportid", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" }, { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].status", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=array].[type=struct].outcomes.[type=string].voidreason", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"string\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].payout", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].rewardextrapayout", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + }, + { + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].stake", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" } ] } @@ -1517,12 +1704,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" + "container": "urn:li:container:5ada0a9773235325e506410c512feabb" } }, "systemMetadata": { @@ -1533,7 +1720,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -1554,9 +1741,9 @@ "updated_by": "abc@acryl.io", "updated_at": "2022-10-19 13:27:29.633000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default/quickstart_table", "name": "quickstart_table", - "qualifiedName": "quickstart_catalog.default.quickstart_table", + "qualifiedName": "main.default.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -1576,7 +1763,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -1594,12 +1781,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.quickstart_catalog.default.quickstart_table", + "schemaName": "acryl_metastore.main.default.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1652,7 +1839,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { @@ -1677,7 +1864,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -1688,12 +1875,12 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" }, { - "id": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "urn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" + "id": "urn:li:container:5ada0a9773235325e506410c512feabb", + "urn": "urn:li:container:5ada0a9773235325e506410c512feabb" } ] } @@ -1706,7 +1893,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -1715,12 +1902,12 @@ "platform": "databricks", "env": "PROD", "metastore": "acryl metastore", - "catalog": "quickstart_catalog", - "unity_schema": "information_schema" + "catalog": "main", + "unity_schema": "quickstart_schema" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/information_schema", - "name": "information_schema", - "description": "Information schema (auto-created)" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema", + "name": "quickstart_schema", + "description": "A new Unity Catalog schema called quickstart_schema" } }, "systemMetadata": { @@ -1731,7 +1918,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -1747,7 +1934,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -1763,7 +1950,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -1781,14 +1968,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:Service Principal 1", + "owner": "urn:li:corpuser:account users", "type": "DATAOWNER" } ], @@ -1806,12 +1993,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" } }, "systemMetadata": { @@ -1822,7 +2009,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:29f99476d533719be0cebc374d5265dc", + "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -1833,8 +2020,8 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" } ] } @@ -1847,12 +2034,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:29f99476d533719be0cebc374d5265dc" + "container": "urn:li:container:481380c5a355638fc626eca8380cdda9" } }, "systemMetadata": { @@ -1863,7 +2050,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -1884,9 +2071,9 @@ "updated_by": "abc@acryl.io", "updated_at": "2022-10-19 13:27:29.633000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/information_schema/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema/quickstart_table", "name": "quickstart_table", - "qualifiedName": "quickstart_catalog.information_schema.quickstart_table", + "qualifiedName": "main.quickstart_schema.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -1906,7 +2093,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -1924,12 +2111,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.quickstart_catalog.information_schema.quickstart_table", + "schemaName": "acryl_metastore.main.quickstart_schema.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1981,8 +2168,136 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + }, + { + "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + }, + { + "id": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "urn": "urn:li:container:481380c5a355638fc626eca8380cdda9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "changeType": "UPSERT", + "aspectName": "containerProperties", + "aspect": { + "json": { + "customProperties": { + "platform": "databricks", + "env": "PROD", + "metastore": "acryl metastore", + "catalog": "quickstart_catalog" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog", + "name": "quickstart_catalog", + "description": "" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "changeType": "UPSERT", + "aspectName": "dataPlatformInstance", + "aspect": { + "json": { + "platform": "urn:li:dataPlatform:databricks" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Catalog" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { @@ -2006,8 +2321,24 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2016,14 +2347,6 @@ { "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - }, - { - "id": "urn:li:container:29f99476d533719be0cebc374d5265dc", - "urn": "urn:li:container:29f99476d533719be0cebc374d5265dc" } ] } @@ -2036,7 +2359,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -2046,11 +2369,11 @@ "env": "PROD", "metastore": "acryl metastore", "catalog": "quickstart_catalog", - "unity_schema": "quickstart_schema" + "unity_schema": "default" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema", - "name": "quickstart_schema", - "description": "A new Unity Catalog schema called quickstart_schema" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default", + "name": "default", + "description": "Default schema (auto-created)" } }, "systemMetadata": { @@ -2061,7 +2384,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2077,7 +2400,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -2093,7 +2416,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2111,14 +2434,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:account users", + "owner": "urn:li:corpuser:abc@acryl.io", "type": "DATAOWNER" } ], @@ -2136,7 +2459,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "container", "aspect": { @@ -2152,7 +2475,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2177,12 +2500,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:47a033e31b92a120f08f297c05d286f1" + "container": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" } }, "systemMetadata": { @@ -2193,7 +2516,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -2214,9 +2537,9 @@ "updated_by": "abc@acryl.io", "updated_at": "2022-10-19 13:27:29.633000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default/quickstart_table", "name": "quickstart_table", - "qualifiedName": "quickstart_catalog.quickstart_schema.quickstart_table", + "qualifiedName": "quickstart_catalog.default.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -2236,7 +2559,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2254,12 +2577,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table", + "schemaName": "acryl_metastore.quickstart_catalog.default.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -2312,7 +2635,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { @@ -2324,153 +2647,9 @@ } ], "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - }, - { - "id": "urn:li:container:47a033e31b92a120f08f297c05d286f1", - "urn": "urn:li:container:47a033e31b92a120f08f297c05d286f1" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "system" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system", - "name": "system", - "description": "System catalog (auto-created)" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Catalog" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:Service Principal 2", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -2480,8 +2659,8 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2490,6 +2669,14 @@ { "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + }, + { + "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + }, + { + "id": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "urn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" } ] } @@ -2502,7 +2689,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -2511,12 +2698,12 @@ "platform": "databricks", "env": "PROD", "metastore": "acryl metastore", - "catalog": "system", - "unity_schema": "default" + "catalog": "quickstart_catalog", + "unity_schema": "quickstart_schema" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default", - "name": "default", - "description": "Default schema (auto-created)" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema", + "name": "quickstart_schema", + "description": "A new Unity Catalog schema called quickstart_schema" } }, "systemMetadata": { @@ -2527,7 +2714,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2543,7 +2730,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -2559,7 +2746,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2577,14 +2764,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:abc@acryl.io", + "owner": "urn:li:corpuser:account users", "type": "DATAOWNER" } ], @@ -2602,12 +2789,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "container": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" } }, "systemMetadata": { @@ -2618,7 +2805,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2629,8 +2816,8 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" } ] } @@ -2643,12 +2830,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:b330768923270ff5450695bee1c94247" + "container": "urn:li:container:47a033e31b92a120f08f297c05d286f1" } }, "systemMetadata": { @@ -2659,7 +2846,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -2680,9 +2867,9 @@ "updated_by": "abc@acryl.io", "updated_at": "2022-10-19 13:27:29.633000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema/quickstart_table", "name": "quickstart_table", - "qualifiedName": "system.default.quickstart_table", + "qualifiedName": "quickstart_catalog.quickstart_schema.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -2702,7 +2889,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2720,12 +2907,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.system.default.quickstart_table", + "schemaName": "acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -2778,7 +2965,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { @@ -2803,7 +2990,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2814,12 +3001,148 @@ "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" }, { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" }, { - "id": "urn:li:container:b330768923270ff5450695bee1c94247", - "urn": "urn:li:container:b330768923270ff5450695bee1c94247" + "id": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "urn": "urn:li:container:47a033e31b92a120f08f297c05d286f1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "containerProperties", + "aspect": { + "json": { + "customProperties": { + "platform": "databricks", + "env": "PROD", + "metastore": "acryl metastore", + "catalog": "system" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system", + "name": "system", + "description": "System catalog (auto-created)" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "dataPlatformInstance", + "aspect": { + "json": { + "platform": "urn:li:dataPlatform:databricks" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Catalog" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ + { + "owner": "urn:li:corpuser:Service Principal 2", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" } ] } @@ -2832,7 +3155,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -2842,11 +3165,11 @@ "env": "PROD", "metastore": "acryl metastore", "catalog": "system", - "unity_schema": "information_schema" + "unity_schema": "default" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/information_schema", - "name": "information_schema", - "description": "Information schema (auto-created)" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default", + "name": "default", + "description": "Default schema (auto-created)" } }, "systemMetadata": { @@ -2857,7 +3180,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2873,7 +3196,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -2889,7 +3212,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2907,14 +3230,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:Service Principal 1", + "owner": "urn:li:corpuser:abc@acryl.io", "type": "DATAOWNER" } ], @@ -2932,7 +3255,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "container", "aspect": { @@ -2948,7 +3271,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", + "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -2973,12 +3296,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59" + "container": "urn:li:container:b330768923270ff5450695bee1c94247" } }, "systemMetadata": { @@ -2989,7 +3312,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -3010,9 +3333,9 @@ "updated_by": "abc@acryl.io", "updated_at": "2022-10-19 13:27:29.633000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/information_schema/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default/quickstart_table", "name": "quickstart_table", - "qualifiedName": "system.information_schema.quickstart_table", + "qualifiedName": "system.default.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -3032,7 +3355,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -3050,12 +3373,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.system.information_schema.quickstart_table", + "schemaName": "acryl_metastore.system.default.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -3108,7 +3431,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { @@ -3133,7 +3456,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { @@ -3148,8 +3471,8 @@ "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" }, { - "id": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59", - "urn": "urn:li:container:cb26af5fb7ba2e1c6f2cd804101a5a59" + "id": "urn:li:container:b330768923270ff5450695bee1c94247", + "urn": "urn:li:container:b330768923270ff5450695bee1c94247" } ] } @@ -3506,22 +3829,6 @@ "lastRunId": "no-run-id-provided" } }, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.information_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", @@ -3556,7 +3863,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3572,7 +3879,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3588,7 +3895,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3604,7 +3911,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.information_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3620,7 +3927,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { diff --git a/metadata-ingestion/tests/unit/test_unity_catalog_config.py b/metadata-ingestion/tests/unit/test_unity_catalog_config.py index 4098ed4074de2f..3c0994cde7889f 100644 --- a/metadata-ingestion/tests/unit/test_unity_catalog_config.py +++ b/metadata-ingestion/tests/unit/test_unity_catalog_config.py @@ -67,7 +67,6 @@ def test_profiling_requires_warehouses_id(): @freeze_time(FROZEN_TIME) def test_workspace_url_should_start_with_https(): - with pytest.raises(ValueError, match="Workspace URL must start with http scheme"): UnityCatalogSourceConfig.parse_obj( { @@ -76,3 +75,67 @@ def test_workspace_url_should_start_with_https(): "profiling": {"enabled": True}, } ) + + +def test_global_warehouse_id_is_set_from_profiling(): + config = UnityCatalogSourceConfig.parse_obj( + { + "token": "token", + "workspace_url": "https://XXXXXXXXXXXXXXXXXXXXX", + "profiling": { + "method": "ge", + "enabled": True, + "warehouse_id": "my_warehouse_id", + }, + } + ) + assert config.profiling.warehouse_id == "my_warehouse_id" + assert config.warehouse_id == "my_warehouse_id" + + +def test_set_different_warehouse_id_from_profiling(): + with pytest.raises( + ValueError, + match="When `warehouse_id` is set, it must match the `warehouse_id` in `profiling`.", + ): + UnityCatalogSourceConfig.parse_obj( + { + "token": "token", + "workspace_url": "https://XXXXXXXXXXXXXXXXXXXXX", + "warehouse_id": "my_global_warehouse_id", + "profiling": { + "method": "ge", + "enabled": True, + "warehouse_id": "my_warehouse_id", + }, + } + ) + + +def test_warehouse_id_must_be_set_if_include_hive_metastore_is_true(): + with pytest.raises( + ValueError, + match="When `include_hive_metastore` is set, `warehouse_id` must be set.", + ): + UnityCatalogSourceConfig.parse_obj( + { + "token": "token", + "workspace_url": "https://XXXXXXXXXXXXXXXXXXXXX", + "include_hive_metastore": True, + } + ) + + +def test_set_profiling_warehouse_id_from_global(): + config = UnityCatalogSourceConfig.parse_obj( + { + "token": "token", + "workspace_url": "https://XXXXXXXXXXXXXXXXXXXXX", + "warehouse_id": "my_global_warehouse_id", + "profiling": { + "method": "ge", + "enabled": True, + }, + } + ) + assert config.profiling.warehouse_id == "my_global_warehouse_id" From 0d6a5e5df25b58af0a434d5d2f83f6ef463ba99b Mon Sep 17 00:00:00 2001 From: siddiquebagwan-gslab Date: Thu, 14 Dec 2023 21:06:28 +0530 Subject: [PATCH 025/540] feat(ingestion/transformer): create tag if not exist (#9076) --- .../src/datahub/ingestion/graph/client.py | 24 ++++++ .../ingestion/transformer/add_dataset_tags.py | 42 ++++++++++- .../ingestion/transformer/base_transformer.py | 75 +++++++++++++++---- .../tests/unit/test_transform_dataset.py | 32 ++++++-- 4 files changed, 154 insertions(+), 19 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/graph/client.py b/metadata-ingestion/src/datahub/ingestion/graph/client.py index d91165ac9777ca..5c24b06dde9998 100644 --- a/metadata-ingestion/src/datahub/ingestion/graph/client.py +++ b/metadata-ingestion/src/datahub/ingestion/graph/client.py @@ -787,9 +787,11 @@ def get_aspect_counts(self, aspect: str, urn_like: Optional[str] = None) -> int: def execute_graphql(self, query: str, variables: Optional[Dict] = None) -> Dict: url = f"{self.config.server}/api/graphql" + body: Dict = { "query": query, } + if variables: body["variables"] = variables @@ -1065,6 +1067,28 @@ def parse_sql_lineage( default_schema=default_schema, ) + def create_tag(self, tag_name: str) -> str: + graph_query: str = """ + mutation($tag_detail: CreateTagInput!) { + createTag(input: $tag_detail) + } + """ + + variables = { + "tag_detail": { + "name": tag_name, + "id": tag_name, + }, + } + + res = self.execute_graphql( + query=graph_query, + variables=variables, + ) + + # return urn + return res["createTag"] + def close(self) -> None: self._make_schema_resolver.cache_clear() super().close() diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_tags.py b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_tags.py index 5a276ad899c482..72a8c226e491ed 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_tags.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/add_dataset_tags.py @@ -1,14 +1,24 @@ +import logging from typing import Callable, List, Optional, cast +import datahub.emitter.mce_builder as builder from datahub.configuration.common import ( KeyValuePattern, TransformerSemanticsConfigModel, ) from datahub.configuration.import_resolver import pydantic_resolve_key from datahub.emitter.mce_builder import Aspect +from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.transformer.dataset_transformer import DatasetTagsTransformer -from datahub.metadata.schema_classes import GlobalTagsClass, TagAssociationClass +from datahub.metadata.schema_classes import ( + GlobalTagsClass, + TagAssociationClass, + TagKeyClass, +) +from datahub.utilities.urns.tag_urn import TagUrn + +logger = logging.getLogger(__name__) class AddDatasetTagsConfig(TransformerSemanticsConfigModel): @@ -22,11 +32,13 @@ class AddDatasetTags(DatasetTagsTransformer): ctx: PipelineContext config: AddDatasetTagsConfig + processed_tags: List[TagAssociationClass] def __init__(self, config: AddDatasetTagsConfig, ctx: PipelineContext): super().__init__() self.ctx = ctx self.config = config + self.processed_tags = [] @classmethod def create(cls, config_dict: dict, ctx: PipelineContext) -> "AddDatasetTags": @@ -45,11 +57,38 @@ def transform_aspect( tags_to_add = self.config.get_tags_to_add(entity_urn) if tags_to_add is not None: out_global_tags_aspect.tags.extend(tags_to_add) + self.processed_tags.extend( + tags_to_add + ) # Keep track of tags added so that we can create them in handle_end_of_stream return self.get_result_semantics( self.config, self.ctx.graph, entity_urn, out_global_tags_aspect ) + def handle_end_of_stream(self) -> List[MetadataChangeProposalWrapper]: + + mcps: List[MetadataChangeProposalWrapper] = [] + + logger.debug("Generating tags") + + for tag_association in self.processed_tags: + ids: List[str] = TagUrn.create_from_string( + tag_association.tag + ).get_entity_id() + + assert len(ids) == 1, "Invalid Tag Urn" + + tag_name: str = ids[0] + + mcps.append( + MetadataChangeProposalWrapper( + entityUrn=builder.make_tag_urn(tag=tag_name), + aspect=TagKeyClass(name=tag_name), + ) + ) + + return mcps + class SimpleDatasetTagConfig(TransformerSemanticsConfigModel): tag_urns: List[str] @@ -82,6 +121,7 @@ class PatternAddDatasetTags(AddDatasetTags): """Transformer that adds a specified set of tags to each dataset.""" def __init__(self, config: PatternDatasetTagsConfig, ctx: PipelineContext): + config.tag_pattern.all tag_pattern = config.tag_pattern generic_config = AddDatasetTagsConfig( get_tags_to_add=lambda _: [ diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py index e0d6ae720c9a18..8b6f42dcfba4b8 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py @@ -17,13 +17,30 @@ log = logging.getLogger(__name__) -class LegacyMCETransformer(Transformer, metaclass=ABCMeta): +def _update_work_unit_id( + envelope: RecordEnvelope, urn: str, aspect_name: str +) -> Dict[Any, Any]: + structured_urn = Urn.create_from_string(urn) + simple_name = "-".join(structured_urn.get_entity_id()) + record_metadata = envelope.metadata.copy() + record_metadata.update({"workunit_id": f"txform-{simple_name}-{aspect_name}"}) + return record_metadata + + +class HandleEndOfStreamTransformer: + def handle_end_of_stream(self) -> List[MetadataChangeProposalWrapper]: + return [] + + +class LegacyMCETransformer( + Transformer, HandleEndOfStreamTransformer, metaclass=ABCMeta +): @abstractmethod def transform_one(self, mce: MetadataChangeEventClass) -> MetadataChangeEventClass: pass -class SingleAspectTransformer(metaclass=ABCMeta): +class SingleAspectTransformer(HandleEndOfStreamTransformer, metaclass=ABCMeta): @abstractmethod def aspect_name(self) -> str: """Implement this method to specify a single aspect that the transformer is interested in subscribing to. No default provided.""" @@ -180,6 +197,32 @@ def _transform_or_record_mcpw( self._record_mcp(envelope.record) return envelope if envelope.record.aspect is not None else None + def _handle_end_of_stream( + self, envelope: RecordEnvelope + ) -> Iterable[RecordEnvelope]: + + if not isinstance(self, SingleAspectTransformer) and not isinstance( + self, LegacyMCETransformer + ): + return + + mcps: List[MetadataChangeProposalWrapper] = self.handle_end_of_stream() + + for mcp in mcps: + if mcp.aspect is None or mcp.entityUrn is None: # to silent the lint error + continue + + record_metadata = _update_work_unit_id( + envelope=envelope, + aspect_name=mcp.aspect.get_aspect_name(), # type: ignore + urn=mcp.entityUrn, + ) + + yield RecordEnvelope( + record=mcp, + metadata=record_metadata, + ) + def transform( self, record_envelopes: Iterable[RecordEnvelope] ) -> Iterable[RecordEnvelope]: @@ -216,17 +259,10 @@ def transform( else None, ) if transformed_aspect: - # for end of stream records, we modify the workunit-id structured_urn = Urn.create_from_string(urn) - simple_name = "-".join(structured_urn.get_entity_id()) - record_metadata = envelope.metadata.copy() - record_metadata.update( - { - "workunit_id": f"txform-{simple_name}-{self.aspect_name()}" - } - ) - yield RecordEnvelope( - record=MetadataChangeProposalWrapper( + + mcp: MetadataChangeProposalWrapper = ( + MetadataChangeProposalWrapper( entityUrn=urn, entityType=structured_urn.get_type(), systemMetadata=last_seen_mcp.systemMetadata @@ -234,8 +270,21 @@ def transform( else last_seen_mce_system_metadata, aspectName=self.aspect_name(), aspect=transformed_aspect, - ), + ) + ) + + record_metadata = _update_work_unit_id( + envelope=envelope, + aspect_name=mcp.aspect.get_aspect_name(), # type: ignore + urn=mcp.entityUrn, + ) + + yield RecordEnvelope( + record=mcp, metadata=record_metadata, ) + self._mark_processed(urn) + yield from self._handle_end_of_stream(envelope=envelope) + yield envelope diff --git a/metadata-ingestion/tests/unit/test_transform_dataset.py b/metadata-ingestion/tests/unit/test_transform_dataset.py index 8014df2f5c519d..546549dcf37a4a 100644 --- a/metadata-ingestion/tests/unit/test_transform_dataset.py +++ b/metadata-ingestion/tests/unit/test_transform_dataset.py @@ -813,13 +813,25 @@ def test_simple_dataset_tags_transformation(mock_time): ] ) ) - assert len(outputs) == 3 + + assert len(outputs) == 5 # Check that tags were added. tags_aspect = outputs[1].record.aspect + assert tags_aspect.tags[0].tag == builder.make_tag_urn("NeedsDocumentation") assert tags_aspect assert len(tags_aspect.tags) == 2 - assert tags_aspect.tags[0].tag == builder.make_tag_urn("NeedsDocumentation") + + # Check new tag entity should be there + assert outputs[2].record.aspectName == "tagKey" + assert outputs[2].record.aspect.name == "NeedsDocumentation" + assert outputs[2].record.entityUrn == builder.make_tag_urn("NeedsDocumentation") + + assert outputs[3].record.aspectName == "tagKey" + assert outputs[3].record.aspect.name == "Legacy" + assert outputs[3].record.entityUrn == builder.make_tag_urn("Legacy") + + assert isinstance(outputs[4].record, EndOfStream) def dummy_tag_resolver_method(dataset_snapshot): @@ -853,7 +865,7 @@ def test_pattern_dataset_tags_transformation(mock_time): ) ) - assert len(outputs) == 3 + assert len(outputs) == 5 tags_aspect = outputs[1].record.aspect assert tags_aspect assert len(tags_aspect.tags) == 2 @@ -1363,7 +1375,7 @@ def test_mcp_add_tags_missing(mock_time): ] input_stream.append(RecordEnvelope(record=EndOfStream(), metadata={})) outputs = list(transformer.transform(input_stream)) - assert len(outputs) == 3 + assert len(outputs) == 5 assert outputs[0].record == dataset_mcp # Check that tags were added, this will be the second result tags_aspect = outputs[1].record.aspect @@ -1395,13 +1407,23 @@ def test_mcp_add_tags_existing(mock_time): ] input_stream.append(RecordEnvelope(record=EndOfStream(), metadata={})) outputs = list(transformer.transform(input_stream)) - assert len(outputs) == 2 + + assert len(outputs) == 4 + # Check that tags were added, this will be the second result tags_aspect = outputs[0].record.aspect assert tags_aspect assert len(tags_aspect.tags) == 3 assert tags_aspect.tags[0].tag == builder.make_tag_urn("Test") assert tags_aspect.tags[1].tag == builder.make_tag_urn("NeedsDocumentation") + assert tags_aspect.tags[2].tag == builder.make_tag_urn("Legacy") + + # Check tag entities got added + assert outputs[1].record.entityType == "tag" + assert outputs[1].record.entityUrn == builder.make_tag_urn("NeedsDocumentation") + assert outputs[2].record.entityType == "tag" + assert outputs[2].record.entityUrn == builder.make_tag_urn("Legacy") + assert isinstance(outputs[-1].record, EndOfStream) From ecef50f8fc75309562cf2729380ed18d5020ae8b Mon Sep 17 00:00:00 2001 From: Shirshanka Das Date: Thu, 14 Dec 2023 08:03:36 -0800 Subject: [PATCH 026/540] =?UTF-8?q?fix(ingest):=20make=20user=5Furn=20and?= =?UTF-8?q?=20group=5Furn=20generation=20consider=20user=20and=E2=80=A6=20?= =?UTF-8?q?(#9026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aseem Bansal --- .../src/datahub/emitter/mce_builder.py | 8 +++---- .../tests/unit/test_mce_builder.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/metadata-ingestion/src/datahub/emitter/mce_builder.py b/metadata-ingestion/src/datahub/emitter/mce_builder.py index 3b2c87ea25a314..9da1b0ab56f890 100644 --- a/metadata-ingestion/src/datahub/emitter/mce_builder.py +++ b/metadata-ingestion/src/datahub/emitter/mce_builder.py @@ -193,20 +193,20 @@ def assertion_urn_to_key(assertion_urn: str) -> Optional[AssertionKeyClass]: def make_user_urn(username: str) -> str: """ - Makes a user urn if the input is not a user urn already + Makes a user urn if the input is not a user or group urn already """ return ( f"urn:li:corpuser:{username}" - if not username.startswith("urn:li:corpuser:") + if not username.startswith(("urn:li:corpuser:", "urn:li:corpGroup:")) else username ) def make_group_urn(groupname: str) -> str: """ - Makes a group urn if the input is not a group urn already + Makes a group urn if the input is not a user or group urn already """ - if groupname and groupname.startswith("urn:li:corpGroup:"): + if groupname and groupname.startswith(("urn:li:corpGroup:", "urn:li:corpuser:")): return groupname else: return f"urn:li:corpGroup:{groupname}" diff --git a/metadata-ingestion/tests/unit/test_mce_builder.py b/metadata-ingestion/tests/unit/test_mce_builder.py index b9025d76a3a1d1..d7c84f7863b407 100644 --- a/metadata-ingestion/tests/unit/test_mce_builder.py +++ b/metadata-ingestion/tests/unit/test_mce_builder.py @@ -33,3 +33,25 @@ def test_create_dataset_urn_with_reserved_chars() -> None: ) == "urn:li:dataset:(urn:li:dataPlatform:platform%29,platform%2Cinstance.table_%28name%29,PROD)" ) + + +def test_make_user_urn() -> None: + assert builder.make_user_urn("someUser") == "urn:li:corpuser:someUser" + assert ( + builder.make_user_urn("urn:li:corpuser:someUser") == "urn:li:corpuser:someUser" + ) + assert ( + builder.make_user_urn("urn:li:corpGroup:someGroup") + == "urn:li:corpGroup:someGroup" + ) + + +def test_make_group_urn() -> None: + assert builder.make_group_urn("someGroup") == "urn:li:corpGroup:someGroup" + assert ( + builder.make_group_urn("urn:li:corpGroup:someGroup") + == "urn:li:corpGroup:someGroup" + ) + assert ( + builder.make_group_urn("urn:li:corpuser:someUser") == "urn:li:corpuser:someUser" + ) From 1741c07d769f56a9cf066172725384b4e8780839 Mon Sep 17 00:00:00 2001 From: Shubham Jagtap <132359390+shubhamjagtap639@users.noreply.github.com> Date: Thu, 14 Dec 2023 23:01:51 +0530 Subject: [PATCH 027/540] feat(ingestion): Add test_connection methods for important sources (#9334) --- .../datahub/ingestion/source/dbt/dbt_cloud.py | 89 ++-- .../datahub/ingestion/source/dbt/dbt_core.py | 56 ++- .../src/datahub/ingestion/source/kafka.py | 74 ++- .../ingestion/source/powerbi/powerbi.py | 22 +- .../ingestion/source/sql/sql_common.py | 26 +- .../src/datahub/ingestion/source/tableau.py | 23 +- .../ingestion/source_config/sql/snowflake.py | 2 +- .../tests/integration/dbt/test_dbt.py | 69 ++- .../tests/integration/kafka/test_kafka.py | 85 +++- .../tests/integration/mysql/test_mysql.py | 38 +- .../tests/integration/powerbi/test_powerbi.py | 23 +- .../tableau/test_tableau_ingest.py | 21 +- .../test_helpers/test_connection_helpers.py | 47 ++ .../tests/unit/test_snowflake_source.py | 428 +++++++----------- .../tests/unit/test_sql_common.py | 62 ++- 15 files changed, 684 insertions(+), 381 deletions(-) create mode 100644 metadata-ingestion/tests/test_helpers/test_connection_helpers.py diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_cloud.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_cloud.py index a9685b2554553d..069c1f2781460a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_cloud.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_cloud.py @@ -14,7 +14,12 @@ platform_name, support_status, ) -from datahub.ingestion.api.source import SourceCapability +from datahub.ingestion.api.source import ( + CapabilityReport, + SourceCapability, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.source.dbt.dbt_common import ( DBTColumn, DBTCommonConfig, @@ -177,7 +182,7 @@ class DBTCloudConfig(DBTCommonConfig): @support_status(SupportStatus.INCUBATING) @capability(SourceCapability.DELETION_DETECTION, "Enabled via stateful ingestion") @capability(SourceCapability.LINEAGE_COARSE, "Enabled by default") -class DBTCloudSource(DBTSourceBase): +class DBTCloudSource(DBTSourceBase, TestableSource): """ This source pulls dbt metadata directly from the dbt Cloud APIs. @@ -199,6 +204,57 @@ def create(cls, config_dict, ctx): config = DBTCloudConfig.parse_obj(config_dict) return cls(config, ctx, "dbt") + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + test_report = TestConnectionReport() + try: + source_config = DBTCloudConfig.parse_obj_allow_extras(config_dict) + DBTCloudSource._send_graphql_query( + metadata_endpoint=source_config.metadata_endpoint, + token=source_config.token, + query=_DBT_GRAPHQL_QUERY.format(type="tests", fields="jobId"), + variables={ + "jobId": source_config.job_id, + "runId": source_config.run_id, + }, + ) + test_report.basic_connectivity = CapabilityReport(capable=True) + except Exception as e: + test_report.basic_connectivity = CapabilityReport( + capable=False, failure_reason=str(e) + ) + return test_report + + @staticmethod + def _send_graphql_query( + metadata_endpoint: str, token: str, query: str, variables: Dict + ) -> Dict: + logger.debug(f"Sending GraphQL query to dbt Cloud: {query}") + response = requests.post( + metadata_endpoint, + json={ + "query": query, + "variables": variables, + }, + headers={ + "Authorization": f"Bearer {token}", + "X-dbt-partner-source": "acryldatahub", + }, + ) + + try: + res = response.json() + if "errors" in res: + raise ValueError( + f'Unable to fetch metadata from dbt Cloud: {res["errors"]}' + ) + data = res["data"] + except JSONDecodeError as e: + response.raise_for_status() + raise e + + return data + def load_nodes(self) -> Tuple[List[DBTNode], Dict[str, Optional[str]]]: # TODO: In dbt Cloud, commands are scheduled as part of jobs, where # each job can have multiple runs. We currently only fully support @@ -213,6 +269,8 @@ def load_nodes(self) -> Tuple[List[DBTNode], Dict[str, Optional[str]]]: for node_type, fields in _DBT_FIELDS_BY_TYPE.items(): logger.info(f"Fetching {node_type} from dbt Cloud") data = self._send_graphql_query( + metadata_endpoint=self.config.metadata_endpoint, + token=self.config.token, query=_DBT_GRAPHQL_QUERY.format(type=node_type, fields=fields), variables={ "jobId": self.config.job_id, @@ -232,33 +290,6 @@ def load_nodes(self) -> Tuple[List[DBTNode], Dict[str, Optional[str]]]: return nodes, additional_metadata - def _send_graphql_query(self, query: str, variables: Dict) -> Dict: - logger.debug(f"Sending GraphQL query to dbt Cloud: {query}") - response = requests.post( - self.config.metadata_endpoint, - json={ - "query": query, - "variables": variables, - }, - headers={ - "Authorization": f"Bearer {self.config.token}", - "X-dbt-partner-source": "acryldatahub", - }, - ) - - try: - res = response.json() - if "errors" in res: - raise ValueError( - f'Unable to fetch metadata from dbt Cloud: {res["errors"]}' - ) - data = res["data"] - except JSONDecodeError as e: - response.raise_for_status() - raise e - - return data - def _parse_into_dbt_node(self, node: Dict) -> DBTNode: key = node["uniqueId"] diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py index ac2b2815f3caaa..563b005d7a88d2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py @@ -18,7 +18,12 @@ platform_name, support_status, ) -from datahub.ingestion.api.source import SourceCapability +from datahub.ingestion.api.source import ( + CapabilityReport, + SourceCapability, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.source.aws.aws_common import AwsConnectionConfig from datahub.ingestion.source.dbt.dbt_common import ( DBTColumn, @@ -60,11 +65,6 @@ class DBTCoreConfig(DBTCommonConfig): _github_info_deprecated = pydantic_renamed_field("github_info", "git_info") - @property - def s3_client(self): - assert self.aws_connection - return self.aws_connection.get_s3_client() - @validator("aws_connection") def aws_connection_needed_if_s3_uris_present( cls, aws_connection: Optional[AwsConnectionConfig], values: Dict, **kwargs: Any @@ -363,7 +363,7 @@ def load_test_results( @support_status(SupportStatus.CERTIFIED) @capability(SourceCapability.DELETION_DETECTION, "Enabled via stateful ingestion") @capability(SourceCapability.LINEAGE_COARSE, "Enabled by default") -class DBTCoreSource(DBTSourceBase): +class DBTCoreSource(DBTSourceBase, TestableSource): """ The artifacts used by this source are: - [dbt manifest file](https://docs.getdbt.com/reference/artifacts/manifest-json) @@ -387,12 +387,34 @@ def create(cls, config_dict, ctx): config = DBTCoreConfig.parse_obj(config_dict) return cls(config, ctx, "dbt") - def load_file_as_json(self, uri: str) -> Any: + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + test_report = TestConnectionReport() + try: + source_config = DBTCoreConfig.parse_obj_allow_extras(config_dict) + DBTCoreSource.load_file_as_json( + source_config.manifest_path, source_config.aws_connection + ) + DBTCoreSource.load_file_as_json( + source_config.catalog_path, source_config.aws_connection + ) + test_report.basic_connectivity = CapabilityReport(capable=True) + except Exception as e: + test_report.basic_connectivity = CapabilityReport( + capable=False, failure_reason=str(e) + ) + return test_report + + @staticmethod + def load_file_as_json( + uri: str, aws_connection: Optional[AwsConnectionConfig] + ) -> Dict: if re.match("^https?://", uri): return json.loads(requests.get(uri).text) elif re.match("^s3://", uri): u = urlparse(uri) - response = self.config.s3_client.get_object( + assert aws_connection + response = aws_connection.get_s3_client().get_object( Bucket=u.netloc, Key=u.path.lstrip("/") ) return json.loads(response["Body"].read().decode("utf-8")) @@ -410,12 +432,18 @@ def loadManifestAndCatalog( Optional[str], Optional[str], ]: - dbt_manifest_json = self.load_file_as_json(self.config.manifest_path) + dbt_manifest_json = self.load_file_as_json( + self.config.manifest_path, self.config.aws_connection + ) - dbt_catalog_json = self.load_file_as_json(self.config.catalog_path) + dbt_catalog_json = self.load_file_as_json( + self.config.catalog_path, self.config.aws_connection + ) if self.config.sources_path is not None: - dbt_sources_json = self.load_file_as_json(self.config.sources_path) + dbt_sources_json = self.load_file_as_json( + self.config.sources_path, self.config.aws_connection + ) sources_results = dbt_sources_json["results"] else: sources_results = {} @@ -491,7 +519,9 @@ def load_nodes(self) -> Tuple[List[DBTNode], Dict[str, Optional[str]]]: # This will populate the test_results field on each test node. all_nodes = load_test_results( self.config, - self.load_file_as_json(self.config.test_results_path), + self.load_file_as_json( + self.config.test_results_path, self.config.aws_connection + ), all_nodes, ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/kafka.py b/metadata-ingestion/src/datahub/ingestion/source/kafka.py index 25520e7aa66fff..99ef737206ab0c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/kafka.py +++ b/metadata-ingestion/src/datahub/ingestion/source/kafka.py @@ -15,6 +15,7 @@ ConfigResource, TopicMetadata, ) +from confluent_kafka.schema_registry.schema_registry_client import SchemaRegistryClient from datahub.configuration.common import AllowDenyPattern from datahub.configuration.kafka import KafkaConsumerConnectionConfig @@ -40,7 +41,13 @@ support_status, ) from datahub.ingestion.api.registry import import_path -from datahub.ingestion.api.source import MetadataWorkUnitProcessor, SourceCapability +from datahub.ingestion.api.source import ( + CapabilityReport, + MetadataWorkUnitProcessor, + SourceCapability, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.ingestion.source.common.subtypes import DatasetSubTypes from datahub.ingestion.source.kafka_schema_registry_base import KafkaSchemaRegistryBase @@ -133,6 +140,18 @@ class KafkaSourceConfig( ) +def get_kafka_consumer( + connection: KafkaConsumerConnectionConfig, +) -> confluent_kafka.Consumer: + return confluent_kafka.Consumer( + { + "group.id": "test", + "bootstrap.servers": connection.bootstrap, + **connection.consumer_config, + } + ) + + @dataclass class KafkaSourceReport(StaleEntityRemovalSourceReport): topics_scanned: int = 0 @@ -145,6 +164,45 @@ def report_dropped(self, topic: str) -> None: self.filtered.append(topic) +class KafkaConnectionTest: + def __init__(self, config_dict: dict): + self.config = KafkaSourceConfig.parse_obj_allow_extras(config_dict) + self.report = KafkaSourceReport() + self.consumer: confluent_kafka.Consumer = get_kafka_consumer( + self.config.connection + ) + + def get_connection_test(self) -> TestConnectionReport: + capability_report = { + SourceCapability.SCHEMA_METADATA: self.schema_registry_connectivity(), + } + return TestConnectionReport( + basic_connectivity=self.basic_connectivity(), + capability_report={ + k: v for k, v in capability_report.items() if v is not None + }, + ) + + def basic_connectivity(self) -> CapabilityReport: + try: + self.consumer.list_topics(timeout=10) + return CapabilityReport(capable=True) + except Exception as e: + return CapabilityReport(capable=False, failure_reason=str(e)) + + def schema_registry_connectivity(self) -> CapabilityReport: + try: + SchemaRegistryClient( + { + "url": self.config.connection.schema_registry_url, + **self.config.connection.schema_registry_config, + } + ).get_subjects() + return CapabilityReport(capable=True) + except Exception as e: + return CapabilityReport(capable=False, failure_reason=str(e)) + + @platform_name("Kafka") @config_class(KafkaSourceConfig) @support_status(SupportStatus.CERTIFIED) @@ -160,7 +218,7 @@ def report_dropped(self, topic: str) -> None: SourceCapability.SCHEMA_METADATA, "Schemas associated with each topic are extracted from the schema registry. Avro and Protobuf (certified), JSON (incubating). Schema references are supported.", ) -class KafkaSource(StatefulIngestionSourceBase): +class KafkaSource(StatefulIngestionSourceBase, TestableSource): """ This plugin extracts the following: - Topics from the Kafka broker @@ -183,12 +241,8 @@ def create_schema_registry( def __init__(self, config: KafkaSourceConfig, ctx: PipelineContext): super().__init__(config, ctx) self.source_config: KafkaSourceConfig = config - self.consumer: confluent_kafka.Consumer = confluent_kafka.Consumer( - { - "group.id": "test", - "bootstrap.servers": self.source_config.connection.bootstrap, - **self.source_config.connection.consumer_config, - } + self.consumer: confluent_kafka.Consumer = get_kafka_consumer( + self.source_config.connection ) self.init_kafka_admin_client() self.report: KafkaSourceReport = KafkaSourceReport() @@ -226,6 +280,10 @@ def init_kafka_admin_client(self) -> None: f"Failed to create Kafka Admin Client due to error {e}.", ) + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + return KafkaConnectionTest(config_dict).get_connection_test() + @classmethod def create(cls, config_dict: Dict, ctx: PipelineContext) -> "KafkaSource": config: KafkaSourceConfig = KafkaSourceConfig.parse_obj(config_dict) diff --git a/metadata-ingestion/src/datahub/ingestion/source/powerbi/powerbi.py b/metadata-ingestion/src/datahub/ingestion/source/powerbi/powerbi.py index 4b1d0403ac7760..cdf7c975c0614f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/powerbi/powerbi.py +++ b/metadata-ingestion/src/datahub/ingestion/source/powerbi/powerbi.py @@ -19,7 +19,13 @@ platform_name, support_status, ) -from datahub.ingestion.api.source import MetadataWorkUnitProcessor, SourceReport +from datahub.ingestion.api.source import ( + CapabilityReport, + MetadataWorkUnitProcessor, + SourceReport, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.api.source_helpers import auto_workunit from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.ingestion.source.common.subtypes import ( @@ -1147,7 +1153,7 @@ def report_to_datahub_work_units( SourceCapability.LINEAGE_FINE, "Disabled by default, configured using `extract_column_level_lineage`. ", ) -class PowerBiDashboardSource(StatefulIngestionSourceBase): +class PowerBiDashboardSource(StatefulIngestionSourceBase, TestableSource): """ This plugin extracts the following: - Power BI dashboards, tiles and datasets @@ -1186,6 +1192,18 @@ def __init__(self, config: PowerBiDashboardSourceConfig, ctx: PipelineContext): self, self.source_config, self.ctx ) + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + test_report = TestConnectionReport() + try: + PowerBiAPI(PowerBiDashboardSourceConfig.parse_obj_allow_extras(config_dict)) + test_report.basic_connectivity = CapabilityReport(capable=True) + except Exception as e: + test_report.basic_connectivity = CapabilityReport( + capable=False, failure_reason=str(e) + ) + return test_report + @classmethod def create(cls, config_dict, ctx): config = PowerBiDashboardSourceConfig.parse_obj(config_dict) diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_common.py b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_common.py index 590bc7f696784e..a831dfa50342d7 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_common.py @@ -15,6 +15,7 @@ Tuple, Type, Union, + cast, ) import sqlalchemy.dialects.postgresql.base @@ -35,7 +36,12 @@ from datahub.emitter.sql_parsing_builder import SqlParsingBuilder from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.incremental_lineage_helper import auto_incremental_lineage -from datahub.ingestion.api.source import MetadataWorkUnitProcessor +from datahub.ingestion.api.source import ( + CapabilityReport, + MetadataWorkUnitProcessor, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.ingestion.source.common.subtypes import ( DatasetContainerSubTypes, @@ -298,7 +304,7 @@ class ProfileMetadata: dataset_name_to_storage_bytes: Dict[str, int] = field(default_factory=dict) -class SQLAlchemySource(StatefulIngestionSourceBase): +class SQLAlchemySource(StatefulIngestionSourceBase, TestableSource): """A Base class for all SQL Sources that use SQLAlchemy to extend""" def __init__(self, config: SQLCommonConfig, ctx: PipelineContext, platform: str): @@ -348,6 +354,22 @@ def __init__(self, config: SQLCommonConfig, ctx: PipelineContext, platform: str) else: self._view_definition_cache = {} + @classmethod + def test_connection(cls, config_dict: dict) -> TestConnectionReport: + test_report = TestConnectionReport() + try: + source = cast( + SQLAlchemySource, + cls.create(config_dict, PipelineContext(run_id="test_connection")), + ) + list(source.get_inspectors()) + test_report.basic_connectivity = CapabilityReport(capable=True) + except Exception as e: + test_report.basic_connectivity = CapabilityReport( + capable=False, failure_reason=str(e) + ) + return test_report + def warn(self, log: logging.Logger, key: str, reason: str) -> None: self.report.report_warning(key, reason[:100]) log.warning(f"{key} => {reason}") diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau.py index f870e99df27c5f..ed5fe543310b8f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau.py @@ -58,7 +58,13 @@ platform_name, support_status, ) -from datahub.ingestion.api.source import MetadataWorkUnitProcessor, Source +from datahub.ingestion.api.source import ( + CapabilityReport, + MetadataWorkUnitProcessor, + Source, + TestableSource, + TestConnectionReport, +) from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.ingestion.source import tableau_constant as c from datahub.ingestion.source.common.subtypes import ( @@ -469,7 +475,7 @@ class TableauSourceReport(StaleEntityRemovalSourceReport): SourceCapability.LINEAGE_FINE, "Enabled by default, configure using `extract_column_level_lineage`", ) -class TableauSource(StatefulIngestionSourceBase): +class TableauSource(StatefulIngestionSourceBase, TestableSource): platform = "tableau" def __hash__(self): @@ -509,6 +515,19 @@ def __init__( self._authenticate() + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + test_report = TestConnectionReport() + try: + source_config = TableauConfig.parse_obj_allow_extras(config_dict) + source_config.make_tableau_client() + test_report.basic_connectivity = CapabilityReport(capable=True) + except Exception as e: + test_report.basic_connectivity = CapabilityReport( + capable=False, failure_reason=str(e) + ) + return test_report + def close(self) -> None: try: if self.server is not None: diff --git a/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py b/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py index ccc4e115729a2c..46bd24c7e1f4c3 100644 --- a/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py +++ b/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py @@ -143,7 +143,7 @@ def _check_oauth_config(oauth_config: Optional[OAuthConfiguration]) -> None: "'oauth_config' is none but should be set when using OAUTH_AUTHENTICATOR authentication" ) if oauth_config.use_certificate is True: - if oauth_config.provider == OAuthIdentityProvider.OKTA.value: + if oauth_config.provider == OAuthIdentityProvider.OKTA: raise ValueError( "Certificate authentication is not supported for Okta." ) diff --git a/metadata-ingestion/tests/integration/dbt/test_dbt.py b/metadata-ingestion/tests/integration/dbt/test_dbt.py index 95b5374bbb41df..587831495c1ea7 100644 --- a/metadata-ingestion/tests/integration/dbt/test_dbt.py +++ b/metadata-ingestion/tests/integration/dbt/test_dbt.py @@ -10,20 +10,25 @@ from datahub.ingestion.run.pipeline import Pipeline from datahub.ingestion.run.pipeline_config import PipelineConfig, SourceConfig from datahub.ingestion.source.dbt.dbt_common import DBTEntitiesEnabled, EmitDirective -from datahub.ingestion.source.dbt.dbt_core import DBTCoreConfig +from datahub.ingestion.source.dbt.dbt_core import DBTCoreConfig, DBTCoreSource from datahub.ingestion.source.sql.sql_types import ( ATHENA_SQL_TYPES_MAP, TRINO_SQL_TYPES_MAP, resolve_athena_modified_type, resolve_trino_modified_type, ) -from tests.test_helpers import mce_helpers +from tests.test_helpers import mce_helpers, test_connection_helpers FROZEN_TIME = "2022-02-03 07:00:00" GMS_PORT = 8080 GMS_SERVER = f"http://localhost:{GMS_PORT}" +@pytest.fixture(scope="module") +def test_resources_dir(pytestconfig): + return pytestconfig.rootpath / "tests/integration/dbt" + + @dataclass class DbtTestConfig: run_id: str @@ -195,7 +200,14 @@ def set_paths( ) @pytest.mark.integration @freeze_time(FROZEN_TIME) -def test_dbt_ingest(dbt_test_config, pytestconfig, tmp_path, mock_time, requests_mock): +def test_dbt_ingest( + dbt_test_config, + test_resources_dir, + pytestconfig, + tmp_path, + mock_time, + requests_mock, +): config: DbtTestConfig = dbt_test_config test_resources_dir = pytestconfig.rootpath / "tests/integration/dbt" @@ -233,11 +245,48 @@ def test_dbt_ingest(dbt_test_config, pytestconfig, tmp_path, mock_time, requests ) +@pytest.mark.parametrize( + "config_dict, is_success", + [ + ( + { + "manifest_path": "dbt_manifest.json", + "catalog_path": "dbt_catalog.json", + "target_platform": "postgres", + }, + True, + ), + ( + { + "manifest_path": "dbt_manifest.json", + "catalog_path": "dbt_catalog-this-file-does-not-exist.json", + "target_platform": "postgres", + }, + False, + ), + ], +) @pytest.mark.integration @freeze_time(FROZEN_TIME) -def test_dbt_tests(pytestconfig, tmp_path, mock_time, **kwargs): - test_resources_dir = pytestconfig.rootpath / "tests/integration/dbt" +def test_dbt_test_connection(test_resources_dir, config_dict, is_success): + config_dict["manifest_path"] = str( + (test_resources_dir / config_dict["manifest_path"]).resolve() + ) + config_dict["catalog_path"] = str( + (test_resources_dir / config_dict["catalog_path"]).resolve() + ) + report = test_connection_helpers.run_test_connection(DBTCoreSource, config_dict) + if is_success: + test_connection_helpers.assert_basic_connectivity_success(report) + else: + test_connection_helpers.assert_basic_connectivity_failure( + report, "No such file or directory" + ) + +@pytest.mark.integration +@freeze_time(FROZEN_TIME) +def test_dbt_tests(test_resources_dir, pytestconfig, tmp_path, mock_time, **kwargs): # Run the metadata ingestion pipeline. output_file = tmp_path / "dbt_test_events.json" golden_path = test_resources_dir / "dbt_test_events_golden.json" @@ -340,9 +389,9 @@ def test_resolve_athena_modified_type(data_type, expected_data_type): @pytest.mark.integration @freeze_time(FROZEN_TIME) -def test_dbt_tests_only_assertions(pytestconfig, tmp_path, mock_time, **kwargs): - test_resources_dir = pytestconfig.rootpath / "tests/integration/dbt" - +def test_dbt_tests_only_assertions( + test_resources_dir, pytestconfig, tmp_path, mock_time, **kwargs +): # Run the metadata ingestion pipeline. output_file = tmp_path / "test_only_assertions.json" @@ -418,10 +467,8 @@ def test_dbt_tests_only_assertions(pytestconfig, tmp_path, mock_time, **kwargs): @pytest.mark.integration @freeze_time(FROZEN_TIME) def test_dbt_only_test_definitions_and_results( - pytestconfig, tmp_path, mock_time, **kwargs + test_resources_dir, pytestconfig, tmp_path, mock_time, **kwargs ): - test_resources_dir = pytestconfig.rootpath / "tests/integration/dbt" - # Run the metadata ingestion pipeline. output_file = tmp_path / "test_only_definitions_and_assertions.json" diff --git a/metadata-ingestion/tests/integration/kafka/test_kafka.py b/metadata-ingestion/tests/integration/kafka/test_kafka.py index 63d284801c94cd..dfdbea5de5cbfd 100644 --- a/metadata-ingestion/tests/integration/kafka/test_kafka.py +++ b/metadata-ingestion/tests/integration/kafka/test_kafka.py @@ -3,18 +3,22 @@ import pytest from freezegun import freeze_time -from tests.test_helpers import mce_helpers +from datahub.ingestion.api.source import SourceCapability +from datahub.ingestion.source.kafka import KafkaSource +from tests.test_helpers import mce_helpers, test_connection_helpers from tests.test_helpers.click_helpers import run_datahub_cmd from tests.test_helpers.docker_helpers import wait_for_port FROZEN_TIME = "2020-04-14 07:00:00" -@freeze_time(FROZEN_TIME) -@pytest.mark.integration -def test_kafka_ingest(docker_compose_runner, pytestconfig, tmp_path, mock_time): - test_resources_dir = pytestconfig.rootpath / "tests/integration/kafka" +@pytest.fixture(scope="module") +def test_resources_dir(pytestconfig): + return pytestconfig.rootpath / "tests/integration/kafka" + +@pytest.fixture(scope="module") +def mock_kafka_service(docker_compose_runner, test_resources_dir): with docker_compose_runner( test_resources_dir / "docker-compose.yml", "kafka", cleanup=False ) as docker_services: @@ -31,14 +35,67 @@ def test_kafka_ingest(docker_compose_runner, pytestconfig, tmp_path, mock_time): command = f"{test_resources_dir}/send_records.sh {test_resources_dir}" subprocess.run(command, shell=True, check=True) - # Run the metadata ingestion pipeline. - config_file = (test_resources_dir / "kafka_to_file.yml").resolve() - run_datahub_cmd(["ingest", "-c", f"{config_file}"], tmp_path=tmp_path) + yield docker_compose_runner + + +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_kafka_ingest( + mock_kafka_service, test_resources_dir, pytestconfig, tmp_path, mock_time +): + # Run the metadata ingestion pipeline. + config_file = (test_resources_dir / "kafka_to_file.yml").resolve() + run_datahub_cmd(["ingest", "-c", f"{config_file}"], tmp_path=tmp_path) - # Verify the output. - mce_helpers.check_golden_file( - pytestconfig, - output_path=tmp_path / "kafka_mces.json", - golden_path=test_resources_dir / "kafka_mces_golden.json", - ignore_paths=[], + # Verify the output. + mce_helpers.check_golden_file( + pytestconfig, + output_path=tmp_path / "kafka_mces.json", + golden_path=test_resources_dir / "kafka_mces_golden.json", + ignore_paths=[], + ) + + +@pytest.mark.parametrize( + "config_dict, is_success", + [ + ( + { + "connection": { + "bootstrap": "localhost:29092", + "schema_registry_url": "http://localhost:28081", + }, + }, + True, + ), + ( + { + "connection": { + "bootstrap": "localhost:2909", + "schema_registry_url": "http://localhost:2808", + }, + }, + False, + ), + ], +) +@pytest.mark.integration +@freeze_time(FROZEN_TIME) +def test_kafka_test_connection(mock_kafka_service, config_dict, is_success): + report = test_connection_helpers.run_test_connection(KafkaSource, config_dict) + if is_success: + test_connection_helpers.assert_basic_connectivity_success(report) + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + success_capabilities=[SourceCapability.SCHEMA_METADATA], + ) + else: + test_connection_helpers.assert_basic_connectivity_failure( + report, "Failed to get metadata" + ) + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + failure_capabilities={ + SourceCapability.SCHEMA_METADATA: "Failed to establish a new connection" + }, ) diff --git a/metadata-ingestion/tests/integration/mysql/test_mysql.py b/metadata-ingestion/tests/integration/mysql/test_mysql.py index 23fd97ff2671ed..c19198c7d2bbd0 100644 --- a/metadata-ingestion/tests/integration/mysql/test_mysql.py +++ b/metadata-ingestion/tests/integration/mysql/test_mysql.py @@ -3,7 +3,8 @@ import pytest from freezegun import freeze_time -from tests.test_helpers import mce_helpers +from datahub.ingestion.source.sql.mysql import MySQLSource +from tests.test_helpers import mce_helpers, test_connection_helpers from tests.test_helpers.click_helpers import run_datahub_cmd from tests.test_helpers.docker_helpers import wait_for_port @@ -75,3 +76,38 @@ def test_mysql_ingest_no_db( output_path=tmp_path / "mysql_mces.json", golden_path=test_resources_dir / golden_file, ) + + +@pytest.mark.parametrize( + "config_dict, is_success", + [ + ( + { + "host_port": "localhost:53307", + "database": "northwind", + "username": "root", + "password": "example", + }, + True, + ), + ( + { + "host_port": "localhost:5330", + "database": "wrong_db", + "username": "wrong_user", + "password": "wrong_pass", + }, + False, + ), + ], +) +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_mysql_test_connection(mysql_runner, config_dict, is_success): + report = test_connection_helpers.run_test_connection(MySQLSource, config_dict) + if is_success: + test_connection_helpers.assert_basic_connectivity_success(report) + else: + test_connection_helpers.assert_basic_connectivity_failure( + report, "Connection refused" + ) diff --git a/metadata-ingestion/tests/integration/powerbi/test_powerbi.py b/metadata-ingestion/tests/integration/powerbi/test_powerbi.py index b2cbccf983eb0c..4e8469f919db9c 100644 --- a/metadata-ingestion/tests/integration/powerbi/test_powerbi.py +++ b/metadata-ingestion/tests/integration/powerbi/test_powerbi.py @@ -21,7 +21,7 @@ Report, Workspace, ) -from tests.test_helpers import mce_helpers +from tests.test_helpers import mce_helpers, test_connection_helpers pytestmark = pytest.mark.integration_batch_2 FROZEN_TIME = "2022-02-03 07:00:00" @@ -681,6 +681,27 @@ def test_powerbi_ingest( ) +@freeze_time(FROZEN_TIME) +@mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) +@pytest.mark.integration +def test_powerbi_test_connection_success(mock_msal): + report = test_connection_helpers.run_test_connection( + PowerBiDashboardSource, default_source_config() + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_powerbi_test_connection_failure(): + report = test_connection_helpers.run_test_connection( + PowerBiDashboardSource, default_source_config() + ) + test_connection_helpers.assert_basic_connectivity_failure( + report, "Unable to get authority configuration" + ) + + @freeze_time(FROZEN_TIME) @mock.patch("msal.ConfidentialClientApplication", side_effect=mock_msal_cca) @pytest.mark.integration diff --git a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py index 0510f4a40f6597..90fa71013338da 100644 --- a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py +++ b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py @@ -28,7 +28,7 @@ ) from datahub.metadata.schema_classes import MetadataChangeProposalClass, UpstreamClass from datahub.utilities.sqlglot_lineage import SqlParsingResult -from tests.test_helpers import mce_helpers +from tests.test_helpers import mce_helpers, test_connection_helpers from tests.test_helpers.state_helpers import ( get_current_checkpoint_from_pipeline, validate_all_providers_have_committed_successfully, @@ -290,6 +290,25 @@ def test_tableau_ingest(pytestconfig, tmp_path, mock_datahub_graph): ) +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_tableau_test_connection_success(): + with mock.patch("datahub.ingestion.source.tableau.Server"): + report = test_connection_helpers.run_test_connection( + TableauSource, config_source_default + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_tableau_test_connection_failure(): + report = test_connection_helpers.run_test_connection( + TableauSource, config_source_default + ) + test_connection_helpers.assert_basic_connectivity_failure(report, "Unable to login") + + @freeze_time(FROZEN_TIME) @pytest.mark.integration def test_tableau_cll_ingest(pytestconfig, tmp_path, mock_datahub_graph): diff --git a/metadata-ingestion/tests/test_helpers/test_connection_helpers.py b/metadata-ingestion/tests/test_helpers/test_connection_helpers.py new file mode 100644 index 00000000000000..45543033ae010c --- /dev/null +++ b/metadata-ingestion/tests/test_helpers/test_connection_helpers.py @@ -0,0 +1,47 @@ +from typing import Dict, List, Optional, Type, Union + +from datahub.ingestion.api.source import ( + CapabilityReport, + SourceCapability, + TestableSource, + TestConnectionReport, +) + + +def run_test_connection( + source_cls: Type[TestableSource], config_dict: Dict +) -> TestConnectionReport: + return source_cls.test_connection(config_dict) + + +def assert_basic_connectivity_success(report: TestConnectionReport) -> None: + assert report is not None + assert report.basic_connectivity + assert report.basic_connectivity.capable + assert report.basic_connectivity.failure_reason is None + + +def assert_basic_connectivity_failure( + report: TestConnectionReport, expected_reason: str +) -> None: + assert report is not None + assert report.basic_connectivity + assert not report.basic_connectivity.capable + assert report.basic_connectivity.failure_reason + assert expected_reason in report.basic_connectivity.failure_reason + + +def assert_capability_report( + capability_report: Optional[Dict[Union[SourceCapability, str], CapabilityReport]], + success_capabilities: List[SourceCapability] = [], + failure_capabilities: Dict[SourceCapability, str] = {}, +) -> None: + assert capability_report + for capability in success_capabilities: + assert capability_report[capability] + assert capability_report[capability].failure_reason is None + for capability, expected_reason in failure_capabilities.items(): + assert not capability_report[capability].capable + failure_reason = capability_report[capability].failure_reason + assert failure_reason + assert expected_reason in failure_reason diff --git a/metadata-ingestion/tests/unit/test_snowflake_source.py b/metadata-ingestion/tests/unit/test_snowflake_source.py index 343f4466fd6fdf..536c91ace4f5ed 100644 --- a/metadata-ingestion/tests/unit/test_snowflake_source.py +++ b/metadata-ingestion/tests/unit/test_snowflake_source.py @@ -1,3 +1,4 @@ +from typing import Any, Dict from unittest.mock import MagicMock, patch import pytest @@ -24,10 +25,20 @@ SnowflakeObjectAccessEntry, ) from datahub.ingestion.source.snowflake.snowflake_v2 import SnowflakeV2Source +from tests.test_helpers import test_connection_helpers + +default_oauth_dict: Dict[str, Any] = { + "client_id": "client_id", + "client_secret": "secret", + "use_certificate": False, + "provider": "microsoft", + "scopes": ["datahub_role"], + "authority_url": "https://dev-abc.okta.com/oauth2/def/v1/token", +} def test_snowflake_source_throws_error_on_account_id_missing(): - with pytest.raises(ValidationError): + with pytest.raises(ValidationError, match="account_id\n field required"): SnowflakeV2Config.parse_obj( { "username": "user", @@ -37,27 +48,21 @@ def test_snowflake_source_throws_error_on_account_id_missing(): def test_no_client_id_invalid_oauth_config(): - oauth_dict = { - "provider": "microsoft", - "scopes": ["https://microsoft.com/f4b353d5-ef8d/.default"], - "client_secret": "6Hb9apkbc6HD7", - "authority_url": "https://login.microsoftonline.com/yourorganisation.com", - } - with pytest.raises(ValueError): + oauth_dict = default_oauth_dict.copy() + del oauth_dict["client_id"] + with pytest.raises(ValueError, match="client_id\n field required"): OAuthConfiguration.parse_obj(oauth_dict) def test_snowflake_throws_error_on_client_secret_missing_if_use_certificate_is_false(): - oauth_dict = { - "client_id": "882e9831-7ea51cb2b954", - "provider": "microsoft", - "scopes": ["https://microsoft.com/f4b353d5-ef8d/.default"], - "use_certificate": False, - "authority_url": "https://login.microsoftonline.com/yourorganisation.com", - } + oauth_dict = default_oauth_dict.copy() + del oauth_dict["client_secret"] OAuthConfiguration.parse_obj(oauth_dict) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="'oauth_config.client_secret' was none but should be set when using use_certificate false for oauth_config", + ): SnowflakeV2Config.parse_obj( { "account_id": "test", @@ -68,16 +73,13 @@ def test_snowflake_throws_error_on_client_secret_missing_if_use_certificate_is_f def test_snowflake_throws_error_on_encoded_oauth_private_key_missing_if_use_certificate_is_true(): - oauth_dict = { - "client_id": "882e9831-7ea51cb2b954", - "provider": "microsoft", - "scopes": ["https://microsoft.com/f4b353d5-ef8d/.default"], - "use_certificate": True, - "authority_url": "https://login.microsoftonline.com/yourorganisation.com", - "encoded_oauth_public_key": "fkdsfhkshfkjsdfiuwrwfkjhsfskfhksjf==", - } + oauth_dict = default_oauth_dict.copy() + oauth_dict["use_certificate"] = True OAuthConfiguration.parse_obj(oauth_dict) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="'base64_encoded_oauth_private_key' was none but should be set when using certificate for oauth_config", + ): SnowflakeV2Config.parse_obj( { "account_id": "test", @@ -88,16 +90,13 @@ def test_snowflake_throws_error_on_encoded_oauth_private_key_missing_if_use_cert def test_snowflake_oauth_okta_does_not_support_certificate(): - oauth_dict = { - "client_id": "882e9831-7ea51cb2b954", - "provider": "okta", - "scopes": ["https://microsoft.com/f4b353d5-ef8d/.default"], - "use_certificate": True, - "authority_url": "https://login.microsoftonline.com/yourorganisation.com", - "encoded_oauth_public_key": "fkdsfhkshfkjsdfiuwrwfkjhsfskfhksjf==", - } + oauth_dict = default_oauth_dict.copy() + oauth_dict["use_certificate"] = True + oauth_dict["provider"] = "okta" OAuthConfiguration.parse_obj(oauth_dict) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Certificate authentication is not supported for Okta." + ): SnowflakeV2Config.parse_obj( { "account_id": "test", @@ -108,79 +107,52 @@ def test_snowflake_oauth_okta_does_not_support_certificate(): def test_snowflake_oauth_happy_paths(): - okta_dict = { - "client_id": "client_id", - "client_secret": "secret", - "provider": "okta", - "scopes": ["datahub_role"], - "authority_url": "https://dev-abc.okta.com/oauth2/def/v1/token", - } + oauth_dict = default_oauth_dict.copy() + oauth_dict["provider"] = "okta" assert SnowflakeV2Config.parse_obj( { "account_id": "test", "authentication_type": "OAUTH_AUTHENTICATOR", - "oauth_config": okta_dict, + "oauth_config": oauth_dict, } ) - - microsoft_dict = { - "client_id": "client_id", - "provider": "microsoft", - "scopes": ["https://microsoft.com/f4b353d5-ef8d/.default"], - "use_certificate": True, - "authority_url": "https://login.microsoftonline.com/yourorganisation.com", - "encoded_oauth_public_key": "publickey", - "encoded_oauth_private_key": "privatekey", - } + oauth_dict["use_certificate"] = True + oauth_dict["provider"] = "microsoft" + oauth_dict["encoded_oauth_public_key"] = "publickey" + oauth_dict["encoded_oauth_private_key"] = "privatekey" assert SnowflakeV2Config.parse_obj( { "account_id": "test", "authentication_type": "OAUTH_AUTHENTICATOR", - "oauth_config": microsoft_dict, + "oauth_config": oauth_dict, } ) +default_config_dict: Dict[str, Any] = { + "username": "user", + "password": "password", + "account_id": "https://acctname.snowflakecomputing.com", + "warehouse": "COMPUTE_WH", + "role": "sysadmin", +} + + def test_account_id_is_added_when_host_port_is_present(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "host_port": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - ) + config_dict = default_config_dict.copy() + del config_dict["account_id"] + config_dict["host_port"] = "acctname" + config = SnowflakeV2Config.parse_obj(config_dict) assert config.account_id == "acctname" def test_account_id_with_snowflake_host_suffix(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "https://acctname.snowflakecomputing.com", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - ) + config = SnowflakeV2Config.parse_obj(default_config_dict) assert config.account_id == "acctname" def test_snowflake_uri_default_authentication(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - ) - + config = SnowflakeV2Config.parse_obj(default_config_dict) assert config.get_sql_alchemy_url() == ( "snowflake://user:password@acctname" "?application=acryl_datahub" @@ -191,17 +163,10 @@ def test_snowflake_uri_default_authentication(): def test_snowflake_uri_external_browser_authentication(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - "authentication_type": "EXTERNAL_BROWSER_AUTHENTICATOR", - } - ) - + config_dict = default_config_dict.copy() + del config_dict["password"] + config_dict["authentication_type"] = "EXTERNAL_BROWSER_AUTHENTICATOR" + config = SnowflakeV2Config.parse_obj(config_dict) assert config.get_sql_alchemy_url() == ( "snowflake://user@acctname" "?application=acryl_datahub" @@ -212,18 +177,12 @@ def test_snowflake_uri_external_browser_authentication(): def test_snowflake_uri_key_pair_authentication(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - "authentication_type": "KEY_PAIR_AUTHENTICATOR", - "private_key_path": "/a/random/path", - "private_key_password": "a_random_password", - } - ) + config_dict = default_config_dict.copy() + del config_dict["password"] + config_dict["authentication_type"] = "KEY_PAIR_AUTHENTICATOR" + config_dict["private_key_path"] = "/a/random/path" + config_dict["private_key_password"] = "a_random_password" + config = SnowflakeV2Config.parse_obj(config_dict) assert config.get_sql_alchemy_url() == ( "snowflake://user@acctname" @@ -235,63 +194,35 @@ def test_snowflake_uri_key_pair_authentication(): def test_options_contain_connect_args(): - config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - ) + config = SnowflakeV2Config.parse_obj(default_config_dict) connect_args = config.get_options().get("connect_args") assert connect_args is not None def test_snowflake_config_with_view_lineage_no_table_lineage_throws_error(): - with pytest.raises(ValidationError): - SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - "include_view_lineage": True, - "include_table_lineage": False, - } - ) + config_dict = default_config_dict.copy() + config_dict["include_view_lineage"] = True + config_dict["include_table_lineage"] = False + with pytest.raises( + ValidationError, + match="include_table_lineage must be True for include_view_lineage to be set", + ): + SnowflakeV2Config.parse_obj(config_dict) def test_snowflake_config_with_column_lineage_no_table_lineage_throws_error(): - with pytest.raises(ValidationError): - SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - "include_column_lineage": True, - "include_table_lineage": False, - } - ) + config_dict = default_config_dict.copy() + config_dict["include_column_lineage"] = True + config_dict["include_table_lineage"] = False + with pytest.raises( + ValidationError, + match="include_table_lineage must be True for include_column_lineage to be set", + ): + SnowflakeV2Config.parse_obj(config_dict) def test_snowflake_config_with_no_connect_args_returns_base_connect_args(): - config: SnowflakeV2Config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - ) + config: SnowflakeV2Config = SnowflakeV2Config.parse_obj(default_config_dict) assert config.get_options()["connect_args"] is not None assert config.get_options()["connect_args"] == { CLIENT_PREFETCH_THREADS: 10, @@ -300,7 +231,10 @@ def test_snowflake_config_with_no_connect_args_returns_base_connect_args(): def test_private_key_set_but_auth_not_changed(): - with pytest.raises(ValidationError): + with pytest.raises( + ValidationError, + match="Either `private_key` and `private_key_path` is set but `authentication_type` is DEFAULT_AUTHENTICATOR. Should be set to 'KEY_PAIR_AUTHENTICATOR' when using key pair authentication", + ): SnowflakeV2Config.parse_obj( { "account_id": "acctname", @@ -310,19 +244,11 @@ def test_private_key_set_but_auth_not_changed(): def test_snowflake_config_with_connect_args_overrides_base_connect_args(): - config: SnowflakeV2Config = SnowflakeV2Config.parse_obj( - { - "username": "user", - "password": "password", - "account_id": "acctname", - "database_pattern": {"allow": {"^demo$"}}, - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - "connect_args": { - CLIENT_PREFETCH_THREADS: 5, - }, - } - ) + config_dict = default_config_dict.copy() + config_dict["connect_args"] = { + CLIENT_PREFETCH_THREADS: 5, + } + config: SnowflakeV2Config = SnowflakeV2Config.parse_obj(config_dict) assert config.get_options()["connect_args"] is not None assert config.get_options()["connect_args"][CLIENT_PREFETCH_THREADS] == 5 assert config.get_options()["connect_args"][CLIENT_SESSION_KEEP_ALIVE] is True @@ -331,35 +257,20 @@ def test_snowflake_config_with_connect_args_overrides_base_connect_args(): @patch("snowflake.connector.connect") def test_test_connection_failure(mock_connect): mock_connect.side_effect = Exception("Failed to connect to snowflake") - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - report = SnowflakeV2Source.test_connection(config) - assert report is not None - assert report.basic_connectivity - assert not report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason - assert "Failed to connect to snowflake" in report.basic_connectivity.failure_reason + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_failure( + report, "Failed to connect to snowflake" + ) @patch("snowflake.connector.connect") def test_test_connection_basic_success(mock_connect): - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - report = SnowflakeV2Source.test_connection(config) - assert report is not None - assert report.basic_connectivity - assert report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason is None + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_success(report) def setup_mock_connect(mock_connect, query_results=None): @@ -400,31 +311,18 @@ def query_results(query): return [] raise ValueError(f"Unexpected query: {query}") - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } setup_mock_connect(mock_connect, query_results) - report = SnowflakeV2Source.test_connection(config) - assert report is not None - assert report.basic_connectivity - assert report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason is None - - assert report.capability_report - assert report.capability_report[SourceCapability.CONTAINERS].capable - assert not report.capability_report[SourceCapability.SCHEMA_METADATA].capable - failure_reason = report.capability_report[ - SourceCapability.SCHEMA_METADATA - ].failure_reason - assert failure_reason - - assert ( - "Current role TEST_ROLE does not have permissions to use warehouse" - in failure_reason + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + success_capabilities=[SourceCapability.CONTAINERS], + failure_capabilities={ + SourceCapability.SCHEMA_METADATA: "Current role TEST_ROLE does not have permissions to use warehouse" + }, ) @@ -445,25 +343,17 @@ def query_results(query): setup_mock_connect(mock_connect, query_results) - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - report = SnowflakeV2Source.test_connection(config) - assert report is not None - assert report.basic_connectivity - assert report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason is None - assert report.capability_report - - assert report.capability_report[SourceCapability.CONTAINERS].capable - assert not report.capability_report[SourceCapability.SCHEMA_METADATA].capable - assert ( - report.capability_report[SourceCapability.SCHEMA_METADATA].failure_reason - is not None + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + success_capabilities=[SourceCapability.CONTAINERS], + failure_capabilities={ + SourceCapability.SCHEMA_METADATA: "Either no tables exist or current role does not have permissions to access them" + }, ) @@ -488,24 +378,19 @@ def query_results(query): setup_mock_connect(mock_connect, query_results) - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - report = SnowflakeV2Source.test_connection(config) - - assert report is not None - assert report.basic_connectivity - assert report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason is None - assert report.capability_report - - assert report.capability_report[SourceCapability.CONTAINERS].capable - assert report.capability_report[SourceCapability.SCHEMA_METADATA].capable - assert report.capability_report[SourceCapability.DESCRIPTIONS].capable + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + success_capabilities=[ + SourceCapability.CONTAINERS, + SourceCapability.SCHEMA_METADATA, + SourceCapability.DESCRIPTIONS, + ], + ) @patch("snowflake.connector.connect") @@ -538,25 +423,21 @@ def query_results(query): setup_mock_connect(mock_connect, query_results) - config = { - "username": "user", - "password": "password", - "account_id": "missing", - "warehouse": "COMPUTE_WH", - "role": "sysadmin", - } - report = SnowflakeV2Source.test_connection(config) - assert report is not None - assert report.basic_connectivity - assert report.basic_connectivity.capable - assert report.basic_connectivity.failure_reason is None - assert report.capability_report - - assert report.capability_report[SourceCapability.CONTAINERS].capable - assert report.capability_report[SourceCapability.SCHEMA_METADATA].capable - assert report.capability_report[SourceCapability.DATA_PROFILING].capable - assert report.capability_report[SourceCapability.DESCRIPTIONS].capable - assert report.capability_report[SourceCapability.LINEAGE_COARSE].capable + report = test_connection_helpers.run_test_connection( + SnowflakeV2Source, default_config_dict + ) + test_connection_helpers.assert_basic_connectivity_success(report) + + test_connection_helpers.assert_capability_report( + capability_report=report.capability_report, + success_capabilities=[ + SourceCapability.CONTAINERS, + SourceCapability.SCHEMA_METADATA, + SourceCapability.DATA_PROFILING, + SourceCapability.DESCRIPTIONS, + SourceCapability.LINEAGE_COARSE, + ], + ) def test_aws_cloud_region_from_snowflake_region_id(): @@ -610,11 +491,10 @@ def test_azure_cloud_region_from_snowflake_region_id(): def test_unknown_cloud_region_from_snowflake_region_id(): - with pytest.raises(Exception) as e: + with pytest.raises(Exception, match="Unknown snowflake region"): SnowflakeV2Source.get_cloud_region_from_snowflake_region_id( "somecloud_someregion" ) - assert "Unknown snowflake region" in str(e) def test_snowflake_object_access_entry_missing_object_id(): diff --git a/metadata-ingestion/tests/unit/test_sql_common.py b/metadata-ingestion/tests/unit/test_sql_common.py index e23d290b611f4c..a98bf641711220 100644 --- a/metadata-ingestion/tests/unit/test_sql_common.py +++ b/metadata-ingestion/tests/unit/test_sql_common.py @@ -1,8 +1,7 @@ from typing import Dict -from unittest.mock import Mock +from unittest import mock import pytest -from sqlalchemy.engine.reflection import Inspector from datahub.ingestion.source.sql.sql_common import PipelineContext, SQLAlchemySource from datahub.ingestion.source.sql.sql_config import SQLCommonConfig @@ -13,19 +12,24 @@ class _TestSQLAlchemyConfig(SQLCommonConfig): def get_sql_alchemy_url(self): - pass + return "mysql+pymysql://user:pass@localhost:5330" class _TestSQLAlchemySource(SQLAlchemySource): - pass + @classmethod + def create(cls, config_dict, ctx): + config = _TestSQLAlchemyConfig.parse_obj(config_dict) + return cls(config, ctx, "TEST") + + +def get_test_sql_alchemy_source(): + return _TestSQLAlchemySource.create( + config_dict={}, ctx=PipelineContext(run_id="test_ctx") + ) def test_generate_foreign_key(): - config: SQLCommonConfig = _TestSQLAlchemyConfig() - ctx: PipelineContext = PipelineContext(run_id="test_ctx") - platform: str = "TEST" - inspector: Inspector = Mock() - source = _TestSQLAlchemySource(config=config, ctx=ctx, platform=platform) + source = get_test_sql_alchemy_source() fk_dict: Dict[str, str] = { "name": "test_constraint", "referred_table": "test_table", @@ -37,7 +41,7 @@ def test_generate_foreign_key(): dataset_urn="test_urn", schema="test_schema", fk_dict=fk_dict, - inspector=inspector, + inspector=mock.Mock(), ) assert fk_dict.get("name") == foreign_key.name @@ -48,11 +52,7 @@ def test_generate_foreign_key(): def test_use_source_schema_for_foreign_key_if_not_specified(): - config: SQLCommonConfig = _TestSQLAlchemyConfig() - ctx: PipelineContext = PipelineContext(run_id="test_ctx") - platform: str = "TEST" - inspector: Inspector = Mock() - source = _TestSQLAlchemySource(config=config, ctx=ctx, platform=platform) + source = get_test_sql_alchemy_source() fk_dict: Dict[str, str] = { "name": "test_constraint", "referred_table": "test_table", @@ -63,7 +63,7 @@ def test_use_source_schema_for_foreign_key_if_not_specified(): dataset_urn="test_urn", schema="test_schema", fk_dict=fk_dict, - inspector=inspector, + inspector=mock.Mock(), ) assert fk_dict.get("name") == foreign_key.name @@ -105,14 +105,32 @@ def test_get_platform_from_sqlalchemy_uri(uri: str, expected_platform: str) -> N def test_get_db_schema_with_dots_in_view_name(): - config: SQLCommonConfig = _TestSQLAlchemyConfig() - ctx: PipelineContext = PipelineContext(run_id="test_ctx") - platform: str = "TEST" - source = _TestSQLAlchemySource(config=config, ctx=ctx, platform=platform) - + source = get_test_sql_alchemy_source() database, schema = source.get_db_schema( dataset_identifier="database.schema.long.view.name1" ) - assert database == "database" assert schema == "schema" + + +def test_test_connection_success(): + source = get_test_sql_alchemy_source() + with mock.patch( + "datahub.ingestion.source.sql.sql_common.SQLAlchemySource.get_inspectors", + side_effect=lambda: [], + ): + report = source.test_connection({}) + assert report is not None + assert report.basic_connectivity + assert report.basic_connectivity.capable + assert report.basic_connectivity.failure_reason is None + + +def test_test_connection_failure(): + source = get_test_sql_alchemy_source() + report = source.test_connection({}) + assert report is not None + assert report.basic_connectivity + assert not report.basic_connectivity.capable + assert report.basic_connectivity.failure_reason + assert "Connection refused" in report.basic_connectivity.failure_reason From 26114dfeb2d255f1b2a562396908f48c8dd0ad64 Mon Sep 17 00:00:00 2001 From: naoki kuroda <68233204+nnnkkk7@users.noreply.github.com> Date: Fri, 15 Dec 2023 05:42:45 +0900 Subject: [PATCH 028/540] docs: fix sample command for container logs (#9427) --- docs/how/extract-container-logs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how/extract-container-logs.md b/docs/how/extract-container-logs.md index 9251d0665c02cf..b5fbb4c83cc645 100644 --- a/docs/how/extract-container-logs.md +++ b/docs/how/extract-container-logs.md @@ -86,7 +86,7 @@ Depending on your issue, you may be interested to view both debug and normal inf Since log files are named based on the current date, you'll need to use "ls" to see which files currently exist. To do so, you can use the `kubectl exec` command, using the pod name recorded in step one: ``` -kubectl exec datahub-frontend-1231ead-6767 -n default -- ls -la /tmp/datahub/logs/gms +kubectl exec datahub-gms-c578b47cd-7676 -n default -- ls -la /tmp/datahub/logs/gms total 36388 drwxr-xr-x 2 datahub datahub 4096 Jul 29 07:45 . @@ -131,5 +131,5 @@ Now you should be able to view the logs locally. There are a few ways to get files out of the pod and into a local file. You can either use `kubectl cp` or simply `cat` and pipe the file of interest. We'll show an example using the latter approach: ``` -kubectl exec datahub-frontend-1231ead-6767 -n default -- cat /tmp/datahub/logs/gms/gms.log > my-local-gms.log +kubectl exec datahub-gms-c578b47cd-7676 -n default -- cat /tmp/datahub/logs/gms/gms.log > my-local-gms.log ``` \ No newline at end of file From 4354af20126d1befb2c7391c23310a4eca5bb688 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 14 Dec 2023 16:54:40 -0500 Subject: [PATCH 029/540] fix(ingest): bump source configs json schema version (#9424) --- docs-website/genJsonSchema/gen_json_schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-website/genJsonSchema/gen_json_schema.py b/docs-website/genJsonSchema/gen_json_schema.py index 81c1d5a2c1a30f..4af72487644bd6 100644 --- a/docs-website/genJsonSchema/gen_json_schema.py +++ b/docs-website/genJsonSchema/gen_json_schema.py @@ -7,7 +7,7 @@ def get_base() -> Any: return { - "$schema": "http://json-schema.org/draft-04/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "id": "https://json.schemastore.org/datahub-ingestion", "title": "Datahub Ingestion", "description": "Root schema of Datahub Ingestion", @@ -116,7 +116,7 @@ def get_base() -> Any: "bootstrap": { "type": "string", "description": "Kafka bootstrap URL.", - "default": "localhost:9092" + "default": "localhost:9092", }, "producer_config": { "type": "object", @@ -125,7 +125,7 @@ def get_base() -> Any: "schema_registry_url": { "type": "string", "description": "URL of schema registry being used.", - "default": "http://localhost:8081" + "default": "http://localhost:8081", }, "schema_registry_config": { "type": "object", From 0ea6145a9d491a1b882ba5a7a4667fb323d31dc4 Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Fri, 15 Dec 2023 00:12:45 +0100 Subject: [PATCH 030/540] fix(ingest/profiling): Add option to enable external table profiling (#9463) --- .../datahub/ingestion/source/ge_profiling_config.py | 5 +++++ .../src/datahub/ingestion/source/redshift/profile.py | 9 +++++++++ .../ingestion/source/snowflake/snowflake_profiler.py | 10 ++++++++++ .../ingestion/source/snowflake/snowflake_schema.py | 3 +++ .../ingestion/source/sql/sql_generic_profiler.py | 3 +++ .../tests/integration/snowflake/common.py | 1 + 6 files changed, 31 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/ge_profiling_config.py b/metadata-ingestion/src/datahub/ingestion/source/ge_profiling_config.py index 24a3e520d8caff..f340a7b41b7af8 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/ge_profiling_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/ge_profiling_config.py @@ -167,6 +167,11 @@ class GEProfilingConfig(ConfigModel): "Applicable only if `use_sampling` is set to True.", ) + profile_external_tables: bool = Field( + default=False, + description="Whether to profile external tables. Only Snowflake and Redshift supports this.", + ) + @pydantic.root_validator(pre=True) def deprecate_bigquery_temp_table_schema(cls, values): # TODO: Update docs to remove mention of this field. diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/profile.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/profile.py index b05850cef6e948..eed82ec4d83e76 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/profile.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/profile.py @@ -48,6 +48,15 @@ def get_workunits( if not self.config.schema_pattern.allowed(schema): continue for table in tables[db].get(schema, {}): + if ( + not self.config.profiling.profile_external_tables + and table.type == "EXTERNAL_TABLE" + ): + self.report.profiling_skipped_other[schema] += 1 + logger.info( + f"Skipping profiling of external table {db}.{schema}.{table.name}" + ) + continue # Emit the profile work unit profile_request = self.get_profile_request(table, schema, db) if profile_request is not None: diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py index 89857c45642678..4bda7da422e9d6 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py @@ -50,6 +50,16 @@ def get_workunits( profile_requests = [] for schema in database.schemas: for table in db_tables[schema.name]: + if ( + not self.config.profiling.profile_external_tables + and table.type == "EXTERNAL TABLE" + ): + logger.info( + f"Skipping profiling of external table {database.name}.{schema.name}.{table.name}" + ) + self.report.profiling_skipped_other[schema.name] += 1 + continue + profile_request = self.get_profile_request( table, schema.name, database.name ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py index e5b214ba35e4b6..9526bdec4b05dc 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py @@ -77,6 +77,7 @@ def get_precise_native_type(self): @dataclass class SnowflakeTable(BaseTable): + type: Optional[str] = None clustering_key: Optional[str] = None pk: Optional[SnowflakePK] = None columns: List[SnowflakeColumn] = field(default_factory=list) @@ -265,6 +266,7 @@ def get_tables_for_database( tables[table["TABLE_SCHEMA"]].append( SnowflakeTable( name=table["TABLE_NAME"], + type=table["TABLE_TYPE"], created=table["CREATED"], last_altered=table["LAST_ALTERED"], size_in_bytes=table["BYTES"], @@ -288,6 +290,7 @@ def get_tables_for_schema( tables.append( SnowflakeTable( name=table["TABLE_NAME"], + type=table["TABLE_TYPE"], created=table["CREATED"], last_altered=table["LAST_ALTERED"], size_in_bytes=table["BYTES"], diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py index a2f91e5fae1a98..30fad9ad584c12 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_generic_profiler.py @@ -35,6 +35,9 @@ class DetailedProfilerReportMixin: profiling_skipped_row_limit: TopKDict[str, int] = field( default_factory=int_top_k_dict ) + + profiling_skipped_other: TopKDict[str, int] = field(default_factory=int_top_k_dict) + num_tables_not_eligible_profiling: Dict[str, int] = field( default_factory=int_top_k_dict ) diff --git a/metadata-ingestion/tests/integration/snowflake/common.py b/metadata-ingestion/tests/integration/snowflake/common.py index b21cea5f0988d0..53b87636068bfe 100644 --- a/metadata-ingestion/tests/integration/snowflake/common.py +++ b/metadata-ingestion/tests/integration/snowflake/common.py @@ -79,6 +79,7 @@ def default_query_results( # noqa: C901 { "TABLE_SCHEMA": "TEST_SCHEMA", "TABLE_NAME": "TABLE_{}".format(tbl_idx), + "TABLE_TYPE": "BASE TABLE", "CREATED": datetime(2021, 6, 8, 0, 0, 0, 0), "LAST_ALTERED": datetime(2021, 6, 8, 0, 0, 0, 0), "BYTES": 1024, From 6a169357283790e158472957f87f8c6cfbe67136 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Fri, 15 Dec 2023 11:23:04 -0600 Subject: [PATCH 031/540] fix(operations): fix get index sizes integer wrap (#9450) --- .../ElasticSearchTimeseriesAspectService.java | 8 +- .../TimeseriesAspectServiceUnitTest.java | 78 +++++++++++++++++++ .../timeseries/TimeseriesIndexSizeResult.pdl | 3 + ...nkedin.operations.operations.snapshot.json | 5 ++ 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceUnitTest.java diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java index eec7680a56ecb0..f9ab86d41335db 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java @@ -206,10 +206,10 @@ public List getIndexSizes() { elemResult.setEntityName(indexEntityAndAspect.get().getFirst()); elemResult.setAspectName(indexEntityAndAspect.get().getSecond()); } - int sizeBytes = - entry.getValue().get("primaries").get("store").get("size_in_bytes").asInt(); - float sizeMb = (float) sizeBytes / 1000; - elemResult.setSizeMb(sizeMb); + long sizeBytes = + entry.getValue().get("primaries").get("store").get("size_in_bytes").asLong(); + double sizeMb = (double) sizeBytes / 1000000; + elemResult.setSizeInMb(sizeMb); res.add(elemResult); }); return res; diff --git a/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceUnitTest.java b/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceUnitTest.java new file mode 100644 index 00000000000000..a23267dcf6f55e --- /dev/null +++ b/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceUnitTest.java @@ -0,0 +1,78 @@ +package com.linkedin.metadata.timeseries.search; + +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.NumericNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.search.elasticsearch.update.ESBulkProcessor; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.metadata.timeseries.elastic.ElasticSearchTimeseriesAspectService; +import com.linkedin.metadata.timeseries.elastic.indexbuilder.TimeseriesAspectIndexBuilders; +import com.linkedin.metadata.utils.elasticsearch.IndexConvention; +import com.linkedin.timeseries.TimeseriesIndexSizeResult; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpEntity; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.client.RestClient; +import org.opensearch.client.RestHighLevelClient; +import org.testng.Assert; +import org.testng.annotations.Test; + +/** + * Test using mocks instead of integration for testing functionality not dependent on a real server + * response + */ +public class TimeseriesAspectServiceUnitTest { + + private final RestHighLevelClient _searchClient = mock(RestHighLevelClient.class); + private final IndexConvention _indexConvention = mock(IndexConvention.class); + private final TimeseriesAspectIndexBuilders _timeseriesAspectIndexBuilders = + mock(TimeseriesAspectIndexBuilders.class); + private final EntityRegistry _entityRegistry = mock(EntityRegistry.class); + private final ESBulkProcessor _bulkProcessor = mock(ESBulkProcessor.class); + private final RestClient _restClient = mock(RestClient.class); + private final TimeseriesAspectService _timeseriesAspectService = + new ElasticSearchTimeseriesAspectService( + _searchClient, + _indexConvention, + _timeseriesAspectIndexBuilders, + _entityRegistry, + _bulkProcessor, + 0); + + private static final String INDEX_PATTERN = "indexPattern"; + + @Test + public void testGetIndicesIntegerWrap() throws IOException { + when(_indexConvention.getAllTimeseriesAspectIndicesPattern()).thenReturn(INDEX_PATTERN); + when(_searchClient.getLowLevelClient()).thenReturn(_restClient); + ObjectNode jsonNode = JsonNodeFactory.instance.objectNode(); + ObjectNode indicesNode = JsonNodeFactory.instance.objectNode(); + ObjectNode indexNode = JsonNodeFactory.instance.objectNode(); + ObjectNode primariesNode = JsonNodeFactory.instance.objectNode(); + ObjectNode storeNode = JsonNodeFactory.instance.objectNode(); + NumericNode bytesNode = JsonNodeFactory.instance.numberNode(8078398031L); + storeNode.set("size_in_bytes", bytesNode); + primariesNode.set("store", storeNode); + indexNode.set("primaries", primariesNode); + indicesNode.set("someIndexName", indexNode); + jsonNode.set("indices", indicesNode); + + Response response = mock(Response.class); + HttpEntity responseEntity = mock(HttpEntity.class); + when(response.getEntity()).thenReturn(responseEntity); + when(responseEntity.getContent()) + .thenReturn(IOUtils.toInputStream(jsonNode.toString(), StandardCharsets.UTF_8)); + when(_restClient.performRequest(any(Request.class))).thenReturn(response); + + List results = _timeseriesAspectService.getIndexSizes(); + + Assert.assertEquals(results.get(0).getSizeInMb(), 8078.398031); + } +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/timeseries/TimeseriesIndexSizeResult.pdl b/metadata-models/src/main/pegasus/com/linkedin/timeseries/TimeseriesIndexSizeResult.pdl index b888ef7c0716b1..35297314187bf0 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/timeseries/TimeseriesIndexSizeResult.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/timeseries/TimeseriesIndexSizeResult.pdl @@ -22,5 +22,8 @@ record TimeseriesIndexSizeResult{ /** * Size */ + @deprecated = "use sizeInMb instead" sizeMb: float = 0 + + sizeInMb: double = 0 } diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json index 339ce62de62980..eae0eed2dd50ba 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json @@ -3668,6 +3668,11 @@ "name" : "sizeMb", "type" : "float", "doc" : "Size", + "default" : 0.0, + "deprecated" : "use sizeInMb instead" + }, { + "name" : "sizeInMb", + "type" : "double", "default" : 0.0 } ] }, { From 824df5a6a3e9fed2f18f3e454c40b8d822011b5c Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:28:33 -0600 Subject: [PATCH 032/540] feat(build): gradle 8, jdk17, neo4j 5 (#9458) --- .github/workflows/airflow-plugin.yml | 5 + .github/workflows/build-and-test.yml | 4 +- .github/workflows/check-datahub-jars.yml | 4 +- .github/workflows/docker-unified.yml | 39 +- .github/workflows/documentation.yml | 4 +- .github/workflows/metadata-ingestion.yml | 5 + .github/workflows/metadata-io.yml | 4 +- .github/workflows/metadata-model.yml | 5 + .github/workflows/publish-datahub-jars.yml | 4 +- .github/workflows/spark-smoke-test.yml | 4 +- build.gradle | 137 +- buildSrc/build.gradle | 13 +- .../pegasus/gradle/PegasusPlugin.java | 2444 +++++++++++++++++ .../gradle/tasks/ChangedFileReportTask.java | 124 + datahub-frontend/build.gradle | 22 +- datahub-frontend/play.gradle | 19 +- datahub-graphql-core/build.gradle | 3 +- datahub-web-react/build.gradle | 10 +- docker/datahub-frontend/Dockerfile | 7 +- docker/datahub-frontend/start.sh | 2 + docker/datahub-gms/Dockerfile | 4 +- docker/datahub-ingestion/build.gradle | 6 +- docker/datahub-mae-consumer/Dockerfile | 4 +- docker/datahub-mce-consumer/Dockerfile | 4 +- docker/datahub-upgrade/Dockerfile | 4 +- docker/kafka-setup/Dockerfile | 2 +- docs-website/build.gradle | 18 +- docs-website/vercel-setup.sh | 2 +- docs/developers.md | 10 +- docs/how/updating-datahub.md | 4 + docs/troubleshooting/build.md | 4 +- entity-registry/build.gradle | 7 +- gradle/wrapper/gradle-wrapper.properties | 2 +- li-utils/build.gradle | 20 +- metadata-auth/auth-api/build.gradle | 9 +- metadata-events/mxe-utils-avro/build.gradle | 5 +- .../java/datahub-client/build.gradle | 16 +- .../datahub-protobuf-example/build.gradle | 4 - .../java/datahub-protobuf/build.gradle | 8 +- .../java/examples/build.gradle | 16 +- .../java/spark-lineage/build.gradle | 68 +- .../java/spark-lineage/scripts/check_jar.sh | 4 +- .../docker/SparkBase.Dockerfile | 2 +- .../python_test_run.sh | 13 +- .../spark-smoke-test/spark-docker.conf | 4 + .../test-spark-lineage/build.gradle | 11 - .../datahub/spark/TestCoalesceJobLineage.java | 5 +- .../datahub/spark/TestSparkJobsLineage.java | 3 + metadata-io/build.gradle | 5 +- .../graph/neo4j/Neo4jGraphService.java | 4 +- metadata-jobs/mae-consumer/build.gradle | 1 + metadata-jobs/mce-consumer/build.gradle | 3 +- metadata-jobs/pe-consumer/build.gradle | 3 +- metadata-models-custom/build.gradle | 2 +- metadata-models-validator/build.gradle | 4 +- metadata-models/build.gradle | 20 +- metadata-service/auth-config/build.gradle | 4 +- metadata-service/auth-filter/build.gradle | 4 +- metadata-service/auth-impl/build.gradle | 4 +- ...formInstanceFieldResolverProviderTest.java | 4 +- .../auth-servlet-impl/build.gradle | 4 +- metadata-service/factories/build.gradle | 4 +- .../graphql-servlet-impl/build.gradle | 4 +- metadata-service/openapi-servlet/build.gradle | 4 +- metadata-service/plugin/build.gradle | 6 +- .../src/test/sample-test-plugins/build.gradle | 4 +- metadata-service/restli-api/build.gradle | 6 +- metadata-service/restli-client/build.gradle | 6 +- .../restli-servlet-impl/build.gradle | 6 +- .../schema-registry-api/build.gradle | 7 +- .../schema-registry-servlet/build.gradle | 4 +- metadata-service/services/build.gradle | 6 +- metadata-service/servlet/build.gradle | 4 +- metadata-utils/build.gradle | 4 +- mock-entity-registry/build.gradle | 4 +- smoke-test/build.gradle | 7 +- test-models/build.gradle | 16 +- vercel.json | 2 +- 78 files changed, 3008 insertions(+), 266 deletions(-) create mode 100644 buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java create mode 100644 buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index d0c0f52781b9af..cd1e159b7d53cc 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -49,6 +49,11 @@ jobs: extra_pip_extras: plugin-v2 fail-fast: false steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 10c137a206531a..dab64cf2dca5e6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,11 +37,11 @@ jobs: with: timezoneLinux: ${{ matrix.timezone }} - uses: hsheth2/sane-checkout-action@v1 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/check-datahub-jars.yml b/.github/workflows/check-datahub-jars.yml index 8e507ea40fd963..46d97ffec88618 100644 --- a/.github/workflows/check-datahub-jars.yml +++ b/.github/workflows/check-datahub-jars.yml @@ -28,11 +28,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: hsheth2/sane-checkout-action@v1 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index fef23f9efa85f1..169a86000adccb 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -79,6 +79,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -135,6 +140,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -191,6 +201,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -247,6 +262,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -303,6 +323,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -537,6 +562,11 @@ jobs: needs_artifact_download: ${{ (steps.filter.outputs.datahub-ingestion-base == 'true' || steps.filter.outputs.datahub-ingestion == 'true') && needs.setup.outputs.publish != 'true' }} needs: [setup, datahub_ingestion_base_slim_build] steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - uses: dorny/paths-filter@v2 @@ -618,6 +648,11 @@ jobs: needs_artifact_download: ${{ (steps.filter.outputs.datahub-ingestion-base == 'true' || steps.filter.outputs.datahub-ingestion == 'true') && needs.setup.outputs.publish != 'true' }} needs: [setup, datahub_ingestion_base_full_build] steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - uses: dorny/paths-filter@v2 @@ -720,11 +755,11 @@ jobs: run: df -h . && docker images - name: Check out the repo uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index c94282938120e4..29953b8b70d911 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -27,11 +27,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/metadata-ingestion.yml b/.github/workflows/metadata-ingestion.yml index ec6bd4141cc6fc..4e04fef3b3980b 100644 --- a/.github/workflows/metadata-ingestion.yml +++ b/.github/workflows/metadata-ingestion.yml @@ -44,6 +44,11 @@ jobs: - python-version: "3.10" fail-fast: false steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index 48f230ce14c8db..2188fcb07c77a6 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -29,11 +29,11 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/metadata-model.yml b/.github/workflows/metadata-model.yml index eb098a327e4cb5..d0112f1b14e7af 100644 --- a/.github/workflows/metadata-model.yml +++ b/.github/workflows/metadata-model.yml @@ -29,6 +29,11 @@ jobs: runs-on: ubuntu-latest needs: setup steps: + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/publish-datahub-jars.yml b/.github/workflows/publish-datahub-jars.yml index ec7985ef3b3d03..24d1c5436b3156 100644 --- a/.github/workflows/publish-datahub-jars.yml +++ b/.github/workflows/publish-datahub-jars.yml @@ -49,11 +49,11 @@ jobs: if: ${{ needs.check-secret.outputs.publish-enabled == 'true' }} steps: - uses: hsheth2/sane-checkout-action@v1 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/spark-smoke-test.yml b/.github/workflows/spark-smoke-test.yml index 70b66d6452b266..60e183cce5179c 100644 --- a/.github/workflows/spark-smoke-test.yml +++ b/.github/workflows/spark-smoke-test.yml @@ -30,11 +30,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: hsheth2/sane-checkout-action@v1 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: "zulu" - java-version: 11 + java-version: 17 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/build.gradle b/build.gradle index b16e3ca169c717..a7a85db0398e21 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,20 @@ buildscript { + ext.jdkVersion = 17 + ext.javaClassVersion = 11 + ext.junitJupiterVersion = '5.6.1' // Releases: https://github.com/linkedin/rest.li/blob/master/CHANGELOG.md - ext.pegasusVersion = '29.46.8' + ext.pegasusVersion = '29.48.4' ext.mavenVersion = '3.6.3' ext.springVersion = '5.3.29' ext.springBootVersion = '2.7.14' ext.openTelemetryVersion = '1.18.0' - ext.neo4jVersion = '4.4.9' - ext.neo4jTestVersion = '4.4.25' - ext.neo4jApocVersion = '4.4.0.20:all' + ext.neo4jVersion = '5.14.0' + ext.neo4jTestVersion = '5.14.0' + ext.neo4jApocVersion = '5.14.0' ext.testContainersVersion = '1.17.4' ext.elasticsearchVersion = '2.9.0' // ES 7.10, Opensearch 1.x, 2.x - ext.jacksonVersion = '2.15.2' + ext.jacksonVersion = '2.15.3' ext.jettyVersion = '9.4.46.v20220331' ext.playVersion = '2.8.18' ext.log4jVersion = '2.19.0' @@ -29,19 +32,19 @@ buildscript { buildscript.repositories.addAll(project.repositories) dependencies { classpath 'com.linkedin.pegasus:gradle-plugins:' + pegasusVersion - classpath 'com.github.node-gradle:gradle-node-plugin:2.2.4' + classpath 'com.github.node-gradle:gradle-node-plugin:7.0.1' classpath 'io.acryl.gradle.plugin:gradle-avro-plugin:0.2.0' classpath 'org.springframework.boot:spring-boot-gradle-plugin:' + springBootVersion classpath "io.codearte.gradle.nexus:gradle-nexus-staging-plugin:0.30.0" classpath "com.palantir.gradle.gitversion:gradle-git-version:3.0.0" classpath "org.gradle.playframework:gradle-playframework:0.14" - classpath "gradle.plugin.org.hidetake:gradle-swagger-generator-plugin:2.19.1" + classpath "gradle.plugin.org.hidetake:gradle-swagger-generator-plugin:2.19.2" } } plugins { - id 'com.gorylenko.gradle-git-properties' version '2.4.0-rc2' - id 'com.github.johnrengelman.shadow' version '6.1.0' + id 'com.gorylenko.gradle-git-properties' version '2.4.1' + id 'com.github.johnrengelman.shadow' version '8.1.1' apply false id 'com.palantir.docker' version '0.35.0' apply false id "com.diffplug.spotless" version "6.23.3" // https://blog.ltgt.net/javax-jakarta-mess-and-gradle-solution/ @@ -149,19 +152,20 @@ project.ext.externalDependency = [ 'log4jApi': "org.apache.logging.log4j:log4j-api:$log4jVersion", 'log4j12Api': "org.slf4j:log4j-over-slf4j:$slf4jVersion", 'log4j2Api': "org.apache.logging.log4j:log4j-to-slf4j:$log4jVersion", - 'lombok': 'org.projectlombok:lombok:1.18.16', + 'lombok': 'org.projectlombok:lombok:1.18.30', 'mariadbConnector': 'org.mariadb.jdbc:mariadb-java-client:2.6.0', 'mavenArtifact': "org.apache.maven:maven-artifact:$mavenVersion", 'mixpanel': 'com.mixpanel:mixpanel-java:1.4.4', - 'mockito': 'org.mockito:mockito-core:3.0.0', - 'mockitoInline': 'org.mockito:mockito-inline:3.0.0', + 'mockito': 'org.mockito:mockito-core:4.11.0', + 'mockitoInline': 'org.mockito:mockito-inline:4.11.0', 'mockServer': 'org.mock-server:mockserver-netty:5.11.2', 'mockServerClient': 'org.mock-server:mockserver-client-java:5.11.2', 'mysqlConnector': 'mysql:mysql-connector-java:8.0.20', 'neo4jHarness': 'org.neo4j.test:neo4j-harness:' + neo4jTestVersion, 'neo4jJavaDriver': 'org.neo4j.driver:neo4j-java-driver:' + neo4jVersion, 'neo4jTestJavaDriver': 'org.neo4j.driver:neo4j-java-driver:' + neo4jTestVersion, - 'neo4jApoc': 'org.neo4j.procedure:apoc:' + neo4jApocVersion, + 'neo4jApocCore': 'org.neo4j.procedure:apoc-core:' + neo4jApocVersion, + 'neo4jApocCommon': 'org.neo4j.procedure:apoc-common:' + neo4jApocVersion, 'opentelemetryApi': 'io.opentelemetry:opentelemetry-api:' + openTelemetryVersion, 'opentelemetryAnnotations': 'io.opentelemetry:opentelemetry-extension-annotations:' + openTelemetryVersion, 'opentracingJdbc':'io.opentracing.contrib:opentracing-jdbc:0.2.15', @@ -190,8 +194,8 @@ project.ext.externalDependency = [ 'servletApi': 'javax.servlet:javax.servlet-api:3.1.0', 'shiroCore': 'org.apache.shiro:shiro-core:1.11.0', 'snakeYaml': 'org.yaml:snakeyaml:2.0', - 'sparkSql' : 'org.apache.spark:spark-sql_2.11:2.4.8', - 'sparkHive' : 'org.apache.spark:spark-hive_2.11:2.4.8', + 'sparkSql' : 'org.apache.spark:spark-sql_2.12:3.0.3', + 'sparkHive' : 'org.apache.spark:spark-hive_2.12:3.0.3', 'springBeans': "org.springframework:spring-beans:$springVersion", 'springContext': "org.springframework:spring-context:$springVersion", 'springCore': "org.springframework:spring-core:$springVersion", @@ -210,7 +214,6 @@ project.ext.externalDependency = [ 'springActuator': "org.springframework.boot:spring-boot-starter-actuator:$springBootVersion", 'swaggerAnnotations': 'io.swagger.core.v3:swagger-annotations:2.2.15', 'swaggerCli': 'io.swagger.codegen.v3:swagger-codegen-cli:3.0.46', - 'testngJava8': 'org.testng:testng:7.5.1', 'testng': 'org.testng:testng:7.8.0', 'testContainers': 'org.testcontainers:testcontainers:' + testContainersVersion, 'testContainersJunit': 'org.testcontainers:junit-jupiter:' + testContainersVersion, @@ -226,13 +229,69 @@ project.ext.externalDependency = [ 'charle': 'com.charleskorn.kaml:kaml:0.53.0', 'common': 'commons-io:commons-io:2.7', 'jline':'jline:jline:1.4.1', - 'jetbrains':' org.jetbrains.kotlin:kotlin-stdlib:1.6.0' + 'jetbrains':' org.jetbrains.kotlin:kotlin-stdlib:1.6.0', + 'annotationApi': 'javax.annotation:javax.annotation-api:1.3.2' ] allprojects { apply plugin: 'idea' apply plugin: 'eclipse' // apply plugin: 'org.gradlex.java-ecosystem-capabilities' + + tasks.withType(Test).configureEach { + // https://docs.gradle.org/current/userguide/performance.html + maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 + + if (project.configurations.getByName("testImplementation").getDependencies() + .any{ it.getName().contains("testng") }) { + useTestNG() + } + } + + if (project.plugins.hasPlugin('java') + || project.plugins.hasPlugin('java-library') + || project.plugins.hasPlugin('application') + || project.plugins.hasPlugin('pegasus')) { + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(jdkVersion) + } + } + + compileJava { + options.release = javaClassVersion + } + tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(jdkVersion) + } + } + + tasks.withType(JavaExec).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(jdkVersion) + } + } + + // not duplicated, need to set this outside and inside afterEvaluate + afterEvaluate { + compileJava { + options.release = javaClassVersion + } + tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(jdkVersion) + } + } + + tasks.withType(JavaExec).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(jdkVersion) + } + } + } + } } configure(subprojects.findAll {! it.name.startsWith('spark-lineage')}) { @@ -264,8 +323,9 @@ subprojects { failOnNoGitDirectory = false } - plugins.withType(JavaPlugin) { + plugins.withType(JavaPlugin).configureEach { dependencies { + implementation externalDependency.annotationApi constraints { implementation("com.google.googlejavaformat:google-java-format:$googleJavaFormatVersion") implementation('io.netty:netty-all:4.1.100.Final') @@ -276,18 +336,30 @@ subprojects { implementation("com.fasterxml.jackson.core:jackson-dataformat-cbor:$jacksonVersion") } } + spotless { java { googleJavaFormat() target project.fileTree(project.projectDir) { - include '**/*.java' - exclude 'build/**/*.java' - exclude '**/generated/**/*.*' - exclude '**/mainGeneratedDataTemplate/**/*.*' - exclude '**/mainGeneratedRest/**/*.*' + include 'src/**/*.java' + exclude 'src/**/resources/' + exclude 'src/**/generated/' + exclude 'src/**/mainGeneratedDataTemplate/' + exclude 'src/**/mainGeneratedRest/' + exclude 'src/renamed/avro/' + exclude 'src/test/sample-test-plugins/' } } } + + if (project.plugins.hasPlugin('pegasus')) { + dependencies { + dataTemplateCompile spec.product.pegasus.data + dataTemplateCompile externalDependency.annotationApi // support > jdk8 + restClientCompile spec.product.pegasus.restliClient + } + } + afterEvaluate { def spotlessJavaTask = tasks.findByName('spotlessJava') def processTask = tasks.findByName('processResources') @@ -305,28 +377,11 @@ subprojects { } } - tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(11) - } - } - tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(11) - } - // https://docs.gradle.org/current/userguide/performance.html - maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 - - if (project.configurations.getByName("testImplementation").getDependencies() - .any{ it.getName().contains("testng") }) { - useTestNG() - } - } - afterEvaluate { if (project.plugins.hasPlugin('pegasus')) { dependencies { dataTemplateCompile spec.product.pegasus.data + dataTemplateCompile externalDependency.annotationApi // support > jdk8 restClientCompile spec.product.pegasus.restliClient } } diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 1f9d30d520171b..0c2d91e1f7ac1b 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,9 +1,11 @@ -apply plugin: 'java' - buildscript { apply from: '../repositories.gradle' } +plugins { + id 'java' +} + dependencies { /** * Forked version of abandoned repository: https://github.com/fge/json-schema-avro @@ -21,6 +23,9 @@ dependencies { implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.13.5' implementation 'commons-io:commons-io:2.11.0' - compileOnly 'org.projectlombok:lombok:1.18.14' - annotationProcessor 'org.projectlombok:lombok:1.18.14' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + // pegasus dependency, overrides for tasks + implementation 'com.linkedin.pegasus:gradle-plugins:29.48.4' } \ No newline at end of file diff --git a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java new file mode 100644 index 00000000000000..2460abcad6f9e9 --- /dev/null +++ b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java @@ -0,0 +1,2444 @@ +/* + * Copyright (c) 2019 LinkedIn Corp. + * + * 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. + */ + +package com.linkedin.pegasus.gradle; + +import com.linkedin.pegasus.gradle.PegasusOptions.IdlOptions; +import com.linkedin.pegasus.gradle.internal.CompatibilityLogChecker; +import com.linkedin.pegasus.gradle.tasks.ChangedFileReportTask; +import com.linkedin.pegasus.gradle.tasks.CheckIdlTask; +import com.linkedin.pegasus.gradle.tasks.CheckPegasusSnapshotTask; +import com.linkedin.pegasus.gradle.tasks.CheckRestModelTask; +import com.linkedin.pegasus.gradle.tasks.CheckSnapshotTask; +import com.linkedin.pegasus.gradle.tasks.GenerateAvroSchemaTask; +import com.linkedin.pegasus.gradle.tasks.GenerateDataTemplateTask; +import com.linkedin.pegasus.gradle.tasks.GeneratePegasusSnapshotTask; +import com.linkedin.pegasus.gradle.tasks.GenerateRestClientTask; +import com.linkedin.pegasus.gradle.tasks.GenerateRestModelTask; +import com.linkedin.pegasus.gradle.tasks.PublishRestModelTask; +import com.linkedin.pegasus.gradle.tasks.TranslateSchemasTask; +import com.linkedin.pegasus.gradle.tasks.ValidateExtensionSchemaTask; +import com.linkedin.pegasus.gradle.tasks.ValidateSchemaAnnotationTask; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ConfigurationContainer; +import org.gradle.api.file.FileCollection; +import org.gradle.api.plugins.JavaBasePlugin; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.plugins.JavaPluginConvention; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.publish.PublishingExtension; +import org.gradle.api.publish.ivy.IvyPublication; +import org.gradle.api.publish.ivy.plugins.IvyPublishPlugin; +import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.Delete; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.api.tasks.Sync; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.api.tasks.bundling.Jar; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.api.tasks.javadoc.Javadoc; +import org.gradle.language.base.plugins.LifecycleBasePlugin; +import org.gradle.language.jvm.tasks.ProcessResources; +import org.gradle.plugins.ide.eclipse.EclipsePlugin; +import org.gradle.plugins.ide.eclipse.model.EclipseModel; +import org.gradle.plugins.ide.idea.IdeaPlugin; +import org.gradle.plugins.ide.idea.model.IdeaModule; +import org.gradle.util.GradleVersion; + + +/** + * Pegasus code generation plugin. + * The supported project layout for this plugin is as follows: + * + *
+ *   --- api/
+ *   |   --- build.gradle
+ *   |   --- src/
+ *   |       --- <sourceSet>/
+ *   |       |   --- idl/
+ *   |       |   |   --- <published idl (.restspec.json) files>
+ *   |       |   --- java/
+ *   |       |   |   --- <packageName>/
+ *   |       |   |       --- <common java files>
+ *   |       |   --- pegasus/
+ *   |       |       --- <packageName>/
+ *   |       |           --- <data schema (.pdsc) files>
+ *   |       --- <sourceSet>GeneratedDataTemplate/
+ *   |       |   --- java/
+ *   |       |       --- <packageName>/
+ *   |       |           --- <data template source files generated from data schema (.pdsc) files>
+ *   |       --- <sourceSet>GeneratedAvroSchema/
+ *   |       |   --- avro/
+ *   |       |       --- <packageName>/
+ *   |       |           --- <avsc avro schema files (.avsc) generated from pegasus schema files>
+ *   |       --- <sourceSet>GeneratedRest/
+ *   |           --- java/
+ *   |               --- <packageName>/
+ *   |                   --- <rest client source (.java) files generated from published idl>
+ *   --- impl/
+ *   |   --- build.gradle
+ *   |   --- src/
+ *   |       --- <sourceSet>/
+ *   |       |   --- java/
+ *   |       |       --- <packageName>/
+ *   |       |           --- <resource class source (.java) files>
+ *   |       --- <sourceSet>GeneratedRest/
+ *   |           --- idl/
+ *   |               --- <generated idl (.restspec.json) files>
+ *   --- <other projects>/
+ * 
+ *
    + *
  • + * api: contains all the files which are commonly depended by the server and + * client implementation. The common files include the data schema (.pdsc) files, + * the idl (.restspec.json) files and potentially Java interface files used by both sides. + *
  • + *
  • + * impl: contains the resource class for server implementation. + *
  • + *
+ *

Performs the following functions:

+ * + *

Generate data model and data template jars for each source set.

+ * + *

Overview:

+ * + *

+ * In the api project, the plugin generates the data template source (.java) files from the + * data schema (.pdsc) files, and furthermore compiles the source files and packages them + * to jar files. Details of jar contents will be explained in following paragraphs. + * In general, data schema files should exist only in api projects. + *

+ * + *

+ * Configure the server and client implementation projects to depend on the + * api project's dataTemplate configuration to get access to the generated data templates + * from within these projects. This allows api classes to be built first so that implementation + * projects can consume them. We recommend this structure to avoid circular dependencies + * (directly or indirectly) among implementation projects. + *

+ * + *

Detail:

+ * + *

+ * Generates data template source (.java) files from data schema (.pdsc) files, + * compiles the data template source (.java) files into class (.class) files, + * creates a data model jar file and a data template jar file. + * The data model jar file contains the source data schema (.pdsc) files. + * The data template jar file contains both the source data schema (.pdsc) files + * and the generated data template class (.class) files. + *

+ * + *

+ * In the data template generation phase, the plugin creates a new target source set + * for the generated files. The new target source set's name is the input source set name's + * suffixed with "GeneratedDataTemplate", e.g. "mainGeneratedDataTemplate". + * The plugin invokes PegasusDataTemplateGenerator to generate data template source (.java) files + * for all data schema (.pdsc) files present in the input source set's pegasus + * directory, e.g. "src/main/pegasus". The generated data template source (.java) files + * will be in the new target source set's java source directory, e.g. + * "src/mainGeneratedDataTemplate/java". In addition to + * the data schema (.pdsc) files in the pegasus directory, the dataModel configuration + * specifies resolver path for the PegasusDataTemplateGenerator. The resolver path + * provides the data schemas and previously generated data template classes that + * may be referenced by the input source set's data schemas. In most cases, the dataModel + * configuration should contain data template jars. + *

+ * + *

+ * The next phase is the data template compilation phase, the plugin compiles the generated + * data template source (.java) files into class files. The dataTemplateCompile configuration + * specifies the pegasus jars needed to compile these classes. The compileClasspath of the + * target source set is a composite of the dataModel configuration which includes the data template + * classes that were previously generated and included in the dependent data template jars, + * and the dataTemplateCompile configuration. + * This configuration should specify a dependency on the Pegasus data jar. + *

+ * + *

+ * The following phase is creating the the data model jar and the data template jar. + * This plugin creates the data model jar that includes the contents of the + * input source set's pegasus directory, and sets the jar file's classification to + * "data-model". Hence, the resulting jar file's name should end with "-data-model.jar". + * It adds the data model jar as an artifact to the dataModel configuration. + * This jar file should only contain data schema (.pdsc) files. + *

+ * + *

+ * This plugin also create the data template jar that includes the contents of the input + * source set's pegasus directory and the java class output directory of the + * target source set. It sets the jar file's classification to "data-template". + * Hence, the resulting jar file's name should end with "-data-template.jar". + * It adds the data template jar file as an artifact to the dataTemplate configuration. + * This jar file contains both data schema (.pdsc) files and generated data template + * class (.class) files. + *

+ * + *

+ * This plugin will ensure that data template source files are generated before + * compiling the input source set and before the idea and eclipse tasks. It + * also adds the generated classes to the compileClasspath of the input source set. + *

+ * + *

+ * The configurations that apply to generating the data model and data template jars + * are as follow: + *

    + *
  • + * The dataTemplateCompile configuration specifies the classpath for compiling + * the generated data template source (.java) files. In most cases, + * it should be the Pegasus data jar. + * (The default compile configuration is not used for compiling data templates because + * it is not desirable to include non data template dependencies in the data template jar.) + * The configuration should not directly include data template jars. Data template jars + * should be included in the dataModel configuration. + *
  • + *
  • + * The dataModel configuration provides the value of the "generator.resolver.path" + * system property that is passed to PegasusDataTemplateGenerator. In most cases, + * this configuration should contain only data template jars. The data template jars + * contain both data schema (.pdsc) files and generated data template (.class) files. + * PegasusDataTemplateGenerator will not generate data template (.java) files for + * classes that can be found in the resolver path. This avoids redundant generation + * of the same classes, and inclusion of these classes in multiple jars. + * The dataModel configuration is also used to publish the data model jar which + * contains only data schema (.pdsc) files. + *
  • + *
  • + * The testDataModel configuration is similar to the dataModel configuration + * except it is used when generating data templates from test source sets. + * It extends from the dataModel configuration. It is also used to publish + * the data model jar from test source sets. + *
  • + *
  • + * The dataTemplate configuration is used to publish the data template + * jar which contains both data schema (.pdsc) files and the data template class + * (.class) files generated from these data schema (.pdsc) files. + *
  • + *
  • + * The testDataTemplate configuration is similar to the dataTemplate configuration + * except it is used when publishing the data template jar files generated from + * test source sets. + *
  • + *
+ *

+ * + *

Performs the following functions:

+ * + *

Generate avro schema jars for each source set.

+ * + *

Overview:

+ * + *

+ * In the api project, the task 'generateAvroSchema' generates the avro schema (.avsc) + * files from pegasus schema (.pdsc) files. In general, data schema files should exist + * only in api projects. + *

+ * + *

+ * Configure the server and client implementation projects to depend on the + * api project's avroSchema configuration to get access to the generated avro schemas + * from within these projects. + *

+ * + *

+ * This plugin also create the avro schema jar that includes the contents of the input + * source set's avro directory and the avsc schema files. + * The resulting jar file's name should end with "-avro-schema.jar". + *

+ * + *

Generate rest model and rest client jars for each source set.

+ * + *

Overview:

+ * + *

+ * In the api project, generates rest client source (.java) files from the idl, + * compiles the rest client source (.java) files to rest client class (.class) files + * and puts them in jar files. In general, the api project should be only place that + * contains the publishable idl files. If the published idl changes an existing idl + * in the api project, the plugin will emit message indicating this has occurred and + * suggest that the entire project be rebuilt if it is desirable for clients of the + * idl to pick up the newly published changes. + *

+ * + *

+ * In the impl project, generates the idl (.restspec.json) files from the input + * source set's resource class files, then compares them against the existing idl + * files in the api project for compatibility checking. If incompatible changes are + * found, the build fails (unless certain flag is specified, see below). If the + * generated idl passes compatibility checks (see compatibility check levels below), + * publishes the generated idl (.restspec.json) to the api project. + *

+ * + *

Detail:

+ * + *

rest client generation phase: in api project

+ * + *

+ * In this phase, the rest client source (.java) files are generated from the + * api project idl (.restspec.json) files using RestRequestBuilderGenerator. + * The generated rest client source files will be in the new target source set's + * java source directory, e.g. "src/mainGeneratedRest/java". + *

+ * + *

+ * RestRequestBuilderGenerator requires access to the data schemas referenced + * by the idl. The dataModel configuration specifies the resolver path needed + * by RestRequestBuilderGenerator to access the data schemas referenced by + * the idl that is not in the source set's pegasus directory. + * This plugin automatically includes the data schema (.pdsc) files in the + * source set's pegasus directory in the resolver path. + * In most cases, the dataModel configuration should contain data template jars. + * The data template jars contains both data schema (.pdsc) files and generated + * data template class (.class) files. By specifying data template jars instead + * of data model jars, redundant generation of data template classes is avoided + * as classes that can be found in the resolver path are not generated. + *

+ * + *

rest client compilation phase: in api project

+ * + *

+ * In this phase, the plugin compiles the generated rest client source (.java) + * files into class files. The restClientCompile configuration specifies the + * pegasus jars needed to compile these classes. The compile classpath is a + * composite of the dataModel configuration which includes the data template + * classes that were previously generated and included in the dependent data template + * jars, and the restClientCompile configuration. + * This configuration should specify a dependency on the Pegasus restli-client jar. + *

+ * + *

+ * The following stage is creating the the rest model jar and the rest client jar. + * This plugin creates the rest model jar that includes the + * generated idl (.restspec.json) files, and sets the jar file's classification to + * "rest-model". Hence, the resulting jar file's name should end with "-rest-model.jar". + * It adds the rest model jar as an artifact to the restModel configuration. + * This jar file should only contain idl (.restspec.json) files. + *

+ * + *

+ * This plugin also create the rest client jar that includes the generated + * idl (.restspec.json) files and the java class output directory of the + * target source set. It sets the jar file's classification to "rest-client". + * Hence, the resulting jar file's name should end with "-rest-client.jar". + * It adds the rest client jar file as an artifact to the restClient configuration. + * This jar file contains both idl (.restspec.json) files and generated rest client + * class (.class) files. + *

+ * + *

idl generation phase: in server implementation project

+ * + *

+ * Before entering this phase, the plugin will ensure that generating idl will + * occur after compiling the input source set. It will also ensure that IDEA + * and Eclipse tasks runs after rest client source (.java) files are generated. + *

+ * + *

+ * In this phase, the plugin creates a new target source set for the generated files. + * The new target source set's name is the input source set name's* suffixed with + * "GeneratedRest", e.g. "mainGeneratedRest". The plugin invokes + * RestLiResourceModelExporter to generate idl (.restspec.json) files for each + * IdlItem in the input source set's pegasus IdlOptions. The generated idl files + * will be in target source set's idl directory, e.g. "src/mainGeneratedRest/idl". + * For example, the following adds an IdlItem to the source set's pegasus IdlOptions. + * This line should appear in the impl project's build.gradle. If no IdlItem is added, + * this source set will be excluded from generating idl and checking idl compatibility, + * even there are existing idl files. + *

+ *   pegasus.main.idlOptions.addIdlItem(["com.linkedin.restli.examples.groups.server"])
+ * 
+ *

+ * + *

+ * After the idl generation phase, each included idl file is checked for compatibility against + * those in the api project. In case the current interface breaks compatibility, + * by default the build fails and reports all compatibility errors and warnings. Otherwise, + * the build tasks in the api project later will package the resource classes into jar files. + * User can change the compatibility requirement between the current and published idl by + * setting the "rest.model.compatibility" project property, i.e. + * "gradle -Prest.model.compatibility= ..." The following levels are supported: + *

    + *
  • ignore: idl compatibility check will occur but its result will be ignored. + * The result will be aggregated and printed at the end of the build.
  • + *
  • backwards: build fails if there are backwards incompatible changes in idl. + * Build continues if there are only compatible changes.
  • + *
  • equivalent (default): build fails if there is any functional changes (compatible or + * incompatible) in the current idl. Only docs and comments are allowed to be different.
  • + *
+ * The plugin needs to know where the api project is. It searches the api project in the + * following steps. If all searches fail, the build fails. + *
    + *
  1. + * Use the specified project from the impl project build.gradle file. The ext.apiProject + * property explicitly assigns the api project. E.g. + *
    + *       ext.apiProject = project(':groups:groups-server-api')
    + *     
    + * If multiple such statements exist, the last will be used. Wrong project path causes Gradle + * evaluation error. + *
  2. + *
  3. + * If no ext.apiProject property is defined, the plugin will try to guess the + * api project name with the following conventions. The search stops at the first successful match. + *
      + *
    1. + * If the impl project name ends with the following suffixes, substitute the suffix with "-api". + *
        + *
      1. -impl
      2. + *
      3. -service
      4. + *
      5. -server
      6. + *
      7. -server-impl
      8. + *
      + * This list can be overridden by inserting the following line to the project build.gradle: + *
      + *           ext.apiProjectSubstitutionSuffixes = ['-new-suffix-1', '-new-suffix-2']
      + *         
      + * Alternatively, this setting could be applied globally to all projects by putting it in + * the subprojects section of the root build.gradle. + *
    2. + *
    3. + * Append "-api" to the impl project name. + *
    4. + *
    + *
  4. + *
+ * The plugin invokes RestLiResourceModelCompatibilityChecker to check compatibility. + *

+ * + *

+ * The idl files in the api project are not generated by the plugin, but rather + * "published" from the impl project. The publishRestModel task is used to copy the + * idl files to the api project. This task is invoked automatically if the idls are + * verified to be "safe". "Safe" is determined by the "rest.model.compatibility" + * property. Because this task is skipped if the idls are functionally equivalent + * (not necessarily identical, e.g. differ in doc fields), if the default "equivalent" + * compatibility level is used, no file will be copied. If such automatic publishing + * is intended to be skip, set the "rest.model.skipPublish" property to true. + * Note that all the properties are per-project and can be overridden in each project's + * build.gradle file. + *

+ * + *

+ * Please always keep in mind that if idl publishing is happened, a subsequent whole-project + * rebuild is necessary to pick up the changes. Otherwise, the Hudson job will fail and + * the source code commit will fail. + *

+ * + *

+ * The configurations that apply to generating the rest model and rest client jars + * are as follow: + *

    + *
  • + * The restClientCompile configuration specifies the classpath for compiling + * the generated rest client source (.java) files. In most cases, + * it should be the Pegasus restli-client jar. + * (The default compile configuration is not used for compiling rest client because + * it is not desirable to include non rest client dependencies, such as + * the rest server implementation classes, in the data template jar.) + * The configuration should not directly include data template jars. Data template jars + * should be included in the dataModel configuration. + *
  • + *
  • + * The dataModel configuration provides the value of the "generator.resolver.path" + * system property that is passed to RestRequestBuilderGenerator. + * This configuration should contain only data template jars. The data template jars + * contain both data schema (.pdsc) files and generated data template (.class) files. + * The RestRequestBuilderGenerator will only generate rest client classes. + * The dataModel configuration is also included in the compile classpath for the + * generated rest client source files. The dataModel configuration does not + * include generated data template classes, then the Java compiler may not able to + * find the data template classes referenced by the generated rest client. + *
  • + *
  • + * The testDataModel configuration is similar to the dataModel configuration + * except it is used when generating rest client source files from + * test source sets. + *
  • + *
  • + * The restModel configuration is used to publish the rest model jar + * which contains generated idl (.restspec.json) files. + *
  • + *
  • + * The testRestModel configuration is similar to the restModel configuration + * except it is used to publish rest model jar files generated from + * test source sets. + *
  • + *
  • + * The restClient configuration is used to publish the rest client jar + * which contains both generated idl (.restspec.json) files and + * the rest client class (.class) files generated from from these + * idl (.restspec.json) files. + *
  • + *
  • + * The testRestClient configuration is similar to the restClient configuration + * except it is used to publish rest client jar files generated from + * test source sets. + *
  • + *
+ *

+ * + *

+ * This plugin considers test source sets whose names begin with 'test' or 'integTest' to be + * test source sets. + *

+ */ +public class PegasusPlugin implements Plugin +{ + public static boolean debug = false; + + private static final GradleVersion MIN_REQUIRED_VERSION = GradleVersion.version("1.0"); // Next: 5.2.1 + private static final GradleVersion MIN_SUGGESTED_VERSION = GradleVersion.version("5.2.1"); // Next: 5.3 + + // + // Constants for generating sourceSet names and corresponding directory names + // for generated code + // + private static final String DATA_TEMPLATE_GEN_TYPE = "DataTemplate"; + private static final String REST_GEN_TYPE = "Rest"; + private static final String AVRO_SCHEMA_GEN_TYPE = "AvroSchema"; + + public static final String DATA_TEMPLATE_FILE_SUFFIX = ".pdsc"; + public static final String PDL_FILE_SUFFIX = ".pdl"; + // gradle property to opt OUT schema annotation validation, by default this feature is enabled. + private static final String DISABLE_SCHEMA_ANNOTATION_VALIDATION = "schema.annotation.validation.disable"; + // gradle property to opt in for destroying stale files from the build directory, + // by default it is disabled, because it triggers hot-reload (even if it results in a no-op) + private static final String DESTROY_STALE_FILES_ENABLE = "enableDestroyStaleFiles"; + public static final Collection DATA_TEMPLATE_FILE_SUFFIXES = new ArrayList<>(); + + public static final String IDL_FILE_SUFFIX = ".restspec.json"; + public static final String SNAPSHOT_FILE_SUFFIX = ".snapshot.json"; + public static final String SNAPSHOT_COMPAT_REQUIREMENT = "rest.model.compatibility"; + public static final String IDL_COMPAT_REQUIREMENT = "rest.idl.compatibility"; + // Pegasus schema compatibility level configuration, which is used to define the {@link CompatibilityLevel}. + public static final String PEGASUS_SCHEMA_SNAPSHOT_REQUIREMENT = "pegasusPlugin.pegasusSchema.compatibility"; + // Pegasus extension schema compatibility level configuration, which is used to define the {@link CompatibilityLevel} + public static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_REQUIREMENT = "pegasusPlugin.extensionSchema.compatibility"; + // CompatibilityOptions Mode configuration, which is used to define the {@link CompatibilityOptions#Mode} in the compatibility checker. + private static final String PEGASUS_COMPATIBILITY_MODE = "pegasusPlugin.pegasusSchemaCompatibilityCheckMode"; + + private static final Pattern TEST_DIR_REGEX = Pattern.compile("^(integ)?[Tt]est"); + private static final String SNAPSHOT_NO_PUBLISH = "rest.model.noPublish"; + private static final String SNAPSHOT_FORCE_PUBLISH = "rest.model.forcePublish"; + private static final String PROCESS_EMPTY_IDL_DIR = "rest.idl.processEmptyIdlDir"; + private static final String IDL_NO_PUBLISH = "rest.idl.noPublish"; + private static final String IDL_FORCE_PUBLISH = "rest.idl.forcePublish"; + private static final String SKIP_IDL_CHECK = "rest.idl.skipCheck"; + // gradle property to skip running GenerateRestModel task. + // Note it affects GenerateRestModel task only, and does not skip tasks depends on GenerateRestModel. + private static final String SKIP_GENERATE_REST_MODEL= "rest.model.skipGenerateRestModel"; + private static final String SUPPRESS_REST_CLIENT_RESTLI_2 = "rest.client.restli2.suppress"; + private static final String SUPPRESS_REST_CLIENT_RESTLI_1 = "rest.client.restli1.suppress"; + + private static final String GENERATOR_CLASSLOADER_NAME = "pegasusGeneratorClassLoader"; + + private static final String CONVERT_TO_PDL_REVERSE = "convertToPdl.reverse"; + private static final String CONVERT_TO_PDL_KEEP_ORIGINAL = "convertToPdl.keepOriginal"; + private static final String CONVERT_TO_PDL_SKIP_VERIFICATION = "convertToPdl.skipVerification"; + private static final String CONVERT_TO_PDL_PRESERVE_SOURCE_CMD = "convertToPdl.preserveSourceCmd"; + + // Below variables are used to collect data across all pegasus projects (sub-projects) and then print information + // to the user at the end after build is finished. + private static StringBuffer _restModelCompatMessage = new StringBuffer(); + private static final Collection _needCheckinFiles = new ArrayList<>(); + private static final Collection _needBuildFolders = new ArrayList<>(); + private static final Collection _possibleMissingFilesInEarlierCommit = new ArrayList<>(); + + private static final String RUN_ONCE = "runOnce"; + private static final Object STATIC_PROJECT_EVALUATED_LOCK = new Object(); + + private static final List UNUSED_CONFIGURATIONS = Arrays.asList( + "dataTemplateGenerator", "restTools", "avroSchemaGenerator"); + // Directory in the dataTemplate jar that holds schemas translated from PDL to PDSC. + private static final String TRANSLATED_SCHEMAS_DIR = "legacyPegasusSchemas"; + // Enable the use of argFiles for the tasks that support them + private static final String ENABLE_ARG_FILE = "pegasusPlugin.enableArgFile"; + // Enable the generation of fluent APIs + private static final String ENABLE_FLUENT_API = "pegasusPlugin.enableFluentApi"; + + // This config impacts GenerateDataTemplateTask and GenerateRestClientTask; + // If not set, by default all paths generated in these two tasks will be lower-case. + // This default behavior is needed because Linux, MacOS, Windows treat case sensitive paths differently, + // and we want to be consistent, so we choose lower-case as default case for path generated + private static final String CODE_GEN_PATH_CASE_SENSITIVE = "pegasusPlugin.generateCaseSensitivePath"; + + private static final String PEGASUS_PLUGIN_CONFIGURATION = "pegasusPlugin"; + + // Enable the use of generic pegasus schema compatibility checker + private static final String ENABLE_PEGASUS_SCHEMA_COMPATIBILITY_CHECK = "pegasusPlugin.enablePegasusSchemaCompatibilityCheck"; + + private static final String PEGASUS_SCHEMA_SNAPSHOT = "PegasusSchemaSnapshot"; + + private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT = "PegasusExtensionSchemaSnapshot"; + + private static final String PEGASUS_SCHEMA_SNAPSHOT_DIR = "pegasusSchemaSnapshot"; + + private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR = "pegasusExtensionSchemaSnapshot"; + + private static final String PEGASUS_SCHEMA_SNAPSHOT_DIR_OVERRIDE = "overridePegasusSchemaSnapshotDir"; + + private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR_OVERRIDE = "overridePegasusExtensionSchemaSnapshotDir"; + + private static final String SRC = "src"; + + private static final String SCHEMA_ANNOTATION_HANDLER_CONFIGURATION = "schemaAnnotationHandler"; + + private static final String COMPATIBILITY_OPTIONS_MODE_EXTENSION = "EXTENSION"; + + + @SuppressWarnings("unchecked") + private Class> _thisPluginType = (Class>) + getClass().asSubclass(Plugin.class); + + private Task _generateSourcesJarTask; + private Javadoc _generateJavadocTask; + private Task _generateJavadocJarTask; + private boolean _configureIvyPublications = true; + + public void setPluginType(Class> pluginType) + { + _thisPluginType = pluginType; + } + + public void setSourcesJarTask(Task sourcesJarTask) + { + _generateSourcesJarTask = sourcesJarTask; + } + + public void setJavadocJarTask(Task javadocJarTask) + { + _generateJavadocJarTask = javadocJarTask; + } + + public void setConfigureIvyPublications(boolean configureIvyPublications) { + _configureIvyPublications = configureIvyPublications; + } + + @Override + public void apply(Project project) + { + checkGradleVersion(project); + + project.getPlugins().apply(JavaPlugin.class); + + // this HashMap will have a PegasusOptions per sourceSet + project.getExtensions().getExtraProperties().set("pegasus", new HashMap<>()); + // this map will extract PegasusOptions.GenerationMode to project property + project.getExtensions().getExtraProperties().set("PegasusGenerationMode", + Arrays.stream(PegasusOptions.GenerationMode.values()) + .collect(Collectors.toMap(PegasusOptions.GenerationMode::name, Function.identity()))); + + synchronized (STATIC_PROJECT_EVALUATED_LOCK) + { + // Check if this is the first time the block will run. Pegasus plugin can run multiple times in a build if + // multiple sub-projects applied the plugin. + if (!project.getRootProject().hasProperty(RUN_ONCE) + || !Boolean.parseBoolean(String.valueOf(project.getRootProject().property(RUN_ONCE)))) + { + project.getGradle().projectsEvaluated(gradle -> + gradle.getRootProject().subprojects(subproject -> + UNUSED_CONFIGURATIONS.forEach(configurationName -> { + Configuration conf = subproject.getConfigurations().findByName(configurationName); + if (conf != null && !conf.getDependencies().isEmpty()) { + subproject.getLogger().warn("*** Project {} declares dependency to unused configuration \"{}\". " + + "This configuration is deprecated and you can safely remove the dependency. ***", + subproject.getPath(), configurationName); + } + }) + ) + ); + + // Re-initialize the static variables as they might have stale values from previous run. With Gradle 3.0 and + // gradle daemon enabled, the plugin class might not be loaded for every run. + DATA_TEMPLATE_FILE_SUFFIXES.clear(); + DATA_TEMPLATE_FILE_SUFFIXES.add(DATA_TEMPLATE_FILE_SUFFIX); + DATA_TEMPLATE_FILE_SUFFIXES.add(PDL_FILE_SUFFIX); + + _restModelCompatMessage = new StringBuffer(); + _needCheckinFiles.clear(); + _needBuildFolders.clear(); + _possibleMissingFilesInEarlierCommit.clear(); + + project.getGradle().buildFinished(result -> + { + StringBuilder endOfBuildMessage = new StringBuilder(); + if (_restModelCompatMessage.length() > 0) + { + endOfBuildMessage.append(_restModelCompatMessage); + } + + if (!_needCheckinFiles.isEmpty()) + { + endOfBuildMessage.append(createModifiedFilesMessage(_needCheckinFiles, _needBuildFolders)); + } + + if (!_possibleMissingFilesInEarlierCommit.isEmpty()) + { + endOfBuildMessage.append(createPossibleMissingFilesMessage(_possibleMissingFilesInEarlierCommit)); + } + + if (endOfBuildMessage.length() > 0) + { + result.getGradle().getRootProject().getLogger().quiet(endOfBuildMessage.toString()); + } + }); + + // Set an extra property on the root project to indicate the initialization is complete for the current build. + project.getRootProject().getExtensions().getExtraProperties().set(RUN_ONCE, true); + } + } + + ConfigurationContainer configurations = project.getConfigurations(); + + // configuration for getting the required classes to make pegasus call main methods + configurations.maybeCreate(PEGASUS_PLUGIN_CONFIGURATION); + + // configuration for compiling generated data templates + Configuration dataTemplateCompile = configurations.maybeCreate("dataTemplateCompile"); + dataTemplateCompile.setVisible(false); + + // configuration for running rest client generator + Configuration restClientCompile = configurations.maybeCreate("restClientCompile"); + restClientCompile.setVisible(false); + + // configuration for running data template generator + // DEPRECATED! This configuration is no longer used. Please stop using it. + Configuration dataTemplateGenerator = configurations.maybeCreate("dataTemplateGenerator"); + dataTemplateGenerator.setVisible(false); + + // configuration for running rest client generator + // DEPRECATED! This configuration is no longer used. Please stop using it. + Configuration restTools = configurations.maybeCreate("restTools"); + restTools.setVisible(false); + + // configuration for running Avro schema generator + // DEPRECATED! To skip avro schema generation, use PegasusOptions.generationModes + Configuration avroSchemaGenerator = configurations.maybeCreate("avroSchemaGenerator"); + avroSchemaGenerator.setVisible(false); + + // configuration for depending on data schemas and potentially generated data templates + // and for publishing jars containing data schemas to the project artifacts for including in the ivy.xml + Configuration dataModel = configurations.maybeCreate("dataModel"); + Configuration testDataModel = configurations.maybeCreate("testDataModel"); + testDataModel.extendsFrom(dataModel); + + // configuration for depending on data schemas and potentially generated data templates + // and for publishing jars containing data schemas to the project artifacts for including in the ivy.xml + Configuration avroSchema = configurations.maybeCreate("avroSchema"); + Configuration testAvroSchema = configurations.maybeCreate("testAvroSchema"); + testAvroSchema.extendsFrom(avroSchema); + + // configuration for depending on rest idl and potentially generated client builders + // and for publishing jars containing rest idl to the project artifacts for including in the ivy.xml + Configuration restModel = configurations.maybeCreate("restModel"); + Configuration testRestModel = configurations.maybeCreate("testRestModel"); + testRestModel.extendsFrom(restModel); + + // configuration for publishing jars containing data schemas and generated data templates + // to the project artifacts for including in the ivy.xml + // + // published data template jars depends on the configurations used to compile the classes + // in the jar, this includes the data models/templates used by the data template generator + // and the classes used to compile the generated classes. + Configuration dataTemplate = configurations.maybeCreate("dataTemplate"); + dataTemplate.extendsFrom(dataTemplateCompile, dataModel); + Configuration testDataTemplate = configurations.maybeCreate("testDataTemplate"); + testDataTemplate.extendsFrom(dataTemplate, testDataModel); + + // configuration for processing and validating schema annotation during build time. + // + // The configuration contains dependencies to schema annotation handlers which would process schema annotations + // and validate. + Configuration schemaAnnotationHandler = configurations.maybeCreate(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION); + + // configuration for publishing jars containing rest idl and generated client builders + // to the project artifacts for including in the ivy.xml + // + // published client builder jars depends on the configurations used to compile the classes + // in the jar, this includes the data models/templates (potentially generated by this + // project and) used by the data template generator and the classes used to compile + // the generated classes. + Configuration restClient = configurations.maybeCreate("restClient"); + restClient.extendsFrom(restClientCompile, dataTemplate); + Configuration testRestClient = configurations.maybeCreate("testRestClient"); + testRestClient.extendsFrom(restClient, testDataTemplate); + + Properties properties = new Properties(); + InputStream inputStream = getClass().getResourceAsStream("/pegasus-version.properties"); + if (inputStream != null) + { + try + { + properties.load(inputStream); + } + catch (IOException e) + { + throw new GradleException("Unable to read pegasus-version.properties file.", e); + } + + String version = properties.getProperty("pegasus.version"); + + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:data:" + version); + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:data-avro-generator:" + version); + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:generator:" + version); + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:restli-tools:" + version); + } + else + { + project.getLogger().lifecycle("Unable to add pegasus dependencies to {}. Please be sure that " + + "'com.linkedin.pegasus:data', 'com.linkedin.pegasus:data-avro-generator', 'com.linkedin.pegasus:generator', 'com.linkedin.pegasus:restli-tools'" + + " are available on the configuration pegasusPlugin", + project.getPath()); + } + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "org.slf4j:slf4j-simple:1.7.2"); + project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, project.files(System.getProperty("java.home") + "/../lib/tools.jar")); + + // this call has to be here because: + // 1) artifact cannot be published once projects has been evaluated, so we need to first + // create the tasks and artifact handler, then progressively append sources + // 2) in order to append sources progressively, the source and documentation tasks and artifacts must be + // configured/created before configuring and creating the code generation tasks. + + configureGeneratedSourcesAndJavadoc(project); + + ChangedFileReportTask changedFileReportTask = project.getTasks() + .create("changedFilesReport", ChangedFileReportTask.class); + + project.getTasks().getByName("check").dependsOn(changedFileReportTask); + + SourceSetContainer sourceSets = project.getConvention() + .getPlugin(JavaPluginConvention.class).getSourceSets(); + + sourceSets.all(sourceSet -> + { + if (sourceSet.getName().toLowerCase(Locale.US).contains("generated")) + { + return; + } + + checkAvroSchemaExist(project, sourceSet); + + // the idl Generator input options will be inside the PegasusOptions class. Users of the + // plugin can set the inputOptions in their build.gradle + @SuppressWarnings("unchecked") + Map pegasusOptions = (Map) project + .getExtensions().getExtraProperties().get("pegasus"); + + pegasusOptions.put(sourceSet.getName(), new PegasusOptions()); + + // rest model generation could fail on incompatibility + // if it can fail, fail it early + configureRestModelGeneration(project, sourceSet); + + // Do compatibility check for schemas under "pegasus" directory if the configuration property is provided. + if (isPropertyTrue(project, ENABLE_PEGASUS_SCHEMA_COMPATIBILITY_CHECK)) + { + configurePegasusSchemaSnapshotGeneration(project, sourceSet, false); + } + + configurePegasusSchemaSnapshotGeneration(project, sourceSet, true); + + configureConversionUtilities(project, sourceSet); + + GenerateDataTemplateTask generateDataTemplateTask = configureDataTemplateGeneration(project, sourceSet); + + configureAvroSchemaGeneration(project, sourceSet); + + configureRestClientGeneration(project, sourceSet); + + if (!isPropertyTrue(project, DISABLE_SCHEMA_ANNOTATION_VALIDATION)) + { + configureSchemaAnnotationValidation(project, sourceSet, generateDataTemplateTask); + } + + Task cleanGeneratedDirTask = project.task(sourceSet.getTaskName("clean", "GeneratedDir")); + cleanGeneratedDirTask.doLast(new CacheableAction<>(task -> + { + deleteGeneratedDir(project, sourceSet, REST_GEN_TYPE); + deleteGeneratedDir(project, sourceSet, AVRO_SCHEMA_GEN_TYPE); + deleteGeneratedDir(project, sourceSet, DATA_TEMPLATE_GEN_TYPE); + })); + + // make clean depends on deleting the generated directories + project.getTasks().getByName("clean").dependsOn(cleanGeneratedDirTask); + + // Set data schema directories as resource roots + configureDataSchemaResourcesRoot(project, sourceSet); + }); + + project.getExtensions().getExtraProperties().set(GENERATOR_CLASSLOADER_NAME, getClass().getClassLoader()); + } + + protected void configureSchemaAnnotationValidation(Project project, + SourceSet sourceSet, + GenerateDataTemplateTask generateDataTemplatesTask) + { + // Task would execute based on the following order. + // generateDataTemplatesTask -> validateSchemaAnnotationTask + + // Create ValidateSchemaAnnotation task + ValidateSchemaAnnotationTask validateSchemaAnnotationTask = project.getTasks() + .create(sourceSet.getTaskName("validate", "schemaAnnotation"), ValidateSchemaAnnotationTask.class, task -> + { + task.setInputDir(generateDataTemplatesTask.getInputDir()); + task.setResolverPath(getDataModelConfig(project, sourceSet)); // same resolver path as generateDataTemplatesTask + task.setClassPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION) + .plus(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)) + .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME))); + task.setHandlerJarPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + } + ); + + // validateSchemaAnnotationTask depend on generateDataTemplatesTask + validateSchemaAnnotationTask.dependsOn(generateDataTemplatesTask); + + // Check depends on validateSchemaAnnotationTask. + project.getTasks().getByName("check").dependsOn(validateSchemaAnnotationTask); + } + + + + @SuppressWarnings("deprecation") + protected void configureGeneratedSourcesAndJavadoc(Project project) + { + _generateJavadocTask = project.getTasks().create("generateJavadoc", Javadoc.class); + + if (_generateSourcesJarTask == null) + { + // + // configuration for publishing jars containing sources for generated classes + // to the project artifacts for including in the ivy.xml + // + ConfigurationContainer configurations = project.getConfigurations(); + Configuration generatedSources = configurations.maybeCreate("generatedSources"); + Configuration testGeneratedSources = configurations.maybeCreate("testGeneratedSources"); + testGeneratedSources.extendsFrom(generatedSources); + + _generateSourcesJarTask = project.getTasks().create("generateSourcesJar", Jar.class, jarTask -> { + jarTask.setGroup(JavaBasePlugin.DOCUMENTATION_GROUP); + jarTask.setDescription("Generates a jar file containing the sources for the generated Java classes."); + // FIXME change to #getArchiveClassifier().set("sources"); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + jarTask.getArchiveClassifier().set("sources"); + }); + + project.getArtifacts().add("generatedSources", _generateSourcesJarTask); + } + + if (_generateJavadocJarTask == null) + { + // + // configuration for publishing jars containing Javadoc for generated classes + // to the project artifacts for including in the ivy.xml + // + ConfigurationContainer configurations = project.getConfigurations(); + Configuration generatedJavadoc = configurations.maybeCreate("generatedJavadoc"); + Configuration testGeneratedJavadoc = configurations.maybeCreate("testGeneratedJavadoc"); + testGeneratedJavadoc.extendsFrom(generatedJavadoc); + + _generateJavadocJarTask = project.getTasks().create("generateJavadocJar", Jar.class, jarTask -> { + jarTask.dependsOn(_generateJavadocTask); + jarTask.setGroup(JavaBasePlugin.DOCUMENTATION_GROUP); + jarTask.setDescription("Generates a jar file containing the Javadoc for the generated Java classes."); + // FIXME change to #getArchiveClassifier().set("sources"); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + jarTask.getArchiveClassifier().set("javadoc"); + jarTask.from(_generateJavadocTask.getDestinationDir()); + }); + + project.getArtifacts().add("generatedJavadoc", _generateJavadocJarTask); + } + else + { + // TODO: Tighten the types so that _generateJavadocJarTask must be of type Jar. + ((Jar) _generateJavadocJarTask).from(_generateJavadocTask.getDestinationDir()); + _generateJavadocJarTask.dependsOn(_generateJavadocTask); + } + } + + private static void deleteGeneratedDir(Project project, SourceSet sourceSet, String dirType) + { + String generatedDirPath = getGeneratedDirPath(project, sourceSet, dirType); + project.getLogger().info("Delete generated directory {}", generatedDirPath); + project.delete(generatedDirPath); + } + + private static > Class getCompatibilityLevelClass(Project project) + { + ClassLoader generatorClassLoader = (ClassLoader) project.property(GENERATOR_CLASSLOADER_NAME); + + String className = "com.linkedin.restli.tools.idlcheck.CompatibilityLevel"; + try + { + @SuppressWarnings("unchecked") + Class enumClass = (Class) generatorClassLoader.loadClass(className).asSubclass(Enum.class); + return enumClass; + } + catch (ClassNotFoundException e) + { + throw new RuntimeException("Could not load class " + className); + } + } + + private static void addGeneratedDir(Project project, SourceSet sourceSet, Collection configurations) + { + project.getPlugins().withType(IdeaPlugin.class, ideaPlugin -> { + IdeaModule ideaModule = ideaPlugin.getModel().getModule(); + // stupid if block needed because of stupid assignment required to update source dirs + if (isTestSourceSet(sourceSet)) + { + Set sourceDirs = ideaModule.getTestSourceDirs(); + sourceDirs.addAll(sourceSet.getJava().getSrcDirs()); + // this is stupid but assignment is required + ideaModule.setTestSourceDirs(sourceDirs); + if (debug) + { + System.out.println("Added " + sourceSet.getJava().getSrcDirs() + " to IdeaModule testSourceDirs " + + ideaModule.getTestSourceDirs()); + } + } + else + { + Set sourceDirs = ideaModule.getSourceDirs(); + sourceDirs.addAll(sourceSet.getJava().getSrcDirs()); + // this is stupid but assignment is required + ideaModule.setSourceDirs(sourceDirs); + if (debug) + { + System.out.println("Added " + sourceSet.getJava().getSrcDirs() + " to IdeaModule sourceDirs " + + ideaModule.getSourceDirs()); + } + } + Collection compilePlus = ideaModule.getScopes().get("COMPILE").get("plus"); + compilePlus.addAll(configurations); + ideaModule.getScopes().get("COMPILE").put("plus", compilePlus); + }); + } + + private static void checkAvroSchemaExist(Project project, SourceSet sourceSet) + { + String sourceDir = "src" + File.separatorChar + sourceSet.getName(); + File avroSourceDir = project.file(sourceDir + File.separatorChar + "avro"); + if (avroSourceDir.exists()) + { + project.getLogger().lifecycle("{}'s {} has non-empty avro directory. pegasus plugin does not process avro directory", + project.getName(), sourceDir); + } + } + + // Compute the name of the source set that will contain a type of an input generated code. + // e.g. genType may be 'DataTemplate' or 'Rest' + private static String getGeneratedSourceSetName(SourceSet sourceSet, String genType) + { + return sourceSet.getName() + "Generated" + genType; + } + + // Compute the directory name that will contain a type generated code of an input source set. + // e.g. genType may be 'DataTemplate' or 'Rest' + public static String getGeneratedDirPath(Project project, SourceSet sourceSet, String genType) + { + String override = getOverridePath(project, sourceSet, "overrideGeneratedDir"); + String sourceSetName = getGeneratedSourceSetName(sourceSet, genType); + String base = override == null ? "src" : override; + + return base + File.separatorChar + sourceSetName; + } + + public static String getDataSchemaPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, "overridePegasusDir"); + if (override == null) + { + return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "pegasus"; + } + else + { + return override; + } + } + + private static String getExtensionSchemaPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, "overrideExtensionSchemaDir"); + if(override == null) + { + return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "extensions"; + } + else + { + return override; + } + } + + private static String getSnapshotPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, "overrideSnapshotDir"); + if (override == null) + { + return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "snapshot"; + } + else + { + return override; + } + } + + private static String getIdlPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, "overrideIdlDir"); + if (override == null) + { + return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "idl"; + } + else + { + return override; + } + } + + private static String getPegasusSchemaSnapshotPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, PEGASUS_SCHEMA_SNAPSHOT_DIR_OVERRIDE); + if (override == null) + { + return SRC + File.separatorChar + sourceSet.getName() + File.separatorChar + PEGASUS_SCHEMA_SNAPSHOT_DIR; + } + else + { + return override; + } + } + + private static String getPegasusExtensionSchemaSnapshotPath(Project project, SourceSet sourceSet) + { + String override = getOverridePath(project, sourceSet, PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR_OVERRIDE); + if (override == null) + { + return SRC + File.separatorChar + sourceSet.getName() + File.separatorChar + PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR; + } + else + { + return override; + } + } + + private static String getOverridePath(Project project, SourceSet sourceSet, String overridePropertyName) + { + String sourceSetPropertyName = sourceSet.getName() + '.' + overridePropertyName; + String override = getNonEmptyProperty(project, sourceSetPropertyName); + + if (override == null && sourceSet.getName().equals("main")) + { + override = getNonEmptyProperty(project, overridePropertyName); + } + + return override; + } + + private static boolean isTestSourceSet(SourceSet sourceSet) + { + return TEST_DIR_REGEX.matcher(sourceSet.getName()).find(); + } + + private static Configuration getDataModelConfig(Project project, SourceSet sourceSet) + { + return isTestSourceSet(sourceSet) + ? project.getConfigurations().getByName("testDataModel") + : project.getConfigurations().getByName("dataModel"); + } + + private static boolean isTaskSuccessful(Task task) + { + return task.getState().getExecuted() + // Task is not successful if it is not upto date and is skipped. + && !(task.getState().getSkipped() && !task.getState().getUpToDate()) + && task.getState().getFailure() == null; + } + + private static boolean isResultEquivalent(File compatibilityLogFile) + { + return isResultEquivalent(compatibilityLogFile, false); + } + + private static boolean isResultEquivalent(File compatibilityLogFile, boolean restSpecOnly) + { + CompatibilityLogChecker logChecker = new CompatibilityLogChecker(); + try + { + logChecker.write(Files.readAllBytes(compatibilityLogFile.toPath())); + } + catch (IOException e) + { + throw new GradleException("Error while processing compatibility report: " + e.getMessage()); + } + return logChecker.getRestSpecCompatibility().isEmpty() && + (restSpecOnly || logChecker.getModelCompatibility().isEmpty()); + } + + protected void configureRestModelGeneration(Project project, SourceSet sourceSet) + { + if (sourceSet.getAllSource().isEmpty()) + { + project.getLogger().info("No source files found for sourceSet {}. Skipping idl generation.", sourceSet.getName()); + return; + } + + // afterEvaluate needed so that api project can be overridden via ext.apiProject + project.afterEvaluate(p -> + { + // find api project here instead of in each project's plugin configuration + // this allows api project relation options (ext.api*) to be specified anywhere in the build.gradle file + // alternatively, pass closures to task configuration, and evaluate the closures when task is executed + Project apiProject = getCheckedApiProject(project); + + // make sure the api project is evaluated. Important for configure-on-demand mode. + if (apiProject != null) + { + project.evaluationDependsOn(apiProject.getPath()); + + if (!apiProject.getPlugins().hasPlugin(_thisPluginType)) + { + apiProject = null; + } + } + + if (apiProject == null) + { + return; + } + + Task untypedJarTask = project.getTasks().findByName(sourceSet.getJarTaskName()); + if (!(untypedJarTask instanceof Jar)) + { + return; + } + Jar jarTask = (Jar) untypedJarTask; + + String snapshotCompatPropertyName = findProperty(FileCompatibilityType.SNAPSHOT); + if (project.hasProperty(snapshotCompatPropertyName) && "off".equalsIgnoreCase((String) project.property(snapshotCompatPropertyName))) + { + project.getLogger().lifecycle("Project {} snapshot compatibility level \"OFF\" is deprecated. Default to \"IGNORE\".", + project.getPath()); + } + + // generate the rest model + FileCollection restModelCodegenClasspath = project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION) + .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME)) + .plus(sourceSet.getRuntimeClasspath()); + String destinationDirPrefix = getGeneratedDirPath(project, sourceSet, REST_GEN_TYPE) + File.separatorChar; + FileCollection restModelResolverPath = apiProject.files(getDataSchemaPath(project, sourceSet)) + .plus(getDataModelConfig(apiProject, sourceSet)); + Set watchedRestModelInputDirs = buildWatchedRestModelInputDirs(project, sourceSet); + Set restModelInputDirs = difference(sourceSet.getAllSource().getSrcDirs(), + sourceSet.getResources().getSrcDirs()); + + Task generateRestModelTask = project.getTasks() + .create(sourceSet.getTaskName("generate", "restModel"), GenerateRestModelTask.class, task -> + { + task.dependsOn(project.getTasks().getByName(sourceSet.getClassesTaskName())); + task.setCodegenClasspath(restModelCodegenClasspath); + task.setWatchedCodegenClasspath(restModelCodegenClasspath + .filter(file -> !"main".equals(file.getName()) && !"classes".equals(file.getName()))); + task.setInputDirs(restModelInputDirs); + task.setWatchedInputDirs(watchedRestModelInputDirs.isEmpty() + ? restModelInputDirs : watchedRestModelInputDirs); + // we need all the artifacts from runtime for any private implementation classes the server code might need. + task.setSnapshotDestinationDir(project.file(destinationDirPrefix + "snapshot")); + task.setIdlDestinationDir(project.file(destinationDirPrefix + "idl")); + + @SuppressWarnings("unchecked") + Map pegasusOptions = (Map) project + .getExtensions().getExtraProperties().get("pegasus"); + task.setIdlOptions(pegasusOptions.get(sourceSet.getName()).idlOptions); + + task.setResolverPath(restModelResolverPath); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + + task.onlyIf(t -> !isPropertyTrue(project, SKIP_GENERATE_REST_MODEL)); + + task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, REST_GEN_TYPE))); + }); + + File apiSnapshotDir = apiProject.file(getSnapshotPath(apiProject, sourceSet)); + File apiIdlDir = apiProject.file(getIdlPath(apiProject, sourceSet)); + apiSnapshotDir.mkdirs(); + + if (!isPropertyTrue(project, SKIP_IDL_CHECK)) + { + apiIdlDir.mkdirs(); + } + + CheckRestModelTask checkRestModelTask = project.getTasks() + .create(sourceSet.getTaskName("check", "RestModel"), CheckRestModelTask.class, task -> + { + task.dependsOn(generateRestModelTask); + task.setCurrentSnapshotFiles(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); + task.setPreviousSnapshotDirectory(apiSnapshotDir); + task.setCurrentIdlFiles(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); + task.setPreviousIdlDirectory(apiIdlDir); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setModelCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.SNAPSHOT)); + task.onlyIf(t -> !isPropertyTrue(project, SKIP_IDL_CHECK)); + + task.doLast(new CacheableAction<>(t -> + { + if (!task.isEquivalent()) + { + _restModelCompatMessage.append(task.getWholeMessage()); + } + })); + }); + + CheckSnapshotTask checkSnapshotTask = project.getTasks() + .create(sourceSet.getTaskName("check", "Snapshot"), CheckSnapshotTask.class, task -> { + task.dependsOn(generateRestModelTask); + task.setCurrentSnapshotFiles(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); + task.setPreviousSnapshotDirectory(apiSnapshotDir); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setSnapshotCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.SNAPSHOT)); + + task.onlyIf(t -> isPropertyTrue(project, SKIP_IDL_CHECK)); + }); + + CheckIdlTask checkIdlTask = project.getTasks() + .create(sourceSet.getTaskName("check", "Idl"), CheckIdlTask.class, task -> + { + task.dependsOn(generateRestModelTask); + task.setCurrentIdlFiles(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); + task.setPreviousIdlDirectory(apiIdlDir); + task.setResolverPath(restModelResolverPath); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setIdlCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.IDL)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + + + task.onlyIf(t -> !isPropertyTrue(project, SKIP_IDL_CHECK) + && !"OFF".equals(PropertyUtil.findCompatLevel(project, FileCompatibilityType.IDL))); + }); + + // rest model publishing involves cross-project reference + // configure after all projects have been evaluated + // the file copy can be turned off by "rest.model.noPublish" flag + Task publishRestliSnapshotTask = project.getTasks() + .create(sourceSet.getTaskName("publish", "RestliSnapshot"), PublishRestModelTask.class, task -> + { + task.dependsOn(checkRestModelTask, checkSnapshotTask, checkIdlTask); + task.from(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); + task.into(apiSnapshotDir); + task.setSuffix(SNAPSHOT_FILE_SUFFIX); + + task.onlyIf(t -> + isPropertyTrue(project, SNAPSHOT_FORCE_PUBLISH) || + ( + !isPropertyTrue(project, SNAPSHOT_NO_PUBLISH) && + ( + ( + isPropertyTrue(project, SKIP_IDL_CHECK) && + isTaskSuccessful(checkSnapshotTask) && + checkSnapshotTask.getSummaryTarget().exists() && + !isResultEquivalent(checkSnapshotTask.getSummaryTarget()) + ) || + ( + !isPropertyTrue(project, SKIP_IDL_CHECK) && + isTaskSuccessful(checkRestModelTask) && + checkRestModelTask.getSummaryTarget().exists() && + !isResultEquivalent(checkRestModelTask.getSummaryTarget()) + ) + )) + ); + }); + + Task publishRestliIdlTask = project.getTasks() + .create(sourceSet.getTaskName("publish", "RestliIdl"), PublishRestModelTask.class, task -> { + task.dependsOn(checkRestModelTask, checkIdlTask, checkSnapshotTask); + task.from(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); + task.into(apiIdlDir); + task.setSuffix(IDL_FILE_SUFFIX); + + task.onlyIf(t -> + isPropertyTrue(project, IDL_FORCE_PUBLISH) || + ( + !isPropertyTrue(project, IDL_NO_PUBLISH) && + ( + ( + isPropertyTrue(project, SKIP_IDL_CHECK) && + isTaskSuccessful(checkSnapshotTask) && + checkSnapshotTask.getSummaryTarget().exists() && + !isResultEquivalent(checkSnapshotTask.getSummaryTarget(), true) + ) || + ( + !isPropertyTrue(project, SKIP_IDL_CHECK) && + ( + (isTaskSuccessful(checkRestModelTask) && + checkRestModelTask.getSummaryTarget().exists() && + !isResultEquivalent(checkRestModelTask.getSummaryTarget(), true)) || + (isTaskSuccessful(checkIdlTask) && + checkIdlTask.getSummaryTarget().exists() && + !isResultEquivalent(checkIdlTask.getSummaryTarget())) + ) + ) + )) + ); + }); + + project.getLogger().info("API project selected for {} is {}", + publishRestliIdlTask.getPath(), apiProject.getPath()); + + jarTask.from(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); + // add generated .restspec.json files as resources to the jar + jarTask.dependsOn(publishRestliSnapshotTask, publishRestliIdlTask); + + ChangedFileReportTask changedFileReportTask = (ChangedFileReportTask) project.getTasks() + .getByName("changedFilesReport"); + + // Use the files from apiDir for generating the changed files report as we need to notify user only when + // source system files are modified. + changedFileReportTask.setIdlFiles(SharedFileUtils.getSuffixedFiles(project, apiIdlDir, IDL_FILE_SUFFIX)); + changedFileReportTask.setSnapshotFiles(SharedFileUtils.getSuffixedFiles(project, apiSnapshotDir, + SNAPSHOT_FILE_SUFFIX)); + changedFileReportTask.mustRunAfter(publishRestliSnapshotTask, publishRestliIdlTask); + changedFileReportTask.doLast(new CacheableAction<>(t -> + { + if (!changedFileReportTask.getNeedCheckinFiles().isEmpty()) + { + project.getLogger().info("Adding modified files to need checkin list..."); + _needCheckinFiles.addAll(changedFileReportTask.getNeedCheckinFiles()); + _needBuildFolders.add(getCheckedApiProject(project).getPath()); + } + })); + }); + } + + protected void configurePegasusSchemaSnapshotGeneration(Project project, SourceSet sourceSet, boolean isExtensionSchema) + { + File schemaDir = isExtensionSchema? project.file(getExtensionSchemaPath(project, sourceSet)) + : project.file(getDataSchemaPath(project, sourceSet)); + + if ((isExtensionSchema && SharedFileUtils.getSuffixedFiles(project, schemaDir, PDL_FILE_SUFFIX).isEmpty()) || + (!isExtensionSchema && SharedFileUtils.getSuffixedFiles(project, schemaDir, DATA_TEMPLATE_FILE_SUFFIXES).isEmpty())) + { + return; + } + + Path publishablePegasusSchemaSnapshotDir = project.getBuildDir().toPath().resolve(sourceSet.getName() + + (isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT)); + + Task generatePegasusSchemaSnapshot = generatePegasusSchemaSnapshot(project, sourceSet, + isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT, schemaDir, + publishablePegasusSchemaSnapshotDir.toFile(), isExtensionSchema); + + File pegasusSchemaSnapshotDir = project.file(isExtensionSchema ? getPegasusExtensionSchemaSnapshotPath(project, sourceSet) + : getPegasusSchemaSnapshotPath(project, sourceSet)); + pegasusSchemaSnapshotDir.mkdirs(); + + Task checkSchemaSnapshot = project.getTasks().create(sourceSet.getTaskName("check", + isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT), + CheckPegasusSnapshotTask.class, task -> + { + task.dependsOn(generatePegasusSchemaSnapshot); + task.setCurrentSnapshotDirectory(publishablePegasusSchemaSnapshotDir.toFile()); + task.setPreviousSnapshotDirectory(pegasusSchemaSnapshotDir); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION) + .plus(project.getConfigurations().getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)) + .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME))); + task.setCompatibilityLevel(isExtensionSchema ? + PropertyUtil.findCompatLevel(project, FileCompatibilityType.PEGASUS_EXTENSION_SCHEMA_SNAPSHOT) + :PropertyUtil.findCompatLevel(project, FileCompatibilityType.PEGASUS_SCHEMA_SNAPSHOT)); + task.setCompatibilityMode(isExtensionSchema ? COMPATIBILITY_OPTIONS_MODE_EXTENSION : + PropertyUtil.findCompatMode(project, PEGASUS_COMPATIBILITY_MODE)); + task.setExtensionSchema(isExtensionSchema); + task.setHandlerJarPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)); + + task.onlyIf(t -> + { + String pegasusSnapshotCompatPropertyName = isExtensionSchema ? + findProperty(FileCompatibilityType.PEGASUS_EXTENSION_SCHEMA_SNAPSHOT) + : findProperty(FileCompatibilityType.PEGASUS_SCHEMA_SNAPSHOT); + return !project.hasProperty(pegasusSnapshotCompatPropertyName) || + !"off".equalsIgnoreCase((String) project.property(pegasusSnapshotCompatPropertyName)); + }); + }); + + Task publishPegasusSchemaSnapshot = publishPegasusSchemaSnapshot(project, sourceSet, + isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT, checkSchemaSnapshot, + publishablePegasusSchemaSnapshotDir.toFile(), pegasusSchemaSnapshotDir); + + project.getTasks().getByName(LifecycleBasePlugin.ASSEMBLE_TASK_NAME).dependsOn(publishPegasusSchemaSnapshot); + } + + @SuppressWarnings("deprecation") + protected void configureAvroSchemaGeneration(Project project, SourceSet sourceSet) + { + File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); + File avroDir = project.file(getGeneratedDirPath(project, sourceSet, AVRO_SCHEMA_GEN_TYPE) + + File.separatorChar + "avro"); + + // generate avro schema files from data schema + Task generateAvroSchemaTask = project.getTasks() + .create(sourceSet.getTaskName("generate", "avroSchema"), GenerateAvroSchemaTask.class, task -> { + task.setInputDir(dataSchemaDir); + task.setDestinationDir(avroDir); + task.setResolverPath(getDataModelConfig(project, sourceSet)); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + + task.onlyIf(t -> + { + if (task.getInputDir().exists()) + { + @SuppressWarnings("unchecked") + Map pegasusOptions = (Map) project + .getExtensions().getExtraProperties().get("pegasus"); + + if (pegasusOptions.get(sourceSet.getName()).hasGenerationMode(PegasusOptions.GenerationMode.AVRO)) + { + return true; + } + } + + return !project.getConfigurations().getByName("avroSchemaGenerator").isEmpty(); + }); + + task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, AVRO_SCHEMA_GEN_TYPE))); + }); + + project.getTasks().getByName(sourceSet.getCompileJavaTaskName()).dependsOn(generateAvroSchemaTask); + + // create avro schema jar file + + Task avroSchemaJarTask = project.getTasks().create(sourceSet.getName() + "AvroSchemaJar", Jar.class, task -> + { + // add path prefix to each file in the data schema directory + task.from(avroDir, copySpec -> + copySpec.eachFile(fileCopyDetails -> + fileCopyDetails.setPath("avro" + File.separatorChar + fileCopyDetails.getPath()))); + + // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + task.getArchiveAppendix().set(getAppendix(sourceSet, "avro-schema")); + task.setDescription("Generate an avro schema jar"); + }); + + if (!isTestSourceSet(sourceSet)) + { + project.getArtifacts().add("avroSchema", avroSchemaJarTask); + } + else + { + project.getArtifacts().add("testAvroSchema", avroSchemaJarTask); + } + } + + protected void configureConversionUtilities(Project project, SourceSet sourceSet) + { + File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); + boolean reverse = isPropertyTrue(project, CONVERT_TO_PDL_REVERSE); + boolean keepOriginal = isPropertyTrue(project, CONVERT_TO_PDL_KEEP_ORIGINAL); + boolean skipVerification = isPropertyTrue(project, CONVERT_TO_PDL_SKIP_VERIFICATION); + String preserveSourceCmd = getNonEmptyProperty(project, CONVERT_TO_PDL_PRESERVE_SOURCE_CMD); + + // Utility task for migrating between PDSC and PDL. + project.getTasks().create(sourceSet.getTaskName("convert", "ToPdl"), TranslateSchemasTask.class, task -> + { + task.setInputDir(dataSchemaDir); + task.setDestinationDir(dataSchemaDir); + task.setResolverPath(getDataModelConfig(project, sourceSet)); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setPreserveSourceCmd(preserveSourceCmd); + if (reverse) + { + task.setSourceFormat(SchemaFileType.PDL); + task.setDestinationFormat(SchemaFileType.PDSC); + } + else + { + task.setSourceFormat(SchemaFileType.PDSC); + task.setDestinationFormat(SchemaFileType.PDL); + } + task.setKeepOriginal(keepOriginal); + task.setSkipVerification(skipVerification); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + + task.onlyIf(t -> task.getInputDir().exists()); + task.doLast(new CacheableAction<>(t -> + { + project.getLogger().lifecycle("Pegasus schema conversion complete."); + project.getLogger().lifecycle("All pegasus schema files in " + dataSchemaDir + " have been converted"); + project.getLogger().lifecycle("You can use '-PconvertToPdl.reverse=true|false' to change the direction of conversion."); + })); + }); + + // Helper task for reformatting existing PDL schemas by generating them again. + project.getTasks().create(sourceSet.getTaskName("reformat", "Pdl"), TranslateSchemasTask.class, task -> + { + task.setInputDir(dataSchemaDir); + task.setDestinationDir(dataSchemaDir); + task.setResolverPath(getDataModelConfig(project, sourceSet)); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setSourceFormat(SchemaFileType.PDL); + task.setDestinationFormat(SchemaFileType.PDL); + task.setKeepOriginal(true); + task.setSkipVerification(true); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + + task.onlyIf(t -> task.getInputDir().exists()); + task.doLast(new CacheableAction<>(t -> project.getLogger().lifecycle("PDL reformat complete."))); + }); + } + + @SuppressWarnings("deprecation") + protected GenerateDataTemplateTask configureDataTemplateGeneration(Project project, SourceSet sourceSet) + { + File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); + File generatedDataTemplateDir = project.file(getGeneratedDirPath(project, sourceSet, DATA_TEMPLATE_GEN_TYPE) + + File.separatorChar + "java"); + File publishableSchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() + + File.separatorChar + sourceSet.getName() + "Schemas"); + File publishableLegacySchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() + + File.separatorChar + sourceSet.getName() + "LegacySchemas"); + File publishableExtensionSchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() + + File.separatorChar + sourceSet.getName() + "ExtensionSchemas"); + + // generate data template source files from data schema + GenerateDataTemplateTask generateDataTemplatesTask = project.getTasks() + .create(sourceSet.getTaskName("generate", "dataTemplate"), GenerateDataTemplateTask.class, task -> + { + task.setInputDir(dataSchemaDir); + task.setDestinationDir(generatedDataTemplateDir); + task.setResolverPath(getDataModelConfig(project, sourceSet)); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + if (isPropertyTrue(project, CODE_GEN_PATH_CASE_SENSITIVE)) + { + task.setGenerateLowercasePath(false); + } + + task.onlyIf(t -> + { + if (task.getInputDir().exists()) + { + @SuppressWarnings("unchecked") + Map pegasusOptions = (Map) project + .getExtensions().getExtraProperties().get("pegasus"); + + return pegasusOptions.get(sourceSet.getName()).hasGenerationMode(PegasusOptions.GenerationMode.PEGASUS); + } + + return false; + }); + + task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, DATA_TEMPLATE_GEN_TYPE))); + }); + + // TODO: Tighten the types so that _generateSourcesJarTask must be of type Jar. + ((Jar) _generateSourcesJarTask).from(generateDataTemplatesTask.getDestinationDir()); + _generateSourcesJarTask.dependsOn(generateDataTemplatesTask); + + _generateJavadocTask.source(generateDataTemplatesTask.getDestinationDir()); + _generateJavadocTask.setClasspath(_generateJavadocTask.getClasspath() + .plus(project.getConfigurations().getByName("dataTemplateCompile")) + .plus(generateDataTemplatesTask.getResolverPath())); + _generateJavadocTask.dependsOn(generateDataTemplatesTask); + + // Add extra dependencies for data model compilation + project.getDependencies().add("dataTemplateCompile", "com.google.code.findbugs:jsr305:3.0.2"); + + // create new source set for generated java source and class files + String targetSourceSetName = getGeneratedSourceSetName(sourceSet, DATA_TEMPLATE_GEN_TYPE); + + SourceSetContainer sourceSets = project.getConvention() + .getPlugin(JavaPluginConvention.class).getSourceSets(); + + SourceSet targetSourceSet = sourceSets.create(targetSourceSetName, ss -> + { + ss.java(sourceDirectorySet -> sourceDirectorySet.srcDir(generatedDataTemplateDir)); + ss.setCompileClasspath(getDataModelConfig(project, sourceSet) + .plus(project.getConfigurations().getByName("dataTemplateCompile"))); + }); + + // idea plugin needs to know about new generated java source directory and its dependencies + addGeneratedDir(project, targetSourceSet, Arrays.asList( + getDataModelConfig(project, sourceSet), + project.getConfigurations().getByName("dataTemplateCompile"))); + + // Set source compatibility to 1.8 as the data-templates now generate code with Java 8 features. + JavaCompile compileTask = project.getTasks() + .withType(JavaCompile.class).getByName(targetSourceSet.getCompileJavaTaskName()); + compileTask.doFirst(new CacheableAction<>(task -> { + ((JavaCompile) task).setSourceCompatibility("1.8"); + ((JavaCompile) task).setTargetCompatibility("1.8"); + })); + // make sure that java source files have been generated before compiling them + compileTask.dependsOn(generateDataTemplatesTask); + + // Dummy task to maintain backward compatibility + // TODO: Delete this task once use cases have had time to reference the new task + Task destroyStaleFiles = project.getTasks().create(sourceSet.getName() + "DestroyStaleFiles", Delete.class); + destroyStaleFiles.onlyIf(task -> { + project.getLogger().lifecycle("{} task is a NO-OP task.", task.getPath()); + return false; + }); + + // Dummy task to maintain backward compatibility, as this task was replaced by CopySchemas + // TODO: Delete this task once use cases have had time to reference the new task + Task copyPdscSchemasTask = project.getTasks().create(sourceSet.getName() + "CopyPdscSchemas", Copy.class); + copyPdscSchemasTask.dependsOn(destroyStaleFiles); + copyPdscSchemasTask.onlyIf(task -> { + project.getLogger().lifecycle("{} task is a NO-OP task.", task.getPath()); + return false; + }); + + // Prepare schema files for publication by syncing schema folders. + Task prepareSchemasForPublishTask = project.getTasks() + .create(sourceSet.getName() + "CopySchemas", Sync.class, task -> + { + task.from(dataSchemaDir, syncSpec -> DATA_TEMPLATE_FILE_SUFFIXES.forEach(suffix -> syncSpec.include("**/*" + suffix))); + task.into(publishableSchemasBuildDir); + }); + prepareSchemasForPublishTask.dependsOn(copyPdscSchemasTask); + + Collection dataTemplateJarDepends = new ArrayList<>(); + dataTemplateJarDepends.add(compileTask); + dataTemplateJarDepends.add(prepareSchemasForPublishTask); + + // Convert all PDL files back to PDSC for publication + // TODO: Remove this conversion permanently once translated PDSCs are no longer needed. + Task prepareLegacySchemasForPublishTask = project.getTasks() + .create(sourceSet.getName() + "TranslateSchemas", TranslateSchemasTask.class, task -> + { + task.setInputDir(dataSchemaDir); + task.setDestinationDir(publishableLegacySchemasBuildDir); + task.setResolverPath(getDataModelConfig(project, sourceSet)); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setSourceFormat(SchemaFileType.PDL); + task.setDestinationFormat(SchemaFileType.PDSC); + task.setKeepOriginal(true); + task.setSkipVerification(true); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + }); + + prepareLegacySchemasForPublishTask.dependsOn(destroyStaleFiles); + dataTemplateJarDepends.add(prepareLegacySchemasForPublishTask); + + // extension schema directory + File extensionSchemaDir = project.file(getExtensionSchemaPath(project, sourceSet)); + + if (!SharedFileUtils.getSuffixedFiles(project, extensionSchemaDir, PDL_FILE_SUFFIX).isEmpty()) + { + // Validate extension schemas if extension schemas are provided. + ValidateExtensionSchemaTask validateExtensionSchemaTask = project.getTasks() + .create(sourceSet.getTaskName("validate", "ExtensionSchemas"), ValidateExtensionSchemaTask.class, task -> + { + task.setInputDir(extensionSchemaDir); + task.setResolverPath( + getDataModelConfig(project, sourceSet).plus(project.files(getDataSchemaPath(project, sourceSet)))); + task.setClassPath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + }); + + Task prepareExtensionSchemasForPublishTask = project.getTasks() + .create(sourceSet.getName() + "CopyExtensionSchemas", Sync.class, task -> + { + task.from(extensionSchemaDir, syncSpec -> syncSpec.include("**/*" + PDL_FILE_SUFFIX)); + task.into(publishableExtensionSchemasBuildDir); + }); + + prepareExtensionSchemasForPublishTask.dependsOn(validateExtensionSchemaTask); + prepareExtensionSchemasForPublishTask.dependsOn(copyPdscSchemasTask); + dataTemplateJarDepends.add(prepareExtensionSchemasForPublishTask); + } + + // include pegasus files in the output of this SourceSet + project.getTasks().withType(ProcessResources.class).getByName(targetSourceSet.getProcessResourcesTaskName(), it -> + { + it.from(prepareSchemasForPublishTask, copy -> copy.into("pegasus")); + // TODO: Remove this permanently once translated PDSCs are no longer needed. + it.from(prepareLegacySchemasForPublishTask, copy -> copy.into(TRANSLATED_SCHEMAS_DIR)); + Sync copyExtensionSchemasTask = project.getTasks().withType(Sync.class).findByName(sourceSet.getName() + "CopyExtensionSchemas"); + if (copyExtensionSchemasTask != null) + { + it.from(copyExtensionSchemasTask, copy -> copy.into("extensions")); + } + }); + + // create data template jar file + Jar dataTemplateJarTask = project.getTasks() + .create(sourceSet.getName() + "DataTemplateJar", Jar.class, task -> + { + task.dependsOn(dataTemplateJarDepends); + task.from(targetSourceSet.getOutput()); + + // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + task.getArchiveAppendix().set(getAppendix(sourceSet, "data-template")); + task.setDescription("Generate a data template jar"); + }); + + // add the data model and date template jars to the list of project artifacts. + if (!isTestSourceSet(sourceSet)) + { + project.getArtifacts().add("dataTemplate", dataTemplateJarTask); + } + else + { + project.getArtifacts().add("testDataTemplate", dataTemplateJarTask); + } + + // include additional dependencies into the appropriate configuration used to compile the input source set + // must include the generated data template classes and their dependencies the configuration. + // "compile" and "testCompile" configurations have been removed in Gradle 7, + // but to keep the maximum backward compatibility, here we handle Gradle 7 and earlier version differently + // Once MIN_REQUIRED_VERSION reaches 7.0, we can remove the check of isAtLeastGradle7() + String compileConfigName; + if (isAtLeastGradle7()) { + compileConfigName = isTestSourceSet(sourceSet) ? "testImplementation" : project.getConfigurations().findByName("api") != null ? "api" : "implementation"; + } + else + { + compileConfigName = isTestSourceSet(sourceSet) ? "testCompile" : "compile"; + } + + Configuration compileConfig = project.getConfigurations().maybeCreate(compileConfigName); + compileConfig.extendsFrom( + getDataModelConfig(project, sourceSet), + project.getConfigurations().getByName("dataTemplateCompile")); + + // The getArchivePath() API doesn’t carry any task dependency and has been deprecated. + // Replace it with getArchiveFile() on Gradle 7, + // but keep getArchivePath() to be backwards-compatibility with Gradle version older than 5.1 + // DataHub Note - applied FIXME + project.getDependencies().add(compileConfigName, project.files( + isAtLeastGradle7() ? dataTemplateJarTask.getArchiveFile() : dataTemplateJarTask.getArchivePath())); + + if (_configureIvyPublications) { + // The below Action is only applied when the 'ivy-publish' is applied by the consumer. + // If the consumer does not use ivy-publish, this is a noop. + // this Action prepares the project applying the pegasus plugin to publish artifacts using these steps: + // 1. Registers "feature variants" for pegasus-specific artifacts; + // see https://docs.gradle.org/6.1/userguide/feature_variants.html + // 2. Wires legacy configurations like `dataTemplateCompile` to auto-generated feature variant *Api and + // *Implementation configurations for backwards compatibility. + // 3. Configures the Ivy Publication to include auto-generated feature variant *Api and *Implementation + // configurations and their dependencies. + project.getPlugins().withType(IvyPublishPlugin.class, ivyPublish -> { + if (!isAtLeastGradle61()) + { + throw new GradleException("Using the ivy-publish plugin with the pegasus plugin requires Gradle 6.1 or higher " + + "at build time. Please upgrade."); + } + + JavaPluginExtension java = project.getExtensions().getByType(JavaPluginExtension.class); + // create new capabilities per source set; automatically creates api and implementation configurations + String featureName = mapSourceSetToFeatureName(targetSourceSet); + try + { + /* + reflection is required to preserve compatibility with Gradle 5.2.1 and below + TODO once Gradle 5.3+ is required, remove reflection and replace with: + java.registerFeature(featureName, featureSpec -> { + featureSpec.usingSourceSet(targetSourceSet); + }); + */ + Method registerFeature = JavaPluginExtension.class.getDeclaredMethod("registerFeature", String.class, Action.class); + Action/**/ featureSpecAction = createFeatureVariantFromSourceSet(targetSourceSet); + registerFeature.invoke(java, featureName, featureSpecAction); + } + catch (ReflectiveOperationException e) + { + throw new GradleException("Unable to register new feature variant", e); + } + + // expose transitive dependencies to consumers via variant configurations + Configuration featureConfiguration = project.getConfigurations().getByName(featureName); + Configuration mainGeneratedDataTemplateApi = project.getConfigurations().getByName(targetSourceSet.getApiConfigurationName()); + featureConfiguration.extendsFrom(mainGeneratedDataTemplateApi); + mainGeneratedDataTemplateApi.extendsFrom( + getDataModelConfig(project, targetSourceSet), + project.getConfigurations().getByName("dataTemplateCompile")); + + // Configure the existing IvyPublication + // For backwards-compatibility, make the legacy dataTemplate/testDataTemplate configurations extend + // their replacements, auto-created when we registered the new feature variant + project.afterEvaluate(p -> { + PublishingExtension publishing = p.getExtensions().getByType(PublishingExtension.class); + // When configuring a Gradle Publication, use this value to find the name of the publication to configure. Defaults to "ivy". + String publicationName = p.getExtensions().getExtraProperties().getProperties().getOrDefault("PegasusPublicationName", "ivy").toString(); + IvyPublication ivyPublication = publishing.getPublications().withType(IvyPublication.class).getByName(publicationName); + ivyPublication.configurations(configurations -> configurations.create(featureName, legacyConfiguration -> { + legacyConfiguration.extend(p.getConfigurations().getByName(targetSourceSet.getApiElementsConfigurationName()).getName()); + legacyConfiguration.extend(p.getConfigurations().getByName(targetSourceSet.getRuntimeElementsConfigurationName()).getName()); + })); + }); + }); + } + + if (debug) + { + System.out.println("configureDataTemplateGeneration sourceSet " + sourceSet.getName()); + System.out.println(compileConfigName + ".allDependencies : " + + project.getConfigurations().getByName(compileConfigName).getAllDependencies()); + System.out.println(compileConfigName + ".extendsFrom: " + + project.getConfigurations().getByName(compileConfigName).getExtendsFrom()); + System.out.println(compileConfigName + ".transitive: " + + project.getConfigurations().getByName(compileConfigName).isTransitive()); + } + + project.getTasks().getByName(sourceSet.getCompileJavaTaskName()).dependsOn(dataTemplateJarTask); + return generateDataTemplatesTask; + } + + private String mapSourceSetToFeatureName(SourceSet sourceSet) { + String featureName = ""; + switch (sourceSet.getName()) { + case "mainGeneratedDataTemplate": + featureName = "dataTemplate"; + break; + case "testGeneratedDataTemplate": + featureName = "testDataTemplate"; + break; + case "mainGeneratedRest": + featureName = "restClient"; + break; + case "testGeneratedRest": + featureName = "testRestClient"; + break; + case "mainGeneratedAvroSchema": + featureName = "avroSchema"; + break; + case "testGeneratedAvroSchema": + featureName = "testAvroSchema"; + break; + default: + String msg = String.format("Unable to map %s to an appropriate feature name", sourceSet); + throw new GradleException(msg); + } + return featureName; + } + + // Generate rest client from idl files generated from java source files in the specified source set. + // + // This generates rest client source files from idl file generated from java source files + // in the source set. The generated rest client source files will be in a new source set. + // It also compiles the rest client source files into classes, and creates both the + // rest model and rest client jar files. + // + @SuppressWarnings("deprecation") + protected void configureRestClientGeneration(Project project, SourceSet sourceSet) + { + // idl directory for api project + File idlDir = project.file(getIdlPath(project, sourceSet)); + if (SharedFileUtils.getSuffixedFiles(project, idlDir, IDL_FILE_SUFFIX).isEmpty() && !isPropertyTrue(project, + PROCESS_EMPTY_IDL_DIR)) + { + return; + } + File generatedRestClientDir = project.file(getGeneratedDirPath(project, sourceSet, REST_GEN_TYPE) + + File.separatorChar + "java"); + + // always include imported data template jars in compileClasspath of rest client + FileCollection dataModelConfig = getDataModelConfig(project, sourceSet); + + // if data templates generated from this source set, add the generated data template jar to compileClasspath + // of rest client. + String dataTemplateSourceSetName = getGeneratedSourceSetName(sourceSet, DATA_TEMPLATE_GEN_TYPE); + + Jar dataTemplateJarTask = null; + + SourceSetContainer sourceSets = project.getConvention() + .getPlugin(JavaPluginConvention.class).getSourceSets(); + + FileCollection dataModels; + if (sourceSets.findByName(dataTemplateSourceSetName) != null) + { + if (debug) + { + System.out.println("sourceSet " + sourceSet.getName() + " has generated sourceSet " + dataTemplateSourceSetName); + } + dataTemplateJarTask = (Jar) project.getTasks().getByName(sourceSet.getName() + "DataTemplateJar"); + // The getArchivePath() API doesn’t carry any task dependency and has been deprecated. + // Replace it with getArchiveFile() on Gradle 7, + // but keep getArchivePath() to be backwards-compatibility with Gradle version older than 5.1 + // DataHub Note - applied FIXME + dataModels = dataModelConfig.plus(project.files( + isAtLeastGradle7() ? dataTemplateJarTask.getArchiveFile() : dataTemplateJarTask.getArchivePath())); + } + else + { + dataModels = dataModelConfig; + } + + // create source set for generated rest model, rest client source and class files. + String targetSourceSetName = getGeneratedSourceSetName(sourceSet, REST_GEN_TYPE); + SourceSet targetSourceSet = sourceSets.create(targetSourceSetName, ss -> + { + ss.java(sourceDirectorySet -> sourceDirectorySet.srcDir(generatedRestClientDir)); + ss.setCompileClasspath(dataModels.plus(project.getConfigurations().getByName("restClientCompile"))); + }); + + project.getPlugins().withType(EclipsePlugin.class, eclipsePlugin -> { + EclipseModel eclipseModel = (EclipseModel) project.getExtensions().findByName("eclipse"); + eclipseModel.getClasspath().getPlusConfigurations() + .add(project.getConfigurations().getByName("restClientCompile")); + }); + + // idea plugin needs to know about new rest client source directory and its dependencies + addGeneratedDir(project, targetSourceSet, Arrays.asList( + getDataModelConfig(project, sourceSet), + project.getConfigurations().getByName("restClientCompile"))); + + // generate the rest client source files + GenerateRestClientTask generateRestClientTask = project.getTasks() + .create(targetSourceSet.getTaskName("generate", "restClient"), GenerateRestClientTask.class, task -> + { + task.dependsOn(project.getConfigurations().getByName("dataTemplate")); + task.setInputDir(idlDir); + task.setResolverPath(dataModels.plus(project.getConfigurations().getByName("restClientCompile"))); + task.setRuntimeClasspath(project.getConfigurations().getByName("dataModel") + .plus(project.getConfigurations().getByName("dataTemplate").getArtifacts().getFiles())); + task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setDestinationDir(generatedRestClientDir); + task.setRestli2FormatSuppressed(project.hasProperty(SUPPRESS_REST_CLIENT_RESTLI_2)); + task.setRestli1FormatSuppressed(project.hasProperty(SUPPRESS_REST_CLIENT_RESTLI_1)); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + if (isPropertyTrue(project, CODE_GEN_PATH_CASE_SENSITIVE)) + { + task.setGenerateLowercasePath(false); + } + if (isPropertyTrue(project, ENABLE_FLUENT_API)) + { + task.setGenerateFluentApi(true); + } + task.doFirst(new CacheableAction<>(t -> project.delete(generatedRestClientDir))); + }); + + if (dataTemplateJarTask != null) + { + generateRestClientTask.dependsOn(dataTemplateJarTask); + } + + // TODO: Tighten the types so that _generateSourcesJarTask must be of type Jar. + ((Jar) _generateSourcesJarTask).from(generateRestClientTask.getDestinationDir()); + _generateSourcesJarTask.dependsOn(generateRestClientTask); + + _generateJavadocTask.source(generateRestClientTask.getDestinationDir()); + _generateJavadocTask.setClasspath(_generateJavadocTask.getClasspath() + .plus(project.getConfigurations().getByName("restClientCompile")) + .plus(generateRestClientTask.getResolverPath())); + _generateJavadocTask.dependsOn(generateRestClientTask); + + // make sure rest client source files have been generated before compiling them + JavaCompile compileGeneratedRestClientTask = (JavaCompile) project.getTasks() + .getByName(targetSourceSet.getCompileJavaTaskName()); + compileGeneratedRestClientTask.dependsOn(generateRestClientTask); + compileGeneratedRestClientTask.getOptions().getCompilerArgs().add("-Xlint:-deprecation"); + + // create the rest model jar file + Task restModelJarTask = project.getTasks().create(sourceSet.getName() + "RestModelJar", Jar.class, task -> + { + task.from(idlDir, copySpec -> + { + copySpec.eachFile(fileCopyDetails -> project.getLogger() + .info("Add idl file: {}", fileCopyDetails)); + copySpec.setIncludes(Collections.singletonList('*' + IDL_FILE_SUFFIX)); + }); + // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + task.getArchiveAppendix().set(getAppendix(sourceSet, "rest-model")); + task.setDescription("Generate rest model jar"); + }); + + // create the rest client jar file + Task restClientJarTask = project.getTasks() + .create(sourceSet.getName() + "RestClientJar", Jar.class, task -> + { + task.dependsOn(compileGeneratedRestClientTask); + task.from(idlDir, copySpec -> { + copySpec.eachFile(fileCopyDetails -> { + project.getLogger().info("Add interface file: {}", fileCopyDetails); + fileCopyDetails.setPath("idl" + File.separatorChar + fileCopyDetails.getPath()); + }); + copySpec.setIncludes(Collections.singletonList('*' + IDL_FILE_SUFFIX)); + }); + task.from(targetSourceSet.getOutput()); + // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 + // DataHub Note - applied FIXME + task.getArchiveAppendix().set(getAppendix(sourceSet, "rest-client")); + task.setDescription("Generate rest client jar"); + }); + + // add the rest model jar and the rest client jar to the list of project artifacts. + if (!isTestSourceSet(sourceSet)) + { + project.getArtifacts().add("restModel", restModelJarTask); + project.getArtifacts().add("restClient", restClientJarTask); + } + else + { + project.getArtifacts().add("testRestModel", restModelJarTask); + project.getArtifacts().add("testRestClient", restClientJarTask); + } + } + + // Return the appendix for generated jar files. + // The source set name is not included for the main source set. + private static String getAppendix(SourceSet sourceSet, String suffix) + { + return sourceSet.getName().equals("main") ? suffix : sourceSet.getName() + '-' + suffix; + } + + private static Project getApiProject(Project project) + { + if (project.getExtensions().getExtraProperties().has("apiProject")) + { + return (Project) project.getExtensions().getExtraProperties().get("apiProject"); + } + + List subsSuffixes; + if (project.getExtensions().getExtraProperties().has("apiProjectSubstitutionSuffixes")) + { + @SuppressWarnings("unchecked") + List suffixValue = (List) project.getExtensions() + .getExtraProperties().get("apiProjectSubstitutionSuffixes"); + + subsSuffixes = suffixValue; + } + else + { + subsSuffixes = Arrays.asList("-impl", "-service", "-server", "-server-impl"); + } + + for (String suffix : subsSuffixes) + { + if (project.getPath().endsWith(suffix)) + { + String searchPath = project.getPath().substring(0, project.getPath().length() - suffix.length()) + "-api"; + Project apiProject = project.findProject(searchPath); + if (apiProject != null) + { + return apiProject; + } + } + } + + return project.findProject(project.getPath() + "-api"); + } + + private static Project getCheckedApiProject(Project project) + { + Project apiProject = getApiProject(project); + + if (apiProject == project) + { + throw new GradleException("The API project of ${project.path} must not be itself."); + } + + return apiProject; + } + + /** + * return the property value if the property exists and is not empty (-Pname=value) + * return null if property does not exist or the property is empty (-Pname) + * + * @param project the project where to look for the property + * @param propertyName the name of the property + */ + public static String getNonEmptyProperty(Project project, String propertyName) + { + if (!project.hasProperty(propertyName)) + { + return null; + } + + String propertyValue = project.property(propertyName).toString(); + if (propertyValue.isEmpty()) + { + return null; + } + + return propertyValue; + } + + /** + * Return true if the given property exists and its value is true + * + * @param project the project where to look for the property + * @param propertyName the name of the property + */ + public static boolean isPropertyTrue(Project project, String propertyName) + { + return project.hasProperty(propertyName) && Boolean.valueOf(project.property(propertyName).toString()); + } + + private static String createModifiedFilesMessage(Collection nonEquivExpectedFiles, + Collection foldersToBeBuilt) + { + StringBuilder builder = new StringBuilder(); + builder.append("\nRemember to checkin the changes to the following new or modified files:\n"); + for (String file : nonEquivExpectedFiles) + { + builder.append(" "); + builder.append(file); + builder.append("\n"); + } + + if (!foldersToBeBuilt.isEmpty()) + { + builder.append("\nThe file modifications include service interface changes, you can build the the following projects " + + "to re-generate the client APIs accordingly:\n"); + for (String folder : foldersToBeBuilt) + { + builder.append(" "); + builder.append(folder); + builder.append("\n"); + } + } + + return builder.toString(); + } + + private static String createPossibleMissingFilesMessage(Collection missingFiles) + { + StringBuilder builder = new StringBuilder(); + builder.append("If this is the result of an automated build, then you may have forgotten to check in some snapshot or idl files:\n"); + for (String file : missingFiles) + { + builder.append(" "); + builder.append(file); + builder.append("\n"); + } + + return builder.toString(); + } + + private static String findProperty(FileCompatibilityType type) + { + String property; + switch (type) + { + case SNAPSHOT: + property = SNAPSHOT_COMPAT_REQUIREMENT; + break; + case IDL: + property = IDL_COMPAT_REQUIREMENT; + break; + case PEGASUS_SCHEMA_SNAPSHOT: + property = PEGASUS_SCHEMA_SNAPSHOT_REQUIREMENT; + break; + case PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: + property = PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_REQUIREMENT; + break; + default: + throw new GradleException("No property defined for compatibility type " + type); + } + return property; + } + + private static Set buildWatchedRestModelInputDirs(Project project, SourceSet sourceSet) { + @SuppressWarnings("unchecked") + Map pegasusOptions = (Map) project + .getExtensions().getExtraProperties().get("pegasus"); + + File rootPath = new File(project.getProjectDir(), + pegasusOptions.get(sourceSet.getName()).restModelOptions.getRestResourcesRootPath()); + + IdlOptions idlOptions = pegasusOptions.get(sourceSet.getName()).idlOptions; + + // if idlItems exist, only watch the smaller subset + return idlOptions.getIdlItems().stream() + .flatMap(idlItem -> Arrays.stream(idlItem.packageNames)) + .map(packageName -> new File(rootPath, packageName.replace('.', '/'))) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static Set difference(Set left, Set right) + { + Set result = new HashSet<>(left); + result.removeAll(right); + return result; + } + + /** + * Configures the given source set so that its data schema directory (usually 'pegasus') is marked as a resource root. + * The purpose of this is to improve the IDE experience. Makes sure to exclude this directory from being packaged in + * with the default Jar task. + */ + private static void configureDataSchemaResourcesRoot(Project project, SourceSet sourceSet) + { + sourceSet.resources(sourceDirectorySet -> { + final String dataSchemaPath = getDataSchemaPath(project, sourceSet); + final File dataSchemaRoot = project.file(dataSchemaPath); + sourceDirectorySet.srcDir(dataSchemaPath); + project.getLogger().info("Adding resource root '{}'", dataSchemaPath); + + final String extensionsSchemaPath = getExtensionSchemaPath(project, sourceSet); + final File extensionsSchemaRoot = project.file(extensionsSchemaPath); + sourceDirectorySet.srcDir(extensionsSchemaPath); + project.getLogger().info("Adding resource root '{}'", extensionsSchemaPath); + + // Exclude the data schema and extensions schema directory from being copied into the default Jar task + sourceDirectorySet.getFilter().exclude(fileTreeElement -> { + final File file = fileTreeElement.getFile(); + // Traversal starts with the children of a resource root, so checking the direct parent is sufficient + final boolean underDataSchemaRoot = dataSchemaRoot.equals(file.getParentFile()); + final boolean underExtensionsSchemaRoot = extensionsSchemaRoot.equals(file.getParentFile()); + final boolean exclude = (underDataSchemaRoot || underExtensionsSchemaRoot); + if (exclude) + { + project.getLogger().info("Excluding resource directory '{}'", file); + } + return exclude; + }); + }); + } + + private Task generatePegasusSchemaSnapshot(Project project, SourceSet sourceSet, String taskName, File inputDir, File outputDir, + boolean isExtensionSchema) + { + return project.getTasks().create(sourceSet.getTaskName("generate", taskName), + GeneratePegasusSnapshotTask.class, task -> + { + task.setInputDir(inputDir); + task.setResolverPath(getDataModelConfig(project, sourceSet).plus(project.files(getDataSchemaPath(project, sourceSet)))); + task.setClassPath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); + task.setPegasusSchemaSnapshotDestinationDir(outputDir); + task.setExtensionSchema(isExtensionSchema); + if (isPropertyTrue(project, ENABLE_ARG_FILE)) + { + task.setEnableArgFile(true); + } + }); + } + + private Task publishPegasusSchemaSnapshot(Project project, SourceSet sourceSet, String taskName, Task checkPegasusSnapshotTask, + File inputDir, File outputDir) + { + return project.getTasks().create(sourceSet.getTaskName("publish", taskName), + Sync.class, task -> + { + task.dependsOn(checkPegasusSnapshotTask); + task.from(inputDir); + task.into(outputDir); + task.onlyIf(t -> !SharedFileUtils.getSuffixedFiles(project, inputDir, PDL_FILE_SUFFIX).isEmpty()); + }); + } + + private void checkGradleVersion(Project project) + { + if (MIN_REQUIRED_VERSION.compareTo(GradleVersion.current()) > 0) + { + throw new GradleException(String.format("This plugin does not support %s. Please use %s or later.", + GradleVersion.current(), + MIN_REQUIRED_VERSION)); + } + if (MIN_SUGGESTED_VERSION.compareTo(GradleVersion.current()) > 0) + { + project.getLogger().warn(String.format("Pegasus supports %s, but it may not be supported in the next major release. Please use %s or later.", + GradleVersion.current(), + MIN_SUGGESTED_VERSION)); + } + } + + /** + * Reflection is necessary to obscure types introduced in Gradle 5.3 + * + * @param sourceSet the target sourceset upon which to create a new feature variant + * @return an Action which modifies a org.gradle.api.plugins.FeatureSpec instance + */ + private Action/**/ createFeatureVariantFromSourceSet(SourceSet sourceSet) + { + return featureSpec -> { + try + { + Class clazz = Class.forName("org.gradle.api.plugins.FeatureSpec"); + Method usingSourceSet = clazz.getDeclaredMethod("usingSourceSet", SourceSet.class); + usingSourceSet.invoke(featureSpec, sourceSet); + } + catch (ReflectiveOperationException e) + { + throw new GradleException("Unable to invoke FeatureSpec#usingSourceSet(SourceSet)", e); + } + }; + } + + protected static boolean isAtLeastGradle61() + { + return GradleVersion.current().getBaseVersion().compareTo(GradleVersion.version("6.1")) >= 0; + } + + public static boolean isAtLeastGradle7() { + return GradleVersion.current().getBaseVersion().compareTo(GradleVersion.version("7.0")) >= 0; + } +} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java new file mode 100644 index 00000000000000..a2aafaf1be0172 --- /dev/null +++ b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java @@ -0,0 +1,124 @@ +package com.linkedin.pegasus.gradle.tasks; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.FileCollection; +import org.gradle.api.specs.Specs; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.SkipWhenEmpty; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.FileChange; +import org.gradle.work.InputChanges; + + +public class ChangedFileReportTask extends DefaultTask +{ + private final Collection _needCheckinFiles = new ArrayList<>(); + + private FileCollection _idlFiles = getProject().files(); + private FileCollection _snapshotFiles = getProject().files(); + + public ChangedFileReportTask() + { + //with Gradle 6.0, Declaring an incremental task without outputs is not allowed. + getOutputs().upToDateWhen(Specs.satisfyNone()); + } + + // DataHub Note - updated for InputChanges + @TaskAction + public void checkFilesForChanges(InputChanges inputChanges) + { + getLogger().lifecycle("Checking idl and snapshot files for changes..."); + getLogger().info("idlFiles: " + _idlFiles.getAsPath()); + getLogger().info("snapshotFiles: " + _snapshotFiles.getAsPath()); + + Set filesRemoved = new HashSet<>(); + Set filesAdded = new HashSet<>(); + Set filesChanged = new HashSet<>(); + + if (inputChanges.isIncremental()) + { + Consumer handleChange = change -> + { + switch (change.getChangeType()) { + case ADDED: + filesAdded.add(change.getFile().getAbsolutePath()); + break; + case REMOVED: + filesRemoved.add(change.getFile().getAbsolutePath()); + break; + case MODIFIED: + filesChanged.add(change.getFile().getAbsolutePath()); + break; + } + }; + + inputChanges.getFileChanges(_idlFiles).forEach(handleChange); + inputChanges.getFileChanges(_snapshotFiles).forEach(handleChange); + + if (!filesRemoved.isEmpty()) + { + String files = joinByComma(filesRemoved); + _needCheckinFiles.add(files); + getLogger().lifecycle( + "The following files have been removed, be sure to remove them from source control: {}", files); + } + + if (!filesAdded.isEmpty()) + { + String files = joinByComma(filesAdded); + _needCheckinFiles.add(files); + getLogger().lifecycle("The following files have been added, be sure to add them to source control: {}", files); + } + + if (!filesChanged.isEmpty()) + { + String files = joinByComma(filesChanged); + _needCheckinFiles.add(files); + getLogger().lifecycle( + "The following files have been changed, be sure to commit the changes to source control: {}", files); + } + } + } + + private String joinByComma(Set files) + { + return files.stream().collect(Collectors.joining(", ")); + } + + @InputFiles + @SkipWhenEmpty + public FileCollection getSnapshotFiles() + { + return _snapshotFiles; + } + + public void setSnapshotFiles(FileCollection snapshotFiles) + { + _snapshotFiles = snapshotFiles; + } + + @InputFiles + @SkipWhenEmpty + public FileCollection getIdlFiles() + { + return _idlFiles; + } + + public void setIdlFiles(FileCollection idlFiles) + { + _idlFiles = idlFiles; + } + + @Internal + public Collection getNeedCheckinFiles() + { + return _needCheckinFiles; + } +} \ No newline at end of file diff --git a/datahub-frontend/build.gradle b/datahub-frontend/build.gradle index a1b97701dbf882..437c72e6394ea6 100644 --- a/datahub-frontend/build.gradle +++ b/datahub-frontend/build.gradle @@ -2,6 +2,7 @@ plugins { id "io.github.kobylynskyi.graphql.codegen" version "4.1.1" id 'scala' id 'com.palantir.docker' + id 'org.gradle.playframework' } apply from: "../gradle/versioning/versioning.gradle" @@ -20,7 +21,6 @@ model { } task myTar(type: Tar) { - extension = "tgz" compression = Compression.GZIP from("${buildDir}/stage") @@ -119,3 +119,23 @@ task cleanLocalDockerImages { } } dockerClean.finalizedBy(cleanLocalDockerImages) + +// gradle 8 fixes +tasks.getByName('createDatahub-frontendTarDist').dependsOn 'stageMainDist' +tasks.getByName('createDatahub-frontendZipDist').dependsOn 'stageMainDist' +stagePlayBinaryDist.dependsOn tasks.getByName('createDatahub-frontendStartScripts') +playBinaryDistTar.dependsOn tasks.getByName('createDatahub-frontendStartScripts') +playBinaryDistZip.dependsOn tasks.getByName('createDatahub-frontendStartScripts') +tasks.getByName('stageDatahub-frontendDist').dependsOn stagePlayBinaryDist +tasks.getByName('stageDatahub-frontendDist').dependsOn createPlayBinaryStartScripts +tasks.getByName('datahub-frontendDistTar').dependsOn createPlayBinaryStartScripts +tasks.getByName('datahub-frontendDistTar').dependsOn createMainStartScripts +tasks.getByName('datahub-frontendDistZip').dependsOn createPlayBinaryStartScripts +tasks.getByName('datahub-frontendDistZip').dependsOn createMainStartScripts +playBinaryDistTar.dependsOn createMainStartScripts +playBinaryDistZip.dependsOn createMainStartScripts +createMainStartScripts.dependsOn 'stageDatahub-frontendDist' +createPlayBinaryTarDist.dependsOn 'stageDatahub-frontendDist' +createPlayBinaryZipDist.dependsOn 'stageDatahub-frontendDist' +createPlayBinaryTarDist.dependsOn 'stageMainDist' +createPlayBinaryZipDist.dependsOn 'stageMainDist' diff --git a/datahub-frontend/play.gradle b/datahub-frontend/play.gradle index dd1ceee411f746..84fb4c02620b8e 100644 --- a/datahub-frontend/play.gradle +++ b/datahub-frontend/play.gradle @@ -1,4 +1,3 @@ -apply plugin: "org.gradle.playframework" // Change this to listen on a different port project.ext.httpPort = 9001 @@ -101,4 +100,22 @@ play { test { useJUnitPlatform() + + def playJava17CompatibleJvmArgs = [ + "--add-opens=java.base/java.lang=ALL-UNNAMED", + //"--add-opens=java.base/java.lang.invoke=ALL-UNNAMED", + //"--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + //"--add-opens=java.base/java.io=ALL-UNNAMED", + //"--add-opens=java.base/java.net=ALL-UNNAMED", + //"--add-opens=java.base/java.nio=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + //"--add-opens=java.base/java.util.concurrent=ALL-UNNAMED", + //"--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED", + //"--add-opens=java.base/sun.nio.ch=ALL-UNNAMED", + //"--add-opens=java.base/sun.nio.cs=ALL-UNNAMED", + //"--add-opens=java.base/sun.security.action=ALL-UNNAMED", + //"--add-opens=java.base/sun.util.calendar=ALL-UNNAMED", + //"--add-opens=java.security.jgss/sun.security.krb5=ALL-UNNAMED", + ] + jvmArgs = playJava17CompatibleJvmArgs } diff --git a/datahub-graphql-core/build.gradle b/datahub-graphql-core/build.gradle index fba0031351b588..6e8cb939669226 100644 --- a/datahub-graphql-core/build.gradle +++ b/datahub-graphql-core/build.gradle @@ -1,7 +1,8 @@ plugins { + id 'java' id "io.github.kobylynskyi.graphql.codegen" version "4.1.1" } -apply plugin: 'java' + dependencies { implementation project(':metadata-service:restli-client') diff --git a/datahub-web-react/build.gradle b/datahub-web-react/build.gradle index fd36e5ac4bc2c3..72821d8b97dc0b 100644 --- a/datahub-web-react/build.gradle +++ b/datahub-web-react/build.gradle @@ -1,8 +1,8 @@ plugins { id 'java' + id 'distribution' + id 'com.github.node-gradle.node' } -apply plugin: 'distribution' -apply plugin: 'com.github.node-gradle.node' node { @@ -35,7 +35,7 @@ node { yarnWorkDir = file("${project.projectDir}/.gradle/yarn") // Set the work directory where node_modules should be located - nodeModulesDir = file("${project.projectDir}") + nodeProjectDir = file("${project.projectDir}") } @@ -94,7 +94,7 @@ configurations { distZip { dependsOn yarnQuickBuild - baseName 'datahub-web-react' + archiveFileName = "datahub-web-react-${archiveVersion}.${archiveExtension}" from 'dist' } @@ -112,5 +112,5 @@ jar { into('public') { from zipTree(distZip.outputs.files.first()) } - classifier = 'assets' + archiveClassifier = 'assets' } diff --git a/docker/datahub-frontend/Dockerfile b/docker/datahub-frontend/Dockerfile index 0c4c229af34f0c..17d691177aa345 100644 --- a/docker/datahub-frontend/Dockerfile +++ b/docker/datahub-frontend/Dockerfile @@ -17,7 +17,7 @@ RUN if [ "${ALPINE_REPO_URL}" != "http://dl-cdn.alpinelinux.org/alpine" ] ; then # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl sqlite libc6-compat java-snappy \ - && apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ + && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ ENV LD_LIBRARY_PATH="/lib:/lib64" @@ -25,7 +25,10 @@ ENV LD_LIBRARY_PATH="/lib:/lib64" FROM base as prod-install COPY ./datahub-frontend.zip / -RUN unzip datahub-frontend.zip && rm datahub-frontend.zip +RUN unzip datahub-frontend.zip -d /datahub-frontend \ + && mv /datahub-frontend/main/* /datahub-frontend \ + && rmdir /datahub-frontend/main \ + && rm datahub-frontend.zip COPY ./docker/monitoring/client-prometheus-config.yaml /datahub-frontend/ RUN chown -R datahub:datahub /datahub-frontend && chmod 755 /datahub-frontend diff --git a/docker/datahub-frontend/start.sh b/docker/datahub-frontend/start.sh index 12e6b8915096d6..f5de9c87968b0d 100755 --- a/docker/datahub-frontend/start.sh +++ b/docker/datahub-frontend/start.sh @@ -49,6 +49,8 @@ export JAVA_OPTS="${JAVA_MEMORY_OPTS:-"-Xms512m -Xmx1024m"} \ -Djava.security.auth.login.config=datahub-frontend/conf/jaas.conf \ -Dlogback.configurationFile=datahub-frontend/conf/logback.xml \ -Dlogback.debug=false \ + --add-opens java.base/java.lang=ALL-UNNAMED \ + --add-opens=java.base/java.util=ALL-UNNAMED \ ${PROMETHEUS_AGENT:-} ${OTEL_AGENT:-} \ ${TRUSTSTORE_FILE:-} ${TRUSTSTORE_TYPE:-} ${TRUSTSTORE_PASSWORD:-} \ ${HTTP_PROXY:-} ${HTTPS_PROXY:-} ${NO_PROXY:-} \ diff --git a/docker/datahub-gms/Dockerfile b/docker/datahub-gms/Dockerfile index 9c79e1da542f0b..b26a02c1d3b15a 100644 --- a/docker/datahub-gms/Dockerfile +++ b/docker/datahub-gms/Dockerfile @@ -40,14 +40,14 @@ RUN if [ "${ALPINE_REPO_URL}" != "http://dl-cdn.alpinelinux.org/alpine" ] ; then # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl bash coreutils gcompat sqlite libc6-compat java-snappy \ - && apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ + && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-runner/9.4.46.v20220331/jetty-runner-9.4.46.v20220331.jar --output jetty-runner.jar \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-jmx/9.4.46.v20220331/jetty-jmx-9.4.46.v20220331.jar --output jetty-jmx.jar \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-util/9.4.46.v20220331/jetty-util-9.4.46.v20220331.jar --output jetty-util.jar \ && wget --no-verbose ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar \ && wget --no-verbose ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar \ - && cp /usr/lib/jvm/java-11-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks + && cp /usr/lib/jvm/java-17-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks COPY --from=binary /go/bin/dockerize /usr/local/bin ENV LD_LIBRARY_PATH="/lib:/lib64" diff --git a/docker/datahub-ingestion/build.gradle b/docker/datahub-ingestion/build.gradle index 52db594e2ef852..36444210f1938b 100644 --- a/docker/datahub-ingestion/build.gradle +++ b/docker/datahub-ingestion/build.gradle @@ -45,9 +45,9 @@ docker { buildArgs(dockerBuildArgs) } -tasks.getByName('docker').dependsOn(['build', - ':docker:datahub-ingestion-base:docker', - ':metadata-ingestion:codegen']) +tasks.getByName('dockerPrepare').dependsOn(['build', + ':docker:datahub-ingestion-base:docker', + ':metadata-ingestion:codegen']) task mkdirBuildDocker { doFirst { diff --git a/docker/datahub-mae-consumer/Dockerfile b/docker/datahub-mae-consumer/Dockerfile index 5bfa5f35ace179..9b7c6e762462e3 100644 --- a/docker/datahub-mae-consumer/Dockerfile +++ b/docker/datahub-mae-consumer/Dockerfile @@ -38,11 +38,11 @@ ENV JMX_VERSION=0.18.0 # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl bash coreutils sqlite libc6-compat java-snappy \ - && apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ + && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ \ && wget --no-verbose ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar \ && wget --no-verbose ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar \ - && cp /usr/lib/jvm/java-11-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks + && cp /usr/lib/jvm/java-17-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks COPY --from=binary /go/bin/dockerize /usr/local/bin ENV LD_LIBRARY_PATH="/lib:/lib64" diff --git a/docker/datahub-mce-consumer/Dockerfile b/docker/datahub-mce-consumer/Dockerfile index cc79a3072c1930..4da94794e0ead3 100644 --- a/docker/datahub-mce-consumer/Dockerfile +++ b/docker/datahub-mce-consumer/Dockerfile @@ -38,11 +38,11 @@ ENV JMX_VERSION=0.18.0 # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl bash sqlite libc6-compat java-snappy \ - && apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ + && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ \ && wget --no-verbose ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar \ && wget --no-verbose ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar \ - && cp /usr/lib/jvm/java-11-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks + && cp /usr/lib/jvm/java-17-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks COPY --from=binary /go/bin/dockerize /usr/local/bin FROM base as prod-install diff --git a/docker/datahub-upgrade/Dockerfile b/docker/datahub-upgrade/Dockerfile index 2beb5b54dac383..00dae87dfc3ded 100644 --- a/docker/datahub-upgrade/Dockerfile +++ b/docker/datahub-upgrade/Dockerfile @@ -38,13 +38,13 @@ ENV JMX_VERSION=0.18.0 # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl bash coreutils gcompat sqlite libc6-compat java-snappy \ - && apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ + && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-runner/9.4.46.v20220331/jetty-runner-9.4.46.v20220331.jar --output jetty-runner.jar \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-jmx/9.4.46.v20220331/jetty-jmx-9.4.46.v20220331.jar --output jetty-jmx.jar \ && curl -sS ${MAVEN_CENTRAL_REPO_URL}/org/eclipse/jetty/jetty-util/9.4.46.v20220331/jetty-util-9.4.46.v20220331.jar --output jetty-util.jar \ && wget --no-verbose ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar \ && wget --no-verbose ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar \ - && cp /usr/lib/jvm/java-11-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks + && cp /usr/lib/jvm/java-17-openjdk/jre/lib/security/cacerts /tmp/kafka.client.truststore.jks COPY --from=binary /go/bin/dockerize /usr/local/bin ENV LD_LIBRARY_PATH="/lib:/lib64" diff --git a/docker/kafka-setup/Dockerfile b/docker/kafka-setup/Dockerfile index f6a4b62a793562..53353863b6e5f6 100644 --- a/docker/kafka-setup/Dockerfile +++ b/docker/kafka-setup/Dockerfile @@ -31,7 +31,7 @@ LABEL name="kafka" version=${KAFKA_VERSION} RUN if [ "${ALPINE_REPO_URL}" != "http://dl-cdn.alpinelinux.org/alpine" ] ; then sed -i "s#http.*://dl-cdn.alpinelinux.org/alpine#${ALPINE_REPO_URL}#g" /etc/apk/repositories ; fi RUN apk add --no-cache bash coreutils -RUN apk --no-cache add openjdk11-jre-headless --repository=${ALPINE_REPO_URL}/edge/community +RUN apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community RUN apk add --no-cache -t .build-deps git curl ca-certificates jq gcc musl-dev libffi-dev zip RUN mkdir -p /opt \ diff --git a/docs-website/build.gradle b/docs-website/build.gradle index a213ec1ae8194d..2644491a2a5f80 100644 --- a/docs-website/build.gradle +++ b/docs-website/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'distribution' -apply plugin: 'com.github.node-gradle.node' +plugins { + id 'distribution' + id 'com.github.node-gradle.node' +} node { @@ -12,10 +14,10 @@ node { } // Version of node to use. - version = '16.16.0' + version = '21.2.0' // Version of Yarn to use. - yarnVersion = '1.22.0' + yarnVersion = '1.22.1' // Base URL for fetching node distributions (set nodeDistBaseUrl if you have a mirror). if (project.hasProperty('nodeDistBaseUrl')) { @@ -31,7 +33,7 @@ node { yarnWorkDir = file("${project.projectDir}/.gradle/yarn") // Set the work directory where node_modules should be located - nodeModulesDir = file("${project.projectDir}") + nodeProjectDir = file("${project.projectDir}") } /* @@ -122,7 +124,11 @@ task yarnBuild(type: YarnTask, dependsOn: [yarnLint, yarnGenerate, downloadHisto // See https://stackoverflow.com/questions/53230823/fatal-error-ineffective-mark-compacts-near-heap-limit-allocation-failed-java // and https://github.com/facebook/docusaurus/issues/8329. // TODO: As suggested in https://github.com/facebook/docusaurus/issues/4765, try switching to swc-loader. - environment = ['NODE_OPTIONS': '--max-old-space-size=10248'] + if (project.hasProperty('useSystemNode') && project.getProperty('useSystemNode').toBoolean()) { + environment = ['NODE_OPTIONS': '--max-old-space-size=10248'] + } else { + environment = ['NODE_OPTIONS': '--max-old-space-size=10248 --openssl-legacy-provider'] + } args = ['run', 'build'] } diff --git a/docs-website/vercel-setup.sh b/docs-website/vercel-setup.sh index db532e167b59f1..915635b24ee884 100755 --- a/docs-website/vercel-setup.sh +++ b/docs-website/vercel-setup.sh @@ -12,7 +12,7 @@ set -euxo pipefail yum groupinstall "Development Tools" -y yum erase openssl-devel -y -yum install openssl11 openssl11-devel libffi-devel bzip2-devel wget -y +yum install openssl11 openssl11-devel libffi-devel bzip2-devel wget nodejs -y wget https://www.python.org/ftp/python/3.10.11/Python-3.10.11.tgz tar -xf Python-3.10.11.tgz diff --git a/docs/developers.md b/docs/developers.md index c3c3a59283e662..60d31f5e4523f7 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -6,16 +6,12 @@ title: "Local Development" ## Requirements -- Both [Java 11 JDK](https://openjdk.org/projects/jdk/11/) and [Java 8 JDK](https://openjdk.java.net/projects/jdk8/) +- [Java 17 JDK](https://openjdk.org/projects/jdk/17/) - [Python 3.10](https://www.python.org/downloads/release/python-3100/) - [Docker](https://www.docker.com/) - [Docker Compose](https://docs.docker.com/compose/) - Docker engine with at least 8GB of memory to run tests. -:::caution - -Do not try to use a JDK newer than JDK 11. The build process does not currently work with newer JDKs versions. - ::: On macOS, these can be installed using [Homebrew](https://brew.sh/). @@ -147,11 +143,11 @@ You're probably using a Java version that's too new for gradle. Run the followin java --version ``` -While it may be possible to build and run DataHub using newer versions of Java, we currently only support [Java 11](https://openjdk.org/projects/jdk/11/) (aka Java 11). +While it may be possible to build and run DataHub using newer versions of Java, we currently only support [Java 17](https://openjdk.org/projects/jdk/17/) (aka Java 17). #### Getting `cannot find symbol` error for `javax.annotation.Generated` -Similar to the previous issue, please use Java 1.8 to build the project. +Similar to the previous issue, please use Java 17 to build the project. You can install multiple version of Java on a single machine and switch between them using the `JAVA_HOME` environment variable. See [this document](https://docs.oracle.com/cd/E21454_01/html/821-2531/inst_jdk_javahome_t.html) for more details. #### `:metadata-models:generateDataTemplate` task fails with `java.nio.file.InvalidPathException: Illegal char <:> at index XX` or `Caused by: java.lang.IllegalArgumentException: 'other' has different root` error diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index 36be572f2886e5..61ad2d623d72a4 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -7,11 +7,15 @@ This file documents any backwards-incompatible changes in DataHub and assists pe ### Breaking Changes - Updating MySQL version for quickstarts to 8.2, may cause quickstart issues for existing instances. +- Neo4j 5.x, may require migration from 4.x +- Build now requires JDK17 (Runtime Java 11) ### Potential Downtime ### Deprecations +- Spark 2.x (including previous JDK8 build requirements) + ### Other Notable Changes ## 0.12.1 diff --git a/docs/troubleshooting/build.md b/docs/troubleshooting/build.md index 112bcdc47e9567..7b4ae98cdb03bd 100644 --- a/docs/troubleshooting/build.md +++ b/docs/troubleshooting/build.md @@ -10,11 +10,11 @@ You're probably using a Java version that's too new for gradle. Run the followin java --version ``` -While it may be possible to build and run DataHub using newer versions of Java, we currently only support [Java 11](https://openjdk.org/projects/jdk/11/) (aka Java 11). +While it may be possible to build and run DataHub using newer versions of Java, we currently only support [Java 17](https://openjdk.org/projects/jdk/17/) (aka Java 17). ## Getting `cannot find symbol` error for `javax.annotation.Generated` -Similar to the previous issue, please use Java 1.8 to build the project. +Similar to the previous issue, please use Java 17 to build the project. You can install multiple version of Java on a single machine and switch between them using the `JAVA_HOME` environment variable. See [this document](https://docs.oracle.com/cd/E21454_01/html/821-2531/inst_jdk_javahome_t.html) for more details. ## `:metadata-models:generateDataTemplate` task fails with `java.nio.file.InvalidPathException: Illegal char <:> at index XX` or `Caused by: java.lang.IllegalArgumentException: 'other' has different root` error diff --git a/entity-registry/build.gradle b/entity-registry/build.gradle index 3da0bf5bb4fb81..77cca24c0e7234 100644 --- a/entity-registry/build.gradle +++ b/entity-registry/build.gradle @@ -1,10 +1,13 @@ -apply plugin: 'pegasus' -apply plugin: 'java-library' +plugins { + id 'pegasus' + id 'java-library' +} dependencies { implementation spec.product.pegasus.data implementation spec.product.pegasus.generator api project(path: ':metadata-models') + api project(path: ':metadata-models', configuration: "dataTemplate") implementation externalDependency.slf4jApi compileOnly externalDependency.lombok implementation externalDependency.guava diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4e86b9270786fb..bdc9a83b1e6524 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/li-utils/build.gradle b/li-utils/build.gradle index 1d5222e39185af..975cd2bccccf31 100644 --- a/li-utils/build.gradle +++ b/li-utils/build.gradle @@ -1,17 +1,9 @@ -apply plugin: 'java-library' -apply plugin: 'pegasus' - -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } +plugins { + id 'java-library' + id 'pegasus' } + dependencies { api spec.product.pegasus.data implementation externalDependency.commonsLang @@ -28,7 +20,7 @@ dependencies { testImplementation externalDependency.commonsIo testImplementation project(':test-models') testImplementation project(path: ':test-models', configuration: 'testDataTemplate') - testImplementation externalDependency.testngJava8 + testImplementation externalDependency.testng } idea { @@ -38,4 +30,4 @@ idea { } // Need to compile backing java parameterDefinitions with the data template. -sourceSets.mainGeneratedDataTemplate.java.srcDirs('src/main/javaPegasus/') \ No newline at end of file +sourceSets.mainGeneratedDataTemplate.java.srcDirs('src/main/javaPegasus/') diff --git a/metadata-auth/auth-api/build.gradle b/metadata-auth/auth-api/build.gradle index 7159aa5f15e61e..c68c3019bd2b45 100644 --- a/metadata-auth/auth-api/build.gradle +++ b/metadata-auth/auth-api/build.gradle @@ -15,13 +15,12 @@ test { } jar { - archiveName = "$project.name-lib.jar" + archiveClassifier = "lib" } shadowJar { zip64 true - classifier = null - archiveName = "$project.name-${version}.jar" + archiveClassifier = "" exclude "META-INF/*.RSA", "META-INF/*.SF","META-INF/*.DSA" } @@ -39,12 +38,12 @@ dependencies() { } task sourcesJar(type: Jar) { - classifier 'sources' + archiveClassifier = 'sources' from sourceSets.main.allJava } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier 'javadoc' + archiveClassifier = 'javadoc' from javadoc.destinationDir } diff --git a/metadata-events/mxe-utils-avro/build.gradle b/metadata-events/mxe-utils-avro/build.gradle index 3493797ab4f972..98bfb9127b2094 100644 --- a/metadata-events/mxe-utils-avro/build.gradle +++ b/metadata-events/mxe-utils-avro/build.gradle @@ -1,8 +1,11 @@ -apply plugin: 'java-library' +plugins { + id 'java-library' +} dependencies { api project(':metadata-events:mxe-avro') api project(':metadata-models') + api project(path: ':metadata-models', configuration: "dataTemplate") api spec.product.pegasus.dataAvro testImplementation externalDependency.testng diff --git a/metadata-integration/java/datahub-client/build.gradle b/metadata-integration/java/datahub-client/build.gradle index 7ae01faaaabddb..b14953d7ce0218 100644 --- a/metadata-integration/java/datahub-client/build.gradle +++ b/metadata-integration/java/datahub-client/build.gradle @@ -14,19 +14,9 @@ import org.apache.tools.ant.filters.ReplaceTokens jar.enabled = false // Since we only want to build shadow jars, disabling the regular jar creation -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } -} - dependencies { implementation project(':metadata-models') + implementation project(path: ':metadata-models', configuration: "dataTemplate") implementation(externalDependency.kafkaAvroSerializer) { exclude group: "org.apache.avro" } @@ -49,7 +39,7 @@ dependencies { annotationProcessor externalDependency.lombok // VisibleForTesting compileOnly externalDependency.guava - testImplementation externalDependency.testngJava8 + testImplementation externalDependency.testng testImplementation externalDependency.mockito testImplementation externalDependency.mockServer testImplementation externalDependency.mockServerClient @@ -241,4 +231,4 @@ sourceSets.main.resources.srcDir "${generateOpenApiPojos.outputDir}/src/main/res clean { project.delete("$projectDir/generated") -} +} \ No newline at end of file diff --git a/metadata-integration/java/datahub-protobuf-example/build.gradle b/metadata-integration/java/datahub-protobuf-example/build.gradle index 4e53d8ed763baa..1efb43360457a0 100644 --- a/metadata-integration/java/datahub-protobuf-example/build.gradle +++ b/metadata-integration/java/datahub-protobuf-example/build.gradle @@ -64,10 +64,6 @@ protobuf { task publishSchema(dependsOn: build) { description "Publishes protobuf schema in the `main` sourceSet to DataHub" - def javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(11) - } - fileTree("schema").matching { exclude "protobuf/meta/**" }.each {f -> diff --git a/metadata-integration/java/datahub-protobuf/build.gradle b/metadata-integration/java/datahub-protobuf/build.gradle index bc919119f8fac7..2cb36a14cb9c7d 100644 --- a/metadata-integration/java/datahub-protobuf/build.gradle +++ b/metadata-integration/java/datahub-protobuf/build.gradle @@ -12,12 +12,6 @@ apply from: '../versioning.gradle' jar.enabled = false // Since we only want to build shadow jars, disabling the regular jar creation -afterEvaluate { - if (project.plugins.hasPlugin('java')) { - sourceCompatibility = 11 - targetCompatibility = 11 - } -} ext { javaMainClass = "datahub.protobuf.Proto2DataHub" } @@ -211,4 +205,4 @@ nexusStaging { password = System.getenv("NEXUS_PASSWORD") } - +startScripts.dependsOn shadowJar \ No newline at end of file diff --git a/metadata-integration/java/examples/build.gradle b/metadata-integration/java/examples/build.gradle index 581e9f82da0dc8..ddf574e8c8905d 100644 --- a/metadata-integration/java/examples/build.gradle +++ b/metadata-integration/java/examples/build.gradle @@ -1,16 +1,6 @@ -apply plugin: 'java' -apply plugin: 'jacoco' - - -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } +plugins { + id 'java' + id 'jacoco' } dependencies { diff --git a/metadata-integration/java/spark-lineage/build.gradle b/metadata-integration/java/spark-lineage/build.gradle index 7143ac48331439..c5dd9b5012c290 100644 --- a/metadata-integration/java/spark-lineage/build.gradle +++ b/metadata-integration/java/spark-lineage/build.gradle @@ -11,17 +11,6 @@ apply from: '../versioning.gradle' jar.enabled = false // Since we only want to build shadow jars, disabling the regular jar creation -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } -} - //to rename artifacts for publish project.archivesBaseName = 'datahub-'+project.name @@ -34,18 +23,19 @@ configurations { dependencies { - //Needed for tie breaking of guava version need for spark and wiremock - provided(externalDependency.hadoopMapreduceClient) { - force = true + constraints { + provided(externalDependency.hadoopMapreduceClient) { + because 'Needed for tie breaking of guava version need for spark and wiremock' + } + provided(externalDependency.hadoopCommon) { + because 'required for org.apache.hadoop.util.StopWatch' + } + provided(externalDependency.commonsIo) { + because 'required for org.apache.commons.io.Charsets that is used internally' + } } - provided(externalDependency.hadoopCommon) { - force = true - } // required for org.apache.hadoop.util.StopWatch - - provided(externalDependency.commonsIo) { - force = true - } // required for org.apache.commons.io.Charsets that is used internally + provided 'org.scala-lang:scala-library:2.12.18' implementation externalDependency.slf4jApi compileOnly externalDependency.lombok @@ -86,7 +76,7 @@ task checkShadowJar(type: Exec) { shadowJar { zip64=true - classifier='' + archiveClassifier = '' mergeServiceFiles() def exclude_modules = project @@ -107,7 +97,7 @@ shadowJar { // preventing java multi-release JAR leakage // https://github.com/johnrengelman/shadow/issues/729 - exclude('module-info.class', 'META-INF/versions/**') + exclude('module-info.class', 'META-INF/versions/**', 'LICENSE', 'NOTICE') // prevent jni conflict with spark exclude '**/libzstd-jni.*' @@ -138,6 +128,25 @@ jacocoTestReport { test { forkEvery = 1 useJUnit() + + def sparkJava17CompatibleJvmArgs = [ + "--add-opens=java.base/java.lang=ALL-UNNAMED", + //"--add-opens=java.base/java.lang.invoke=ALL-UNNAMED", + //"--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + //"--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.nio=ALL-UNNAMED", + //"--add-opens=java.base/java.util=ALL-UNNAMED", + //"--add-opens=java.base/java.util.concurrent=ALL-UNNAMED", + //"--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED", + "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED", + //"--add-opens=java.base/sun.nio.cs=ALL-UNNAMED", + //"--add-opens=java.base/sun.security.action=ALL-UNNAMED", + //"--add-opens=java.base/sun.util.calendar=ALL-UNNAMED", + //"--add-opens=java.security.jgss/sun.security.krb5=ALL-UNNAMED", + ] + jvmArgs = sparkJava17CompatibleJvmArgs + finalizedBy jacocoTestReport } @@ -151,12 +160,12 @@ task integrationTest(type: Exec, dependsOn: [shadowJar, ':docker:quickstartSlim' } task sourcesJar(type: Jar) { - classifier 'sources' + archiveClassifier = 'sources' from sourceSets.main.allJava } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier 'javadoc' + archiveClassifier = 'javadoc' from javadoc.destinationDir } @@ -224,3 +233,12 @@ nexusStaging { username = System.getenv("NEXUS_USERNAME") password = System.getenv("NEXUS_PASSWORD") } + +task cleanExtraDirs { + delete "$projectDir/derby.log" + delete "$projectDir/src/test/resources/data/hive" + delete "$projectDir/src/test/resources/data/out.csv" + delete "$projectDir/src/test/resources/data/out_persist.csv" + delete "$projectDir/spark-smoke-test/venv" +} +clean.finalizedBy(cleanExtraDirs) diff --git a/metadata-integration/java/spark-lineage/scripts/check_jar.sh b/metadata-integration/java/spark-lineage/scripts/check_jar.sh index dd9cae68f31cb0..275b91304e7ee3 100755 --- a/metadata-integration/java/spark-lineage/scripts/check_jar.sh +++ b/metadata-integration/java/spark-lineage/scripts/check_jar.sh @@ -34,7 +34,9 @@ jar -tvf $jarFile |\ grep -v "linux/" |\ grep -v "darwin" |\ grep -v "MetadataChangeProposal.avsc" |\ - grep -v "aix" + grep -v "aix" |\ + grep -v "library.properties" |\ + grep -v "rootdoc.txt" if [ $? -ne 0 ]; then echo "✅ No unexpected class paths found in ${jarFile}" diff --git a/metadata-integration/java/spark-lineage/spark-smoke-test/docker/SparkBase.Dockerfile b/metadata-integration/java/spark-lineage/spark-smoke-test/docker/SparkBase.Dockerfile index 119338be6c2a9b..21d0701fcfcd68 100644 --- a/metadata-integration/java/spark-lineage/spark-smoke-test/docker/SparkBase.Dockerfile +++ b/metadata-integration/java/spark-lineage/spark-smoke-test/docker/SparkBase.Dockerfile @@ -17,7 +17,7 @@ RUN apt-get update -y && \ apt-get install /tmp/zulu-repo_1.0.0-3_all.deb && \ apt-get update && \ # apt-cache search zulu && \ - apt-get install -y --no-install-recommends zulu11-jre && \ + apt-get install -y --no-install-recommends zulu17-jre && \ apt-get clean && \ curl -sS https://archive.apache.org/dist/spark/spark-${spark_version}/spark-${spark_version}-bin-hadoop${hadoop_version}.tgz -o spark.tgz && \ tar -xf spark.tgz && \ diff --git a/metadata-integration/java/spark-lineage/spark-smoke-test/python-spark-lineage-test/python_test_run.sh b/metadata-integration/java/spark-lineage/spark-smoke-test/python-spark-lineage-test/python_test_run.sh index 429f692500c802..c06e2faec0bcba 100755 --- a/metadata-integration/java/spark-lineage/spark-smoke-test/python-spark-lineage-test/python_test_run.sh +++ b/metadata-integration/java/spark-lineage/spark-smoke-test/python-spark-lineage-test/python_test_run.sh @@ -7,25 +7,24 @@ saluation () { echo "--------------------------------------------------------" - echo "Starting execution $1" + echo "Starting execution $1 (properties: $2)" echo "--------------------------------------------------------" } -saluation "HdfsIn2HdfsOut1.py" - +saluation "HdfsIn2HdfsOut1.py" $2 spark-submit --properties-file $2 HdfsIn2HdfsOut1.py -saluation "HdfsIn2HdfsOut2.py" +saluation "HdfsIn2HdfsOut2.py" $2 spark-submit --properties-file $2 HdfsIn2HdfsOut2.py -saluation "HdfsIn2HiveCreateTable.py" +saluation "HdfsIn2HiveCreateTable.py" $2 spark-submit --properties-file $2 HdfsIn2HiveCreateTable.py -saluation "HdfsIn2HiveCreateInsertTable.py" +saluation "HdfsIn2HiveCreateInsertTable.py" $2 spark-submit --properties-file $2 HdfsIn2HiveCreateInsertTable.py -saluation "HiveInHiveOut.py" +saluation "HiveInHiveOut.py" $2 spark-submit --properties-file $2 HiveInHiveOut.py diff --git a/metadata-integration/java/spark-lineage/spark-smoke-test/spark-docker.conf b/metadata-integration/java/spark-lineage/spark-smoke-test/spark-docker.conf index 43103c3db65ad1..a511d9f114f2b2 100644 --- a/metadata-integration/java/spark-lineage/spark-smoke-test/spark-docker.conf +++ b/metadata-integration/java/spark-lineage/spark-smoke-test/spark-docker.conf @@ -4,3 +4,7 @@ spark.jars file:///opt/workspace/datahub-spark-lineage*.jar spark.extraListeners datahub.spark.DatahubSparkListener spark.datahub.rest.server http://datahub-gms:8080 + +spark.driver.extraJavaOptions --add-opens java.base/java.lang=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED +spark.executor.extraJavaOptions --add-opens java.base/java.lang=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED + diff --git a/metadata-integration/java/spark-lineage/spark-smoke-test/test-spark-lineage/build.gradle b/metadata-integration/java/spark-lineage/spark-smoke-test/test-spark-lineage/build.gradle index 12aa1775d6104e..6337f8c9beec63 100644 --- a/metadata-integration/java/spark-lineage/spark-smoke-test/test-spark-lineage/build.gradle +++ b/metadata-integration/java/spark-lineage/spark-smoke-test/test-spark-lineage/build.gradle @@ -17,17 +17,6 @@ repositories { jcenter() } -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } -} - dependencies { implementation 'org.apache.spark:spark-sql_2.11:2.4.8' } diff --git a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java index 2df468fc03e743..053055716eaa07 100644 --- a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java +++ b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java @@ -37,7 +37,7 @@ public class TestCoalesceJobLineage { private static final String APP_NAME = "sparkCoalesceTestApp"; - private static final String TEST_RELATIVE_PATH = "../"; + private static final String TEST_RELATIVE_PATH = ""; private static final String RESOURCE_DIR = "src/test/resources"; private static final String DATA_DIR = TEST_RELATIVE_PATH + RESOURCE_DIR + "/data"; private static final String WAREHOUSE_LOC = DATA_DIR + "/hive/warehouse/coalesce"; @@ -142,6 +142,9 @@ public void setup() { "spark.datahub.parent.datajob_urn", "urn:li:dataJob:(urn:li:dataFlow:(airflow,datahub_analytics_refresh,prod),load_dashboard_info_to_snowflake)") .config("spark.sql.warehouse.dir", new File(WAREHOUSE_LOC).getAbsolutePath()) + .config( + "javax.jdo.option.ConnectionURL", + "jdbc:derby:;databaseName=build/tmp/metastore_db_coalesce;create=true") .enableHiveSupport() .getOrCreate(); diff --git a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java index 3a70c10e0c1f9b..fa896814d16f62 100644 --- a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java +++ b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java @@ -191,6 +191,9 @@ public static void setup() { .config("spark.datahub.metadata.dataset.platformInstance", DATASET_PLATFORM_INSTANCE) .config("spark.datahub.metadata.dataset.env", DATASET_ENV.name()) .config("spark.sql.warehouse.dir", new File(WAREHOUSE_LOC).getAbsolutePath()) + .config( + "javax.jdo.option.ConnectionURL", + "jdbc:derby:;databaseName=build/tmp/metastore_db_spark;create=true") .enableHiveSupport() .getOrCreate(); diff --git a/metadata-io/build.gradle b/metadata-io/build.gradle index 48f80f06d07c2c..568b99acdf8943 100644 --- a/metadata-io/build.gradle +++ b/metadata-io/build.gradle @@ -62,7 +62,10 @@ dependencies { testImplementation externalDependency.h2 testImplementation externalDependency.mysqlConnector testImplementation externalDependency.neo4jHarness - testImplementation (externalDependency.neo4jApoc) { + testImplementation (externalDependency.neo4jApocCore) { + exclude group: 'org.yaml', module: 'snakeyaml' + } + testImplementation (externalDependency.neo4jApocCommon) { exclude group: 'org.yaml', module: 'snakeyaml' } testImplementation externalDependency.mockito diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java index 217d54c5c0b0ff..c8d3147711eba5 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java @@ -432,8 +432,8 @@ private Pair> generateLineageStatementAndParameters( + "(b)) " + "WHERE a <> b " + " AND ALL(rt IN relationships(path) WHERE " - + " (EXISTS(rt.source) AND rt.source = 'UI') OR " - + " (NOT EXISTS(rt.createdOn) AND NOT EXISTS(rt.updatedOn)) OR " + + " (rt.source IS NOT NULL AND rt.source = 'UI') OR " + + " (rt.createdOn IS NULL AND rt.updatedOn IS NULL) OR " + " ($startTimeMillis <= rt.createdOn <= $endTimeMillis OR " + " $startTimeMillis <= rt.updatedOn <= $endTimeMillis) " + " ) " diff --git a/metadata-jobs/mae-consumer/build.gradle b/metadata-jobs/mae-consumer/build.gradle index fcb8b62e4ac9d5..2e068d5a3501e2 100644 --- a/metadata-jobs/mae-consumer/build.gradle +++ b/metadata-jobs/mae-consumer/build.gradle @@ -60,6 +60,7 @@ task avroSchemaSources(type: Copy) { } compileJava.dependsOn avroSchemaSources +processResources.dependsOn avroSchemaSources clean { project.delete("src/main/resources/avro") diff --git a/metadata-jobs/mce-consumer/build.gradle b/metadata-jobs/mce-consumer/build.gradle index 97eec9fcff051c..5fa65c06de7149 100644 --- a/metadata-jobs/mce-consumer/build.gradle +++ b/metadata-jobs/mce-consumer/build.gradle @@ -1,8 +1,8 @@ plugins { id 'java' + id 'pegasus' } -apply plugin: 'pegasus' configurations { avro @@ -49,6 +49,7 @@ task avroSchemaSources(type: Copy) { } compileJava.dependsOn avroSchemaSources +processResources.dependsOn avroSchemaSources clean { project.delete("src/main/resources/avro") diff --git a/metadata-jobs/pe-consumer/build.gradle b/metadata-jobs/pe-consumer/build.gradle index 81e8b8c9971f00..2fd19af92971e2 100644 --- a/metadata-jobs/pe-consumer/build.gradle +++ b/metadata-jobs/pe-consumer/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' + id 'pegasus' } -apply plugin: 'pegasus' configurations { avro @@ -37,6 +37,7 @@ task avroSchemaSources(type: Copy) { } compileJava.dependsOn avroSchemaSources +processResources.dependsOn avroSchemaSources clean { project.delete("src/main/resources/avro") diff --git a/metadata-models-custom/build.gradle b/metadata-models-custom/build.gradle index 71d3b0fd1f736c..3ac08dca7c0dbe 100644 --- a/metadata-models-custom/build.gradle +++ b/metadata-models-custom/build.gradle @@ -16,8 +16,8 @@ buildscript { plugins { id 'base' id 'maven-publish' + id 'pegasus' } -apply plugin: 'pegasus' if (project.hasProperty('projVersion')) { project.version = project.projVersion diff --git a/metadata-models-validator/build.gradle b/metadata-models-validator/build.gradle index c8d1d2e6651d6c..1dae53e817ae14 100644 --- a/metadata-models-validator/build.gradle +++ b/metadata-models-validator/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(":entity-registry") diff --git a/metadata-models/build.gradle b/metadata-models/build.gradle index e90a4042c1921d..04c90fa444f0ca 100644 --- a/metadata-models/build.gradle +++ b/metadata-models/build.gradle @@ -1,20 +1,12 @@ import io.datahubproject.GenerateJsonSchemaTask -apply plugin: 'java-library' -apply plugin: 'pegasus' -apply plugin: 'org.hidetake.swagger.generator' - -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } +plugins { + id 'pegasus' + id 'java-library' + id 'org.hidetake.swagger.generator' } + dependencies { api spec.product.pegasus.data constraints { @@ -35,7 +27,7 @@ dependencies { swaggerCodegen externalDependency.swaggerCli testImplementation externalDependency.guava - testImplementation externalDependency.testngJava8 + testImplementation externalDependency.testng } sourceSets { diff --git a/metadata-service/auth-config/build.gradle b/metadata-service/auth-config/build.gradle index c7a1128897dd5c..8302e3b0c2fe67 100644 --- a/metadata-service/auth-config/build.gradle +++ b/metadata-service/auth-config/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(path: ':metadata-models') diff --git a/metadata-service/auth-filter/build.gradle b/metadata-service/auth-filter/build.gradle index 61e9015adc9423..9d763ca11421b5 100644 --- a/metadata-service/auth-filter/build.gradle +++ b/metadata-service/auth-filter/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':metadata-auth:auth-api') diff --git a/metadata-service/auth-impl/build.gradle b/metadata-service/auth-impl/build.gradle index 60d622dea54475..4f4b0658caf249 100644 --- a/metadata-service/auth-impl/build.gradle +++ b/metadata-service/auth-impl/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} compileJava { diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/DataPlatformInstanceFieldResolverProviderTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/DataPlatformInstanceFieldResolverProviderTest.java index d5d5b0c4e6c71d..f03113f3eb9bdb 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/DataPlatformInstanceFieldResolverProviderTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/DataPlatformInstanceFieldResolverProviderTest.java @@ -8,7 +8,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -68,7 +68,7 @@ public void shouldReturnFieldValueWithResourceSpecIfTypeIsDataPlatformInstance() assertEquals( Set.of(DATA_PLATFORM_INSTANCE_URN), result.getFieldValuesFuture().join().getValues()); - verifyZeroInteractions(entityClientMock); + verifyNoMoreInteractions(entityClientMock); } @Test diff --git a/metadata-service/auth-servlet-impl/build.gradle b/metadata-service/auth-servlet-impl/build.gradle index 7945b3b4e9a06c..b8310bbd4ebc01 100644 --- a/metadata-service/auth-servlet-impl/build.gradle +++ b/metadata-service/auth-servlet-impl/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':metadata-auth:auth-api') diff --git a/metadata-service/factories/build.gradle b/metadata-service/factories/build.gradle index 86644e3b034da4..145ec7e65188c5 100644 --- a/metadata-service/factories/build.gradle +++ b/metadata-service/factories/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java-library' +plugins { + id 'java-library' +} dependencies { api project(':metadata-io') diff --git a/metadata-service/graphql-servlet-impl/build.gradle b/metadata-service/graphql-servlet-impl/build.gradle index 51f67631159d34..57676982421186 100644 --- a/metadata-service/graphql-servlet-impl/build.gradle +++ b/metadata-service/graphql-servlet-impl/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':datahub-graphql-core') diff --git a/metadata-service/openapi-servlet/build.gradle b/metadata-service/openapi-servlet/build.gradle index 1909b4862d294a..0430d4427528dd 100644 --- a/metadata-service/openapi-servlet/build.gradle +++ b/metadata-service/openapi-servlet/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { diff --git a/metadata-service/plugin/build.gradle b/metadata-service/plugin/build.gradle index 00a6384b923a02..3f91b8f6ae6ba5 100644 --- a/metadata-service/plugin/build.gradle +++ b/metadata-service/plugin/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { @@ -30,4 +32,4 @@ test { clean { dependsOn ':metadata-service:plugin:src:test:sample-test-plugins:clean' -} +} \ No newline at end of file diff --git a/metadata-service/plugin/src/test/sample-test-plugins/build.gradle b/metadata-service/plugin/src/test/sample-test-plugins/build.gradle index f299a35db0f64d..d4b2b4c92ad631 100644 --- a/metadata-service/plugin/src/test/sample-test-plugins/build.gradle +++ b/metadata-service/plugin/src/test/sample-test-plugins/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} jar { archiveFileName = "sample-plugins.jar" diff --git a/metadata-service/restli-api/build.gradle b/metadata-service/restli-api/build.gradle index 352738d01f8da7..505320e8267eed 100644 --- a/metadata-service/restli-api/build.gradle +++ b/metadata-service/restli-api/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'pegasus' +plugins { + id 'pegasus' +} dependencies { dataModel project(':metadata-models') @@ -17,4 +19,4 @@ dependencies { because("CVE-2023-1428, CVE-2023-32731") } } -} \ No newline at end of file +} diff --git a/metadata-service/restli-client/build.gradle b/metadata-service/restli-client/build.gradle index 7cad1981ad9112..86336755dc0954 100644 --- a/metadata-service/restli-client/build.gradle +++ b/metadata-service/restli-client/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'pegasus' -apply plugin: 'java-library' +plugins { + id 'pegasus' + id 'java-library' +} dependencies { api project(':metadata-service:restli-api') diff --git a/metadata-service/restli-servlet-impl/build.gradle b/metadata-service/restli-servlet-impl/build.gradle index de6fb6690e693b..ec5b645ee233c7 100644 --- a/metadata-service/restli-servlet-impl/build.gradle +++ b/metadata-service/restli-servlet-impl/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'java' -apply plugin: 'pegasus' +plugins { + id 'java' + id 'pegasus' +} sourceSets { integTest { diff --git a/metadata-service/schema-registry-api/build.gradle b/metadata-service/schema-registry-api/build.gradle index 077d7d4f2d6a44..c146d5202fef9a 100644 --- a/metadata-service/schema-registry-api/build.gradle +++ b/metadata-service/schema-registry-api/build.gradle @@ -1,5 +1,8 @@ -apply plugin: 'java' -apply plugin: 'org.hidetake.swagger.generator' +plugins { + id 'org.hidetake.swagger.generator' + id 'java' +} + dependencies { // Dependencies for open api diff --git a/metadata-service/schema-registry-servlet/build.gradle b/metadata-service/schema-registry-servlet/build.gradle index 554ac696c94fdb..7bab51d51a86c0 100644 --- a/metadata-service/schema-registry-servlet/build.gradle +++ b/metadata-service/schema-registry-servlet/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':metadata-service:factories') diff --git a/metadata-service/services/build.gradle b/metadata-service/services/build.gradle index b6af3d330d185b..c683b0c75f40a8 100644 --- a/metadata-service/services/build.gradle +++ b/metadata-service/services/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'java' -apply plugin: 'org.hidetake.swagger.generator' +plugins { + id 'org.hidetake.swagger.generator' + id 'java' +} configurations { enhance diff --git a/metadata-service/servlet/build.gradle b/metadata-service/servlet/build.gradle index eb2cd9c2d3de7c..f961bf6a9de7eb 100644 --- a/metadata-service/servlet/build.gradle +++ b/metadata-service/servlet/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':metadata-io') diff --git a/metadata-utils/build.gradle b/metadata-utils/build.gradle index 7bc6aa2d434424..3d65675219624d 100644 --- a/metadata-utils/build.gradle +++ b/metadata-utils/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java-library' +plugins { + id 'java-library' +} dependencies { api externalDependency.avro diff --git a/mock-entity-registry/build.gradle b/mock-entity-registry/build.gradle index 12d7e58eee0a1a..8242d6451dd606 100644 --- a/mock-entity-registry/build.gradle +++ b/mock-entity-registry/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(':entity-registry') diff --git a/smoke-test/build.gradle b/smoke-test/build.gradle index ee0ea3c7be384f..1614a4b8527dc9 100644 --- a/smoke-test/build.gradle +++ b/smoke-test/build.gradle @@ -11,10 +11,10 @@ node { } // Version of node to use. - version = '16.8.0' + version = '21.2.0' // Version of Yarn to use. - yarnVersion = '1.22.0' + yarnVersion = '1.22.1' // Base URL for fetching node distributions (set nodeDistBaseUrl if you have a mirror). if (project.hasProperty('nodeDistBaseUrl')) { @@ -30,11 +30,12 @@ node { yarnWorkDir = file("${project.projectDir}/.gradle/yarn") // Set the work directory where node_modules should be located - nodeModulesDir = file("${project.projectDir}") + nodeProjectDir = file("${project.projectDir}") } task yarnInstall(type: YarnTask) { println "Root directory: ${project.rootDir}"; + environment = ['NODE_OPTIONS': '--openssl-legacy-provider'] args = ['install', '--cwd', "${project.rootDir}/smoke-test/tests/cypress"] } \ No newline at end of file diff --git a/test-models/build.gradle b/test-models/build.gradle index c74f7249fa1d9e..e8733f0525870b 100644 --- a/test-models/build.gradle +++ b/test-models/build.gradle @@ -1,17 +1,9 @@ -apply plugin: 'pegasus' -apply plugin: 'java-library' - -tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) - } -} -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(8) - } +plugins { + id 'pegasus' + id 'java-library' } + dependencies { implementation spec.product.pegasus.data implementation externalDependency.commonsIo diff --git a/vercel.json b/vercel.json index d5515e68b05bdb..a1815cab8ae88c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { - "buildCommand": "./gradlew :docs-website:build", + "buildCommand": "./gradlew -PuseSystemNode=true :docs-website:build", "github": { "silent": true, "autoJobCancelation": true From caef6771b828d8ee94f76801a9121f4e1a2e7561 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 15 Dec 2023 15:07:56 -0500 Subject: [PATCH 033/540] feat(ingest/redshift): drop repeated operations (#9440) --- metadata-ingestion/setup.py | 6 +- .../ingestion/source/redshift/report.py | 3 +- .../ingestion/source/redshift/usage.py | 68 +++++++++++++++++-- .../redshift-usage/test_redshift_usage.py | 54 ++++++++++++++- 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 5d15d7167b63e8..1bc1bc5100b08d 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -368,7 +368,11 @@ | {"psycopg2-binary", "pymysql>=1.0.2"}, "pulsar": {"requests"}, "redash": {"redash-toolbelt", "sql-metadata"} | sqllineage_lib, - "redshift": sql_common | redshift_common | usage_common | sqlglot_lib, + "redshift": sql_common + | redshift_common + | usage_common + | sqlglot_lib + | {"cachetools"}, "s3": {*s3_base, *data_lake_profiling}, "gcs": {*s3_base, *data_lake_profiling}, "sagemaker": aws_common, diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py index b845580f359394..333c851650fb3a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py @@ -29,7 +29,8 @@ class RedshiftReport(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowRep lineage_mem_size: Dict[str, str] = field(default_factory=TopKDict) tables_in_mem_size: Dict[str, str] = field(default_factory=TopKDict) views_in_mem_size: Dict[str, str] = field(default_factory=TopKDict) - num_operational_stats_skipped: int = 0 + num_operational_stats_filtered: int = 0 + num_repeated_operations_dropped: int = 0 num_usage_stat_skipped: int = 0 num_lineage_tables_dropped: int = 0 num_lineage_dropped_query_parser: int = 0 diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py index c789e605b9c29f..409027a8805a0d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union +import cachetools import pydantic.error_wrappers import redshift_connector from pydantic.fields import Field @@ -251,7 +252,7 @@ def _get_workunits_internal( ) -> Iterable[MetadataWorkUnit]: self.report.num_usage_workunits_emitted = 0 self.report.num_usage_stat_skipped = 0 - self.report.num_operational_stats_skipped = 0 + self.report.num_operational_stats_filtered = 0 if self.config.include_operational_stats: self.report.report_ingestion_stage_start(USAGE_EXTRACTION_OPERATIONAL_STATS) @@ -304,8 +305,13 @@ def _gen_operation_aspect_workunits( ) # Generate operation aspect work units from the access events - yield from self._gen_operation_aspect_workunits_from_access_events( - access_events_iterable, all_tables=all_tables + yield from ( + mcpw.as_workunit() + for mcpw in self._drop_repeated_operations( + self._gen_operation_aspect_workunits_from_access_events( + access_events_iterable, all_tables=all_tables + ) + ) ) def _should_process_event( @@ -366,11 +372,61 @@ def _gen_access_events_from_history_query( yield access_event results = cursor.fetchmany() + def _drop_repeated_operations( + self, events: Iterable[MetadataChangeProposalWrapper] + ) -> Iterable[MetadataChangeProposalWrapper]: + """Drop repeated operations on the same entity. + + ASSUMPTION: Events are ordered by lastUpdatedTimestamp, descending. + + Operations are only dropped if they were within 1 minute of each other, + and have the same operation type, user, and entity. + + This is particularly useful when we see a string of insert operations + that are all really part of the same overall operation. + """ + + OPERATION_CACHE_MAXSIZE = 1000 + DROP_WINDOW_SEC = 10 + + # All timestamps are in milliseconds. + timestamp_low_watermark = 0 + + def timer(): + return -timestamp_low_watermark + + # dict of entity urn -> (last event's actor, operation type) + # TODO: Remove the type ignore and use TTLCache[key_type, value_type] directly once that's supported in Python 3.9. + last_events: Dict[str, Tuple[Optional[str], str]] = cachetools.TTLCache( # type: ignore[assignment] + maxsize=OPERATION_CACHE_MAXSIZE, ttl=DROP_WINDOW_SEC * 1000, timer=timer + ) + + for event in events: + assert isinstance(event.aspect, OperationClass) + + timestamp_low_watermark = min( + timestamp_low_watermark, event.aspect.lastUpdatedTimestamp + ) + + urn = event.entityUrn + assert urn + assert isinstance(event.aspect.operationType, str) + value: Tuple[Optional[str], str] = ( + event.aspect.actor, + event.aspect.operationType, + ) + if urn in last_events and last_events[urn] == value: + self.report.num_repeated_operations_dropped += 1 + continue + + last_events[urn] = value + yield event + def _gen_operation_aspect_workunits_from_access_events( self, events_iterable: Iterable[RedshiftAccessEvent], all_tables: Dict[str, Dict[str, List[Union[RedshiftView, RedshiftTable]]]], - ) -> Iterable[MetadataWorkUnit]: + ) -> Iterable[MetadataChangeProposalWrapper]: self.report.num_operational_stats_workunits_emitted = 0 for event in events_iterable: if not ( @@ -384,7 +440,7 @@ def _gen_operation_aspect_workunits_from_access_events( continue if not self._should_process_event(event, all_tables=all_tables): - self.report.num_operational_stats_skipped += 1 + self.report.num_operational_stats_filtered += 1 continue assert event.operation_type in ["insert", "delete"] @@ -406,7 +462,7 @@ def _gen_operation_aspect_workunits_from_access_events( resource: str = f"{event.database}.{event.schema_}.{event.table}".lower() yield MetadataChangeProposalWrapper( entityUrn=self.dataset_urn_builder(resource), aspect=operation_aspect - ).as_workunit() + ) self.report.num_operational_stats_workunits_emitted += 1 def _aggregate_access_events( diff --git a/metadata-ingestion/tests/integration/redshift-usage/test_redshift_usage.py b/metadata-ingestion/tests/integration/redshift-usage/test_redshift_usage.py index 74eec82b39ba3d..a9eebb8d54154e 100644 --- a/metadata-ingestion/tests/integration/redshift-usage/test_redshift_usage.py +++ b/metadata-ingestion/tests/integration/redshift-usage/test_redshift_usage.py @@ -2,11 +2,11 @@ import pathlib from pathlib import Path from typing import Dict, List, Union -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from freezegun import freeze_time -from datahub.emitter.mce_builder import make_dataset_urn +from datahub.emitter.mce_builder import make_dataset_urn, make_user_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.sink.file import write_metadata_file from datahub.ingestion.source.redshift.config import RedshiftConfig @@ -20,6 +20,7 @@ MetadataChangeEvent, MetadataChangeProposal, ) +from datahub.metadata.schema_classes import OperationClass, OperationTypeClass from tests.test_helpers import mce_helpers FROZEN_TIME = "2021-09-15 09:00:00" @@ -243,3 +244,52 @@ def load_access_events(test_resources_dir: pathlib.Path) -> List[Dict]: with access_events_history_file.open() as access_events_json: access_events = json.loads(access_events_json.read()) return access_events + + +def test_duplicate_operations_dropped(): + report = RedshiftReport() + usage_extractor = RedshiftUsageExtractor( + config=MagicMock(), + connection=MagicMock(), + report=report, + dataset_urn_builder=MagicMock(), + redundant_run_skip_handler=None, + ) + + user = make_user_urn("jdoe") + urnA = "urn:li:dataset:(urn:li:dataPlatform:redshift,db.schema.tableA,PROD)" + urnB = "urn:li:dataset:(urn:li:dataPlatform:redshift,db.schema.tableB,PROD)" + + opA1 = MetadataChangeProposalWrapper( + entityUrn=urnA, + aspect=OperationClass( + timestampMillis=100 * 1000, + lastUpdatedTimestamp=95 * 1000, + actor=user, + operationType=OperationTypeClass.INSERT, + ), + ) + opB1 = MetadataChangeProposalWrapper( + entityUrn=urnB, + aspect=OperationClass( + timestampMillis=101 * 1000, + lastUpdatedTimestamp=94 * 1000, + actor=user, + operationType=OperationTypeClass.INSERT, + ), + ) + opA2 = MetadataChangeProposalWrapper( + entityUrn=urnA, + aspect=OperationClass( + timestampMillis=102 * 1000, + lastUpdatedTimestamp=90 * 1000, + actor=user, + operationType=OperationTypeClass.INSERT, + ), + ) + + dedups = list(usage_extractor._drop_repeated_operations([opA1, opB1, opA2])) + assert dedups == [ + opA1, + opB1, + ] From e58e2bf3be6cf43923ff400667406ee6dc95cd3a Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Mon, 18 Dec 2023 11:02:33 +0530 Subject: [PATCH 034/540] feat: Deprecation 'Note' changed to Markdown Renderable (#9396) Setting auto merge after test cases are passed --- .../EntityDropdown/UpdateDeprecationModal.tsx | 14 +++- .../components/styled/DeprecationPill.tsx | 82 +++++++++++++++++-- .../tests/cypress/cypress/support/commands.js | 2 +- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx index 6ae893e12575fd..25527497b33a81 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/UpdateDeprecationModal.tsx @@ -1,7 +1,10 @@ import React from 'react'; -import { Button, DatePicker, Form, Input, message, Modal } from 'antd'; +import { Button, DatePicker, Form, message, Modal } from 'antd'; +import styled from 'styled-components'; import { useBatchUpdateDeprecationMutation } from '../../../../graphql/mutations.generated'; import { handleBatchError } from '../utils'; +import { Editor } from '../tabs/Documentation/components/editor/Editor'; +import { ANTD_GRAY } from '../constants'; type Props = { urns: string[]; @@ -9,6 +12,10 @@ type Props = { refetch?: () => void; }; +const StyledEditor = styled(Editor)` + border: 1px solid ${ANTD_GRAY[4.5]}; +`; + export const UpdateDeprecationModal = ({ urns, onClose, refetch }: Props) => { const [batchUpdateDeprecation] = useBatchUpdateDeprecationMutation(); const [form] = Form.useForm(); @@ -64,10 +71,11 @@ export const UpdateDeprecationModal = ({ urns, onClose, refetch }: Props) => { } + width='40%' >
- - + + diff --git a/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx b/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx index f60a74247ebcc2..9ec2aab193aa0b 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/DeprecationPill.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { InfoCircleOutlined } from '@ant-design/icons'; import { Divider, message, Modal, Popover, Tooltip, Typography } from 'antd'; import { blue } from '@ant-design/colors'; @@ -8,6 +8,8 @@ import { Deprecation } from '../../../../../types.generated'; import { getLocaleTimezone } from '../../../../shared/time/timeUtils'; import { ANTD_GRAY } from '../../constants'; import { useBatchUpdateDeprecationMutation } from '../../../../../graphql/mutations.generated'; +import { Editor } from '../../tabs/Documentation/components/editor/Editor'; +import StripMarkdownText, { removeMarkdown } from './StripMarkdownText'; const DeprecatedContainer = styled.div` height: 18px; @@ -38,11 +40,6 @@ const DeprecatedTitle = styled(Typography.Text)` font-weight: bold; `; -const DeprecatedSubTitle = styled(Typography.Text)` - display: block; - margin-bottom: 5px; -`; - const LastEvaluatedAtLabel = styled.div` padding: 0; margin: 0; @@ -70,15 +67,42 @@ const IconGroup = styled.div` } `; +const DescriptionContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 22px; + margin-bottom: 14px; +`; +const StyledViewer = styled(Editor)` + padding-right: 8px; + display: block; + + .remirror-editor.ProseMirror { + padding: 0; + } +`; + +const ExpandedActions = styled.div` + height: 10px; +`; +const ReadLessText = styled(Typography.Link)` + margin-right: 4px; +`; type Props = { urn: string; deprecation: Deprecation; refetch?: () => void; showUndeprecate: boolean | null; }; +const ABBREVIATED_LIMIT = 80; export const DeprecationPill = ({ deprecation, urn, refetch, showUndeprecate }: Props) => { const [batchUpdateDeprecationMutation] = useBatchUpdateDeprecationMutation(); + const [expanded, setExpanded] = useState(false); + const overLimit = deprecation?.note && removeMarkdown(deprecation?.note).length > 80; /** * Deprecation Decommission Timestamp */ @@ -131,14 +155,56 @@ export const DeprecationPill = ({ deprecation, urn, refetch, showUndeprecate }: return ( {deprecation?.note !== '' && Deprecation note} {isDividerNeeded && } - {deprecation?.note !== '' && {deprecation.note}} + + {expanded || !overLimit ? ( + <> + { + deprecation?.note && deprecation?.note !== '' && + <> + + + {overLimit && ( + { + setExpanded(false); + }} + > + Read Less + + )} + + + } + + ) : ( + <> + + { + setExpanded(true); + }} + > + Read More + + + } + shouldWrap + > + {deprecation.note} + + + )} + {deprecation?.decommissionTime !== null && ( diff --git a/smoke-test/tests/cypress/cypress/support/commands.js b/smoke-test/tests/cypress/cypress/support/commands.js index 5e3664f944edf1..ffbd050488181b 100644 --- a/smoke-test/tests/cypress/cypress/support/commands.js +++ b/smoke-test/tests/cypress/cypress/support/commands.js @@ -171,7 +171,7 @@ Cypress.Commands.add("deleteFromDropdown", () => { Cypress.Commands.add("addViaFormModal", (text, modelHeader) => { cy.waitTextVisible(modelHeader); - cy.get(".ant-form-item-control-input-content > input[type='text']").first().type(text); + cy.get('.ProseMirror-focused').type(text); cy.get(".ant-modal-footer > button:nth-child(2)").click(); }); From b4fe451d932315546ebd98623f1572a66c41ad43 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Mon, 18 Dec 2023 12:38:30 +0530 Subject: [PATCH 035/540] feat : markdown support for group description (#9455) --- .../group/EditGroupDescriptionModal.tsx | 64 ++++++++ .../src/app/entity/group/GroupInfoSideBar.tsx | 145 ++++++++++++++++-- .../app/identity/group/CreateGroupModal.tsx | 106 +++++++------ .../cypress/e2e/settings/managing_groups.js | 6 +- 4 files changed, 261 insertions(+), 60 deletions(-) create mode 100644 datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx diff --git a/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx b/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx new file mode 100644 index 00000000000000..a898a73c254efe --- /dev/null +++ b/datahub-web-react/src/app/entity/group/EditGroupDescriptionModal.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { Button, Modal, Form } from 'antd'; +import styled from 'styled-components'; + +import { Editor } from '../shared/tabs/Documentation/components/editor/Editor'; +import { ANTD_GRAY } from '../shared/constants'; + +type Props = { + onClose: () => void; + onSaveAboutMe: () => void; + setStagedDescription: (des: string) => void; + stagedDescription: string | undefined; +}; +const StyledEditor = styled(Editor)` + border: 1px solid ${ANTD_GRAY[4]}; +`; + +export default function EditGroupDescriptionModal({ + onClose, + onSaveAboutMe, + setStagedDescription, + stagedDescription, +}: Props) { + const [form] = Form.useForm(); + const [aboutText,setAboutText] = useState(stagedDescription) + + function updateDescription(description: string) { + setAboutText(aboutText) + setStagedDescription(description); + + } + + const saveDescription = () => { + onSaveAboutMe(); + onClose(); + }; + + return ( + + + + + } + > + + +
+ +
+
+ +
+ ); +} diff --git a/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx b/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx index d9eaed2682ea19..07885a4d0f6304 100644 --- a/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx +++ b/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx @@ -16,14 +16,15 @@ import { EmptyValue, SocialDetails, EditButton, - AboutSection, - AboutSectionText, GroupsSection, + AboutSection, } from '../shared/SidebarStyledComponents'; import GroupMembersSideBarSection from './GroupMembersSideBarSection'; import { useUserContext } from '../../context/useUserContext'; - -const { Paragraph } = Typography; +import StripMarkdownText, { removeMarkdown } from '../shared/components/styled/StripMarkdownText'; +import { Editor } from '../shared/tabs/Documentation/components/editor/Editor'; +import EditGroupDescriptionModal from './EditGroupDescriptionModal'; +import { REDESIGN_COLORS } from '../shared/constants'; type SideBarData = { photoUrl: string | undefined; @@ -80,6 +81,61 @@ const GroupTitle = styled(Typography.Title)` } `; +const EditIcon = styled(EditOutlined)` + cursor: pointer; + color: ${REDESIGN_COLORS.BLUE}; +`; +const AddNewDescription = styled(Button)` + display: none; + margin: -4px; + width: 140px; +`; + +const StyledViewer = styled(Editor)` + padding-right: 8px; + display: block; + + .remirror-editor.ProseMirror { + padding: 0; + } +`; + +const DescriptionContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + width: 100%; + text-align:left; + font-weight: normal; + font + min-height: 22px; + + &:hover ${AddNewDescription} { + display: block; + } + & ins.diff { + background-color: #b7eb8f99; + text-decoration: none; + &:hover { + background-color: #b7eb8faa; + } + } + & del.diff { + background-color: #ffa39e99; + text-decoration: line-through; + &: hover { + background-color: #ffa39eaa; + } + } +`; + +const ExpandedActions = styled.div` + height: 10px; +`; +const ReadLessText = styled(Typography.Link)` + margin-right: 4px; +`; + /** * Responsible for reading & writing users. */ @@ -106,7 +162,17 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) { const me = useUserContext(); const canEditGroup = me?.platformPrivileges?.manageIdentities; const [groupTitle, setGroupTitle] = useState(name); + const [expanded, setExpanded] = useState(false); + const [isUpdatingDescription, SetIsUpdatingDescription] = useState(false); + const [stagedDescription, setStagedDescription] = useState(aboutText); + const [updateName] = useUpdateNameMutation(); + const overLimit = removeMarkdown(aboutText || '').length > 80; + const ABBREVIATED_LIMIT = 80; + + useEffect(() => { + setStagedDescription(aboutText); + }, [aboutText]); useEffect(() => { setGroupTitle(groupTitle); @@ -136,12 +202,12 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) { }; // About Text save - const onSaveAboutMe = (inputString) => { + const onSaveAboutMe = () => { updateCorpGroupPropertiesMutation({ variables: { urn: urn || '', input: { - description: inputString, + description: stagedDescription, }, }, }) @@ -201,16 +267,65 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) { - {TITLES.about} - - - {aboutText || } - - + + {TITLES.about} + + SetIsUpdatingDescription(true)} data-testid="edit-icon" /> + + + + {(aboutText && expanded) || !overLimit ? ( + <> + {/* Read only viewer for displaying group description */} + + + {overLimit && ( + { + setExpanded(false); + }} + > + Read Less + + )} + + + ) : ( + <> + {/* Display abbreviated description with option to read more */} + + { + setExpanded(true); + }} + > + Read More + + + } + shouldWrap + > + {aboutText} + + + )} + + {/* Modal for updating group description */} + {isUpdatingDescription && ( + { + SetIsUpdatingDescription(false); + setStagedDescription(aboutText); + }} + onSaveAboutMe={onSaveAboutMe} + setStagedDescription={setStagedDescription} + stagedDescription={stagedDescription} + /> + )} diff --git a/datahub-web-react/src/app/identity/group/CreateGroupModal.tsx b/datahub-web-react/src/app/identity/group/CreateGroupModal.tsx index 214cb251767c9c..4ba714ca23ae06 100644 --- a/datahub-web-react/src/app/identity/group/CreateGroupModal.tsx +++ b/datahub-web-react/src/app/identity/group/CreateGroupModal.tsx @@ -1,16 +1,23 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { message, Button, Input, Modal, Typography, Form, Collapse } from 'antd'; +import styled from 'styled-components'; import { useCreateGroupMutation } from '../../../graphql/group.generated'; import { useEnterKeyListener } from '../../shared/useEnterKeyListener'; import { validateCustomUrnId } from '../../shared/textUtil'; import analytics, { EventType } from '../../analytics'; import { CorpGroup, EntityType } from '../../../types.generated'; +import { Editor as MarkdownEditor } from '../../entity/shared/tabs/Documentation/components/editor/Editor'; +import { ANTD_GRAY } from '../../entity/shared/constants'; type Props = { onClose: () => void; onCreate: (group: CorpGroup) => void; }; +const StyledEditor = styled(MarkdownEditor)` + border: 1px solid ${ANTD_GRAY[4]}; +`; + export default function CreateGroupModal({ onClose, onCreate }: Props) { const [stagedName, setStagedName] = useState(''); const [stagedDescription, setStagedDescription] = useState(''); @@ -19,45 +26,54 @@ export default function CreateGroupModal({ onClose, onCreate }: Props) { const [createButtonEnabled, setCreateButtonEnabled] = useState(true); const [form] = Form.useForm(); + // Reference to the styled editor for handling focus + const styledEditorRef = useRef(null); + const onCreateGroup = () => { - createGroupMutation({ - variables: { - input: { - id: stagedId, - name: stagedName, - description: stagedDescription, - }, - }, - }) - .then(({ data, errors }) => { - if (!errors) { - analytics.event({ - type: EventType.CreateGroupEvent, - }); - message.success({ - content: `Created group!`, - duration: 3, - }); - // TODO: Get a full corp group back from create endpoint. - onCreate({ - urn: data?.createGroup || '', - type: EntityType.CorpGroup, + // Check if the Enter key was pressed inside the styled editor to prevent unintended form submission + const isEditorNewlineKeypress = + document.activeElement !== styledEditorRef.current && + !styledEditorRef.current?.contains(document.activeElement); + if (isEditorNewlineKeypress) { + createGroupMutation({ + variables: { + input: { + id: stagedId, name: stagedName, - info: { - description: stagedDescription, - }, - }); - } - }) - .catch((e) => { - message.destroy(); - message.error({ content: `Failed to create group!: \n ${e.message || ''}`, duration: 3 }); + description: stagedDescription, + }, + }, }) - .finally(() => { - setStagedName(''); - setStagedDescription(''); - }); - onClose(); + .then(({ data, errors }) => { + if (!errors) { + analytics.event({ + type: EventType.CreateGroupEvent, + }); + message.success({ + content: `Created group!`, + duration: 3, + }); + // TODO: Get a full corp group back from create endpoint. + onCreate({ + urn: data?.createGroup || '', + type: EntityType.CorpGroup, + name: stagedName, + info: { + description: stagedDescription, + }, + }); + } + }) + .catch((e) => { + message.destroy(); + message.error({ content: `Failed to create group!: \n ${e.message || ''}`, duration: 3 }); + }) + .finally(() => { + setStagedName(''); + setStagedDescription(''); + }); + onClose(); + } }; // Handle the Enter press @@ -65,8 +81,13 @@ export default function CreateGroupModal({ onClose, onCreate }: Props) { querySelectorToExecuteClick: '#createGroupButton', }); + function updateDescription(description: string) { + setStagedDescription(description); + } + return ( Description
}> An optional description for your new group. - - setStagedDescription(event.target.value)} - /> + + {/* Styled editor for the group description */} +
+ +
diff --git a/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js b/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js index 70219a550cd8bb..978a245c3d9e33 100644 --- a/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js +++ b/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js @@ -72,8 +72,10 @@ describe("create and manage group", () => { cy.focused().clear().type(`Test group EDITED ${test_id}{enter}`); cy.waitTextVisible("Name Updated"); cy.contains(`Test group EDITED ${test_id}`).should("be.visible"); - cy.contains("Test group description").find('[aria-label="edit"]').click(); - cy.focused().type(" EDITED{enter}"); + cy.get('[data-testid="edit-icon"]').click(); + cy.waitTextVisible("Edit Description"); + cy.get("#description").should("be.visible").type(" EDITED"); + cy.get("#updateGroupButton").click(); cy.waitTextVisible("Changes saved."); cy.contains("Test group description EDITED").should("be.visible"); cy.clickOptionWithText("Add Owners"); From 9d386fbd6f9a0436b25daa2b4603d1fa0b8f44ee Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 18 Dec 2023 05:38:16 -0500 Subject: [PATCH 036/540] feat(ingest): enable CLL for dbt by default (#9466) --- .../ingestion/source/dbt/dbt_common.py | 7 +- .../ingestion/source/looker/looker_common.py | 2 +- .../source/looker/looker_lib_wrapper.py | 2 +- .../dbt_enabled_with_schemas_mces_golden.json | 248 ++++++++++++ .../dbt_test_column_meta_mapping_golden.json | 383 ++++++++++++++++++ ...th_complex_owner_patterns_mces_golden.json | 248 ++++++++++++ ...th_data_platform_instance_mces_golden.json | 248 ++++++++++++ ...h_non_incremental_lineage_mces_golden.json | 248 ++++++++++++ ..._target_platform_instance_mces_golden.json | 248 ++++++++++++ 9 files changed, 1630 insertions(+), 4 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py index af28be310587a8..7bec07b40c4bdf 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py @@ -300,7 +300,7 @@ class DBTCommonConfig( description="When enabled, schemas will be inferred from the dbt node definition.", ) include_column_lineage: bool = Field( - default=False, + default=True, description="When enabled, column-level lineage will be extracted from the dbt node definition. Requires `infer_dbt_schemas` to be enabled. " "If you run into issues where the column name casing does not match up with properly, providing a datahub_api or using the rest sink will improve accuracy.", ) @@ -696,7 +696,10 @@ def get_column_type( @support_status(SupportStatus.CERTIFIED) @capability(SourceCapability.DELETION_DETECTION, "Enabled via stateful ingestion") @capability(SourceCapability.LINEAGE_COARSE, "Enabled by default") -@capability(SourceCapability.LINEAGE_FINE, "Enabled using `include_column_lineage`") +@capability( + SourceCapability.LINEAGE_FINE, + "Enabled by default, configure using `include_column_lineage`", +) class DBTSourceBase(StatefulIngestionSourceBase): def __init__(self, config: DBTCommonConfig, ctx: PipelineContext, platform: str): super().__init__(config, ctx) diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py index e440750cba0d08..53533a8d27c9b5 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py @@ -1015,7 +1015,7 @@ def __init__( self.report = report self.source_config = source_config - @lru_cache() + @lru_cache(maxsize=200) def get_explore(self, model: str, explore: str) -> Optional[LookerExplore]: looker_explore = LookerExplore.from_api( model, diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_lib_wrapper.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_lib_wrapper.py index 988caba1c0d748..8959868c27114c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_lib_wrapper.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_lib_wrapper.py @@ -114,7 +114,7 @@ def get_available_permissions(self) -> Set[str]: return permissions - @lru_cache(maxsize=2000) + @lru_cache(maxsize=1000) def get_user(self, id_: str, user_fields: str) -> Optional[User]: self.client_stats.user_calls += 1 try: diff --git a/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json index e4f01ef7a6c537..4deb725ed2b444 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json @@ -247,6 +247,86 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -428,6 +508,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -650,6 +765,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -789,6 +1002,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json index 4d5b008b695f97..588470ef416314 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json @@ -201,6 +201,98 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.customer_snapshot,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.customer_snapshot,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),initial_full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -360,6 +452,52 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_details,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an-aliased-view-for-monthly-billing,PROD),email)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -574,6 +712,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.an_aliased_view_for_payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -741,6 +977,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.an_aliased_view_for_payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.an_aliased_view_for_payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.an_aliased_view_for_payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.public.an_aliased_view_for_payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -1011,6 +1282,118 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),active)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),active)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),activebool)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),activebool)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),address_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),address_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),create_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),create_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),first_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),last_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_update)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),last_update)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),store_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer_snapshot,PROD),store_id)" + ], + "confidenceScore": 0.9 + } ] } }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json index 0bdd5e3c895c27..926e8b8c8ed84b 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json @@ -211,6 +211,86 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -375,6 +455,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -597,6 +712,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -736,6 +949,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json index 5ab0b11e377716..3727603266f252 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json @@ -212,6 +212,86 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -376,6 +456,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -598,6 +713,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -737,6 +950,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,dbt-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json index 3725e590fee9e4..ec879e6af766ac 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json @@ -212,6 +212,86 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -376,6 +456,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -598,6 +713,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -737,6 +950,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json index a47abab6b40f7a..e25c5e4faf6afd 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json @@ -212,6 +212,86 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),first_name)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),last_name)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),full_name)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.customer,PROD),email)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),email)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),address)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),address)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.city,PROD),city)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),city)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),postal_code)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),postal_code)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.address,PROD),phone)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.customer_details,PROD),phone)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -376,6 +456,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-monthly-billing,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -598,6 +713,104 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),amount)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),customer_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_date)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),payment_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),payment_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),rental_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),rental_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),rental_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_01,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_02,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_03,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_04,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_05,PROD),staff_id)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.public.payment_p2020_06,PROD),staff_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.an-aliased-view-for-payments,PROD),staff_id)" + ], + "confidenceScore": 0.9 + } ] } }, @@ -737,6 +950,41 @@ "dataset": "urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD)", "type": "TRANSFORMED" } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),payment_date)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),billing_month)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),customer_id)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),customer_id)" + ], + "confidenceScore": 0.9 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,ps-instance-1.pagila.dbt_postgres.an-aliased-view-for-payments,PROD),amount)" + ], + "downstreamType": "FIELD_SET", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:dbt,pagila.dbt_postgres.payments_by_customer_by_month,PROD),amount)" + ], + "confidenceScore": 0.9 + } ] } }, From 03590a194885b2fbbb5249aef909d761c3ffc12c Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Mon, 18 Dec 2023 19:54:31 +0100 Subject: [PATCH 037/540] fix(ingest/snowflake) - Fixing snowflake url with default region (#9443) --- metadata-ingestion/setup.py | 8 +- .../source/snowflake/snowflake_utils.py | 28 ++++- .../snowflake/snowflake_golden.json | 116 +++++++++--------- .../integration/sql_server/test_sql_server.py | 5 + .../tests/unit/test_snowflake_source.py | 27 ++++ 5 files changed, 120 insertions(+), 64 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 1bc1bc5100b08d..cb13a40125c0da 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -354,7 +354,11 @@ "mlflow": {"mlflow-skinny>=2.3.0"}, "mode": {"requests", "tenacity>=8.0.1"} | sqllineage_lib, "mongodb": {"pymongo[srv]>=3.11", "packaging"}, - "mssql": sql_common | {"sqlalchemy-pytds>=0.3", "pyOpenSSL"}, + "mssql": sql_common + | { + "sqlalchemy-pytds>=0.3", + "pyOpenSSL", + }, "mssql-odbc": sql_common | {"pyodbc"}, "mysql": mysql, # mariadb should have same dependency as mysql @@ -559,7 +563,7 @@ "kafka-connect", "ldap", "mongodb", - "mssql", + "mssql" if sys.version_info >= (3, 8) else None, "mysql", "mariadb", "redash", diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py index 5a451bf197d347..af8d8824a4b172 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py @@ -9,8 +9,8 @@ from datahub.configuration.pattern_utils import is_schema_allowed from datahub.ingestion.source.snowflake.constants import ( GENERIC_PERMISSION_ERROR_KEY, - SNOWFLAKE_DEFAULT_CLOUD, SNOWFLAKE_REGION_CLOUD_REGION_MAPPING, + SnowflakeCloudProvider, SnowflakeObjectDomain, ) from datahub.ingestion.source.snowflake.snowflake_config import SnowflakeV2Config @@ -72,6 +72,15 @@ def report_error(self, key: str, reason: str) -> None: class SnowflakeCommonMixin: platform = "snowflake" + CLOUD_REGION_IDS_WITHOUT_CLOUD_SUFFIX = [ + "us-west-2", + "us-east-1", + "eu-west-1", + "eu-central-1", + "ap-southeast-1", + "ap-southeast-2", + ] + @staticmethod def create_snowsight_base_url( account_locator: str, @@ -79,12 +88,23 @@ def create_snowsight_base_url( cloud: str, privatelink: bool = False, ) -> Optional[str]: + if cloud: + url_cloud_provider_suffix = f".{cloud}" + + if cloud == SnowflakeCloudProvider.AWS: + # Some AWS regions do not have cloud suffix. See below the list: + # https://docs.snowflake.com/en/user-guide/admin-account-identifier#non-vps-account-locator-formats-by-cloud-platform-and-region + if ( + cloud_region_id + in SnowflakeCommonMixin.CLOUD_REGION_IDS_WITHOUT_CLOUD_SUFFIX + ): + url_cloud_provider_suffix = "" + else: + url_cloud_provider_suffix = f".{cloud}" if privatelink: url = f"https://app.{account_locator}.{cloud_region_id}.privatelink.snowflakecomputing.com/" - elif cloud == SNOWFLAKE_DEFAULT_CLOUD: - url = f"https://app.snowflake.com/{cloud_region_id}/{account_locator}/" else: - url = f"https://app.snowflake.com/{cloud_region_id}.{cloud}/{account_locator}/" + url = f"https://app.snowflake.com/{cloud_region_id}{url_cloud_provider_suffix}/{account_locator}/" return url @staticmethod diff --git a/metadata-ingestion/tests/integration/snowflake/snowflake_golden.json b/metadata-ingestion/tests/integration/snowflake/snowflake_golden.json index c7273fee5a2e58..ece54f00eeaa04 100644 --- a/metadata-ingestion/tests/integration/snowflake/snowflake_golden.json +++ b/metadata-ingestion/tests/integration/snowflake/snowflake_golden.json @@ -11,20 +11,20 @@ "env": "PROD", "database": "test_db" }, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/", "name": "TEST_DB", "description": "Comment for TEST_DB", "created": { - "time": 1623110400000 + "time": 1623103200000 }, "lastModified": { - "time": 1623110400000 + "time": 1623103200000 } } }, "systemMetadata": { - "lastObserved": 1654621200000, - "runId": "snowflake-2022_06_07-17_00_00", + "lastObserved": 1615443388097, + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -144,20 +144,20 @@ "database": "test_db", "schema": "test_schema" }, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/", "name": "TEST_SCHEMA", "description": "comment for TEST_DB.TEST_SCHEMA", "created": { - "time": 1623110400000 + "time": 1623103200000 }, "lastModified": { - "time": 1623110400000 + "time": 1623103200000 } } }, "systemMetadata": { - "lastObserved": 1654621200000, - "runId": "snowflake-2022_06_07-17_00_00", + "lastObserved": 1615443388097, + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -489,22 +489,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_1/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_1/", "name": "TABLE_1", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_1", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -788,22 +788,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_2/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_2/", "name": "TABLE_2", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_2", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -1087,22 +1087,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_3/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_3/", "name": "TABLE_3", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_3", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -1386,22 +1386,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_4/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_4/", "name": "TABLE_4", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_4", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -1685,22 +1685,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_5/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_5/", "name": "TABLE_5", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_5", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -1984,22 +1984,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_6/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_6/", "name": "TABLE_6", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_6", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -2283,22 +2283,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_7/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_7/", "name": "TABLE_7", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_7", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -2582,22 +2582,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_8/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_8/", "name": "TABLE_8", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_8", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -2881,22 +2881,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_9/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_9/", "name": "TABLE_9", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_9", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -3180,22 +3180,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_10/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/table/TABLE_10/", "name": "TABLE_10", "qualifiedName": "TEST_DB.TEST_SCHEMA.TABLE_10", "description": "Comment for Table", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -3470,22 +3470,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/view/VIEW_1/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/view/VIEW_1/", "name": "VIEW_1", "qualifiedName": "TEST_DB.TEST_SCHEMA.VIEW_1", "description": "Comment for View", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, @@ -3805,22 +3805,22 @@ "aspect": { "json": { "customProperties": {}, - "externalUrl": "https://app.snowflake.com/ap-south-1/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/view/VIEW_2/", + "externalUrl": "https://app.snowflake.com/ap-south-1.aws/abc12345/#/data/databases/TEST_DB/schemas/TEST_SCHEMA/view/VIEW_2/", "name": "VIEW_2", "qualifiedName": "TEST_DB.TEST_SCHEMA.VIEW_2", "description": "Comment for View", "created": { - "time": 1623090600000 + "time": 1623103200000 }, "lastModified": { - "time": 1623090600000 + "time": 1623103200000 }, "tags": [] } }, "systemMetadata": { "lastObserved": 1615443388097, - "runId": "snowflake-2023_08_04-09_52_28", + "runId": "snowflake-2023_12_18-10_16_09", "lastRunId": "no-run-id-provided" } }, diff --git a/metadata-ingestion/tests/integration/sql_server/test_sql_server.py b/metadata-ingestion/tests/integration/sql_server/test_sql_server.py index f439a322c26771..5ed672d527264a 100644 --- a/metadata-ingestion/tests/integration/sql_server/test_sql_server.py +++ b/metadata-ingestion/tests/integration/sql_server/test_sql_server.py @@ -1,5 +1,6 @@ import os import subprocess +import sys import time import pytest @@ -8,6 +9,10 @@ from tests.test_helpers.click_helpers import run_datahub_cmd from tests.test_helpers.docker_helpers import cleanup_image, wait_for_port +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 8), reason="requires python 3.8 or higher" +) + @pytest.fixture(scope="module") def mssql_runner(docker_compose_runner, pytestconfig): diff --git a/metadata-ingestion/tests/unit/test_snowflake_source.py b/metadata-ingestion/tests/unit/test_snowflake_source.py index 536c91ace4f5ed..69a7510692df1d 100644 --- a/metadata-ingestion/tests/unit/test_snowflake_source.py +++ b/metadata-ingestion/tests/unit/test_snowflake_source.py @@ -24,6 +24,7 @@ from datahub.ingestion.source.snowflake.snowflake_usage_v2 import ( SnowflakeObjectAccessEntry, ) +from datahub.ingestion.source.snowflake.snowflake_utils import SnowflakeCommonMixin from datahub.ingestion.source.snowflake.snowflake_v2 import SnowflakeV2Source from tests.test_helpers import test_connection_helpers @@ -584,3 +585,29 @@ def test_email_filter_query_generation_with_case_insensitive_filter(): filter_query == "AND (rlike(user_name, '.*@example.com','c')) AND NOT (rlike(user_name, '.*@example2.com','c'))" ) + + +def test_create_snowsight_base_url_us_west(): + ( + cloud, + cloud_region_id, + ) = SnowflakeCommonMixin.get_cloud_region_from_snowflake_region_id("aws_us_west_2") + + result = SnowflakeCommonMixin.create_snowsight_base_url( + "account_locator", cloud_region_id, cloud, False + ) + assert result == "https://app.snowflake.com/us-west-2/account_locator/" + + +def test_create_snowsight_base_url_ap_northeast_1(): + ( + cloud, + cloud_region_id, + ) = SnowflakeCommonMixin.get_cloud_region_from_snowflake_region_id( + "aws_ap_northeast_1" + ) + + result = SnowflakeCommonMixin.create_snowsight_base_url( + "account_locator", cloud_region_id, cloud, False + ) + assert result == "https://app.snowflake.com/ap-northeast-1.aws/account_locator/" From 193d1464a628fc800e926f04fcd4bd1d6774d858 Mon Sep 17 00:00:00 2001 From: noggi Date: Mon, 18 Dec 2023 14:06:17 -0800 Subject: [PATCH 038/540] Fix downstream CI issue (#9479) --- docker/datahub-ingestion-base/Dockerfile | 2 +- docker/datahub-ingestion/Dockerfile | 2 +- docker/datahub-ingestion/build.gradle | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/datahub-ingestion-base/Dockerfile b/docker/datahub-ingestion-base/Dockerfile index e0f9fdc997071c..81fec61ea50733 100644 --- a/docker/datahub-ingestion-base/Dockerfile +++ b/docker/datahub-ingestion-base/Dockerfile @@ -4,7 +4,7 @@ ARG BASE_IMAGE=base # Defining custom repo urls for use in enterprise environments. Re-used between stages below. ARG ALPINE_REPO_URL=http://dl-cdn.alpinelinux.org/alpine ARG GITHUB_REPO_URL=https://github.com -ARG DEBIAN_REPO_URL=http://deb.debian.org/debian +ARG DEBIAN_REPO_URL=https://deb.debian.org/debian ARG PIP_MIRROR_URL=null FROM golang:1-alpine3.18 AS dockerize-binary diff --git a/docker/datahub-ingestion/Dockerfile b/docker/datahub-ingestion/Dockerfile index 9516c31a19e21b..2898a363a0a185 100644 --- a/docker/datahub-ingestion/Dockerfile +++ b/docker/datahub-ingestion/Dockerfile @@ -3,7 +3,7 @@ ARG APP_ENV=full ARG BASE_IMAGE=acryldata/datahub-ingestion-base ARG DOCKER_VERSION=head ARG PIP_MIRROR_URL=null -ARG DEBIAN_REPO_URL=http://deb.debian.org/debian +ARG DEBIAN_REPO_URL=https://deb.debian.org/debian FROM $BASE_IMAGE:$DOCKER_VERSION as base USER 0 diff --git a/docker/datahub-ingestion/build.gradle b/docker/datahub-ingestion/build.gradle index 36444210f1938b..0b08f189e6b45a 100644 --- a/docker/datahub-ingestion/build.gradle +++ b/docker/datahub-ingestion/build.gradle @@ -33,7 +33,7 @@ docker { i -> (!i.file.name.endsWith(".dockerignore") && i.file.isHidden()) } - def dockerBuildArgs = [DOCKER_VERSION: version, RELEASE_VERSION: version.replace('-SNAPSHOT', '').replace('v', '').replace("-slim", '')] + def dockerBuildArgs = [DOCKER_VERSION: version, RELEASE_VERSION: version.replace('-SNAPSHOT', '').replace('v', '').replace("-slim", ''), BASE_IMAGE: "${docker_registry}/datahub-ingestion-base"] // Add build args if they are defined (needed for some CI or enterprise environments) if (project.hasProperty('pipMirrorUrl')) { From ecda3e618704c5eb335ad1a21c30f0c935581f64 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 18 Dec 2023 18:26:33 -0500 Subject: [PATCH 039/540] feat(ingest): pydantic v2 compatibility (#9434) --- .github/workflows/airflow-plugin.yml | 7 ++-- .../airflow-plugin/tox.ini | 9 +++++ metadata-ingestion/setup.py | 39 ++++++++++++++++--- .../api/entities/datacontract/assertion.py | 4 +- .../datacontract/assertion_operator.py | 16 ++++---- .../datacontract/data_quality_assertion.py | 11 +++--- .../api/entities/datacontract/datacontract.py | 23 +++++------ .../datacontract/freshness_assertion.py | 15 ++++--- .../entities/datacontract/schema_assertion.py | 14 ++++--- .../src/datahub/cli/check_cli.py | 13 ++++++- .../src/datahub/configuration/common.py | 16 +++++++- .../src/datahub/configuration/datetimes.py | 4 +- .../pydantic_migration_helpers.py | 29 ++++++++++++++ .../configuration/time_window_config.py | 16 ++++++-- .../configuration/validate_field_rename.py | 4 +- .../ingestion/glossary/datahub_classifier.py | 11 +++++- .../source/bigquery_v2/bigquery_config.py | 2 +- .../ingestion/source/delta_lake/config.py | 4 +- .../source/snowflake/snowflake_config.py | 2 +- .../ingestion/source_config/sql/snowflake.py | 2 +- .../src/datahub/utilities/urns/urn_iter.py | 2 +- .../integration/snowflake/test_snowflake.py | 16 ++++---- .../unit/{ => config}/test_allow_deny.py | 0 .../unit/{ => config}/test_config_clean.py | 0 .../tests/unit/config/test_config_model.py | 18 +++++++-- .../{ => config}/test_pydantic_validators.py | 13 +++++-- .../{ => config}/test_time_window_config.py | 0 27 files changed, 209 insertions(+), 81 deletions(-) rename metadata-ingestion/tests/unit/{ => config}/test_allow_deny.py (100%) rename metadata-ingestion/tests/unit/{ => config}/test_config_clean.py (100%) rename metadata-ingestion/tests/unit/{ => config}/test_pydantic_validators.py (92%) rename metadata-ingestion/tests/unit/{ => config}/test_time_window_config.py (100%) diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index cd1e159b7d53cc..70816e5f093d13 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -32,6 +32,7 @@ jobs: strategy: matrix: include: + # Note: this should be kept in sync with tox.ini. - python-version: "3.8" extra_pip_requirements: "apache-airflow~=2.1.4" extra_pip_extras: plugin-v1 @@ -39,13 +40,13 @@ jobs: extra_pip_requirements: "apache-airflow~=2.2.4" extra_pip_extras: plugin-v1 - python-version: "3.10" - extra_pip_requirements: "apache-airflow~=2.4.0" + extra_pip_requirements: 'apache-airflow~=2.4.0 pluggy==1.0.0 "pendulum<3.0"' extra_pip_extras: plugin-v2 - python-version: "3.10" - extra_pip_requirements: "apache-airflow~=2.6.0" + extra_pip_requirements: 'apache-airflow~=2.6.0 "pendulum<3.0"' extra_pip_extras: plugin-v2 - python-version: "3.10" - extra_pip_requirements: "apache-airflow>=2.7.0" + extra_pip_requirements: "apache-airflow>=2.7.0 pydantic==2.4.2" extra_pip_extras: plugin-v2 fail-fast: false steps: diff --git a/metadata-ingestion-modules/airflow-plugin/tox.ini b/metadata-ingestion-modules/airflow-plugin/tox.ini index 1010bd2933e452..27ae2ce65ba658 100644 --- a/metadata-ingestion-modules/airflow-plugin/tox.ini +++ b/metadata-ingestion-modules/airflow-plugin/tox.ini @@ -10,6 +10,7 @@ envlist = py38-airflow21, py38-airflow22, py310-airflow24, py310-airflow26, py31 use_develop = true extras = dev,integration-tests,plugin-v1 deps = + # This should be kept in sync with the Github Actions matrix. -e ../../metadata-ingestion/ # Airflow version airflow21: apache-airflow~=2.1.0 @@ -20,7 +21,15 @@ deps = # See https://github.com/datahub-project/datahub/pull/9365 airflow24: apache-airflow~=2.4.0,pluggy==1.0.0 airflow26: apache-airflow~=2.6.0 + # Respect the constraints file on pendulum. + # See https://github.com/apache/airflow/issues/36274 + airflow24,airflow26: pendulum>=2.0,<3.0 + # The Airflow 2.7 constraints file points at pydantic v2, so we match that here. + # https://raw.githubusercontent.com/apache/airflow/constraints-2.7.3/constraints-3.10.txt + # Note that Airflow is actually compatible with both pydantic v1 and v2, and the + # constraints file is overly restrictive. airflow27: apache-airflow~=2.7.0 + airflow27: pydantic==2.4.2 commands = pytest --cov-append {posargs} diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index cb13a40125c0da..13c9d3c99aaca1 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -14,9 +14,10 @@ "mypy_extensions>=0.4.3", # Actual dependencies. "typing-inspect", + # pydantic 1.8.2 is incompatible with mypy 0.910. + # See https://github.com/samuelcolvin/pydantic/pull/3175#issuecomment-995382910. # pydantic 1.10.3 is incompatible with typing-extensions 4.1.1 - https://github.com/pydantic/pydantic/issues/4885 - # pydantic 2 makes major, backwards-incompatible changes - https://github.com/pydantic/pydantic/issues/4887 - "pydantic>=1.5.1,!=1.10.3,<2", + "pydantic>=1.10.0,!=1.10.3", "mixpanel>=4.9.0", "sentry-sdk", } @@ -53,6 +54,18 @@ "ruamel.yaml", } +pydantic_no_v2 = { + # pydantic 2 makes major, backwards-incompatible changes - https://github.com/pydantic/pydantic/issues/4887 + # Tags sources that require the pydantic v2 API. + "pydantic<2", +} + +plugin_common = { + # While pydantic v2 support is experimental, require that all plugins + # continue to use v1. This will ensure that no ingestion recipes break. + *pydantic_no_v2, +} + rest_common = {"requests", "requests_file"} kafka_common = { @@ -118,6 +131,7 @@ "sqlalchemy>=1.4.39, <2", # Required for SQL profiling. "great-expectations>=0.15.12, <=0.15.50", + *pydantic_no_v2, # because of great-expectations # scipy version restricted to reduce backtracking, used by great-expectations, "scipy>=1.7.2", # GE added handling for higher version of jinja2 @@ -229,6 +243,7 @@ iceberg_common = { # Iceberg Python SDK "pyiceberg", + *pydantic_no_v2, # because of pyiceberg "pyarrow>=9.0.0, <13.0.0", } @@ -477,9 +492,6 @@ "flake8-bugbear==23.3.12", "isort>=5.7.0", "mypy==1.0.0", - # pydantic 1.8.2 is incompatible with mypy 0.910. - # See https://github.com/samuelcolvin/pydantic/pull/3175#issuecomment-995382910. - "pydantic>=1.10.0", *test_api_requirements, pytest_dep, "pytest-asyncio>=0.16.0", @@ -740,7 +752,22 @@ extras_require={ "base": list(framework_common), **{ - plugin: list(framework_common | dependencies) + plugin: list( + framework_common + | ( + plugin_common + if plugin + not in { + "airflow", + "datahub-rest", + "datahub-kafka", + "sync-file-emitter", + "sql-parser", + } + else set() + ) + | dependencies + ) for (plugin, dependencies) in plugins.items() }, "all": list( diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/assertion.py b/metadata-ingestion/src/datahub/api/entities/datacontract/assertion.py index c45d4ddc924580..89ac528efe81a1 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/assertion.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/assertion.py @@ -1,7 +1,7 @@ from typing import Optional -from datahub.configuration import ConfigModel +from datahub.configuration.pydantic_migration_helpers import v1_ConfigModel -class BaseAssertion(ConfigModel): +class BaseAssertion(v1_ConfigModel): description: Optional[str] = None diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/assertion_operator.py b/metadata-ingestion/src/datahub/api/entities/datacontract/assertion_operator.py index a41b0f7aafd9f2..dc0c97d1c74e56 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/assertion_operator.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/assertion_operator.py @@ -2,7 +2,7 @@ from typing_extensions import Literal, Protocol -from datahub.configuration import ConfigModel +from datahub.configuration.pydantic_migration_helpers import v1_ConfigModel from datahub.metadata.schema_classes import ( AssertionStdOperatorClass, AssertionStdParameterClass, @@ -58,7 +58,7 @@ def _generate_assertion_std_parameters( ) -class EqualToOperator(ConfigModel): +class EqualToOperator(v1_ConfigModel): type: Literal["equal_to"] value: Union[str, int, float] @@ -71,7 +71,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: return _generate_assertion_std_parameters(value=self.value) -class BetweenOperator(ConfigModel): +class BetweenOperator(v1_ConfigModel): type: Literal["between"] min: Union[int, float] max: Union[int, float] @@ -87,7 +87,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: ) -class LessThanOperator(ConfigModel): +class LessThanOperator(v1_ConfigModel): type: Literal["less_than"] value: Union[int, float] @@ -100,7 +100,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: return _generate_assertion_std_parameters(value=self.value) -class GreaterThanOperator(ConfigModel): +class GreaterThanOperator(v1_ConfigModel): type: Literal["greater_than"] value: Union[int, float] @@ -113,7 +113,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: return _generate_assertion_std_parameters(value=self.value) -class LessThanOrEqualToOperator(ConfigModel): +class LessThanOrEqualToOperator(v1_ConfigModel): type: Literal["less_than_or_equal_to"] value: Union[int, float] @@ -126,7 +126,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: return _generate_assertion_std_parameters(value=self.value) -class GreaterThanOrEqualToOperator(ConfigModel): +class GreaterThanOrEqualToOperator(v1_ConfigModel): type: Literal["greater_than_or_equal_to"] value: Union[int, float] @@ -139,7 +139,7 @@ def generate_parameters(self) -> AssertionStdParametersClass: return _generate_assertion_std_parameters(value=self.value) -class NotNullOperator(ConfigModel): +class NotNullOperator(v1_ConfigModel): type: Literal["not_null"] operator: str = AssertionStdOperatorClass.NOT_NULL diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/data_quality_assertion.py b/metadata-ingestion/src/datahub/api/entities/datacontract/data_quality_assertion.py index 6a3944ba36baf0..975aa359bd2031 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/data_quality_assertion.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/data_quality_assertion.py @@ -1,12 +1,11 @@ from typing import List, Optional, Union -import pydantic from typing_extensions import Literal import datahub.emitter.mce_builder as builder from datahub.api.entities.datacontract.assertion import BaseAssertion from datahub.api.entities.datacontract.assertion_operator import Operators -from datahub.configuration.common import ConfigModel +from datahub.configuration.pydantic_migration_helpers import v1_ConfigModel, v1_Field from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.metadata.schema_classes import ( AssertionInfoClass, @@ -25,7 +24,7 @@ class IdConfigMixin(BaseAssertion): - id_raw: Optional[str] = pydantic.Field( + id_raw: Optional[str] = v1_Field( default=None, alias="id", description="The id of the assertion. If not provided, one will be generated using the type.", @@ -38,7 +37,7 @@ def generate_default_id(self) -> str: class CustomSQLAssertion(IdConfigMixin, BaseAssertion): type: Literal["custom_sql"] sql: str - operator: Operators = pydantic.Field(discriminator="type") + operator: Operators = v1_Field(discriminator="type") def generate_default_id(self) -> str: return f"{self.type}-{self.sql}-{self.operator.id()}" @@ -89,11 +88,11 @@ def generate_assertion_info(self, entity_urn: str) -> AssertionInfoClass: ) -class DataQualityAssertion(ConfigModel): +class DataQualityAssertion(v1_ConfigModel): __root__: Union[ CustomSQLAssertion, ColumnUniqueAssertion, - ] = pydantic.Field(discriminator="type") + ] = v1_Field(discriminator="type") @property def id(self) -> str: diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/datacontract.py b/metadata-ingestion/src/datahub/api/entities/datacontract/datacontract.py index f3c6be55e5fea9..e0ef85d5fd66c0 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/datacontract.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/datacontract.py @@ -1,7 +1,6 @@ import collections from typing import Iterable, List, Optional, Tuple -import pydantic from ruamel.yaml import YAML from typing_extensions import Literal @@ -11,7 +10,11 @@ ) from datahub.api.entities.datacontract.freshness_assertion import FreshnessAssertion from datahub.api.entities.datacontract.schema_assertion import SchemaAssertion -from datahub.configuration.common import ConfigModel +from datahub.configuration.pydantic_migration_helpers import ( + v1_ConfigModel, + v1_Field, + v1_validator, +) from datahub.emitter.mce_builder import datahub_guid, make_assertion_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.metadata.schema_classes import ( @@ -26,7 +29,7 @@ from datahub.utilities.urns.urn import guess_entity_type -class DataContract(ConfigModel): +class DataContract(v1_ConfigModel): """A yml representation of a Data Contract. This model is used as a simpler, Python-native representation of a DataHub data contract. @@ -36,29 +39,27 @@ class DataContract(ConfigModel): version: Literal[1] - id: Optional[str] = pydantic.Field( + id: Optional[str] = v1_Field( default=None, alias="urn", description="The data contract urn. If not provided, one will be generated.", ) - entity: str = pydantic.Field( + entity: str = v1_Field( description="The entity urn that the Data Contract is associated with" ) # TODO: add support for properties # properties: Optional[Dict[str, str]] = None - schema_field: Optional[SchemaAssertion] = pydantic.Field( - default=None, alias="schema" - ) + schema_field: Optional[SchemaAssertion] = v1_Field(default=None, alias="schema") - freshness: Optional[FreshnessAssertion] = pydantic.Field(default=None) + freshness: Optional[FreshnessAssertion] = v1_Field(default=None) # TODO: Add a validator to ensure that ids are unique - data_quality: Optional[List[DataQualityAssertion]] = pydantic.Field(default=None) + data_quality: Optional[List[DataQualityAssertion]] = v1_Field(default=None) _original_yaml_dict: Optional[dict] = None - @pydantic.validator("data_quality") + @v1_validator("data_quality") # type: ignore def validate_data_quality( cls, data_quality: Optional[List[DataQualityAssertion]] ) -> Optional[List[DataQualityAssertion]]: diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/freshness_assertion.py b/metadata-ingestion/src/datahub/api/entities/datacontract/freshness_assertion.py index 71741d76b22fc4..86942766889676 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/freshness_assertion.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/freshness_assertion.py @@ -3,11 +3,10 @@ from datetime import timedelta from typing import List, Union -import pydantic from typing_extensions import Literal from datahub.api.entities.datacontract.assertion import BaseAssertion -from datahub.configuration.common import ConfigModel +from datahub.configuration.pydantic_migration_helpers import v1_ConfigModel, v1_Field from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.metadata.schema_classes import ( AssertionInfoClass, @@ -25,10 +24,10 @@ class CronFreshnessAssertion(BaseAssertion): type: Literal["cron"] - cron: str = pydantic.Field( + cron: str = v1_Field( description="The cron expression to use. See https://crontab.guru/ for help." ) - timezone: str = pydantic.Field( + timezone: str = v1_Field( "UTC", description="The timezone to use for the cron schedule. Defaults to UTC.", ) @@ -58,10 +57,10 @@ def generate_freshness_assertion_schedule(self) -> FreshnessAssertionScheduleCla ) -class FreshnessAssertion(ConfigModel): - __root__: Union[ - CronFreshnessAssertion, FixedIntervalFreshnessAssertion - ] = pydantic.Field(discriminator="type") +class FreshnessAssertion(v1_ConfigModel): + __root__: Union[CronFreshnessAssertion, FixedIntervalFreshnessAssertion] = v1_Field( + discriminator="type" + ) @property def id(self): diff --git a/metadata-ingestion/src/datahub/api/entities/datacontract/schema_assertion.py b/metadata-ingestion/src/datahub/api/entities/datacontract/schema_assertion.py index b62f94e0592fce..39297d1a98d026 100644 --- a/metadata-ingestion/src/datahub/api/entities/datacontract/schema_assertion.py +++ b/metadata-ingestion/src/datahub/api/entities/datacontract/schema_assertion.py @@ -3,11 +3,10 @@ import json from typing import List, Union -import pydantic from typing_extensions import Literal from datahub.api.entities.datacontract.assertion import BaseAssertion -from datahub.configuration.common import ConfigModel +from datahub.configuration.pydantic_migration_helpers import v1_ConfigModel, v1_Field from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.extractor.json_schema_util import get_schema_metadata from datahub.metadata.schema_classes import ( @@ -23,7 +22,7 @@ class JsonSchemaContract(BaseAssertion): type: Literal["json-schema"] - json_schema: dict = pydantic.Field(alias="json-schema") + json_schema: dict = v1_Field(alias="json-schema") _schema_metadata: SchemaMetadataClass @@ -37,7 +36,10 @@ def _init_private_attributes(self) -> None: ) -class FieldListSchemaContract(BaseAssertion, arbitrary_types_allowed=True): +class FieldListSchemaContract(BaseAssertion): + class Config: + arbitrary_types_allowed = True + type: Literal["field-list"] fields: List[SchemaFieldClass] @@ -56,8 +58,8 @@ def _init_private_attributes(self) -> None: ) -class SchemaAssertion(ConfigModel): - __root__: Union[JsonSchemaContract, FieldListSchemaContract] = pydantic.Field( +class SchemaAssertion(v1_ConfigModel): + __root__: Union[JsonSchemaContract, FieldListSchemaContract] = v1_Field( discriminator="type" ) diff --git a/metadata-ingestion/src/datahub/cli/check_cli.py b/metadata-ingestion/src/datahub/cli/check_cli.py index f7996900f7a7ad..2732a72aea5399 100644 --- a/metadata-ingestion/src/datahub/cli/check_cli.py +++ b/metadata-ingestion/src/datahub/cli/check_cli.py @@ -126,10 +126,21 @@ def metadata_diff( default=False, help="Include extra information for each plugin.", ) +@click.option( + "--source", + type=str, + default=None, +) @telemetry.with_telemetry() -def plugins(verbose: bool) -> None: +def plugins(source: Optional[str], verbose: bool) -> None: """List the enabled ingestion plugins.""" + if source: + # Quick helper for one-off checks with full stack traces. + source_registry.get(source) + click.echo(f"Source {source} is enabled.") + return + click.secho("Sources:", bold=True) click.echo(source_registry.summary(verbose=verbose, col_width=25)) click.echo() diff --git a/metadata-ingestion/src/datahub/configuration/common.py b/metadata-ingestion/src/datahub/configuration/common.py index f225856ca43ce4..0030332bcfd541 100644 --- a/metadata-ingestion/src/datahub/configuration/common.py +++ b/metadata-ingestion/src/datahub/configuration/common.py @@ -99,8 +99,20 @@ def _schema_extra(schema: Dict[str, Any], model: Type["ConfigModel"]) -> None: @classmethod def parse_obj_allow_extras(cls: Type[_ConfigSelf], obj: Any) -> _ConfigSelf: - with unittest.mock.patch.object(cls.Config, "extra", pydantic.Extra.allow): - return cls.parse_obj(obj) + if PYDANTIC_VERSION_2: + try: + with unittest.mock.patch.dict( + cls.model_config, # type: ignore + {"extra": "allow"}, + clear=False, + ): + cls.model_rebuild(force=True) # type: ignore + return cls.parse_obj(obj) + finally: + cls.model_rebuild(force=True) # type: ignore + else: + with unittest.mock.patch.object(cls.Config, "extra", pydantic.Extra.allow): + return cls.parse_obj(obj) class PermissiveConfigModel(ConfigModel): diff --git a/metadata-ingestion/src/datahub/configuration/datetimes.py b/metadata-ingestion/src/datahub/configuration/datetimes.py index 41af7565593d9b..1520462fa9bf8c 100644 --- a/metadata-ingestion/src/datahub/configuration/datetimes.py +++ b/metadata-ingestion/src/datahub/configuration/datetimes.py @@ -65,6 +65,8 @@ def parse_absolute_time(input: str) -> datetime: def parse_relative_timespan(input: str) -> timedelta: + raw_input = input + neg = False input = input.strip() @@ -79,7 +81,7 @@ def parse_relative_timespan(input: str) -> timedelta: if neg: delta = -delta - logger.debug(f'Parsed "{input}" as {delta}.') + logger.debug(f'Parsed "{raw_input}" as {delta}.') return delta diff --git a/metadata-ingestion/src/datahub/configuration/pydantic_migration_helpers.py b/metadata-ingestion/src/datahub/configuration/pydantic_migration_helpers.py index f1876b500598ba..bd931abe2e84d1 100644 --- a/metadata-ingestion/src/datahub/configuration/pydantic_migration_helpers.py +++ b/metadata-ingestion/src/datahub/configuration/pydantic_migration_helpers.py @@ -19,12 +19,41 @@ class PydanticDeprecatedSince20(Warning): # type: ignore if PYDANTIC_VERSION_2: from pydantic import BaseModel as GenericModel + from pydantic.v1 import ( # type: ignore + BaseModel as v1_BaseModel, + Extra as v1_Extra, + Field as v1_Field, + root_validator as v1_root_validator, + validator as v1_validator, + ) else: + from pydantic import ( # type: ignore + BaseModel as v1_BaseModel, + Extra as v1_Extra, + Field as v1_Field, + root_validator as v1_root_validator, + validator as v1_validator, + ) from pydantic.generics import GenericModel # type: ignore +class v1_ConfigModel(v1_BaseModel): + """A simplified variant of our main ConfigModel class. + + This one only uses pydantic v1 features. + """ + + class Config: + extra = v1_Extra.forbid + underscore_attrs_are_private = True + + __all__ = [ "PYDANTIC_VERSION_2", "PydanticDeprecatedSince20", "GenericModel", + "v1_ConfigModel", + "v1_Field", + "v1_root_validator", + "v1_validator", ] diff --git a/metadata-ingestion/src/datahub/configuration/time_window_config.py b/metadata-ingestion/src/datahub/configuration/time_window_config.py index 15de7470e4d823..f20ab85be05855 100644 --- a/metadata-ingestion/src/datahub/configuration/time_window_config.py +++ b/metadata-ingestion/src/datahub/configuration/time_window_config.py @@ -68,6 +68,12 @@ def default_start_time( assert abs(delta) >= get_bucket_duration_delta( values["bucket_duration"] ), "Relative start time should be in terms of configured bucket duration. e.g '-2 days' or '-2 hours'." + + # The end_time's default value is not yet populated, in which case + # we can just manually generate it here. + if "end_time" not in values: + values["end_time"] = datetime.now(tz=timezone.utc) + return get_time_bucket( values["end_time"] + delta, values["bucket_duration"] ) @@ -80,9 +86,13 @@ def default_start_time( @pydantic.validator("start_time", "end_time") def ensure_timestamps_in_utc(cls, v: datetime) -> datetime: - assert ( - v.tzinfo == timezone.utc - ), 'timezone is not UTC; try adding a "Z" to the value e.g. "2021-07-20T00:00:00Z"' + if v.tzinfo is None: + raise ValueError( + "Timestamps must be in UTC. Try adding a 'Z' to the value e.g. '2021-07-20T00:00:00Z'" + ) + + # If the timestamp is timezone-aware but not in UTC, convert it to UTC. + v = v.astimezone(timezone.utc) return v diff --git a/metadata-ingestion/src/datahub/configuration/validate_field_rename.py b/metadata-ingestion/src/datahub/configuration/validate_field_rename.py index bb01f2b787123a..de2a16e9bf247d 100644 --- a/metadata-ingestion/src/datahub/configuration/validate_field_rename.py +++ b/metadata-ingestion/src/datahub/configuration/validate_field_rename.py @@ -49,4 +49,6 @@ def _validate_field_rename(cls: Type, values: dict) -> dict: # validator with pre=True gets all the values that were passed in. # Given that a renamed field doesn't show up in the fields list, we can't use # the field-level validator, even with a different field name. - return pydantic.root_validator(pre=True, allow_reuse=True)(_validate_field_rename) + return pydantic.root_validator(pre=True, skip_on_failure=True, allow_reuse=True)( + _validate_field_rename + ) diff --git a/metadata-ingestion/src/datahub/ingestion/glossary/datahub_classifier.py b/metadata-ingestion/src/datahub/ingestion/glossary/datahub_classifier.py index 1f2b7f5689ea3c..42eb930c80f9d4 100644 --- a/metadata-ingestion/src/datahub/ingestion/glossary/datahub_classifier.py +++ b/metadata-ingestion/src/datahub/ingestion/glossary/datahub_classifier.py @@ -8,6 +8,7 @@ from pydantic.fields import Field from datahub.configuration.common import ConfigModel +from datahub.configuration.pydantic_migration_helpers import PYDANTIC_VERSION_2 from datahub.ingestion.glossary.classifier import Classifier @@ -50,7 +51,10 @@ class ValuesFactorConfig(ConfigModel): class PredictionFactorsAndWeights(ConfigModel): class Config: - allow_population_by_field_name = True + if PYDANTIC_VERSION_2: + populate_by_name = True + else: + allow_population_by_field_name = True Name: float = Field(alias="name") Description: float = Field(alias="description") @@ -60,7 +64,10 @@ class Config: class InfoTypeConfig(ConfigModel): class Config: - allow_population_by_field_name = True + if PYDANTIC_VERSION_2: + populate_by_name = True + else: + allow_population_by_field_name = True Prediction_Factors_and_Weights: PredictionFactorsAndWeights = Field( description="Factors and their weights to consider when predicting info types", diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py index cbe68a454ea436..c13b08a6d9656b 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py @@ -284,7 +284,7 @@ def validate_bigquery_audit_metadata_datasets( return v - @root_validator(pre=False) + @root_validator(pre=False, skip_on_failure=True) def backward_compatibility_configs_set(cls, values: Dict) -> Dict: project_id = values.get("project_id") project_id_pattern = values.get("project_id_pattern") diff --git a/metadata-ingestion/src/datahub/ingestion/source/delta_lake/config.py b/metadata-ingestion/src/datahub/ingestion/source/delta_lake/config.py index f3616ca648a3e6..81a54d1327d05a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/delta_lake/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/delta_lake/config.py @@ -4,6 +4,7 @@ import pydantic from cached_property import cached_property from pydantic import Field +from typing_extensions import Literal from datahub.configuration.common import AllowDenyPattern from datahub.configuration.source_common import ( @@ -46,10 +47,9 @@ class DeltaLakeSourceConfig(PlatformInstanceConfigMixin, EnvConfigMixin): "'/' and URNs will be created using " "relative_path only.", ) - platform: str = Field( + platform: Literal["delta-lake"] = Field( default="delta-lake", description="The platform that this source connects to", - const=True, ) platform_instance: Optional[str] = Field( default=None, diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py index 032bdef178fdf6..b896df1fa340e3 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py @@ -176,7 +176,7 @@ def validate_include_column_lineage(cls, v, values): ) return v - @root_validator(pre=False) + @root_validator(pre=False, skip_on_failure=True) def validate_unsupported_configs(cls, values: Dict) -> Dict: value = values.get("include_read_operational_stats") if value is not None and value: diff --git a/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py b/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py index 46bd24c7e1f4c3..e9db82ce75cd99 100644 --- a/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py +++ b/metadata-ingestion/src/datahub/ingestion/source_config/sql/snowflake.py @@ -107,7 +107,7 @@ def validate_account_id(cls, account_id: str) -> str: return account_id @pydantic.validator("authentication_type", always=True) - def authenticator_type_is_valid(cls, v, values, field): + def authenticator_type_is_valid(cls, v, values): if v not in VALID_AUTH_TYPES.keys(): raise ValueError( f"unsupported authenticator type '{v}' was provided," diff --git a/metadata-ingestion/src/datahub/utilities/urns/urn_iter.py b/metadata-ingestion/src/datahub/utilities/urns/urn_iter.py index 4f228494f416b8..3389a6fb05ee89 100644 --- a/metadata-ingestion/src/datahub/utilities/urns/urn_iter.py +++ b/metadata-ingestion/src/datahub/utilities/urns/urn_iter.py @@ -150,7 +150,7 @@ def modify_urn(urn: str) -> str: if guess_entity_type(urn) == "dataset": return _lowercase_dataset_urn(urn) elif guess_entity_type(urn) == "schemaField": - cur_urn = Urn.create_from_string(urn) + cur_urn = Urn.from_string(urn) cur_urn._entity_ids[0] = _lowercase_dataset_urn(cur_urn._entity_ids[0]) return str(cur_urn) return urn diff --git a/metadata-ingestion/tests/integration/snowflake/test_snowflake.py b/metadata-ingestion/tests/integration/snowflake/test_snowflake.py index 1b58696e4014c9..39a62056a7e4ad 100644 --- a/metadata-ingestion/tests/integration/snowflake/test_snowflake.py +++ b/metadata-ingestion/tests/integration/snowflake/test_snowflake.py @@ -87,18 +87,18 @@ def test_snowflake_basic(pytestconfig, tmp_path, mock_time, mock_datahub_graph): confidence_level_threshold=0.58, info_types_config={ "Age": InfoTypeConfig( - Prediction_Factors_and_Weights=PredictionFactorsAndWeights( - Name=0, Values=1, Description=0, Datatype=0 + prediction_factors_and_weights=PredictionFactorsAndWeights( + name=0, values=1, description=0, datatype=0 ) ), "CloudRegion": InfoTypeConfig( - Prediction_Factors_and_Weights=PredictionFactorsAndWeights( - Name=0, - Description=0, - Datatype=0, - Values=1, + prediction_factors_and_weights=PredictionFactorsAndWeights( + name=0, + description=0, + datatype=0, + values=1, ), - Values=ValuesFactorConfig( + values=ValuesFactorConfig( prediction_type="regex", regex=[ r"(af|ap|ca|eu|me|sa|us)-(central|north|(north(?:east|west))|south|south(?:east|west)|east|west)-\d+" diff --git a/metadata-ingestion/tests/unit/test_allow_deny.py b/metadata-ingestion/tests/unit/config/test_allow_deny.py similarity index 100% rename from metadata-ingestion/tests/unit/test_allow_deny.py rename to metadata-ingestion/tests/unit/config/test_allow_deny.py diff --git a/metadata-ingestion/tests/unit/test_config_clean.py b/metadata-ingestion/tests/unit/config/test_config_clean.py similarity index 100% rename from metadata-ingestion/tests/unit/test_config_clean.py rename to metadata-ingestion/tests/unit/config/test_config_clean.py diff --git a/metadata-ingestion/tests/unit/config/test_config_model.py b/metadata-ingestion/tests/unit/config/test_config_model.py index ffac5c465f5541..f53390a3deb18c 100644 --- a/metadata-ingestion/tests/unit/config/test_config_model.py +++ b/metadata-ingestion/tests/unit/config/test_config_model.py @@ -3,8 +3,11 @@ import pydantic import pytest -from datahub.configuration.common import ConfigModel, redact_raw_config -from datahub.ingestion.source.unity.config import UnityCatalogSourceConfig +from datahub.configuration.common import ( + AllowDenyPattern, + ConfigModel, + redact_raw_config, +) def test_extras_not_allowed(): @@ -76,8 +79,15 @@ def test_config_redaction(): def test_shared_defaults(): - c1 = UnityCatalogSourceConfig(token="s", workspace_url="https://workspace_url") - c2 = UnityCatalogSourceConfig(token="s", workspace_url="https://workspace_url") + class SourceConfig(ConfigModel): + token: str + workspace_url: str + catalog_pattern: AllowDenyPattern = pydantic.Field( + default=AllowDenyPattern.allow_all(), + ) + + c1 = SourceConfig(token="s", workspace_url="https://workspace_url") + c2 = SourceConfig(token="s", workspace_url="https://workspace_url") assert c2.catalog_pattern.allow == [".*"] c1.catalog_pattern.allow += ["foo"] diff --git a/metadata-ingestion/tests/unit/test_pydantic_validators.py b/metadata-ingestion/tests/unit/config/test_pydantic_validators.py similarity index 92% rename from metadata-ingestion/tests/unit/test_pydantic_validators.py rename to metadata-ingestion/tests/unit/config/test_pydantic_validators.py index 3e9ec6cbaf3579..399245736805cc 100644 --- a/metadata-ingestion/tests/unit/test_pydantic_validators.py +++ b/metadata-ingestion/tests/unit/config/test_pydantic_validators.py @@ -7,7 +7,10 @@ from datahub.configuration.validate_field_deprecation import pydantic_field_deprecated from datahub.configuration.validate_field_removal import pydantic_removed_field from datahub.configuration.validate_field_rename import pydantic_renamed_field -from datahub.utilities.global_warning_util import get_global_warnings +from datahub.utilities.global_warning_util import ( + clear_global_warnings, + get_global_warnings, +) def test_field_rename(): @@ -76,9 +79,11 @@ class TestModel(ConfigModel): def test_field_deprecated(): + clear_global_warnings() + class TestModel(ConfigModel): - d1: Optional[str] - d2: Optional[str] + d1: Optional[str] = None + d2: Optional[str] = None b: str _validate_deprecated_d1 = pydantic_field_deprecated("d1") @@ -93,3 +98,5 @@ class TestModel(ConfigModel): assert v.d2 == "deprecated" assert any(["d1 is deprecated" in warning for warning in get_global_warnings()]) assert any(["d2 is deprecated" in warning for warning in get_global_warnings()]) + + clear_global_warnings() diff --git a/metadata-ingestion/tests/unit/test_time_window_config.py b/metadata-ingestion/tests/unit/config/test_time_window_config.py similarity index 100% rename from metadata-ingestion/tests/unit/test_time_window_config.py rename to metadata-ingestion/tests/unit/config/test_time_window_config.py From 7b067822bd8602c00fe5a0efdd15a6bb7a33bad6 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Mon, 18 Dec 2023 18:35:02 -0800 Subject: [PATCH 040/540] feat(gms): Add support for platform-based browse (#9376) Co-authored-by: John Joyce --- .../graphql/featureflags/FeatureFlags.java | 1 + .../resolvers/chart/BrowseV2Resolver.java | 20 +++- .../resolvers/config/AppConfigResolver.java | 1 + .../graphql/resolvers/search/SearchUtils.java | 14 +++ .../src/main/resources/app.graphql | 5 + .../src/main/resources/search.graphql | 9 +- .../browse/BrowseV2ResolverTest.java | 2 +- datahub-web-react/src/appConfigContext.tsx | 1 + datahub-web-react/src/graphql/app.graphql | 1 + .../metadata/client/JavaEntityClient.java | 24 +++++ .../elasticsearch/ElasticSearchService.java | 12 +++ .../elasticsearch/query/ESBrowseDAO.java | 91 +++++++++++++++++++ .../src/main/resources/application.yml | 1 + .../linkedin/entity/client/EntityClient.java | 22 +++++ .../entity/client/RestliEntityClient.java | 14 +++ .../metadata/search/EntitySearchService.java | 19 ++++ 16 files changed, 231 insertions(+), 6 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java index 07bd1fba5d8a86..e74ed09849763c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java @@ -12,6 +12,7 @@ public class FeatureFlags { private boolean readOnlyModeEnabled = false; private boolean showSearchFiltersV2 = false; private boolean showBrowseV2 = false; + private boolean platformBrowseV2 = false; private PreProcessHooks preProcessHooks; private boolean showAcrylInfo = false; private boolean showAccessManagement = false; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java index 292d6108b7a044..da4a3a76dd7e0e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java @@ -2,14 +2,16 @@ import static com.linkedin.datahub.graphql.Constants.BROWSE_PATH_V2_DELIMITER; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; -import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.resolveView; +import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.*; +import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.BrowseResultGroupV2; import com.linkedin.datahub.graphql.generated.BrowseResultMetadata; import com.linkedin.datahub.graphql.generated.BrowseResultsV2; import com.linkedin.datahub.graphql.generated.BrowseV2Input; +import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.resolvers.search.SearchUtils; @@ -43,8 +45,8 @@ public class BrowseV2Resolver implements DataFetcher get(DataFetchingEnvironment environment) { final QueryContext context = environment.getContext(); final BrowseV2Input input = bindArgument(environment.getArgument("input"), BrowseV2Input.class); - final String entityName = EntityTypeMapper.getName(input.getType()); + final List entityNames = getEntityNames(input); final int start = input.getStart() != null ? input.getStart() : DEFAULT_START; final int count = input.getCount() != null ? input.getCount() : DEFAULT_COUNT; final String query = input.getQuery() != null ? input.getQuery() : "*"; @@ -70,7 +72,7 @@ public CompletableFuture get(DataFetchingEnvironment environmen BrowseResultV2 browseResults = _entityClient.browseV2( - entityName, + entityNames, pathStr, maybeResolvedView != null ? SearchUtils.combineFilters( @@ -87,6 +89,18 @@ public CompletableFuture get(DataFetchingEnvironment environmen }); } + public static List getEntityNames(BrowseV2Input input) { + List entityTypes; + if (input.getTypes() != null && input.getTypes().size() > 0) { + entityTypes = input.getTypes(); + } else if (input.getType() != null) { + entityTypes = ImmutableList.of(input.getType()); + } else { + entityTypes = BROWSE_ENTITY_TYPES; + } + return entityTypes.stream().map(EntityTypeMapper::getName).collect(Collectors.toList()); + } + private BrowseResultsV2 mapBrowseResults(BrowseResultV2 browseResults) { BrowseResultsV2 results = new BrowseResultsV2(); results.setTotal(browseResults.getNumGroups()); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java index 34f7f133f6fb94..81b52991cde90c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java @@ -175,6 +175,7 @@ public CompletableFuture get(final DataFetchingEnvironment environmen .setShowAcrylInfo(_featureFlags.isShowAcrylInfo()) .setShowAccessManagement(_featureFlags.isShowAccessManagement()) .setNestedDomainsEnabled(_featureFlags.isNestedDomainsEnabled()) + .setPlatformBrowseV2(_featureFlags.isPlatformBrowseV2()) .build(); appConfig.setFeatureFlags(featureFlagsConfig); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java index d04cb57e1a860e..444ab4bcc3c3c9 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java @@ -92,6 +92,20 @@ private SearchUtils() {} EntityType.NOTEBOOK, EntityType.DATA_PRODUCT); + /** Entities that are part of browse by default */ + public static final List BROWSE_ENTITY_TYPES = + ImmutableList.of( + EntityType.DATASET, + EntityType.DASHBOARD, + EntityType.CHART, + EntityType.CONTAINER, + EntityType.MLMODEL, + EntityType.MLMODEL_GROUP, + EntityType.MLFEATURE_TABLE, + EntityType.DATA_FLOW, + EntityType.DATA_JOB, + EntityType.NOTEBOOK); + /** A prioritized list of source filter types used to generate quick filters */ public static final List PRIORITIZED_SOURCE_ENTITY_TYPES = Stream.of( diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index 075a3b0fac43bc..52451e195ee841 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -437,6 +437,11 @@ type FeatureFlagsConfig { """ showBrowseV2: Boolean! + """ + Whether browse v2 is platform mode, which means that platforms are displayed instead of entity types at the root. + """ + platformBrowseV2: Boolean! + """ Whether we should show CTAs in the UI related to moving to Managed DataHub by Acryl. """ diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql index e0cde5a2db9f99..8f2377edb546e0 100644 --- a/datahub-graphql-core/src/main/resources/search.graphql +++ b/datahub-graphql-core/src/main/resources/search.graphql @@ -1176,9 +1176,14 @@ Input required for browse queries """ input BrowseV2Input { """ - The browse entity type + The browse entity type - deprecated use types instead """ - type: EntityType! + type: EntityType + + """ + The browse entity type - deprecated use types instead. If not provided, all types will be used. + """ + types: [EntityType!] """ The browse path V2 - a list with each entry being part of the browse path V2 diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java index bffc2b31af2b9a..433772d7e2cfe1 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java @@ -249,7 +249,7 @@ private static EntityClient initMockEntityClient( EntityClient client = Mockito.mock(EntityClient.class); Mockito.when( client.browseV2( - Mockito.eq(entityName), + Mockito.eq(ImmutableList.of(entityName)), Mockito.eq(path), Mockito.eq(filter), Mockito.eq(query), diff --git a/datahub-web-react/src/appConfigContext.tsx b/datahub-web-react/src/appConfigContext.tsx index 4087ad453687c8..8c1089b868e5ab 100644 --- a/datahub-web-react/src/appConfigContext.tsx +++ b/datahub-web-react/src/appConfigContext.tsx @@ -50,6 +50,7 @@ export const DEFAULT_APP_CONFIG = { showAcrylInfo: false, showAccessManagement: false, nestedDomainsEnabled: true, + platformBrowseV2: false, }, }; diff --git a/datahub-web-react/src/graphql/app.graphql b/datahub-web-react/src/graphql/app.graphql index 4e9bbb11d8c5aa..fe283403491479 100644 --- a/datahub-web-react/src/graphql/app.graphql +++ b/datahub-web-react/src/graphql/app.graphql @@ -65,6 +65,7 @@ query appConfig { showAcrylInfo showAccessManagement nestedDomainsEnabled + platformBrowseV2 } } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java index 53b974b560e2a6..e7ec4d313b5f58 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java @@ -235,6 +235,30 @@ public BrowseResultV2 browseV2( return _entitySearchService.browseV2(entityName, path, filter, input, start, count); } + /** + * Gets browse V2 snapshot of a given path + * + * @param entityNames entities being browsed + * @param path path being browsed + * @param filter browse filter + * @param input search query + * @param start start offset of first group + * @param count max number of results requested + * @throws RemoteInvocationException + */ + @Nonnull + public BrowseResultV2 browseV2( + @Nonnull List entityNames, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count, + @Nonnull Authentication authentication) { + // TODO: cache browseV2 results + return _entitySearchService.browseV2(entityNames, path, filter, input, start, count); + } + @SneakyThrows @Deprecated public void update(@Nonnull final Entity entity, @Nonnull final Authentication authentication) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java index f40da59a149faa..fd7491fe32ea34 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java @@ -210,6 +210,18 @@ public BrowseResultV2 browseV2( return esBrowseDAO.browseV2(entityName, path, filter, input, start, count); } + @Nonnull + @Override + public BrowseResultV2 browseV2( + @Nonnull List entityNames, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count) { + return esBrowseDAO.browseV2(entityNames, path, filter, input, start, count); + } + @Nonnull @Override public List getBrowsePaths(@Nonnull String entityName, @Nonnull Urn urn) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java index 5ea60b24a577a0..3c71a2dfd91809 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java @@ -427,6 +427,44 @@ public BrowseResultV2 browseV2( } } + public BrowseResultV2 browseV2( + @Nonnull List entities, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count) { + try { + final SearchResponse groupsResponse; + + try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "esGroupSearch").time()) { + final String finalInput = input.isEmpty() ? "*" : input; + groupsResponse = + client.search( + constructGroupsSearchRequestBrowseAcrossEntities( + entities, path, filter, finalInput), + RequestOptions.DEFAULT); + } + + final BrowseGroupsResultV2 browseGroupsResult = + extractGroupsResponseV2(groupsResponse, path, start, count); + final int numGroups = browseGroupsResult.getTotalGroups(); + + return new BrowseResultV2() + .setMetadata( + new BrowseResultMetadata() + .setTotalNumEntities(browseGroupsResult.getTotalNumEntities()) + .setPath(path)) + .setGroups(new BrowseResultGroupV2Array(browseGroupsResult.getGroups())) + .setNumGroups(numGroups) + .setFrom(start) + .setPageSize(count); + } catch (Exception e) { + log.error("Browse Across Entities query failed: " + e.getMessage()); + throw new ESQueryException("Browse Across Entities query failed: ", e); + } + } + @Nonnull private SearchRequest constructGroupsSearchRequestV2( @Nonnull String entityName, @@ -448,6 +486,33 @@ private SearchRequest constructGroupsSearchRequestV2( return searchRequest; } + @Nonnull + private SearchRequest constructGroupsSearchRequestBrowseAcrossEntities( + @Nonnull List entities, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input) { + + List entitySpecs = + entities.stream().map(entityRegistry::getEntitySpec).collect(Collectors.toList()); + + String[] indexArray = + entities.stream().map(indexConvention::getEntityIndexName).toArray(String[]::new); + + final SearchRequest searchRequest = new SearchRequest(indexArray); + final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.size(0); + searchSourceBuilder.query( + buildQueryStringBrowseAcrossEntities( + entitySpecs, + path, + SearchUtil.transformFilterForEntities(filter, indexConvention), + input)); + searchSourceBuilder.aggregation(buildAggregationsV2(path)); + searchRequest.source(searchSourceBuilder); + return searchRequest; + } + /** * Extracts the name of group from path. * @@ -494,6 +559,32 @@ private QueryBuilder buildQueryStringV2( return queryBuilder; } + @Nonnull + private QueryBuilder buildQueryStringBrowseAcrossEntities( + @Nonnull List entitySpecs, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input) { + final int browseDepthVal = getPathDepthV2(path); + + final BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); + + QueryBuilder query = + SearchRequestHandler.getBuilder(entitySpecs, searchConfiguration, customSearchConfiguration) + .getQuery(input, false); + queryBuilder.must(query); + + if (!path.isEmpty()) { + queryBuilder.filter(QueryBuilders.matchQuery(BROWSE_PATH_V2, path)); + } + + queryBuilder.filter(QueryBuilders.rangeQuery(BROWSE_PATH_V2_DEPTH).gt(browseDepthVal)); + + queryBuilder.filter(SearchRequestHandler.getFilterQuery(filter)); + + return queryBuilder; + } + @Nonnull private AggregationBuilder buildAggregationsV2(@Nonnull String path) { final String currentLevel = ESUtils.escapeReservedCharacters(path) + "␟.*"; diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index a52b705cb8da63..0ea6b8712953e4 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -317,6 +317,7 @@ featureFlags: showAccessManagement: ${SHOW_ACCESS_MANAGEMENT:false} #Whether we should show AccessManagement tab in the datahub UI. showSearchFiltersV2: ${SHOW_SEARCH_FILTERS_V2:true} # Enables showing the search filters V2 experience. showBrowseV2: ${SHOW_BROWSE_V2:true} # Enables showing the browse v2 sidebar experience. + platformBrowseV2: ${PLATFORM_BROWSE_V2:false} # Enables the platform browse experience, instead of the entity-oriented browse default. preProcessHooks: uiEnabled: ${PRE_PROCESS_HOOKS_UI_ENABLED:true} # Circumvents Kafka for processing index updates for UI changes sourced from GraphQL to avoid processing delays showAcrylInfo: ${SHOW_ACRYL_INFO:false} # Show different CTAs within DataHub around moving to Managed DataHub. Set to true for the demo site. diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java index 7bc50a8f3dc7e6..598c252b4f7664 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java @@ -153,6 +153,28 @@ public BrowseResultV2 browseV2( @Nonnull Authentication authentication) throws RemoteInvocationException; + /** + * Gets browse snapshot of a given path + * + * @param entityNames entities being browsed + * @param path path being browsed + * @param filter browse filter + * @param input search query + * @param start start offset of first group + * @param count max number of results requested + * @throws RemoteInvocationException + */ + @Nonnull + public BrowseResultV2 browseV2( + @Nonnull List entityNames, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count, + @Nonnull Authentication authentication) + throws RemoteInvocationException; + @Deprecated public void update(@Nonnull final Entity entity, @Nonnull final Authentication authentication) throws RemoteInvocationException; diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java index c854cb9dd279ec..d68c472ea91709 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java @@ -381,6 +381,20 @@ public BrowseResultV2 browseV2( throw new NotImplementedException("BrowseV2 is not implemented in Restli yet"); } + @Nonnull + @Override + public BrowseResultV2 browseV2( + @Nonnull List entityNames, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count, + @Nonnull Authentication authentication) + throws RemoteInvocationException { + throw new NotImplementedException("BrowseV2 is not implemented in Restli yet"); + } + public void update(@Nonnull final Entity entity, @Nonnull final Authentication authentication) throws RemoteInvocationException { EntitiesDoIngestRequestBuilder requestBuilder = diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java index 09a63e769f0253..189ae09e1b9382 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java @@ -207,6 +207,25 @@ public BrowseResultV2 browseV2( int start, int count); + /** + * Gets browse snapshot of a given path + * + * @param entityNames set of entities being browsed + * @param path path being browsed + * @param filter browse filter + * @param input search query + * @param start start offset of first group + * @param count max number of results requested + */ + @Nonnull + public BrowseResultV2 browseV2( + @Nonnull List entityNames, + @Nonnull String path, + @Nullable Filter filter, + @Nonnull String input, + int start, + int count); + /** * Gets a list of paths for a given urn. * From 1124ccc4ee02e60980af19d525d5203dd6719a1d Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Tue, 19 Dec 2023 17:29:37 +0530 Subject: [PATCH 041/540] fix(ui/users): searching for users on Users page shows incorrect roles (#9474) --- datahub-web-react/src/app/identity/user/UserList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/identity/user/UserList.tsx b/datahub-web-react/src/app/identity/user/UserList.tsx index dce3aa2c68a8dc..8e2bc21f0693f7 100644 --- a/datahub-web-react/src/app/identity/user/UserList.tsx +++ b/datahub-web-react/src/app/identity/user/UserList.tsx @@ -77,7 +77,7 @@ export const UserList = () => { query: (query?.length && query) || undefined, }, }, - fetchPolicy: (query?.length || 0) > 0 ? 'no-cache' : 'cache-first', + fetchPolicy: 'no-cache', }); const totalUsers = usersData?.listUsers?.total || 0; From 94a1603676b6a0fb9e2129b416caf39b100f6d0f Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Tue, 19 Dec 2023 16:30:21 +0100 Subject: [PATCH 042/540] fix(ingest/redshift: Fixing operation query to not return duplicate operations (#9481) --- .../ingestion/source/redshift/usage.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py index 409027a8805a0d..e40406b994c9b2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/usage.py @@ -85,15 +85,18 @@ sq.endtime AS endtime, 'insert' AS operation_type FROM - stl_insert si + (select userid, query, sum(rows) as rows, tbl + from stl_insert si + where si.rows > 0 + AND si.starttime >= '{start_time}' + AND si.starttime < '{end_time}' + group by userid, query, tbl + ) as si JOIN svv_table_info sti ON si.tbl = sti.table_id JOIN stl_query sq ON si.query = sq.query JOIN svl_user_info sui ON sq.userid = sui.usesysid WHERE - si.starttime >= '{start_time}' - AND si.starttime < '{end_time}' - AND si.rows > 0 - AND sq.aborted = 0) + sq.aborted = 0) UNION (SELECT DISTINCT sd.userid AS userid, @@ -109,15 +112,18 @@ sq.endtime AS endtime, 'delete' AS operation_type FROM - stl_delete sd + (select userid, query, sum(rows) as rows, tbl + from stl_delete sd + where sd.rows > 0 + AND sd.starttime >= '{start_time}' + AND sd.starttime < '{end_time}' + group by userid, query, tbl + ) as sd JOIN svv_table_info sti ON sd.tbl = sti.table_id JOIN stl_query sq ON sd.query = sq.query JOIN svl_user_info sui ON sq.userid = sui.usesysid WHERE - sd.starttime >= '{start_time}' - AND sd.starttime < '{end_time}' - AND sd.rows > 0 - AND sq.aborted = 0) + sq.aborted = 0) ORDER BY endtime DESC """.strip() From 265d6bdb534c17b1b370033b81a5c20c434b49d0 Mon Sep 17 00:00:00 2001 From: purnimagarg1 <139125209+purnimagarg1@users.noreply.github.com> Date: Tue, 19 Dec 2023 22:41:18 +0530 Subject: [PATCH 043/540] Fade recipe section to transparent on Ingestion Run Details (#9404) --- .../ExecutionRequestDetailsModal.tsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx b/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx index 96dfc05e391532..0799f8af1173dc 100644 --- a/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx +++ b/datahub-web-react/src/app/ingest/source/executions/ExecutionRequestDetailsModal.tsx @@ -83,11 +83,11 @@ const ShowMoreButton = styled(Button)` padding: 0px; `; -const LogsContainer = styled.div` +const DetailsContainer = styled.div` margin-bottom: -25px; ${(props) => - props.areLogsExpandable && - !props.showExpandedLogs && + props.areDetailsExpandable && + !props.showExpandedDetails && ` -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(255,0,0,0.5) 60%, rgba(255,0,0,0) 90% ); mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(255,0,0,0.5) 60%, rgba(255,0,0,0) 90%); @@ -102,9 +102,9 @@ const modalBodyStyle = { padding: 0, }; -type LogsContainerProps = { - showExpandedLogs: boolean; - areLogsExpandable: boolean; +type DetailsContainerProps = { + showExpandedDetails: boolean; + areDetailsExpandable: boolean; }; type Props = { @@ -124,7 +124,7 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { downloadFile(output, `exec-${urn}.log`); }; - const logs = (showExpandedLogs && output) || output.slice(0, 250); + const logs = (showExpandedLogs && output) || output?.split('\n').slice(0, 5).join('\n'); const result = data?.executionRequest?.result?.status; useEffect(() => { @@ -154,10 +154,10 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { } catch (e) { recipeYaml = ''; } - const recipe = showExpandedRecipe ? recipeYaml : recipeYaml?.split('\n').slice(0, 1).join('\n'); + const recipe = showExpandedRecipe ? recipeYaml : recipeYaml?.split('\n').slice(0, 5).join('\n'); - const areLogsExpandable = output.length > 250; - const isRecipeExpandable = recipeYaml?.includes('\n'); + const areLogsExpandable = output?.split(/\r\n|\r|\n/)?.length > 5; + const isRecipeExpandable = recipeYaml?.split(/\r\n|\r|\n/)?.length > 5; return ( { Download - +
{`${logs}${!showExpandedLogs && areLogsExpandable ? '...' : ''}`}
-
+ {areLogsExpandable && ( setShowExpandedLogs(!showExpandedLogs)}> {showExpandedLogs ? 'Hide' : 'Show More'} @@ -216,9 +216,14 @@ export const ExecutionDetailsModal = ({ urn, visible, onClose }: Props) => { The recipe used for this ingestion run. - -
{`${recipe}${!showExpandedRecipe && isRecipeExpandable ? '\n...' : ''}`}
-
+ + +
{`${recipe}${!showExpandedRecipe && isRecipeExpandable ? '...' : ''}`}
+
+
{isRecipeExpandable && ( setShowExpandedRecipe((v) => !v)}> {showExpandedRecipe ? 'Hide' : 'Show More'} From 92c9940bbd5fd2109f62b7145cfaf981d40704c3 Mon Sep 17 00:00:00 2001 From: Ellie O'Neil <110510035+eboneil@users.noreply.github.com> Date: Tue, 19 Dec 2023 09:24:03 -0800 Subject: [PATCH 044/540] Allow message_name field for protobuf ingestion (#9480) --- .../java/datahub-protobuf/build.gradle | 9 +++------ .../src/main/java/datahub/protobuf/Proto2DataHub.java | 11 +++++++++++ .../java/datahub/protobuf/ProtobufDatasetTest.java | 6 +++--- .../test/java/datahub/protobuf/ProtobufUtilsTest.java | 4 ++-- .../java/datahub/protobuf/model/ProtobufEnumTest.java | 4 ++-- .../datahub/protobuf/model/ProtobufFieldTest.java | 4 ++-- .../datahub/protobuf/model/ProtobufGraphTest.java | 4 ++-- .../datahub/protobuf/model/ProtobufMessageTest.java | 4 ++-- .../protobuf/model/ProtobufOneOfFieldTest.java | 4 ++-- .../datahub/protobuf/visitors/VisitContextTest.java | 4 ++-- .../protobuf/visitors/dataset/DatasetVisitorTest.java | 4 ++-- .../visitors/dataset/DescriptionVisitorTest.java | 4 ++-- .../protobuf/visitors/dataset/DomainVisitorTest.java | 4 ++-- .../dataset/InstitutionalMemoryVisitorTest.java | 4 ++-- .../dataset/KafkaTopicPropertyVisitorTest.java | 4 ++-- .../visitors/dataset/OwnershipVisitorTest.java | 4 ++-- .../visitors/dataset/PropertyVisitorTest.java | 4 ++-- .../visitors/dataset/TermAssociationVisitorTest.java | 4 ++-- .../field/ProtobufExtensionFieldVisitorTest.java | 4 ++-- .../visitors/field/SchemaFieldVisitorTest.java | 4 ++-- .../datahub/protobuf/visitors/tag/TagVisitorTest.java | 4 ++-- 21 files changed, 53 insertions(+), 45 deletions(-) diff --git a/metadata-integration/java/datahub-protobuf/build.gradle b/metadata-integration/java/datahub-protobuf/build.gradle index 2cb36a14cb9c7d..c8082b875d3212 100644 --- a/metadata-integration/java/datahub-protobuf/build.gradle +++ b/metadata-integration/java/datahub-protobuf/build.gradle @@ -31,10 +31,10 @@ dependencies { implementation externalDependency.commonsCli implementation externalDependency.httpAsyncClient implementation externalDependency.slf4jApi + implementation externalDependency.jacksonCore compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok - testImplementation externalDependency.junitJupiterApi - testRuntimeOnly externalDependency.junitJupiterEngine + testImplementation externalDependency.testng } import java.nio.file.Paths @@ -61,10 +61,7 @@ jacocoTestReport { dependsOn test // tests are required to run before generating the report } -test { - useJUnit() - finalizedBy jacocoTestReport -} +test.finalizedBy jacocoTestReport task checkShadowJar(type: Exec) { diff --git a/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/Proto2DataHub.java b/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/Proto2DataHub.java index dcc95222fabf23..429c6d6bfeba48 100644 --- a/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/Proto2DataHub.java +++ b/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/Proto2DataHub.java @@ -67,6 +67,13 @@ public class Proto2DataHub { "[Optional if using --directory] The protobuf source file. Typically a .proto file.") .build(); + private static final Option OPTION_MESSAGE_NAME = + Option.builder() + .longOpt("message_name") + .hasArg() + .desc("[Optional] The protobuf message name to read from.") + .build(); + private static final Option OPTION_DIR = Option.builder() .longOpt("directory") @@ -166,6 +173,7 @@ static class AppConfig { private final String dataPlatform; private final String protoc; private final String inputFile; + private final String messageName; private final String inputDir; private final TransportOptions transport; private final String filename; @@ -191,6 +199,7 @@ static class AppConfig { dataPlatform = cli.getOptionValue(OPTION_DATAHUB_PLATFORM, "kafka").toLowerCase(Locale.ROOT); protoc = cli.getOptionValue(OPTION_DESCRIPTOR); inputFile = cli.getOptionValue(OPTION_FILE, null); + messageName = cli.getOptionValue(OPTION_MESSAGE_NAME, null); transport = TransportOptions.valueOf( cli.getOptionValue(OPTION_TRANSPORT, "rest").toUpperCase(Locale.ROOT)); @@ -250,6 +259,7 @@ public static void main(String[] args) throws Exception { .addOption(OPTION_DATAHUB_TOKEN) .addOption(OPTION_DESCRIPTOR) .addOption(OPTION_FILE) + .addOption(OPTION_MESSAGE_NAME) .addOption(OPTION_DIR) .addOption(OPTION_EXCLUDE_PATTERN) .addOption(OPTION_DATAHUB_USER) @@ -354,6 +364,7 @@ public static void main(String[] args) throws Exception { .setGithubOrganization(config.githubOrg) .setSlackTeamId(config.slackId) .setSubType(config.subType) + .setMessageName(config.messageName) .build(); dataset diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufDatasetTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufDatasetTest.java index e96bb63220b04e..62f3b0453be097 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufDatasetTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufDatasetTest.java @@ -1,8 +1,8 @@ package datahub.protobuf; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; import com.linkedin.common.FabricType; import com.linkedin.common.GlobalTags; @@ -34,7 +34,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufDatasetTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufUtilsTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufUtilsTest.java index e2599cb4c3f685..9bf649041e035f 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufUtilsTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/ProtobufUtilsTest.java @@ -2,13 +2,13 @@ import static datahub.protobuf.TestFixtures.getTestProtobufFileSet; import static datahub.protobuf.TestFixtures.getTestProtoc; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos; import com.google.protobuf.ExtensionRegistry; import datahub.protobuf.model.ProtobufGraph; import java.io.IOException; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufUtilsTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufEnumTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufEnumTest.java index fed9f250b359fe..ae539a8e8fa4a1 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufEnumTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufEnumTest.java @@ -1,6 +1,6 @@ package datahub.protobuf.model; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos.DescriptorProto; import com.google.protobuf.DescriptorProtos.EnumDescriptorProto; @@ -11,7 +11,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufEnumTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java index 6d4dc8bc4d5850..9508f4778e5c88 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java @@ -1,7 +1,7 @@ package datahub.protobuf.model; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos.DescriptorProto; import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; @@ -22,7 +22,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.Set; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufFieldTest { private static final DescriptorProto EXPECTED_MESSAGE_PROTO = diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufGraphTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufGraphTest.java index 488222b87766d8..6ca0c5b45cb5ec 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufGraphTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufGraphTest.java @@ -2,14 +2,14 @@ import static datahub.protobuf.TestFixtures.getTestProtobufFileSet; import static datahub.protobuf.TestFixtures.getTestProtobufGraph; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; import java.io.IOException; import java.util.HashSet; import java.util.List; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufGraphTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufMessageTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufMessageTest.java index 1d6b3907d76d9d..1126895aec57a6 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufMessageTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufMessageTest.java @@ -1,6 +1,6 @@ package datahub.protobuf.model; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos.DescriptorProto; import com.google.protobuf.DescriptorProtos.FileDescriptorProto; @@ -11,7 +11,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufMessageTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufOneOfFieldTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufOneOfFieldTest.java index c8bd8a322aad56..9db06f23a2bdf3 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufOneOfFieldTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufOneOfFieldTest.java @@ -1,6 +1,6 @@ package datahub.protobuf.model; -import static org.junit.jupiter.api.Assertions.*; +import static org.testng.Assert.*; import com.google.protobuf.DescriptorProtos.DescriptorProto; import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; @@ -12,7 +12,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufOneOfFieldTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/VisitContextTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/VisitContextTest.java index 2fc5f3834a749f..fe27af7461860b 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/VisitContextTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/VisitContextTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufFileSet; import static datahub.protobuf.TestFixtures.getTestProtobufGraph; -import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.testng.Assert.assertNotEquals; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; import datahub.protobuf.model.FieldTypeEdge; @@ -13,7 +13,7 @@ import java.util.Set; import java.util.stream.Collectors; import org.jgrapht.GraphPath; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class VisitContextTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DatasetVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DatasetVisitorTest.java index de9a0f5ec4abee..6e99599c852b43 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DatasetVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DatasetVisitorTest.java @@ -1,7 +1,7 @@ package datahub.protobuf.visitors.dataset; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.urn.DatasetUrn; import com.linkedin.data.template.RecordTemplate; @@ -14,7 +14,7 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class DatasetVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DescriptionVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DescriptionVisitorTest.java index 679048fb48a53d..42d8f1ad4c83c6 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DescriptionVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DescriptionVisitorTest.java @@ -1,14 +1,14 @@ package datahub.protobuf.visitors.dataset; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import datahub.protobuf.model.ProtobufGraph; import java.io.IOException; import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class DescriptionVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DomainVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DomainVisitorTest.java index c24fc30766f0ed..3330c09c494364 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DomainVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/DomainVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.urn.Urn; import datahub.protobuf.model.ProtobufGraph; @@ -10,7 +10,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class DomainVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/InstitutionalMemoryVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/InstitutionalMemoryVisitorTest.java index a57916441bfcb3..45be30fe96210b 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/InstitutionalMemoryVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/InstitutionalMemoryVisitorTest.java @@ -1,7 +1,7 @@ package datahub.protobuf.visitors.dataset; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.InstitutionalMemoryMetadata; import com.linkedin.common.url.Url; @@ -9,7 +9,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class InstitutionalMemoryVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/KafkaTopicPropertyVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/KafkaTopicPropertyVisitorTest.java index 5f8572cf6ddd83..2da53dad2c0be1 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/KafkaTopicPropertyVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/KafkaTopicPropertyVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.data.template.StringMap; import com.linkedin.dataset.DatasetProperties; @@ -11,7 +11,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class KafkaTopicPropertyVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/OwnershipVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/OwnershipVisitorTest.java index 1b0aff28eb5176..adc94487dab3cc 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/OwnershipVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/OwnershipVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.Owner; import com.linkedin.common.OwnershipSource; @@ -14,7 +14,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class OwnershipVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/PropertyVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/PropertyVisitorTest.java index 13912100f28a5d..be653309540519 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/PropertyVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/PropertyVisitorTest.java @@ -3,7 +3,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; import static java.util.Map.entry; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.data.template.StringMap; import com.linkedin.dataset.DatasetProperties; @@ -11,7 +11,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class PropertyVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/TermAssociationVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/TermAssociationVisitorTest.java index f734c00bb76e08..79e7075c652094 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/TermAssociationVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/dataset/TermAssociationVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.GlossaryTermAssociation; import com.linkedin.common.urn.GlossaryTermUrn; @@ -10,7 +10,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class TermAssociationVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/ProtobufExtensionFieldVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/ProtobufExtensionFieldVisitorTest.java index eec397011a4ce4..ff1aa643ac8df6 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/ProtobufExtensionFieldVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/ProtobufExtensionFieldVisitorTest.java @@ -1,7 +1,7 @@ package datahub.protobuf.visitors.field; import static datahub.protobuf.TestFixtures.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.common.GlobalTags; import com.linkedin.common.GlossaryTermAssociation; @@ -23,7 +23,7 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class ProtobufExtensionFieldVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/SchemaFieldVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/SchemaFieldVisitorTest.java index af31a80d3b53ad..59d9e0ca6e5183 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/SchemaFieldVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/field/SchemaFieldVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.schema.NumberType; import com.linkedin.schema.SchemaField; @@ -15,7 +15,7 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class SchemaFieldVisitorTest { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/tag/TagVisitorTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/tag/TagVisitorTest.java index 258d816d9d1da3..ab477e19aabe4d 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/tag/TagVisitorTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/visitors/tag/TagVisitorTest.java @@ -2,7 +2,7 @@ import static datahub.protobuf.TestFixtures.getTestProtobufGraph; import static datahub.protobuf.TestFixtures.getVisitContextBuilder; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.testng.Assert.assertEquals; import com.linkedin.tag.TagProperties; import datahub.event.MetadataChangeProposalWrapper; @@ -11,7 +11,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import org.testng.annotations.Test; public class TagVisitorTest { From 8f19138f68ce6376588f4e09617be7e3c325a70f Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:00:54 -0600 Subject: [PATCH 045/540] feat(docker-compose): consolidate docker-compose profiles (#9478) --- build.gradle | 1 + .../upgrade/config/NoCodeCleanupConfig.java | 12 + .../upgrade/config/NoCodeUpgradeConfig.java | 12 + .../upgrade/config/RestoreBackupConfig.java | 12 + .../upgrade/config/RestoreIndicesConfig.java | 12 + .../datahub/upgrade/nocode/NoCodeUpgrade.java | 12 +- .../nocodecleanup/NoCodeCleanupUpgrade.java | 12 +- .../upgrade/restorebackup/RestoreBackup.java | 12 +- .../restoreindices/RestoreIndices.java | 9 +- docker/build.gradle | 216 ++++----- docker/profiles/README.md | 104 +++++ docker/profiles/cassandra | 1 + docker/profiles/datahub-actions | 1 + docker/profiles/datahub-frontend | 1 + docker/profiles/datahub-gms | 1 + docker/profiles/datahub-mae-consumer | 1 + docker/profiles/datahub-mce-consumer | 1 + docker/profiles/datahub-upgrade | 1 + docker/profiles/docker-compose.actions.yml | 45 ++ docker/profiles/docker-compose.frontend.yml | 119 +++++ docker/profiles/docker-compose.gms.yml | 429 ++++++++++++++++++ .../profiles/docker-compose.prerequisites.yml | 387 ++++++++++++++++ docker/profiles/docker-compose.yml | 13 + docker/profiles/elasticsearch | 1 + docker/profiles/elasticsearch-setup | 1 + docker/profiles/kafka-broker | 1 + docker/profiles/kafka-setup | 1 + docker/profiles/monitoring | 1 + docker/profiles/mysql | 1 + docker/profiles/mysql-setup | 1 + docker/profiles/neo4j | 1 + docker/profiles/postgres | 1 + docker/profiles/postgres-setup | 1 + 33 files changed, 1288 insertions(+), 136 deletions(-) create mode 100644 docker/profiles/README.md create mode 120000 docker/profiles/cassandra create mode 120000 docker/profiles/datahub-actions create mode 120000 docker/profiles/datahub-frontend create mode 120000 docker/profiles/datahub-gms create mode 120000 docker/profiles/datahub-mae-consumer create mode 120000 docker/profiles/datahub-mce-consumer create mode 120000 docker/profiles/datahub-upgrade create mode 100644 docker/profiles/docker-compose.actions.yml create mode 100644 docker/profiles/docker-compose.frontend.yml create mode 100644 docker/profiles/docker-compose.gms.yml create mode 100644 docker/profiles/docker-compose.prerequisites.yml create mode 100644 docker/profiles/docker-compose.yml create mode 120000 docker/profiles/elasticsearch create mode 120000 docker/profiles/elasticsearch-setup create mode 120000 docker/profiles/kafka-broker create mode 120000 docker/profiles/kafka-setup create mode 120000 docker/profiles/monitoring create mode 120000 docker/profiles/mysql create mode 120000 docker/profiles/mysql-setup create mode 120000 docker/profiles/neo4j create mode 120000 docker/profiles/postgres create mode 120000 docker/profiles/postgres-setup diff --git a/build.gradle b/build.gradle index a7a85db0398e21..bb01a15a7db8d6 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,7 @@ plugins { id 'com.gorylenko.gradle-git-properties' version '2.4.1' id 'com.github.johnrengelman.shadow' version '8.1.1' apply false id 'com.palantir.docker' version '0.35.0' apply false + id 'com.avast.gradle.docker-compose' version '0.17.5' id "com.diffplug.spotless" version "6.23.3" // https://blog.ltgt.net/javax-jakarta-mess-and-gradle-solution/ // TODO id "org.gradlex.java-ecosystem-capabilities" version "1.0" diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeCleanupConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeCleanupConfig.java index 24bcec5852b4fc..5ba5c8a90fd4ac 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeCleanupConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeCleanupConfig.java @@ -7,13 +7,16 @@ import com.linkedin.metadata.utils.elasticsearch.IndexConvention; import io.ebean.Database; import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; import org.opensearch.client.RestHighLevelClient; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; +@Slf4j @Configuration public class NoCodeCleanupConfig { @@ -26,6 +29,7 @@ public class NoCodeCleanupConfig { "elasticSearchRestHighLevelClient", INDEX_CONVENTION_BEAN }) + @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public NoCodeCleanupUpgrade createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); @@ -34,4 +38,12 @@ public NoCodeCleanupUpgrade createInstance() { final IndexConvention indexConvention = applicationContext.getBean(IndexConvention.class); return new NoCodeCleanupUpgrade(ebeanServer, graphClient, searchClient, indexConvention); } + + @Bean(name = "noCodeCleanup") + @ConditionalOnProperty(name = "entityService.impl", havingValue = "cassandra") + @Nonnull + public NoCodeCleanupUpgrade createNotImplInstance() { + log.warn("NoCode is not supported for cassandra!"); + return new NoCodeCleanupUpgrade(null, null, null, null); + } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java index 68009d7ed1718a..d968e8521867e8 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java @@ -6,12 +6,15 @@ import com.linkedin.metadata.models.registry.EntityRegistry; import io.ebean.Database; import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; +@Slf4j @Configuration public class NoCodeUpgradeConfig { @@ -19,6 +22,7 @@ public class NoCodeUpgradeConfig { @Bean(name = "noCodeUpgrade") @DependsOn({"ebeanServer", "entityService", "systemRestliEntityClient", "entityRegistry"}) + @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public NoCodeUpgrade createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); @@ -29,4 +33,12 @@ public NoCodeUpgrade createInstance() { return new NoCodeUpgrade(ebeanServer, entityService, entityRegistry, entityClient); } + + @Bean(name = "noCodeUpgrade") + @ConditionalOnProperty(name = "entityService.impl", havingValue = "cassandra") + @Nonnull + public NoCodeUpgrade createNotImplInstance() { + log.warn("NoCode is not supported for cassandra!"); + return new NoCodeUpgrade(null, null, null, null); + } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java index 743e4ffe84b0e4..116d62878f5c6e 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java @@ -8,12 +8,15 @@ import com.linkedin.metadata.search.EntitySearchService; import io.ebean.Database; import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; +@Slf4j @Configuration public class RestoreBackupConfig { @Autowired ApplicationContext applicationContext; @@ -27,6 +30,7 @@ public class RestoreBackupConfig { "searchService", "entityRegistry" }) + @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public RestoreBackup createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); @@ -40,4 +44,12 @@ public RestoreBackup createInstance() { return new RestoreBackup( ebeanServer, entityService, entityRegistry, entityClient, graphClient, searchClient); } + + @Bean(name = "restoreBackup") + @ConditionalOnProperty(name = "entityService.impl", havingValue = "cassandra") + @Nonnull + public RestoreBackup createNotImplInstance() { + log.warn("restoreIndices is not supported for cassandra!"); + return new RestoreBackup(null, null, null, null, null, null); + } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java index d258c4a4d1a529..9d229f315d709d 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java @@ -7,18 +7,22 @@ import com.linkedin.metadata.search.EntitySearchService; import io.ebean.Database; import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; +@Slf4j @Configuration public class RestoreIndicesConfig { @Autowired ApplicationContext applicationContext; @Bean(name = "restoreIndices") @DependsOn({"ebeanServer", "entityService", "searchService", "graphService", "entityRegistry"}) + @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public RestoreIndices createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); @@ -31,4 +35,12 @@ public RestoreIndices createInstance() { return new RestoreIndices( ebeanServer, entityService, entityRegistry, entitySearchService, graphService); } + + @Bean(name = "restoreIndices") + @ConditionalOnProperty(name = "entityService.impl", havingValue = "cassandra") + @Nonnull + public RestoreIndices createNotImplInstance() { + log.warn("restoreIndices is not supported for cassandra!"); + return new RestoreIndices(null, null, null, null, null); + } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java index 6753d309b9f501..674efb2b8ba78c 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import javax.annotation.Nullable; public class NoCodeUpgrade implements Upgrade { @@ -26,12 +27,17 @@ public class NoCodeUpgrade implements Upgrade { // Upgrade requires the Database. public NoCodeUpgrade( - final Database server, + @Nullable final Database server, final EntityService entityService, final EntityRegistry entityRegistry, final SystemRestliEntityClient entityClient) { - _steps = buildUpgradeSteps(server, entityService, entityRegistry, entityClient); - _cleanupSteps = buildCleanupSteps(); + if (server != null) { + _steps = buildUpgradeSteps(server, entityService, entityRegistry, entityClient); + _cleanupSteps = buildCleanupSteps(); + } else { + _steps = List.of(); + _cleanupSteps = List.of(); + } } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocodecleanup/NoCodeCleanupUpgrade.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocodecleanup/NoCodeCleanupUpgrade.java index 8a267be6ad8086..6d3125423b4433 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocodecleanup/NoCodeCleanupUpgrade.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocodecleanup/NoCodeCleanupUpgrade.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import javax.annotation.Nullable; import org.opensearch.client.RestHighLevelClient; public class NoCodeCleanupUpgrade implements Upgrade { @@ -18,12 +19,17 @@ public class NoCodeCleanupUpgrade implements Upgrade { // Upgrade requires the Database. public NoCodeCleanupUpgrade( - final Database server, + @Nullable final Database server, final GraphService graphClient, final RestHighLevelClient searchClient, final IndexConvention indexConvention) { - _steps = buildUpgradeSteps(server, graphClient, searchClient, indexConvention); - _cleanupSteps = buildCleanupSteps(); + if (server != null) { + _steps = buildUpgradeSteps(server, graphClient, searchClient, indexConvention); + _cleanupSteps = buildCleanupSteps(); + } else { + _steps = List.of(); + _cleanupSteps = List.of(); + } } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java index b11abb2d6bc23a..4ac295b4fdfb75 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java @@ -16,20 +16,26 @@ import io.ebean.Database; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; public class RestoreBackup implements Upgrade { private final List _steps; public RestoreBackup( - final Database server, + @Nullable final Database server, final EntityService entityService, final EntityRegistry entityRegistry, final SystemRestliEntityClient entityClient, final GraphService graphClient, final EntitySearchService searchClient) { - _steps = - buildSteps(server, entityService, entityRegistry, entityClient, graphClient, searchClient); + if (server != null) { + _steps = + buildSteps( + server, entityService, entityRegistry, entityClient, graphClient, searchClient); + } else { + _steps = List.of(); + } } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java index 8bb3b0073710a3..d38685553dff2f 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java @@ -13,6 +13,7 @@ import io.ebean.Database; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; public class RestoreIndices implements Upgrade { public static final String BATCH_SIZE_ARG_NAME = "batchSize"; @@ -29,12 +30,16 @@ public class RestoreIndices implements Upgrade { private final List _steps; public RestoreIndices( - final Database server, + @Nullable final Database server, final EntityService entityService, final EntityRegistry entityRegistry, final EntitySearchService entitySearchService, final GraphService graphService) { - _steps = buildSteps(server, entityService, entityRegistry, entitySearchService, graphService); + if (server != null) { + _steps = buildSteps(server, entityService, entityRegistry, entitySearchService, graphService); + } else { + _steps = List.of(); + } } @Override diff --git a/docker/build.gradle b/docker/build.gradle index bc79be501b3952..190202620c382c 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -1,6 +1,9 @@ plugins { id 'java' // required by versioning + id 'docker-compose' } +import com.avast.gradle.dockercompose.tasks.ComposeUp +import com.avast.gradle.dockercompose.tasks.ComposeDownForced apply from: "../gradle/versioning/versioning.gradle" @@ -18,144 +21,107 @@ ext { debug_modules = quickstart_modules - [':metadata-jobs:mce-consumer-job', ':metadata-jobs:mae-consumer-job'] - debug_compose_args = [ - '-f', 'docker-compose-without-neo4j.yml', - '-f', 'docker-compose-without-neo4j.override.yml', - '-f', 'docker-compose-without-neo4j.m1.yml', // updates to mariadb - '-f', 'docker-compose.dev.yml' - ] + compose_args = ['-f', 'profiles/docker-compose.yml'] debug_reloadable = [ - 'datahub-gms', - 'datahub-frontend-react' + 'datahub-gms-debug', + 'system-update-debug', + 'frontend-debug' ] - // Postgres pg_quickstart_modules = quickstart_modules - [':docker:mysql-setup'] + [':docker:postgres-setup'] - pg_compose_args = [ - '-f', 'docker-compose-without-neo4j.yml', - '-f', 'docker-compose-without-neo4j.postgres.override.yml' - ] } -task quickstart(type: Exec, dependsOn: ':metadata-ingestion:install') { - dependsOn(quickstart_modules.collect { it + ':dockerTag' }) - shouldRunAfter ':metadata-ingestion:clean', 'quickstartNuke' - - environment "DATAHUB_TELEMETRY_ENABLED", "false" - environment "DOCKER_COMPOSE_BASE", "file://${rootProject.projectDir}" - // environment "ACTIONS_VERSION", 'alpine3.18-slim' - // environment "DATAHUB_ACTIONS_IMAGE", 'nginx' - - // Elastic - // environment "DATAHUB_SEARCH_IMAGE", 'elasticsearch' - // environment "DATAHUB_SEARCH_TAG", '7.10.1' - - // OpenSearch - environment "DATAHUB_SEARCH_IMAGE", 'opensearchproject/opensearch' - environment "DATAHUB_SEARCH_TAG", '2.9.0' - environment "XPACK_SECURITY_ENABLED", 'plugins.security.disabled=true' - environment "USE_AWS_ELASTICSEARCH", 'true' - - def cmd = [ - 'source ../metadata-ingestion/venv/bin/activate && ', - 'datahub docker quickstart', - '--no-pull-images', - '--standalone_consumers', - '--version', "v${version}", - '--dump-logs-on-failure' - ] +tasks.register('quickstart') {} +tasks.register('quickstartSlim') {} +tasks.register('quickstartDebug') {} +tasks.register('quickstartPg') {} - commandLine 'bash', '-c', cmd.join(" ") +tasks.withType(ComposeDownForced) { + removeVolumes = true } - -task quickstartSlim(type: Exec, dependsOn: ':metadata-ingestion:install') { - dependsOn(([':docker:datahub-ingestion'] + quickstart_modules).collect { it + ':dockerTag' }) - shouldRunAfter ':metadata-ingestion:clean', 'quickstartNuke' - - environment "DATAHUB_TELEMETRY_ENABLED", "false" - environment "DOCKER_COMPOSE_BASE", "file://${rootProject.projectDir}" - environment "DATAHUB_ACTIONS_IMAGE", "acryldata/datahub-ingestion" - environment "ACTIONS_VERSION", "v${version}-slim" - environment "ACTIONS_EXTRA_PACKAGES", 'acryl-datahub-actions[executor] acryl-datahub-actions' - environment "ACTIONS_CONFIG", 'https://raw.githubusercontent.com/acryldata/datahub-actions/main/docker/config/executor.yaml' - - def cmd = [ - 'source ../metadata-ingestion/venv/bin/activate && ', - 'datahub docker quickstart', - '--no-pull-images', - '--standalone_consumers', - '--version', "v${version}", - '--dump-logs-on-failure' - ] - - commandLine 'bash', '-c', cmd.join(" ") +task quickstartNuke { + finalizedBy(tasks.withType(ComposeDownForced)) } -task quickstartNuke(type: Exec, dependsOn: ":metadata-ingestion:install") { - shouldRunAfter(':metadata-ingestion:clean') - - def cmd = [ - 'source ../metadata-ingestion/venv/bin/activate && ', - 'datahub docker nuke' - ] - commandLine 'bash', '-c', cmd.join(" ") +dockerCompose { + quickstart { + isRequiredBy(tasks.named('quickstart')) + composeAdditionalArgs = ['--profile', 'quickstart-consumers'] + + environment.put 'DATAHUB_VERSION', "v${version}" + + useComposeFiles = ['profiles/docker-compose.yml'] + projectName = 'datahub' + projectNamePrefix = '' + buildBeforeUp = false + buildBeforePull = false + stopContainers = false + removeVolumes = false + } + + quickstartPg { + isRequiredBy(tasks.named('quickstartPg')) + composeAdditionalArgs = ['--profile', 'quickstart-postgres'] + + environment.put 'DATAHUB_VERSION', "v${version}" + + useComposeFiles = ['profiles/docker-compose.yml'] + projectName = 'datahub' + projectNamePrefix = '' + buildBeforeUp = false + buildBeforePull = false + stopContainers = false + removeVolumes = false + } + + quickstartSlim { + isRequiredBy(tasks.named('quickstartSlim')) + composeAdditionalArgs = ['--profile', 'quickstart-consumers'] + + environment.put 'DATAHUB_VERSION', "v${version}" + environment.put "DATAHUB_ACTIONS_IMAGE", "acryldata/datahub-ingestion" + environment.put "ACTIONS_VERSION", "v${version}-slim" + environment.put "ACTIONS_EXTRA_PACKAGES", 'acryl-datahub-actions[executor] acryl-datahub-actions' + environment.put "ACTIONS_CONFIG", 'https://raw.githubusercontent.com/acryldata/datahub-actions/main/docker/config/executor.yaml' + + useComposeFiles = ['profiles/docker-compose.yml'] + projectName = 'datahub' + projectNamePrefix = '' + buildBeforeUp = false + buildBeforePull = false + stopContainers = false + removeVolumes = false + } + + quickstartDebug { + isRequiredBy(tasks.named('quickstartDebug')) + composeAdditionalArgs = ['--profile', 'debug'] + + useComposeFiles = ['profiles/docker-compose.yml'] + projectName = 'datahub' + projectNamePrefix = '' + buildBeforeUp = false + buildBeforePull = false + stopContainers = false + removeVolumes = false + } } - -task quickstartDebug(type: Exec, dependsOn: ':metadata-ingestion:install') { - dependsOn(debug_modules.collect { it + ':dockerTagDebug' }) - shouldRunAfter ':metadata-ingestion:clean', 'quickstartNuke' - - environment "DATAHUB_TELEMETRY_ENABLED", "false" - environment "DOCKER_COMPOSE_BASE", "file://${rootProject.projectDir}" - - // Elastic - // environment "DATAHUB_SEARCH_IMAGE", 'elasticsearch' - // environment "DATAHUB_SEARCH_TAG", '7.10.1' - - // OpenSearch - environment "DATAHUB_SEARCH_IMAGE", 'opensearchproject/opensearch' - environment "DATAHUB_SEARCH_TAG", '2.9.0' - environment "XPACK_SECURITY_ENABLED", 'plugins.security.disabled=true' - environment "USE_AWS_ELASTICSEARCH", 'true' - - - def cmd = [ - 'source ../metadata-ingestion/venv/bin/activate && ', - 'datahub docker quickstart', - '--no-pull-images', - '--version', "debug", - '--dump-logs-on-failure' - ] + debug_compose_args - commandLine 'bash', '-c', cmd.join(" ") +tasks.getByName('quickstartComposeUp').dependsOn( + quickstart_modules.collect { it + ':dockerTag' }) +tasks.getByName('quickstartPgComposeUp').dependsOn( + pg_quickstart_modules.collect { it + ':dockerTag' }) +tasks.getByName('quickstartSlimComposeUp').dependsOn( + ([':docker:datahub-ingestion'] + quickstart_modules) + .collect { it + ':dockerTag' }) +tasks.getByName('quickstartDebugComposeUp').dependsOn( + debug_modules.collect { it + ':dockerTagDebug' } +) +tasks.withType(ComposeUp).configureEach { + shouldRunAfter('quickstartNuke') } + task debugReload(type: Exec) { - def cmd = ['docker compose -p datahub'] + debug_compose_args + ['restart'] + debug_reloadable + def cmd = ['docker compose -p datahub --profile debug'] + compose_args + ['restart'] + debug_reloadable commandLine 'bash', '-c', cmd.join(" ") } - -task quickstartPg(type: Exec, dependsOn: ':metadata-ingestion:install') { - dependsOn(pg_quickstart_modules.collect { it + ':dockerTag' }) - shouldRunAfter ':metadata-ingestion:clean', 'quickstartNuke' - - environment "DATAHUB_TELEMETRY_ENABLED", "false" - environment "DOCKER_COMPOSE_BASE", "file://${rootProject.projectDir}" - environment "DATAHUB_POSTGRES_VERSION", "15.5" - - // OpenSearch - environment "DATAHUB_SEARCH_IMAGE", 'opensearchproject/opensearch' - environment "DATAHUB_SEARCH_TAG", '2.9.0' - environment "XPACK_SECURITY_ENABLED", 'plugins.security.disabled=true' - environment "USE_AWS_ELASTICSEARCH", 'true' - - def cmd = [ - 'source ../metadata-ingestion/venv/bin/activate && ', - 'datahub docker quickstart', - '--no-pull-images', - '--standalone_consumers', - '--version', "v${version}", - '--dump-logs-on-failure' - ] + pg_compose_args - - commandLine 'bash', '-c', cmd.join(" ") -} \ No newline at end of file diff --git a/docker/profiles/README.md b/docker/profiles/README.md new file mode 100644 index 00000000000000..df09f15cd85cee --- /dev/null +++ b/docker/profiles/README.md @@ -0,0 +1,104 @@ +# Docker Compose Profiles + +This directory contains a set of docker compose definitions which are designed to run several configurations +for quickstart use-cases as well as development use-cases. These configurations cover a few of the wide variety of +infrastructure configurations that DataHub can operate on. + +Requirements: +* Use the profiles requires a modern version of docker. +* If using the debug/development profiles, you will need to have built the `debug` docker images locally. See the Development Profiles section for more details. + +```bash +$ cd docker/profiles +$ docker compose --profile up +``` + +Use Control-c (`^c`) to terminate the running system. This will automatically stop all running containers. + +To remove the containers use the following: + +```bash +docker compose --profile rm +``` + +Please refer to docker's documentation for more details. + +The following sections detail a few of the profiles and their intended use-cases. For a complete list of profiles +and their configuration please see the table at the end of each section. + +## Quickstart Profiles + +Quickstart profiles are primarily a way to test drive DataHub features before committing to a production ready deployment. +A couple of these profiles are also used in our continuous integration (CI) tests. + +Note: Quickstart profiles use docker images with the `head` tag. These images up updated when changes are committed +to the DataHub github repository. This can be overridden to use a stable release tag by prefixing the commands with +`DATAHUB_VERSION=v0.12.1` for example. + +### `quickstart` + +This is the default configuration MySQL and OpenSearch for the storage and GMS running with integrated consumers. + +### `quickstart-consumers` + +This configuration is identical to `quickstart` how it runs standalone consumers instead of consumers integrated with the GMS container. + +### `quickstart-postgres` + +Identical to `quickstart` with Postgres instead of MySQL. + +### `quickstart-cassandra` + +Uses Cassandra as the primary data store along with Neo4j as the graph database. + +### `quickstart-storage` + +Just run the `quickstart` data stores without the DataHub components. This mode is useful for debugging when running the frontend and GMS components outside +of docker. + +### Quickstart Profiles Table +| Profile Name | MySQL | Postgres | Cassandra | Neo4j | Frontend | GMS | Actions | SystemUpdate | MAE | MCE | Kafka | OpenSearch | +|----------------------|-------|----------|-----------|-------|----------|-----|---------|--------------|-----|-----|-------|------------| +| quickstart | X | | | | X | X | X | X | | | X | X | +| quickstart-frontend | X | | | | X | | | X | | | X | X | +| quickstart-backend | X | | | | | X | X | X | | | X | X | +| quickstart-postgres | | X | | | X | X | X | X | | | X | X | +| quickstart-cassandra | | | X | X | X | X | X | X | | | X | X | +| quickstart-consumers | X | | | | X | X | X | X | X | X | X | X | +| quickstart-storage | X | | | | | | | | | | X | X | + +## Development Profiles + +* Runs `debug` tagged images +* JVM Debug Mode Enabled +* Exposes local jars and scripts to the containers +* Can run non-default one-off configurations (neo4j, cassandra, elasticsearch) + +The docker images used are the `debug` images which are created by building locally. These images are +created by running the gradle command. + +```bash +./gradlew dockerTagDebug +``` + +For a complete list of profiles see the table at the end of this section. + +### `quickstart-backend` + +Run everything except for the `frontend` component. Useful for running just a local (non-docker) frontend. + +### `quickstart-frontend` + +Runs everything except for the GMS. Useful for running just a local (non-docker) GMS instance. + +### Development Profiles Table +| Profile Name | MySQL | Postgres | Cassandra | Neo4j | Frontend | GMS | Actions | SystemUpdate | MAE | MCE | Kafka | OpenSearch | Elasticsearch | +|---------------------|-------|----------|-----------|-------|----------|-----|---------|--------------|-----|-----|-------|------------|---------------| +| debug | X | | | | X | X | X | X | | | X | X | | +| debug-frontend | X | | | | X | | | X | | | X | X | | +| debug-backend | X | | | | | X | X | X | | | X | X | | +| debug-postgres | | X | | | X | X | X | X | | | X | X | | +| debug-cassandra | | | X | | X | X | X | X | | | X | X | | +| debug-consumers | X | | | | X | X | X | X | X | X | X | X | | +| debug-neo4j | X | | | X | X | X | X | X | | | X | X | | +| debug-elasticsearch | X | | | | X | X | X | X | | | X | | X | \ No newline at end of file diff --git a/docker/profiles/cassandra b/docker/profiles/cassandra new file mode 120000 index 00000000000000..d9af9adbce5cad --- /dev/null +++ b/docker/profiles/cassandra @@ -0,0 +1 @@ +../cassandra \ No newline at end of file diff --git a/docker/profiles/datahub-actions b/docker/profiles/datahub-actions new file mode 120000 index 00000000000000..fea4275be45ffc --- /dev/null +++ b/docker/profiles/datahub-actions @@ -0,0 +1 @@ +../datahub-actions/ \ No newline at end of file diff --git a/docker/profiles/datahub-frontend b/docker/profiles/datahub-frontend new file mode 120000 index 00000000000000..74a18b81b7e3b8 --- /dev/null +++ b/docker/profiles/datahub-frontend @@ -0,0 +1 @@ +../datahub-frontend \ No newline at end of file diff --git a/docker/profiles/datahub-gms b/docker/profiles/datahub-gms new file mode 120000 index 00000000000000..de2f067e4c0e0d --- /dev/null +++ b/docker/profiles/datahub-gms @@ -0,0 +1 @@ +../datahub-gms \ No newline at end of file diff --git a/docker/profiles/datahub-mae-consumer b/docker/profiles/datahub-mae-consumer new file mode 120000 index 00000000000000..90974047792c50 --- /dev/null +++ b/docker/profiles/datahub-mae-consumer @@ -0,0 +1 @@ +../datahub-mae-consumer \ No newline at end of file diff --git a/docker/profiles/datahub-mce-consumer b/docker/profiles/datahub-mce-consumer new file mode 120000 index 00000000000000..288c9d91c28b3e --- /dev/null +++ b/docker/profiles/datahub-mce-consumer @@ -0,0 +1 @@ +../datahub-mce-consumer \ No newline at end of file diff --git a/docker/profiles/datahub-upgrade b/docker/profiles/datahub-upgrade new file mode 120000 index 00000000000000..8ff77fd5562e7f --- /dev/null +++ b/docker/profiles/datahub-upgrade @@ -0,0 +1 @@ +../datahub-upgrade \ No newline at end of file diff --git a/docker/profiles/docker-compose.actions.yml b/docker/profiles/docker-compose.actions.yml new file mode 100644 index 00000000000000..a509a6a67d2705 --- /dev/null +++ b/docker/profiles/docker-compose.actions.yml @@ -0,0 +1,45 @@ + +x-datahub-actions-service: &datahub-actions-service + hostname: actions + image: ${DATAHUB_ACTIONS_IMAGE:-acryldata/datahub-actions}:${ACTIONS_VERSION:-head} + env_file: datahub-actions/env/docker.env + environment: + ACTIONS_EXTRA_PACKAGES: ${ACTIONS_EXTRA_PACKAGES:-} + ACTIONS_CONFIG: ${ACTIONS_CONFIG:-} + KAFKA_BOOTSTRAP_SERVER: kafka-broker:29092 + SCHEMA_REGISTRY_URL: http://datahub-gms:8080/schema-registry/api/ + +services: + datahub-actions-quickstart: + <<: *datahub-actions-service + container_name: actions + profiles: + - quickstart + - quickstart-backend + depends_on: + datahub-gms-quickstart: + condition: service_healthy + datahub-actions-quickstart-cassandra: + <<: *datahub-actions-service + container_name: actions + profiles: + - quickstart-cassandra + depends_on: + datahub-gms-quickstart-cassandra: + condition: service_healthy + datahub-actions-quickstart-postgres: + <<: *datahub-actions-service + container_name: actions + profiles: + - quickstart-postgres + depends_on: + datahub-gms-quickstart-postgres: + condition: service_healthy + datahub-actions-quickstart-consumers: + <<: *datahub-actions-service + container_name: actions + profiles: + - quickstart-consumers + depends_on: + datahub-gms-quickstart-consumers: + condition: service_healthy diff --git a/docker/profiles/docker-compose.frontend.yml b/docker/profiles/docker-compose.frontend.yml new file mode 100644 index 00000000000000..2b82829648dacb --- /dev/null +++ b/docker/profiles/docker-compose.frontend.yml @@ -0,0 +1,119 @@ + +x-datahub-frontend-service: &datahub-frontend-service + hostname: datahub-frontend-react + image: ${DATAHUB_FRONTEND_IMAGE:-linkedin/datahub-frontend-react}:${DATAHUB_VERSION:-head} + ports: + - ${DATAHUB_MAPPED_FRONTEND_PORT:-9002}:9002 + env_file: datahub-frontend/env/docker.env + environment: &datahub-frontend-service-env + KAFKA_BOOTSTRAP_SERVER: kafka-broker:29092 + volumes: + - ${HOME}/.datahub/plugins:/etc/datahub/plugins + +x-datahub-frontend-service-dev: &datahub-frontend-service-dev + <<: *datahub-frontend-service + image: linkedin/datahub-frontend-react:debug + ports: + - ${DATAHUB_MAPPED_FRONTEND_DEBUG_PORT:-5002}:5002 + - ${DATAHUB_MAPPED_FRONTEND_PORT:-9002}:9002 + environment: + <<: *datahub-frontend-service-env + JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5002 + DATAHUB_ANALYTICS_ENABLED: ${DATAHUB_ANALYTICS_ENABLED:-true} + volumes: + - ../../datahub-frontend/build/stage/playBinary:/datahub-frontend + +services: + frontend-quickstart: + <<: *datahub-frontend-service + container_name: frontend + profiles: + - quickstart + - quickstart-frontend + depends_on: + system-update-quickstart: + condition: service_completed_successfully + frontend-quickstart-cassandra: + <<: *datahub-frontend-service + container_name: frontend + profiles: + - quickstart-cassandra + depends_on: + system-update-quickstart-cassandra: + condition: service_completed_successfully + frontend-quickstart-postgres: + <<: *datahub-frontend-service + container_name: frontend + profiles: + - quickstart-postgres + depends_on: + system-update-quickstart-postgres: + condition: service_completed_successfully + frontend-quickstart-consumers: + <<: *datahub-frontend-service + container_name: frontend + profiles: + - quickstart-consumers + depends_on: + system-update-quickstart: + condition: service_completed_successfully + frontend-debug: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug + depends_on: + system-update-debug: + condition: service_completed_successfully + frontend-debug-frontend: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-frontend + depends_on: + mysql-setup-dev: + condition: service_completed_successfully + opensearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + frontend-debug-postgres: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-postgres + depends_on: + system-update-debug-postgres: + condition: service_completed_successfully + frontend-debug-cassandra: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-cassandra + depends_on: + system-update-debug-cassandra: + condition: service_completed_successfully + frontend-debug-consumers: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-consumers + depends_on: + system-update-debug: + condition: service_completed_successfully + frontend-debug-neo4j: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-neo4j + depends_on: + system-update-debug-neo4j: + condition: service_completed_successfully + frontend-debug-elasticsearch: + <<: *datahub-frontend-service-dev + container_name: datahub-frontend-dev + profiles: + - debug-elasticsearch + depends_on: + system-update-debug-elasticsearch: + condition: service_completed_successfully \ No newline at end of file diff --git a/docker/profiles/docker-compose.gms.yml b/docker/profiles/docker-compose.gms.yml new file mode 100644 index 00000000000000..01602c8b906b91 --- /dev/null +++ b/docker/profiles/docker-compose.gms.yml @@ -0,0 +1,429 @@ +################################# +# Common Environment Variables +################################# +x-primary-datastore-mysql-env: &primary-datastore-mysql-env + EBEAN_DATASOURCE_HOST: mysql:3306 + EBEAN_DATASOURCE_URL: 'jdbc:mysql://mysql:3306/datahub?verifyServerCertificate=false&useSSL=true&useUnicode=yes&characterEncoding=UTF-8&enabledTLSProtocols=TLSv1.2' + EBEAN_DATASOURCE_DRIVER: com.mysql.jdbc.Driver + +x-primary-datastore-postgres-env: &primary-datastore-postgres-env + EBEAN_DATASOURCE_HOST: postgres:5432 + EBEAN_DATASOURCE_URL: 'jdbc:postgresql://postgres:5432/datahub' + EBEAN_DATASOURCE_DRIVER: org.postgresql.Driver + EBEAN_POSTGRES_USE_AWS_IAM_AUTH: ${EBEAN_POSTGRES_USE_AWS_IAM_AUTH:-false} + +x-primary-datastore-cassandra-env: &primary-datastore-cassandra-env + CASSANDRA_DATASOURCE_USERNAME: cassandra + CASSANDRA_DATASOURCE_PASSWORD: cassandra + CASSANDRA_HOSTS: cassandra + CASSANDRA_PORT: 9042 + CASSANDRA_DATASOURCE_HOST: 'cassandra:9042' + ENTITY_SERVICE_IMPL: cassandra + +x-graph-datastore-neo4j-env: &graph-datastore-neo4j-env + GRAPH_SERVICE_IMPL: neo4j + NEO4J_HOST: 'http://neo4j:7474' + NEO4J_URI: 'bolt://neo4j' + NEO4J_USERNAME: neo4j + NEO4J_PASSWORD: datahub +x-graph-datastore-search-env: &graph-datastore-search-env + GRAPH_SERVICE_IMPL: elasticsearch + +x-search-datastore-elasticsearch-env: &search-datastore-env + ELASTICSEARCH_HOST: search + ELASTICSEARCH_PORT: 9200 + ELASTICSEARCH_PROTOCOL: http + ELASTICSEARCH_USE_SSL: ${ELASTICSEARCH_USE_SSL:-false} + +x-kafka-env: &kafka-env + KAFKA_BOOTSTRAP_SERVER: kafka-broker:29092 + # KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 + SCHEMA_REGISTRY_TYPE: INTERNAL + KAFKA_SCHEMAREGISTRY_URL: http://datahub-gms:8080/schema-registry/api/ + +x-datahub-quickstart-telemetry-env: &datahub-quickstart-telemetry-env + DATAHUB_SERVER_TYPE: ${DATAHUB_SERVER_TYPE:-quickstart} + DATAHUB_TELEMETRY_ENABLED: ${DATAHUB_TELEMETRY_ENABLED:-true} + +x-datahub-dev-telemetry-env: &datahub-dev-telemetry-env + DATAHUB_SERVER_TYPE: ${DATAHUB_SERVER_TYPE:-dev} + DATAHUB_TELEMETRY_ENABLED: ${DATAHUB_TELEMETRY_ENABLED:-true} + +################################# +# System Update +################################# +x-datahub-system-update-service: &datahub-system-update-service + hostname: datahub-system-update + image: ${DATAHUB_UPGRADE_IMAGE:-acryldata/datahub-upgrade}:${DATAHUB_VERSION:-head} + command: + - -u + - SystemUpdate + env_file: datahub-upgrade/env/docker.env + environment: &datahub-system-update-env + <<: [*primary-datastore-mysql-env, *graph-datastore-search-env, *search-datastore-env, *kafka-env] + SCHEMA_REGISTRY_SYSTEM_UPDATE: ${SCHEMA_REGISTRY_SYSTEM_UPDATE:-true} + SPRING_KAFKA_PROPERTIES_AUTO_REGISTER_SCHEMAS: ${SPRING_KAFKA_PROPERTIES_AUTO_REGISTER_SCHEMAS:-true} + SPRING_KAFKA_PROPERTIES_USE_LATEST_VERSION: ${SPRING_KAFKA_PROPERTIES_USE_LATEST_VERSION:-true} + +x-datahub-system-update-service-dev: &datahub-system-update-service-dev + <<: *datahub-system-update-service + image: ${DATAHUB_UPGRADE_IMAGE:-acryldata/datahub-upgrade}:debug + ports: + - ${DATAHUB_MAPPED_UPGRADE_DEBUG_PORT:-5003}:5003 + environment: &datahub-system-update-dev-env + <<: [*datahub-dev-telemetry-env, *datahub-system-update-env] + SKIP_ELASTICSEARCH_CHECK: false + REPROCESS_DEFAULT_BROWSE_PATHS_V2: ${REPROCESS_DEFAULT_BROWSE_PATHS_V2:-false} + JAVA_TOOL_OPTIONS: '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5003' + volumes: + - ../../datahub-upgrade/build/libs/:/datahub/datahub-upgrade/bin/ + - ../../metadata-models/src/main/resources/:/datahub/datahub-gms/resources + - ${HOME}/.datahub/plugins:/etc/datahub/plugins + +################################# +# GMS +################################# +x-datahub-gms-service: &datahub-gms-service + hostname: datahub-gms + image: ${DATAHUB_GMS_IMAGE:-linkedin/datahub-gms}:${DATAHUB_VERSION:-head} + ports: + - ${DATAHUB_MAPPED_GMS_PORT:-8080}:8080 + env_file: datahub-gms/env/docker.env + environment: &datahub-gms-env + <<: [*primary-datastore-mysql-env, *graph-datastore-search-env, *search-datastore-env, *datahub-quickstart-telemetry-env, *kafka-env] + healthcheck: + test: curl -sS --fail http://datahub-gms:${DATAHUB_GMS_PORT:-8080}/health + start_period: 90s + interval: 1s + retries: 3 + timeout: 5s + volumes: + - ${HOME}/.datahub/plugins:/etc/datahub/plugins + +x-datahub-gms-service-dev: &datahub-gms-service-dev + <<: *datahub-gms-service + image: ${DATAHUB_GMS_IMAGE:-linkedin/datahub-gms}:debug + ports: + - ${DATAHUB_MAPPED_GMS_DEBUG_PORT:-5001}:5001 + - ${DATAHUB_MAPPED_GMS_PORT:-8080}:8080 + environment: &datahub-gms-dev-env + <<: [*datahub-dev-telemetry-env, *datahub-gms-env] + SKIP_ELASTICSEARCH_CHECK: false + METADATA_SERVICE_AUTH_ENABLED: false + JAVA_TOOL_OPTIONS: '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5001' + BOOTSTRAP_SYSTEM_UPDATE_WAIT_FOR_SYSTEM_UPDATE: false + SEARCH_SERVICE_ENABLE_CACHE: false + LINEAGE_SEARCH_CACHE_ENABLED: false + SHOW_BROWSE_V2: true + volumes: + - ./datahub-gms/start.sh:/datahub/datahub-gms/scripts/start.sh + - ./datahub-gms/jetty.xml:/datahub/datahub-gms/scripts/jetty.xml + - ./monitoring/client-prometheus-config.yaml:/datahub/datahub-gms/scripts/prometheus-config.yaml + - ../../metadata-models/src/main/resources/:/datahub/datahub-gms/resources + - ../../metadata-service/war/build/libs/:/datahub/datahub-gms/bin + - ${HOME}/.datahub/plugins:/etc/datahub/plugins + +################################# +# MAE Consumer +################################# +x-datahub-mae-consumer-service: &datahub-mae-consumer-service + hostname: datahub-mae-consumer + image: ${DATAHUB_MAE_CONSUMER_IMAGE:-linkedin/datahub-mae-consumer}:${DATAHUB_VERSION:-head} + ports: + - 9091:9091 + env_file: datahub-mae-consumer/env/docker.env + environment: &datahub-mae-consumer-env + <<: [*primary-datastore-mysql-env, *graph-datastore-search-env, *search-datastore-env, *kafka-env] + +x-datahub-mae-consumer-service-dev: &datahub-mae-consumer-service-dev + <<: *datahub-mae-consumer-service + image: ${DATAHUB_MAE_CONSUMER_IMAGE:-linkedin/datahub-mae-consumer}:debug + environment: + <<: [*datahub-dev-telemetry-env, *datahub-mae-consumer-env] + volumes: + - ./datahub-mae-consumer/start.sh:/datahub/datahub-mae-consumer/scripts/start.sh + - ../../metadata-models/src/main/resources/:/datahub/datahub-mae-consumer/resources + - ../../metadata-jobs/mae-consumer-job/build/libs/:/datahub/datahub-mae-consumer/bin/ + - ./monitoring/client-prometheus-config.yaml:/datahub/datahub-mae-consumer/scripts/prometheus-config.yaml + +################################# +# MCE Consumer +################################# +x-datahub-mce-consumer-service: &datahub-mce-consumer-service + hostname: datahub-mce-consumer + image: ${DATAHUB_MCE_CONSUMER_IMAGE:-linkedin/datahub-mce-consumer}:${DATAHUB_VERSION:-head} + ports: + - 9090:9090 + env_file: datahub-mce-consumer/env/docker.env + environment: &datahub-mce-consumer-env + <<: [*primary-datastore-mysql-env, *graph-datastore-search-env, *search-datastore-env, *datahub-quickstart-telemetry-env, *kafka-env] + +x-datahub-mce-consumer-service-dev: &datahub-mce-consumer-service-dev + <<: *datahub-mce-consumer-service + image: ${DATAHUB_MCE_CONSUMER_IMAGE:-linkedin/datahub-mce-consumer}:debug + environment: + <<: [*datahub-dev-telemetry-env, *datahub-mce-consumer-env] + volumes: + - ./datahub-mce-consumer/start.sh:/datahub/datahub-mce-consumer/scripts/start.sh + - ../../metadata-jobs/mce-consumer-job/build/libs/:/datahub/datahub-mce-consumer/bin + - ./monitoring/client-prometheus-config.yaml:/datahub/datahub-mce-consumer/scripts/prometheus-config.yaml + +services: + ################################# + # System Update + ################################# + system-update-quickstart: + <<: *datahub-system-update-service + container_name: system-update + profiles: + - quickstart + - quickstart-storage + - quickstart-consumers + - quickstart-frontend + - quickstart-backend + depends_on: + mysql-setup: + condition: service_completed_successfully + opensearch-setup: + condition: service_completed_successfully + kafka-setup: + condition: service_completed_successfully + system-update-quickstart-cassandra: + <<: *datahub-system-update-service + container_name: system-update + profiles: + - quickstart-cassandra + environment: + <<: [*primary-datastore-cassandra-env, *graph-datastore-neo4j-env, *datahub-system-update-env] + depends_on: + neo4j: + condition: service_healthy + cassandra-setup: + condition: service_completed_successfully + opensearch-setup: + condition: service_completed_successfully + kafka-setup: + condition: service_completed_successfully + system-update-quickstart-postgres: + <<: *datahub-system-update-service + container_name: system-update + profiles: + - quickstart-postgres + environment: + <<: [*primary-datastore-postgres-env, *datahub-system-update-env] + depends_on: + postgres-setup: + condition: service_completed_successfully + opensearch-setup: + condition: service_completed_successfully + kafka-setup: + condition: service_completed_successfully + system-update-debug: + <<: *datahub-system-update-service-dev + container_name: system-update-dev + profiles: + - debug + - debug-backend + - debug-consumers + depends_on: + mysql-setup-dev: + condition: service_completed_successfully + opensearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + system-update-debug-elasticsearch: + <<: *datahub-system-update-service-dev + container_name: system-update-dev + profiles: + - debug-elasticsearch + depends_on: + mysql-setup-dev: + condition: service_completed_successfully + elasticsearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + system-update-debug-postgres: + <<: *datahub-system-update-service-dev + container_name: system-update-dev + profiles: + - debug-postgres + environment: + <<: [*primary-datastore-postgres-env, *datahub-system-update-dev-env] + depends_on: + postgres-setup-dev: + condition: service_completed_successfully + opensearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + system-update-debug-cassandra: + <<: *datahub-system-update-service-dev + container_name: system-update-dev + profiles: + - debug-cassandra + environment: + <<: [*primary-datastore-cassandra-env, *datahub-system-update-dev-env] + depends_on: + cassandra-setup: + condition: service_completed_successfully + opensearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + system-update-debug-neo4j: + <<: *datahub-system-update-service-dev + container_name: system-update-dev + profiles: + - debug-neo4j + environment: + <<: [*graph-datastore-neo4j-env, *datahub-system-update-dev-env] + depends_on: + neo4j: + condition: service_healthy + opensearch-setup-dev: + condition: service_completed_successfully + kafka-setup-dev: + condition: service_completed_successfully + ################################# + # GMS + ################################# + datahub-gms-quickstart: + <<: *datahub-gms-service + profiles: + - quickstart + - quickstart-backend + container_name: datahub-gms + depends_on: + system-update-quickstart: + condition: service_completed_successfully + datahub-gms-quickstart-cassandra: + <<: *datahub-gms-service + profiles: + - quickstart-cassandra + container_name: datahub-gms + environment: + <<: [*primary-datastore-cassandra-env, *graph-datastore-neo4j-env, *datahub-gms-env] + depends_on: + system-update-quickstart-cassandra: + condition: service_completed_successfully + datahub-gms-quickstart-postgres: + <<: *datahub-gms-service + profiles: + - quickstart-postgres + container_name: datahub-gms + environment: + <<: [*primary-datastore-postgres-env, *datahub-gms-env] + depends_on: + system-update-quickstart-postgres: + condition: service_completed_successfully + datahub-gms-quickstart-consumers: + <<: *datahub-gms-service + profiles: + - quickstart-consumers + container_name: datahub-gms + environment: + <<: *datahub-gms-env + MAE_CONSUMER_ENABLED: false + MCE_CONSUMER_ENABLED: false + depends_on: + system-update-quickstart: + condition: service_completed_successfully + datahub-gms-debug: + <<: *datahub-gms-service-dev + profiles: + - debug + - debug-backend + container_name: datahub-gms-dev + depends_on: + system-update-debug: + condition: service_completed_successfully + datahub-gms-debug-postgres: + <<: *datahub-gms-service-dev + profiles: + - debug-postgres + environment: + <<: [*primary-datastore-postgres-env, *datahub-gms-dev-env] + container_name: datahub-gms-dev + depends_on: + system-update-debug-postgres: + condition: service_completed_successfully + datahub-gms-debug-cassandra: + <<: *datahub-gms-service-dev + profiles: + - debug-cassandra + environment: + <<: [*primary-datastore-cassandra-env, *datahub-gms-dev-env] + container_name: datahub-gms-dev + depends_on: + system-update-debug-cassandra: + condition: service_completed_successfully + datahub-gms-debug-consumers: + <<: *datahub-gms-service-dev + profiles: + - debug-consumers + environment: + <<: *datahub-gms-dev-env + MAE_CONSUMER_ENABLED: false + MCE_CONSUMER_ENABLED: false + container_name: datahub-gms-dev + depends_on: + system-update-debug: + condition: service_completed_successfully + datahub-gms-debug-neo4j: + <<: *datahub-gms-service-dev + profiles: + - debug-neo4j + environment: + <<: [*graph-datastore-neo4j-env, *datahub-gms-dev-env] + container_name: datahub-gms-dev + depends_on: + system-update-debug-neo4j: + condition: service_completed_successfully + datahub-gms-debug-elasticsearch: + <<: *datahub-gms-service-dev + profiles: + - debug-elasticsearch + container_name: datahub-gms-dev + depends_on: + system-update-debug-elasticsearch: + condition: service_completed_successfully + ################################# + # MAE Consumer + ################################# + datahub-mae-consumer-quickstart-consumers: + <<: *datahub-mae-consumer-service + profiles: + - quickstart-consumers + container_name: datahub-mae-consumer + depends_on: + datahub-gms-quickstart-consumers: + condition: service_healthy + datahub-mae-consumer-quickstart-consumers-dev: + <<: *datahub-mae-consumer-service-dev + profiles: + - debug-consumers + container_name: datahub-mae-consumer-dev + depends_on: + datahub-gms-debug-consumers: + condition: service_healthy + ################################# + # MCE Consumer + ################################# + datahub-mce-consumer-quickstart-consumers: + <<: *datahub-mce-consumer-service + profiles: + - quickstart-consumers + container_name: datahub-mce-consumer + depends_on: + datahub-gms-quickstart-consumers: + condition: service_healthy + datahub-mce-consumer-quickstart-consumers-dev: + <<: *datahub-mce-consumer-service-dev + profiles: + - debug-consumers + container_name: datahub-mce-consumer-dev + depends_on: + datahub-gms-debug-consumers: + condition: service_healthy \ No newline at end of file diff --git a/docker/profiles/docker-compose.prerequisites.yml b/docker/profiles/docker-compose.prerequisites.yml new file mode 100644 index 00000000000000..d90d4a252f9935 --- /dev/null +++ b/docker/profiles/docker-compose.prerequisites.yml @@ -0,0 +1,387 @@ +# Common environment +x-search-datastore-search: &search-datastore-environment + ELASTICSEARCH_HOST: search + ELASTICSEARCH_PORT: 9200 + ELASTICSEARCH_PROTOCOL: http + ELASTICSEARCH_USE_SSL: ${ELASTICSEARCH_USE_SSL:-false} + +# Primary Storage Profiles +x-mysql-profiles-quickstart: &mysql-profiles-quickstart + - quickstart + - quickstart-backend + - quickstart-frontend + - quickstart-storage + - quickstart-consumers +x-mysql-profiles-dev: &mysql-profiles-dev + - debug + - debug-frontend + - debug-backend + - debug-consumers + - debug-neo4j + - debug-elasticsearch +x-mysql-profiles: &mysql-profiles + - quickstart + - quickstart-backend + - quickstart-frontend + - quickstart-storage + - quickstart-consumers + - debug + - debug-frontend + - debug-backend + - debug-consumers + - debug-neo4j + - debug-elasticsearch + +x-postgres-profiles-quickstart: &postgres-profiles-quickstart + - quickstart-postgres +x-postgres-profiles-dev: &postgres-profiles-dev + - debug-postgres +x-postgres-profiles: &postgres-profiles + - quickstart-postgres + - debug-postgres + +x-cassandra-profiles: &cassandra-profiles + - quickstart-cassandra + - debug-cassandra + +# Graph Storage Profiles +x-neo4j-profiles: &neo4j-profiles + - quickstart-cassandra + - debug-neo4j + +# Search Storage Profiles +x-elasticsearch-profiles: &elasticsearch-profiles + - debug-elasticsearch + +x-opensearch-profiles-quickstart: &opensearch-profiles-quickstart + - quickstart + - quickstart-backend + - quickstart-frontend + - quickstart-storage + - quickstart-cassandra + - quickstart-postgres + - quickstart-consumers +x-opensearch-profiles-dev: &opensearch-profiles-dev + - debug + - debug-frontend + - debug-backend + - debug-postgres + - debug-cassandra + - debug-consumers + - debug-neo4j +x-opensearch-profiles: &opensearch-profiles + - quickstart + - quickstart-backend + - quickstart-frontend + - quickstart-storage + - quickstart-cassandra + - quickstart-postgres + - quickstart-consumers + - debug + - debug-frontend + - debug-backend + - debug-postgres + - debug-cassandra + - debug-consumers + - debug-neo4j + +# Debug vs Quickstart Profiles +x-profiles-quickstart: &profiles-quickstart + - quickstart + - quickstart-backend + - quickstart-frontend + - quickstart-storage + - quickstart-cassandra + - quickstart-postgres + - quickstart-consumers +x-profiles-dev: &profiles-dev + - debug + - debug-frontend + - debug-backend + - debug-postgres + - debug-cassandra + - debug-consumers + - debug-neo4j + - debug-elasticsearch + +services: + mysql: + container_name: mysql + profiles: *mysql-profiles + hostname: mysql + image: mysql:${DATAHUB_MYSQL_VERSION:-8.2} + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_bin --default-authentication-plugin=caching_sha2_password + ports: + - ${DATAHUB_MAPPED_MYSQL_PORT:-3306}:3306 + env_file: mysql/env/docker.env + restart: on-failure + healthcheck: + test: mysqladmin ping -h mysql -u $$MYSQL_USER --password=$$MYSQL_PASSWORD + start_period: 10s + interval: 1s + retries: 3 + timeout: 5s + volumes: + - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql + - mysqldata:/var/lib/mysql + mysql-setup: &mysql-setup + container_name: mysql-setup + profiles: *mysql-profiles-quickstart + hostname: mysql-setup + image: ${DATAHUB_MYSQL_SETUP_IMAGE:-acryldata/datahub-mysql-setup}:${DATAHUB_VERSION:-head} + env_file: mysql-setup/env/docker.env + depends_on: + mysql: + condition: service_healthy + labels: + datahub_setup_job: true + mysql-setup-dev: + <<: *mysql-setup + container_name: mysql-setup-dev + profiles: *mysql-profiles-dev + image: ${DATAHUB_MYSQL_SETUP_IMAGE:-acryldata/datahub-mysql-setup}:debug + postgres: + container_name: postgres + profiles: *postgres-profiles + hostname: postgres + image: postgres:${DATAHUB_POSTGRES_VERSION:-15.5} + env_file: postgres/env/docker.env + ports: + - '5432:5432' + restart: on-failure + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + start_period: 20s + interval: 2s + timeout: 10s + retries: 5 + volumes: + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + - postgresdata:/var/lib/postgresql/data + postgres-setup: &postgres-setup + container_name: postgres-setup + profiles: *postgres-profiles-quickstart + hostname: postgres-setup + image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-acryldata/datahub-postgres-setup}:${DATAHUB_VERSION:-head} + env_file: postgres-setup/env/docker.env + depends_on: + postgres: + condition: service_healthy + labels: + datahub_setup_job: true + postgres-setup-dev: + <<: *postgres-setup + container_name: postgres-setup-dev + profiles: *postgres-profiles-dev + image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-acryldata/datahub-postgres-setup}:debug + cassandra: + container_name: cassandra + profiles: *cassandra-profiles + hostname: cassandra + image: cassandra:4.1 + ports: + - 9042:9042 + healthcheck: + test: cqlsh -u cassandra -p cassandra -e 'describe keyspaces' + interval: 15s + timeout: 10s + retries: 10 + volumes: + - cassandradata:/var/lib/cassandra + cassandra-setup: + container_name: cassandra-setup + profiles: *cassandra-profiles + hostname: cassandra-setup + image: cassandra:4.1 + command: /bin/bash -c "cqlsh cassandra -f /init.cql" + depends_on: + cassandra: + condition: service_healthy + volumes: + - ./cassandra/init.cql:/init.cql + labels: + datahub_setup_job: true + neo4j: + container_name: neo4j + profiles: *neo4j-profiles + hostname: neo4j + image: neo4j:4.4.28-community + ports: + - ${DATAHUB_MAPPED_NEO4J_HTTP_PORT:-7474}:7474 + - ${DATAHUB_MAPPED_NEO4J_BOLT_PORT:-7687}:7687 + env_file: neo4j/env/docker.env + healthcheck: + test: wget http://neo4j:$${DATAHUB_NEO4J_HTTP_PORT:-7474} + start_period: 5s + interval: 1s + retries: 5 + timeout: 5s + volumes: + - neo4jdata:/data + kafka-broker: + container_name: kafka-broker + hostname: kafka-broker + image: confluentinc/cp-kafka:7.4.0 + command: + - /bin/bash + - -c + - | + # Generate KRaft clusterID + file_path="/var/lib/kafka/data/clusterID" + + if [ ! -f "$$file_path" ]; then + /bin/kafka-storage random-uuid > $$file_path + echo "Cluster id has been created..." + # KRaft required step: Format the storage directory with a new cluster ID + kafka-storage format --ignore-formatted -t $$(cat "$$file_path") -c /etc/kafka/kafka.properties + fi + + export CLUSTER_ID=$$(cat "$$file_path") + echo "CLUSTER_ID=$$CLUSTER_ID" + + /etc/confluent/docker/run + ports: + - ${DATAHUB_MAPPED_KAFKA_BROKER_PORT:-9092}:9092 + env_file: kafka-broker/env/docker.env + environment: + KAFKA_NODE_ID: 1 + KAFKA_ADVERTISED_LISTENERS: BROKER://kafka-broker:29092,EXTERNAL://kafka-broker:9092 + KAFKA_LISTENERS: BROKER://kafka-broker:29092,EXTERNAL://kafka-broker:9092,CONTROLLER://kafka-broker:39092 + KAFKA_INTER_BROKER_LISTENER_NAME: BROKER + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,BROKER:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_PROCESS_ROLES: controller, broker + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka-broker:39092 + # https://github.com/confluentinc/cp-all-in-one/issues/120 + KAFKA_LOG4J_LOGGERS: 'org.apache.kafka.image.loader.MetadataLoader=WARN' + KAFKA_ZOOKEEPER_CONNECT: null + healthcheck: + test: nc -z kafka-broker $${DATAHUB_KAFKA_BROKER_PORT:-9092} + start_period: 60s + interval: 1s + retries: 5 + timeout: 5s + volumes: + - broker:/var/lib/kafka/data/ + kafka-setup: &kafka-setup + container_name: kafka-setup + profiles: *profiles-quickstart + hostname: kafka-setup + image: ${DATAHUB_KAFKA_SETUP_IMAGE:-linkedin/datahub-kafka-setup}:${DATAHUB_VERSION:-head} + env_file: kafka-setup/env/docker.env + environment: &kafka-setup-env + DATAHUB_PRECREATE_TOPICS: ${DATAHUB_PRECREATE_TOPICS:-false} + KAFKA_BOOTSTRAP_SERVER: kafka-broker:29092 + USE_CONFLUENT_SCHEMA_REGISTRY: false + depends_on: + kafka-broker: + condition: service_healthy + labels: + datahub_setup_job: true + kafka-setup-dev: + <<: *kafka-setup + container_name: kafka-setup-dev + profiles: *profiles-dev + environment: + <<: *kafka-setup-env + DATAHUB_PRECREATE_TOPICS: ${DATAHUB_PRECREATE_TOPICS:-true} + image: ${DATAHUB_KAFKA_SETUP_IMAGE:-linkedin/datahub-kafka-setup}:debug + elasticsearch: + container_name: elasticsearch + profiles: *elasticsearch-profiles + hostname: search + image: ${DATAHUB_SEARCH_IMAGE:-elasticsearch}:${DATAHUB_SEARCH_TAG:-7.10.1} + ports: + - ${DATAHUB_MAPPED_ELASTIC_PORT:-9200}:9200 + env_file: elasticsearch/env/docker.env + environment: + - discovery.type=single-node + - ${XPACK_SECURITY_ENABLED:-xpack.security.enabled=false} + deploy: + resources: + limits: + memory: 1G + healthcheck: + test: curl -sS --fail http://search:$${DATAHUB_ELASTIC_PORT:-9200}/_cluster/health?wait_for_status=yellow&timeout=0s + start_period: 20s + interval: 1s + retries: 3 + timeout: 5s + volumes: + - esdata:/usr/share/elasticsearch/data + elasticsearch-setup-dev: &elasticsearch-setup-dev + container_name: elasticsearch-setup-dev + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:debug + profiles: *elasticsearch-profiles + hostname: elasticsearch-setup + env_file: elasticsearch-setup/env/docker.env + environment: + <<: *search-datastore-environment + USE_AWS_ELASTICSEARCH: ${USE_AWS_ELASTICSEARCH:-false} + depends_on: + elasticsearch: + condition: service_healthy + labels: + datahub_setup_job: true + opensearch: + container_name: opensearch + profiles: *opensearch-profiles + hostname: search + image: ${DATAHUB_SEARCH_IMAGE:-opensearchproject/opensearch}:${DATAHUB_SEARCH_TAG:-2.9.0} + ports: + - ${DATAHUB_MAPPED_ELASTIC_PORT:-9200}:9200 + env_file: elasticsearch/env/docker.env + environment: + - discovery.type=single-node + - ${XPACK_SECURITY_ENABLED:-plugins.security.disabled=true} + deploy: + resources: + limits: + memory: 1G + healthcheck: + test: curl -sS --fail http://search:$${DATAHUB_ELASTIC_PORT:-9200}/_cluster/health?wait_for_status=yellow&timeout=0s + start_period: 20s + interval: 1s + retries: 3 + timeout: 5s + volumes: + - osdata:/usr/share/elasticsearch/data + opensearch-setup: &opensearch-setup + <<: *elasticsearch-setup-dev + container_name: opensearch-setup + profiles: *opensearch-profiles-quickstart + hostname: opensearch-setup + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:${DATAHUB_VERSION:-head} + environment: + <<: *search-datastore-environment + USE_AWS_ELASTICSEARCH: ${USE_AWS_ELASTICSEARCH:-true} + depends_on: + opensearch: + condition: service_healthy + labels: + datahub_setup_job: true + opensearch-setup-dev: + <<: *opensearch-setup + container_name: opensearch-setup-dev + profiles: *opensearch-profiles-dev + hostname: opensearch-setup-dev + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:debug + environment: + <<: *search-datastore-environment + USE_AWS_ELASTICSEARCH: ${USE_AWS_ELASTICSEARCH:-true} + depends_on: + opensearch: + condition: service_healthy + +networks: + default: + name: datahub_network + +volumes: + neo4jdata: + esdata: + osdata: + broker: + mysqldata: + cassandradata: + postgresdata: diff --git a/docker/profiles/docker-compose.yml b/docker/profiles/docker-compose.yml new file mode 100644 index 00000000000000..534ca9702e2d79 --- /dev/null +++ b/docker/profiles/docker-compose.yml @@ -0,0 +1,13 @@ +--- +version: '3.9' +name: datahub + +include: + # Contains storage layers: i.e. mysql, kafka, elasticsearch + - docker-compose.prerequisites.yml + # Actions pod + - docker-compose.actions.yml + # Frontend + - docker-compose.frontend.yml + # Remaining components: i.e. gms, system-update, consumers + - docker-compose.gms.yml diff --git a/docker/profiles/elasticsearch b/docker/profiles/elasticsearch new file mode 120000 index 00000000000000..7712783b3e8d64 --- /dev/null +++ b/docker/profiles/elasticsearch @@ -0,0 +1 @@ +../elasticsearch \ No newline at end of file diff --git a/docker/profiles/elasticsearch-setup b/docker/profiles/elasticsearch-setup new file mode 120000 index 00000000000000..670a10e8c37865 --- /dev/null +++ b/docker/profiles/elasticsearch-setup @@ -0,0 +1 @@ +../elasticsearch-setup \ No newline at end of file diff --git a/docker/profiles/kafka-broker b/docker/profiles/kafka-broker new file mode 120000 index 00000000000000..23b248a4e0bbd4 --- /dev/null +++ b/docker/profiles/kafka-broker @@ -0,0 +1 @@ +../broker \ No newline at end of file diff --git a/docker/profiles/kafka-setup b/docker/profiles/kafka-setup new file mode 120000 index 00000000000000..35b9c167ac26e9 --- /dev/null +++ b/docker/profiles/kafka-setup @@ -0,0 +1 @@ +../kafka-setup \ No newline at end of file diff --git a/docker/profiles/monitoring b/docker/profiles/monitoring new file mode 120000 index 00000000000000..1371b42ae4593c --- /dev/null +++ b/docker/profiles/monitoring @@ -0,0 +1 @@ +../monitoring \ No newline at end of file diff --git a/docker/profiles/mysql b/docker/profiles/mysql new file mode 120000 index 00000000000000..057b59f7601652 --- /dev/null +++ b/docker/profiles/mysql @@ -0,0 +1 @@ +../mysql \ No newline at end of file diff --git a/docker/profiles/mysql-setup b/docker/profiles/mysql-setup new file mode 120000 index 00000000000000..f9199ec3fc58f0 --- /dev/null +++ b/docker/profiles/mysql-setup @@ -0,0 +1 @@ +../mysql-setup \ No newline at end of file diff --git a/docker/profiles/neo4j b/docker/profiles/neo4j new file mode 120000 index 00000000000000..0d4849d989d436 --- /dev/null +++ b/docker/profiles/neo4j @@ -0,0 +1 @@ +../neo4j \ No newline at end of file diff --git a/docker/profiles/postgres b/docker/profiles/postgres new file mode 120000 index 00000000000000..be56a57bd0ab8f --- /dev/null +++ b/docker/profiles/postgres @@ -0,0 +1 @@ +../postgres \ No newline at end of file diff --git a/docker/profiles/postgres-setup b/docker/profiles/postgres-setup new file mode 120000 index 00000000000000..38f51721feacb9 --- /dev/null +++ b/docker/profiles/postgres-setup @@ -0,0 +1 @@ +../postgres-setup/ \ No newline at end of file From a29fce9d823dee31480e2efee1dc1bf16fd4c739 Mon Sep 17 00:00:00 2001 From: Nate Bryant Date: Tue, 19 Dec 2023 15:08:55 -0500 Subject: [PATCH 046/540] Adds urnBasedPagination option to datahub-upgrade RestoreIndices (#9232) Co-authored-by: RyanHolstien --- .../restoreindices/RestoreIndices.java | 1 + .../upgrade/restoreindices/SendMAEStep.java | 62 ++++++++++++++++--- docker/datahub-upgrade/README.md | 12 +++- .../metadata/entity/EntityServiceImpl.java | 2 + .../metadata/entity/ebean/EbeanAspectDao.java | 22 ++++++- .../restoreindices/RestoreIndicesArgs.java | 8 +++ .../restoreindices/RestoreIndicesResult.java | 2 + 7 files changed, 96 insertions(+), 13 deletions(-) diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java index d38685553dff2f..f46bb9b05624db 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java @@ -24,6 +24,7 @@ public class RestoreIndices implements Upgrade { public static final String WRITER_POOL_SIZE = "WRITER_POOL_SIZE"; public static final String URN_ARG_NAME = "urn"; public static final String URN_LIKE_ARG_NAME = "urnLike"; + public static final String URN_BASED_PAGINATION_ARG_NAME = "urnBasedPagination"; public static final String STARTING_OFFSET_ARG_NAME = "startingOffset"; diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java index ce59cf2edb84e9..574b1f08b5f543 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java @@ -31,6 +31,7 @@ public class SendMAEStep implements UpgradeStep { private static final int DEFAULT_STARTING_OFFSET = 0; private static final int DEFAULT_THREADS = 1; + private static final boolean DEFAULT_URN_BASED_PAGINATION = false; private final Database _server; private final EntityService _entityService; @@ -89,6 +90,7 @@ private RestoreIndicesArgs getArgs(UpgradeContext context) { result.numThreads = getThreadCount(context.parsedArgs()); result.batchDelayMs = getBatchDelayMs(context.parsedArgs()); result.start = getStartingOffset(context.parsedArgs()); + result.urnBasedPagination = getUrnBasedPagination(context.parsedArgs()); if (containsKey(context.parsedArgs(), RestoreIndices.ASPECT_NAME_ARG_NAME)) { result.aspectName = context.parsedArgs().get(RestoreIndices.ASPECT_NAME_ARG_NAME).get(); } @@ -140,18 +142,49 @@ public Function executable() { List> futures = new ArrayList<>(); startTime = System.currentTimeMillis(); - while (start < rowCount) { - args = args.clone(); - args.start = start; - futures.add(executor.submit(new KafkaJob(context, args))); - start = start + args.batchSize; - } - while (futures.size() > 0) { - List tmpResults = iterateFutures(futures); - for (RestoreIndicesResult tmpResult : tmpResults) { - reportStats(context, finalJobResult, tmpResult, rowCount, startTime); + if (args.urnBasedPagination) { + RestoreIndicesResult previousResult = null; + int rowsProcessed = 1; + while (rowsProcessed > 0) { + args = args.clone(); + if (previousResult != null) { + args.lastUrn = previousResult.lastUrn; + args.lastAspect = previousResult.lastAspect; + } + args.start = start; + context + .report() + .addLine( + String.format( + "Getting next batch of urns + aspects, starting with %s - %s", + args.lastUrn, args.lastAspect)); + Future future = executor.submit(new KafkaJob(context, args)); + try { + RestoreIndicesResult result = future.get(); + reportStats(context, finalJobResult, result, rowCount, startTime); + previousResult = result; + rowsProcessed = result.rowsMigrated + result.ignored; + context.report().addLine(String.format("Rows processed this loop %d", rowsProcessed)); + start += args.batchSize; + } catch (InterruptedException | ExecutionException e) { + return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.FAILED); + } + } + } else { + while (start < rowCount) { + args = args.clone(); + args.start = start; + futures.add(executor.submit(new KafkaJob(context, args))); + start = start + args.batchSize; + } + while (futures.size() > 0) { + List tmpResults = iterateFutures(futures); + for (RestoreIndicesResult tmpResult : tmpResults) { + reportStats(context, finalJobResult, tmpResult, rowCount, startTime); + } } } + executor.shutdown(); if (finalJobResult.rowsMigrated != rowCount) { float percentFailed = 0.0f; @@ -233,6 +266,15 @@ private int getThreadCount(final Map> parsedArgs) { return getInt(parsedArgs, DEFAULT_THREADS, RestoreIndices.NUM_THREADS_ARG_NAME); } + private boolean getUrnBasedPagination(final Map> parsedArgs) { + boolean urnBasedPagination = DEFAULT_URN_BASED_PAGINATION; + if (containsKey(parsedArgs, RestoreIndices.URN_BASED_PAGINATION_ARG_NAME)) { + urnBasedPagination = + Boolean.parseBoolean(parsedArgs.get(RestoreIndices.URN_BASED_PAGINATION_ARG_NAME).get()); + } + return urnBasedPagination; + } + private int getInt( final Map> parsedArgs, int defaultVal, String argKey) { int result = defaultVal; diff --git a/docker/datahub-upgrade/README.md b/docker/datahub-upgrade/README.md index 0d019971604d6b..9c96114cdb2dd9 100644 --- a/docker/datahub-upgrade/README.md +++ b/docker/datahub-upgrade/README.md @@ -15,8 +15,16 @@ to metadata_aspect_v2 table. Arguments: 2. **NoCodeDataMigrationCleanup**: Cleanses graph index, search index, and key-value store of legacy DataHub data (metadata_aspect table) once the No Code Data Migration has completed successfully. No arguments. -3. **RestoreIndices**: Restores indices by fetching the latest version of each aspect and producing MAE - +3. **RestoreIndices**: Restores indices by fetching the latest version of each aspect and producing MAE. Arguments: + - *batchSize* (Optional): The number of rows to migrate at a time. Defaults to 1000. + - *batchDelayMs* (Optional): The number of milliseconds of delay between migrated batches. Used for rate limiting. Defaults to 250. + - *numThreads* (Optional): The number of threads to use, defaults to 1. Note that this is not used if `urnBasedPagination` is true. + - *aspectName* (Optional): The aspect name for producing events. + - *urn* (Optional): The urn for producing events. + - *urnLike* (Optional): The urn pattern for producing events, using `%` as a wild card + - *urnBasedPagination* (Optional): Paginate the SQL results using the urn + aspect string instead of `OFFSET`. Defaults to false, + though should improve performance for large amounts of data. + 4. **RestoreBackup**: Restores the storage stack from a backup of the local database ## Environment Variables diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index a3338394165567..7bd8e763cdc27a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -1161,6 +1161,7 @@ public RestoreIndicesResult restoreIndices( Urn urn; try { urn = Urn.createFromString(aspect.getKey().getUrn()); + result.lastUrn = urn.toString(); } catch (Exception e) { logger.accept( String.format( @@ -1188,6 +1189,7 @@ public RestoreIndicesResult restoreIndices( result.timeEntityRegistryCheckMs += System.currentTimeMillis() - startTime; startTime = System.currentTimeMillis(); final String aspectName = aspect.getKey().getAspect(); + result.lastAspect = aspectName; // 3. Verify that the aspect is a valid aspect associated with the entity AspectSpec aspectSpec = entitySpec.getAspectSpec(aspectName); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java index b2b47c1d5ba32f..26946890daa3b7 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java @@ -477,11 +477,31 @@ public PagedList getPagedAspects(final RestoreIndicesArgs args) { if (args.urnLike != null) { exp = exp.like(EbeanAspectV2.URN_COLUMN, args.urnLike); } + + int start = args.start; + if (args.urnBasedPagination) { + start = 0; + if (args.lastUrn != null && !args.lastUrn.isEmpty()) { + exp = exp.where().ge(EbeanAspectV2.URN_COLUMN, args.lastUrn); + + // To prevent processing the same aspect multiple times in a restore, it compares against + // the last aspect if the urn matches the last urn + if (args.lastAspect != null && !args.lastAspect.isEmpty()) { + exp = + exp.where() + .and() + .or() + .ne(EbeanAspectV2.URN_COLUMN, args.lastUrn) + .gt(EbeanAspectV2.ASPECT_COLUMN, args.lastAspect); + } + } + } + return exp.orderBy() .asc(EbeanAspectV2.URN_COLUMN) .orderBy() .asc(EbeanAspectV2.ASPECT_COLUMN) - .setFirstRow(args.start) + .setFirstRow(start) .setMaxRows(args.batchSize) .findPagedList(); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesArgs.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesArgs.java index d8fcbe0b7d44d3..e50b44b7f0eca3 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesArgs.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesArgs.java @@ -11,6 +11,9 @@ public class RestoreIndicesArgs implements Cloneable { public String aspectName; public String urn; public String urnLike; + public Boolean urnBasedPagination = false; + public String lastUrn = ""; + public String lastAspect = ""; @Override public RestoreIndicesArgs clone() { @@ -51,4 +54,9 @@ public RestoreIndicesArgs setBatchSize(Integer batchSize) { } return this; } + + public RestoreIndicesArgs setUrnBasedPagination(Boolean urnBasedPagination) { + this.urnBasedPagination = urnBasedPagination; + return this; + } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesResult.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesResult.java index 8479338660db0b..a270cf4548bed5 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesResult.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/restoreindices/RestoreIndicesResult.java @@ -13,4 +13,6 @@ public class RestoreIndicesResult { public long aspectCheckMs = 0; public long createRecordMs = 0; public long sendMessageMs = 0; + public String lastUrn = ""; + public String lastAspect = ""; } From 3777730d782bc1069f7752f74a199aa6447be0d0 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 19 Dec 2023 15:30:47 -0600 Subject: [PATCH 047/540] fix(quickstart): force strings for mysql version (#9485) --- docker/quickstart/quickstart_version_mapping.yaml | 8 ++++---- .../src/datahub/cli/quickstart_versioning.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/quickstart/quickstart_version_mapping.yaml b/docker/quickstart/quickstart_version_mapping.yaml index 9948bd55fdc0b6..b08cfda175aa9f 100644 --- a/docker/quickstart/quickstart_version_mapping.yaml +++ b/docker/quickstart/quickstart_version_mapping.yaml @@ -23,7 +23,7 @@ quickstart_version_map: default: composefile_git_ref: master docker_tag: head - mysql_tag: 5.7 + mysql_tag: "5.7" # default: # Use this to pin default to a specific version. # composefile_git_ref: fd1bd51541a132017a648f4a2f037eec8f70ba26 # v0.10.0 + quickstart compose file fixes # docker_tag: v0.10.0 @@ -31,19 +31,19 @@ quickstart_version_map: head: composefile_git_ref: master docker_tag: head - mysql_tag: 5.7 + mysql_tag: "5.7" # v0.13.0 we upgraded MySQL image for EOL v0.13.0: composefile_git_ref: master docker_tag: head - mysql_tag: 8.2 + mysql_tag: "8.2" # v0.9.6 images contain security vulnerabilities v0.9.6: composefile_git_ref: v0.9.6.1 docker_tag: v0.9.6.1 - mysql_tag: 5.7 + mysql_tag: "5.7" # If stable is not defined the latest released version will be used. # stable: diff --git a/metadata-ingestion/src/datahub/cli/quickstart_versioning.py b/metadata-ingestion/src/datahub/cli/quickstart_versioning.py index be7439f330dfb6..1c3ce93c1f7887 100644 --- a/metadata-ingestion/src/datahub/cli/quickstart_versioning.py +++ b/metadata-ingestion/src/datahub/cli/quickstart_versioning.py @@ -94,7 +94,7 @@ def fetch_quickstart_config(cls) -> "QuickstartVersionMappingConfig": try: release = cls._fetch_latest_version() config.quickstart_version_map["stable"] = QuickstartExecutionPlan( - composefile_git_ref=release, docker_tag=release, mysql_tag=release + composefile_git_ref=release, docker_tag=release, mysql_tag="5.7" ) except Exception: click.echo( @@ -123,7 +123,7 @@ def get_quickstart_execution_plan( QuickstartExecutionPlan( composefile_git_ref=composefile_git_ref, docker_tag=docker_tag, - mysql_tag=mysql_tag, + mysql_tag=str(mysql_tag), ), ) # new CLI version is downloading the composefile corresponding to the requested version From 76be5173b292b936216aad1409090b70615a78f8 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 19 Dec 2023 15:52:59 -0600 Subject: [PATCH 048/540] fix(docker): fix frontend dev docker path (#9488) --- docker/docker-compose.dev.yml | 2 +- docker/profiles/docker-compose.frontend.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 774c4e17bee21f..a69fb977a3417e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -24,7 +24,7 @@ services: - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5002 - DATAHUB_ANALYTICS_ENABLED=${DATAHUB_ANALYTICS_ENABLED:-true} volumes: - - ../datahub-frontend/build/stage/playBinary:/datahub-frontend + - ../datahub-frontend/build/stage/main:/datahub-frontend datahub-gms: image: linkedin/datahub-gms:debug ports: diff --git a/docker/profiles/docker-compose.frontend.yml b/docker/profiles/docker-compose.frontend.yml index 2b82829648dacb..80cb4e7b4b596d 100644 --- a/docker/profiles/docker-compose.frontend.yml +++ b/docker/profiles/docker-compose.frontend.yml @@ -21,7 +21,7 @@ x-datahub-frontend-service-dev: &datahub-frontend-service-dev JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5002 DATAHUB_ANALYTICS_ENABLED: ${DATAHUB_ANALYTICS_ENABLED:-true} volumes: - - ../../datahub-frontend/build/stage/playBinary:/datahub-frontend + - ../../datahub-frontend/build/stage/main:/datahub-frontend services: frontend-quickstart: From 16d3df620f07c4d41118be9c8f38dc0cf46df76f Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:32:52 +0530 Subject: [PATCH 049/540] fix(ui): Tab doesn't represent the page you are on for non-data asset pages (#9468) --- datahub-web-react/src/app/AppProviders.tsx | 13 ++++---- .../src/app/entity/group/GroupInfoSideBar.tsx | 17 +++++++++++ .../src/app/entity/user/UserInfoSideBar.tsx | 19 +++++++++++- .../src/app/search/SearchablePage.tsx | 27 +++++++++++++++++ .../src/app/shared/BrowserTabTitleContext.tsx | 30 +++++++++++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 datahub-web-react/src/app/shared/BrowserTabTitleContext.tsx diff --git a/datahub-web-react/src/app/AppProviders.tsx b/datahub-web-react/src/app/AppProviders.tsx index 81a8ddbfc9bace..00597e1cf76406 100644 --- a/datahub-web-react/src/app/AppProviders.tsx +++ b/datahub-web-react/src/app/AppProviders.tsx @@ -5,6 +5,7 @@ import UserContextProvider from './context/UserContextProvider'; import QuickFiltersProvider from '../providers/QuickFiltersProvider'; import SearchContextProvider from './search/context/SearchContextProvider'; import EntityRegistryProvider from './EntityRegistryProvider'; +import { BrowserTitleProvider } from './shared/BrowserTabTitleContext'; interface Props { children: React.ReactNode; @@ -15,11 +16,13 @@ export default function AppProviders({ children }: Props) { - - - {children} - - + + + + {children} + + + diff --git a/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx b/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx index 07885a4d0f6304..044b09dc185e53 100644 --- a/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx +++ b/datahub-web-react/src/app/entity/group/GroupInfoSideBar.tsx @@ -21,6 +21,7 @@ import { } from '../shared/SidebarStyledComponents'; import GroupMembersSideBarSection from './GroupMembersSideBarSection'; import { useUserContext } from '../../context/useUserContext'; +import { useBrowserTitle } from '../../shared/BrowserTabTitleContext'; import StripMarkdownText, { removeMarkdown } from '../shared/components/styled/StripMarkdownText'; import { Editor } from '../shared/tabs/Documentation/components/editor/Editor'; import EditGroupDescriptionModal from './EditGroupDescriptionModal'; @@ -157,6 +158,22 @@ export default function GroupInfoSidebar({ sideBarData, refetch }: Props) { const { url } = useRouteMatch(); const history = useHistory(); + const { updateTitle } = useBrowserTitle(); + + useEffect(()=>{ + // You can use the title and updateTitle function here + // For example, updating the title when the component mounts + if(name){ + updateTitle(`Group | ${name}`); + } + // // Don't forget to clean up the title when the component unmounts + return () => { + if(name){ // added to condition for rerendering issue + updateTitle(''); + } + }; + }, [name, updateTitle]); + /* eslint-disable @typescript-eslint/no-unused-vars */ const [editGroupModal, showEditGroupModal] = useState(false); const me = useUserContext(); diff --git a/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx b/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx index c01dd3a6359245..71bfbfcd49a16e 100644 --- a/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx +++ b/datahub-web-react/src/app/entity/user/UserInfoSideBar.tsx @@ -1,5 +1,5 @@ import { Divider, message, Space, Button, Typography, Tag } from 'antd'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EditOutlined, MailOutlined, PhoneOutlined, SlackOutlined } from '@ant-design/icons'; import { useUpdateCorpUserPropertiesMutation } from '../../../graphql/user.generated'; import { EntityRelationship, DataHubRole } from '../../../types.generated'; @@ -21,6 +21,7 @@ import { import EntityGroups from '../shared/EntityGroups'; import { mapRoleIcon } from '../../identity/user/UserUtils'; import { useUserContext } from '../../context/useUserContext'; +import { useBrowserTitle } from '../../shared/BrowserTabTitleContext'; const { Paragraph } = Typography; @@ -61,6 +62,22 @@ export default function UserInfoSideBar({ sideBarData, refetch }: Props) { const me = useUserContext(); const isProfileOwner = me?.user?.urn === urn; + const { updateTitle } = useBrowserTitle(); + + useEffect(()=>{ + // You can use the title and updateTitle function here + // For example, updating the title when the component mounts + if(name){ + updateTitle(`User | ${name}`); + } + // // Don't forget to clean up the title when the component unmounts + return () => { + if(name){ // added to condition for rerendering issue + updateTitle(''); + } + }; + }, [name, updateTitle]); + const getEditModalData = { urn, name, diff --git a/datahub-web-react/src/app/search/SearchablePage.tsx b/datahub-web-react/src/app/search/SearchablePage.tsx index 9d02d85d3634c0..53dfc866b9b64b 100644 --- a/datahub-web-react/src/app/search/SearchablePage.tsx +++ b/datahub-web-react/src/app/search/SearchablePage.tsx @@ -3,6 +3,7 @@ import { useHistory, useLocation } from 'react-router'; import { debounce } from 'lodash'; import * as QueryString from 'query-string'; import { useTheme } from 'styled-components'; +import { Helmet } from 'react-helmet-async'; import { SearchHeader } from './SearchHeader'; import { useEntityRegistry } from '../useEntityRegistry'; import { EntityType, FacetFilterInput } from '../../types.generated'; @@ -19,6 +20,7 @@ import { useQuickFiltersContext } from '../../providers/QuickFiltersContext'; import { useUserContext } from '../context/useUserContext'; import { useSelectedSortOption } from './context/SearchContext'; import { HALF_SECOND_IN_MS } from '../entity/shared/tabs/Dataset/Queries/utils/constants'; +import { useBrowserTitle } from '../shared/BrowserTabTitleContext'; const styles = { children: { @@ -68,6 +70,28 @@ export const SearchablePage = ({ onSearch, onAutoComplete, children }: Props) => const { user } = userContext; const viewUrn = userContext.localState?.selectedViewUrn; + const { title, updateTitle } = useBrowserTitle(); + + useEffect(() => { + // Update the title only if it's not already set and there is a valid pathname + if (!title && location.pathname) { + const formattedPath = location.pathname + .split('/') + .filter(word => word !== '') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' | '); + + if (formattedPath) { + return updateTitle(formattedPath); + } + } + + // Clean up the title when the component unmounts + return () => { + updateTitle(''); + }; + }, [location.pathname, title, updateTitle]); + useEffect(() => { if (suggestionsData !== undefined) { setNewSuggestionData(suggestionsData); @@ -140,6 +164,9 @@ export const SearchablePage = ({ onSearch, onAutoComplete, children }: Props) => authenticatedUserPictureLink={user?.editableProperties?.pictureLink} entityRegistry={entityRegistry} /> + + {title} +
{children}
); diff --git a/datahub-web-react/src/app/shared/BrowserTabTitleContext.tsx b/datahub-web-react/src/app/shared/BrowserTabTitleContext.tsx new file mode 100644 index 00000000000000..284e2771124c83 --- /dev/null +++ b/datahub-web-react/src/app/shared/BrowserTabTitleContext.tsx @@ -0,0 +1,30 @@ +import React, { createContext, ReactNode, useContext } from 'react'; + +interface BrowserTitleContextProps { + title: string; + updateTitle: (newTitle: string) => void; +} + +const BrowserTitleContext = createContext(undefined); + +export const BrowserTitleProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [title, setTitle] = React.useState(''); + + const updateTitle = (newTitle: string) => { + setTitle(newTitle); + }; + + return ( + + {children} + + ); +}; + +export const useBrowserTitle = () => { + const context = useContext(BrowserTitleContext); + if (!context) { + throw new Error('useBrowserTitle must be used within a BrowserTitleProvider'); + } + return context; +}; From c8e59aabedb9a6f43f4bcfbf20bdffad6abc85d5 Mon Sep 17 00:00:00 2001 From: noggi Date: Wed, 20 Dec 2023 12:33:23 -0800 Subject: [PATCH 050/540] Do not sync demo in downstream repos (#9493) --- .github/workflows/docker-unified.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index 169a86000adccb..7cef38b1cd47ce 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -911,13 +911,13 @@ jobs: ] steps: - uses: aws-actions/configure-aws-credentials@v1 - if: ${{ needs.setup.outputs.publish != 'false' }} + if: ${{ needs.setup.outputs.publish != 'false' && github.repository_owner == 'datahub-project' && needs.setup.outputs.repository_name == 'datahub' }} with: aws-access-key-id: ${{ secrets.AWS_SQS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SQS_ACCESS_KEY }} aws-region: us-west-2 - uses: isbang/sqs-action@v0.2.0 - if: ${{ needs.setup.outputs.publish != 'false' }} + if: ${{ needs.setup.outputs.publish != 'false' && github.repository_owner == 'datahub-project' && needs.setup.outputs.repository_name == 'datahub' }} with: sqs-url: ${{ secrets.DATAHUB_HEAD_SYNC_QUEUE }} message: '{ "command": "git-sync", "args" : {"repoName": "${{ needs.setup.outputs.repository_name }}", "repoOrg": "${{ github.repository_owner }}", "repoBranch": "${{ needs.setup.outputs.branch_name }}", "repoShaShort": "${{ needs.setup.outputs.short_sha }}" }}' From bf813d1d24107d858260dc2852489e034eb4cf8c Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 20 Dec 2023 15:49:03 -0500 Subject: [PATCH 051/540] fix(ingest): update ingest_stats event with transformer types (#9487) --- metadata-ingestion/src/datahub/ingestion/run/pipeline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py index 25e17d692109a5..d7c70dbea0b141 100644 --- a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py +++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py @@ -528,6 +528,9 @@ def log_ingestion_stats(self) -> None: { "source_type": self.config.source.type, "sink_type": self.config.sink.type, + "transformer_types": [ + transformer.type for transformer in self.config.transformers or [] + ], "records_written": stats.discretize( self.sink.get_report().total_records_written ), From 50be329492048534cb83c6f81bad87c5c49ee05c Mon Sep 17 00:00:00 2001 From: Sumit Patil <91715217+sumitappt@users.noreply.github.com> Date: Thu, 21 Dec 2023 13:24:33 +0530 Subject: [PATCH 052/540] feat(ui/glossary): Keep the same tab selected when browsing Glossary (#9469) --- .../shared/EntityDropdown/EntityDropdown.tsx | 1 + .../containers/profile/header/EntityTabs.tsx | 1 + .../entity/shared/containers/profile/utils.ts | 16 ++++++++ .../app/glossary/GlossaryBrowser/NodeItem.tsx | 2 +- .../app/glossary/GlossaryBrowser/TermItem.tsx | 9 ++++- .../e2e/glossary/glossary_navigation.js | 38 +++++++++++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx index 8d7f1cca9c1cbd..664a77a731d348 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx @@ -180,6 +180,7 @@ function EntityDropdown(props: Props) { )} {menuItems.has(EntityMenuItems.ADD_TERM) && ( setIsCreateTermModalVisible(true)} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityTabs.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityTabs.tsx index 58693eca8af0e8..25e044259f240e 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityTabs.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityTabs.tsx @@ -39,6 +39,7 @@ export const EntityTabs = ({ tabs, selectedTab }: Props) => { return ( ( - + ))} )} diff --git a/datahub-web-react/src/app/glossary/GlossaryBrowser/TermItem.tsx b/datahub-web-react/src/app/glossary/GlossaryBrowser/TermItem.tsx index 6980c15a1c256a..56495b53eded35 100644 --- a/datahub-web-react/src/app/glossary/GlossaryBrowser/TermItem.tsx +++ b/datahub-web-react/src/app/glossary/GlossaryBrowser/TermItem.tsx @@ -5,6 +5,7 @@ import { useEntityRegistry } from '../../useEntityRegistry'; import { ANTD_GRAY } from '../../entity/shared/constants'; import { ChildGlossaryTermFragment } from '../../../graphql/glossaryNode.generated'; import { useGlossaryEntityData } from '../../entity/shared/GlossaryEntityContext'; +import { useGlossaryActiveTabPath } from '../../entity/shared/containers/profile/utils'; const TermWrapper = styled.div` font-weight: normal; @@ -47,13 +48,15 @@ interface Props { term: ChildGlossaryTermFragment; isSelecting?: boolean; selectTerm?: (urn: string, displayName: string) => void; + includeActiveTabPath?: boolean; } function TermItem(props: Props) { - const { term, isSelecting, selectTerm } = props; + const { term, isSelecting, selectTerm, includeActiveTabPath } = props; const { entityData } = useGlossaryEntityData(); const entityRegistry = useEntityRegistry(); + const activeTabPath = useGlossaryActiveTabPath(); function handleSelectTerm() { if (selectTerm) { @@ -68,7 +71,9 @@ function TermItem(props: Props) { {!isSelecting && ( {entityRegistry.getDisplayName(term.type, isOnEntityPage ? entityData : term)} diff --git a/smoke-test/tests/cypress/cypress/e2e/glossary/glossary_navigation.js b/smoke-test/tests/cypress/cypress/e2e/glossary/glossary_navigation.js index 7ddf36aa87c2d1..dd3b0a567c75f8 100644 --- a/smoke-test/tests/cypress/cypress/e2e/glossary/glossary_navigation.js +++ b/smoke-test/tests/cypress/cypress/e2e/glossary/glossary_navigation.js @@ -1,4 +1,5 @@ const glossaryTerm = "CypressGlosssaryNavigationTerm"; +const glossarySecondTerm = "CypressGlossarySecondTerm"; const glossaryTermGroup = "CypressGlosssaryNavigationGroup"; const glossaryParentGroup = "CypressNode"; @@ -30,6 +31,39 @@ describe("glossary sidebar navigation test", () => { cy.get('[data-testid="glossary-browser-sidebar"]').contains(glossaryTermGroup).click().wait(3000); cy.get('*[class^="GlossaryEntitiesList"]').contains(glossaryTerm).should("be.visible"); + // Create another term and move it to the same term group + cy.clickOptionWithText(glossaryTermGroup); + cy.openThreeDotDropdown(); + cy.clickOptionWithTestId("entity-menu-add-term-button"); + + // Wait for the create term modal to be visible + cy.waitTextVisible("Create Glossary Term"); + cy.enterTextInTestId("create-glossary-entity-modal-name", glossarySecondTerm); + cy.clickOptionWithTestId("glossary-entity-modal-create-button"); + + // Wait for the new term to be visible in the sidebar + cy.clickOptionWithText(glossarySecondTerm).wait(3000); + + // Move the term to the created term group + cy.openThreeDotDropdown(); + cy.clickOptionWithTestId("entity-menu-move-button"); + cy.get('[data-testid="move-glossary-entity-modal"]').contains(glossaryTermGroup).click({ force: true }); + cy.get('[data-testid="move-glossary-entity-modal"]').contains(glossaryTermGroup).should("be.visible"); + cy.clickOptionWithTestId("glossary-entity-modal-move-button"); + cy.waitTextVisible("Moved Glossary Term!"); + + // Ensure the new term is under the parent term group in the navigation sidebar + cy.get('[data-testid="glossary-browser-sidebar"]').contains(glossaryTermGroup).click(); + cy.get('*[class^="GlossaryEntitiesList"]').contains(glossarySecondTerm).should("be.visible"); + + + // Switch between terms and ensure the "Properties" tab is active + cy.clickOptionWithText(glossaryTerm); + cy.get('[data-testid="entity-tab-headers-test-id"]').contains("Properties").click({ force: true }); + cy.get('[data-node-key="Properties"]').contains("Properties").should("have.attr", "aria-selected", "true"); + cy.clickOptionWithText(glossarySecondTerm); + cy.get('[data-node-key="Properties"]').contains("Properties").should("have.attr", "aria-selected", "true"); + // Move a term group from the root level to be under a parent term group cy.goToGlossaryList(); cy.clickOptionWithText(glossaryTermGroup); @@ -52,6 +86,10 @@ describe("glossary sidebar navigation test", () => { cy.clickOptionWithText(glossaryTerm).wait(3000); cy.deleteFromDropdown(); cy.waitTextVisible("Deleted Glossary Term!"); + cy.clickOptionWithText(glossaryTermGroup); + cy.clickOptionWithText(glossarySecondTerm).wait(3000); + cy.deleteFromDropdown(); + cy.waitTextVisible("Deleted Glossary Term!"); cy.clickOptionWithText(glossaryParentGroup); cy.clickOptionWithText(glossaryTermGroup).wait(3000); cy.deleteFromDropdown(); From 80fb145a7b85b323f339d7901658dd9fde5bd4db Mon Sep 17 00:00:00 2001 From: Sumit Patil <91715217+sumitappt@users.noreply.github.com> Date: Thu, 21 Dec 2023 17:57:41 +0530 Subject: [PATCH 053/540] style(search): Tag overflow add padding (#9497) --- datahub-web-react/src/app/preview/DefaultPreviewCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx index 36c4c020e71317..a6d8422f827d58 100644 --- a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx +++ b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx @@ -114,6 +114,7 @@ const TagContainer = styled.div` margin-left: 0px; margin-top: 3px; flex-wrap: wrap; + margin-right: 8px; `; const TagSeparator = styled.div` From a49a435eef92b20cdc9878c8189b8ca0288e8b7f Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Thu, 21 Dec 2023 19:38:46 +0530 Subject: [PATCH 054/540] feat(analytics): change MAU chart to be until last month (#9499) --- .../datahub/graphql/analytics/resolver/GetChartsResolver.java | 3 ++- .../main/java/com/linkedin/datahub/graphql/util/DateUtil.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetChartsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetChartsResolver.java index 3f635872747a57..6ba3c5090f1c40 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetChartsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetChartsResolver.java @@ -91,6 +91,7 @@ private List getProductAnalyticsCharts(Authentication authentica final List charts = new ArrayList<>(); DateUtil dateUtil = new DateUtil(); final DateTime startOfNextWeek = dateUtil.getStartOfNextWeek(); + final DateTime startOfThisMonth = dateUtil.getStartOfThisMonth(); final DateTime startOfNextMonth = dateUtil.getStartOfNextMonth(); final DateRange trailingWeekDateRange = dateUtil.getTrailingWeekDateRange(); @@ -103,7 +104,7 @@ private List getProductAnalyticsCharts(Authentication authentica charts.add( getActiveUsersTimeSeriesChart( startOfNextMonth.minusMonths(12), - startOfNextMonth.minusMillis(1), + startOfThisMonth.minusMillis(1), "Monthly Active Users", DateInterval.MONTH)); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/util/DateUtil.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/util/DateUtil.java index 4b837605d4e318..677ad8afbaca31 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/util/DateUtil.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/util/DateUtil.java @@ -13,6 +13,10 @@ public DateTime getStartOfNextWeek() { return setTimeToZero(getNow().withDayOfWeek(DateTimeConstants.SUNDAY).plusDays(1)); } + public DateTime getStartOfThisMonth() { + return setTimeToZero(getNow().withDayOfMonth(1)); + } + public DateTime getStartOfNextMonth() { return setTimeToZero(getNow().withDayOfMonth(1).plusMonths(1)); } From 55cb56821c00ec993ee5a4c560d7b49d8d71258b Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Thu, 21 Dec 2023 10:33:25 -0600 Subject: [PATCH 055/540] fix(kafka): fix infinite deserialization logging (#9494) --- docker/docker-compose-without-neo4j.yml | 2 ++ ...docker-compose.consumers-without-neo4j.yml | 3 ++ docker/docker-compose.consumers.yml | 3 ++ docker/docker-compose.dev.yml | 1 + docker/docker-compose.yml | 2 ++ .../docker-compose-m1.quickstart.yml | 1 + ...er-compose-without-neo4j-m1.quickstart.yml | 1 + ...ocker-compose-without-neo4j.quickstart.yml | 1 + ...ose.consumers-without-neo4j.quickstart.yml | 2 ++ .../docker-compose.consumers.quickstart.yml | 2 ++ .../quickstart/docker-compose.quickstart.yml | 1 + .../config/kafka/ConsumerConfiguration.java | 1 + .../src/main/resources/application.yml | 1 + .../kafka/KafkaEventConsumerFactory.java | 30 ++++++++++++++++--- 14 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docker/docker-compose-without-neo4j.yml b/docker/docker-compose-without-neo4j.yml index 6191994eaa1ea5..0d58a1d91b70b1 100644 --- a/docker/docker-compose-without-neo4j.yml +++ b/docker/docker-compose-without-neo4j.yml @@ -43,6 +43,8 @@ services: context: ../ dockerfile: docker/datahub-gms/Dockerfile env_file: datahub-gms/env/docker-without-neo4j.env + environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} healthcheck: test: curl -sS --fail http://datahub-gms:${DATAHUB_GMS_PORT:-8080}/health start_period: 90s diff --git a/docker/docker-compose.consumers-without-neo4j.yml b/docker/docker-compose.consumers-without-neo4j.yml index 8228951d9385f8..f1be585232a1a8 100644 --- a/docker/docker-compose.consumers-without-neo4j.yml +++ b/docker/docker-compose.consumers-without-neo4j.yml @@ -15,6 +15,8 @@ services: context: ../ dockerfile: docker/datahub-mae-consumer/Dockerfile env_file: datahub-mae-consumer/env/docker-without-neo4j.env + environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} datahub-mce-consumer: container_name: datahub-mce-consumer hostname: datahub-mce-consumer @@ -28,3 +30,4 @@ services: environment: - DATAHUB_SERVER_TYPE=${DATAHUB_SERVER_TYPE:-quickstart} - DATAHUB_TELEMETRY_ENABLED=${DATAHUB_TELEMETRY_ENABLED:-true} + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} diff --git a/docker/docker-compose.consumers.yml b/docker/docker-compose.consumers.yml index 2d37094035859b..8d331cea2f0b95 100644 --- a/docker/docker-compose.consumers.yml +++ b/docker/docker-compose.consumers.yml @@ -15,6 +15,8 @@ services: context: ../ dockerfile: docker/datahub-mae-consumer/Dockerfile env_file: datahub-mae-consumer/env/docker.env + environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} depends_on: neo4j: condition: service_healthy @@ -36,6 +38,7 @@ services: - NEO4J_USERNAME=neo4j - NEO4J_PASSWORD=datahub - GRAPH_SERVICE_IMPL=neo4j + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} depends_on: neo4j: condition: service_healthy diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a69fb977a3417e..7067b68fba3f9c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -45,6 +45,7 @@ services: - SEARCH_SERVICE_ENABLE_CACHE=false - LINEAGE_SEARCH_CACHE_ENABLED=false - SHOW_BROWSE_V2=true + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} volumes: - ./datahub-gms/start.sh:/datahub/datahub-gms/scripts/start.sh - ./datahub-gms/jetty.xml:/datahub/datahub-gms/scripts/jetty.xml diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 95f56fe47e3cca..146055830d04e5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -36,6 +36,8 @@ services: container_name: datahub-gms hostname: datahub-gms image: ${DATAHUB_GMS_IMAGE:-linkedin/datahub-gms}:${DATAHUB_VERSION:-head} + environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} ports: - ${DATAHUB_MAPPED_GMS_PORT:-8080}:8080 build: diff --git a/docker/quickstart/docker-compose-m1.quickstart.yml b/docker/quickstart/docker-compose-m1.quickstart.yml index 7b7ca4052f3245..8b870019152834 100644 --- a/docker/quickstart/docker-compose-m1.quickstart.yml +++ b/docker/quickstart/docker-compose-m1.quickstart.yml @@ -97,6 +97,7 @@ services: - GRAPH_SERVICE_IMPL=${GRAPH_SERVICE_IMPL:-elasticsearch} - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=true - MCE_CONSUMER_ENABLED=true diff --git a/docker/quickstart/docker-compose-without-neo4j-m1.quickstart.yml b/docker/quickstart/docker-compose-without-neo4j-m1.quickstart.yml index 53dacaf6ef63b0..5373e93da6bcb7 100644 --- a/docker/quickstart/docker-compose-without-neo4j-m1.quickstart.yml +++ b/docker/quickstart/docker-compose-without-neo4j-m1.quickstart.yml @@ -97,6 +97,7 @@ services: - GRAPH_SERVICE_IMPL=elasticsearch - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=true - MCE_CONSUMER_ENABLED=true diff --git a/docker/quickstart/docker-compose-without-neo4j.quickstart.yml b/docker/quickstart/docker-compose-without-neo4j.quickstart.yml index 1ca91aa19206da..51a40395e3459f 100644 --- a/docker/quickstart/docker-compose-without-neo4j.quickstart.yml +++ b/docker/quickstart/docker-compose-without-neo4j.quickstart.yml @@ -97,6 +97,7 @@ services: - GRAPH_SERVICE_IMPL=elasticsearch - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=true - MCE_CONSUMER_ENABLED=true diff --git a/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml b/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml index d05933df96a433..4ed57dca1f080a 100644 --- a/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml +++ b/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml @@ -6,6 +6,7 @@ services: datahub-mae-consumer: container_name: datahub-mae-consumer environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - DATAHUB_UPGRADE_HISTORY_KAFKA_CONSUMER_GROUP_ID=generic-duhe-consumer-job-client-mcl - DATAHUB_GMS_HOST=datahub-gms - DATAHUB_GMS_PORT=8080 @@ -44,6 +45,7 @@ services: - GRAPH_SERVICE_IMPL=elasticsearch - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=false - MCE_CONSUMER_ENABLED=true diff --git a/docker/quickstart/docker-compose.consumers.quickstart.yml b/docker/quickstart/docker-compose.consumers.quickstart.yml index f0bd3a0f927c80..ba8432d8a89afe 100644 --- a/docker/quickstart/docker-compose.consumers.quickstart.yml +++ b/docker/quickstart/docker-compose.consumers.quickstart.yml @@ -9,6 +9,7 @@ services: neo4j: condition: service_healthy environment: + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - DATAHUB_UPGRADE_HISTORY_KAFKA_CONSUMER_GROUP_ID=generic-duhe-consumer-job-client-mcl - DATAHUB_GMS_HOST=datahub-gms - DATAHUB_GMS_PORT=8080 @@ -54,6 +55,7 @@ services: - GRAPH_SERVICE_IMPL=neo4j - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=false - MCE_CONSUMER_ENABLED=true diff --git a/docker/quickstart/docker-compose.quickstart.yml b/docker/quickstart/docker-compose.quickstart.yml index c77b4418b6f366..56071cfe1e9e60 100644 --- a/docker/quickstart/docker-compose.quickstart.yml +++ b/docker/quickstart/docker-compose.quickstart.yml @@ -97,6 +97,7 @@ services: - GRAPH_SERVICE_IMPL=${GRAPH_SERVICE_IMPL:-elasticsearch} - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=true - MCE_CONSUMER_ENABLED=true diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java index b505674f2ed9c2..61b9d5c8167900 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java @@ -6,4 +6,5 @@ public class ConsumerConfiguration { private int maxPartitionFetchBytes; + private boolean stopOnDeserializationError; } diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index 0ea6b8712953e4..36498f7c45fea1 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -236,6 +236,7 @@ kafka: maxRequestSize: ${KAFKA_PRODUCER_MAX_REQUEST_SIZE:5242880} # the max bytes sent by the producer, also see kafka-setup MAX_MESSAGE_BYTES for matching value consumer: maxPartitionFetchBytes: ${KAFKA_CONSUMER_MAX_PARTITION_FETCH_BYTES:5242880} # the max bytes consumed per partition + stopOnDeserializationError: ${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:true} # Stops kafka listener container on deserialization error, allows user to fix problems before moving past problematic offset. If false will log and move forward past the offset schemaRegistry: type: ${SCHEMA_REGISTRY_TYPE:KAFKA} # INTERNAL or KAFKA or AWS_GLUE url: ${KAFKA_SCHEMAREGISTRY_URL:http://localhost:8081} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java index 2a6338ac15e93d..4c0308546d857f 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java @@ -21,6 +21,11 @@ import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.config.KafkaListenerContainerFactory; import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.CommonContainerStoppingErrorHandler; +import org.springframework.kafka.listener.CommonDelegatingErrorHandler; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.DeserializationException; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; @Slf4j @Configuration @@ -66,8 +71,6 @@ private static Map buildCustomizedProperties( SchemaRegistryConfig schemaRegistryConfig) { KafkaProperties.Consumer consumerProps = baseKafkaProperties.getConsumer(); - // Specify (de)serializers for record keys and for record values. - consumerProps.setKeyDeserializer(StringDeserializer.class); // Records will be flushed every 10 seconds. consumerProps.setEnableAutoCommit(true); consumerProps.setAutoCommitInterval(Duration.ofSeconds(10)); @@ -81,7 +84,13 @@ private static Map buildCustomizedProperties( Map customizedProperties = baseKafkaProperties.buildConsumerProperties(); customizedProperties.put( - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, schemaRegistryConfig.getDeserializer()); + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + customizedProperties.put( + ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class); + customizedProperties.put( + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + customizedProperties.put( + ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, schemaRegistryConfig.getDeserializer()); // Override KafkaProperties with SchemaRegistryConfig only for non-empty values schemaRegistryConfig.getProperties().entrySet().stream() @@ -98,7 +107,8 @@ private static Map buildCustomizedProperties( @Bean(name = "kafkaEventConsumer") protected KafkaListenerContainerFactory createInstance( @Qualifier("kafkaConsumerFactory") - DefaultKafkaConsumerFactory kafkaConsumerFactory) { + DefaultKafkaConsumerFactory kafkaConsumerFactory, + @Qualifier("configurationProvider") ConfigurationProvider configurationProvider) { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); @@ -106,6 +116,18 @@ protected KafkaListenerContainerFactory createInstance( factory.setContainerCustomizer(new ThreadPoolContainerCustomizer()); factory.setConcurrency(kafkaEventConsumerConcurrency); + /* Sets up a delegating error handler for Deserialization errors, if disabled will + use DefaultErrorHandler (does back-off retry and then logs) rather than stopping the container. Stopping the container + prevents lost messages until the error can be examined, disabling this will allow progress, but may lose data + */ + if (configurationProvider.getKafka().getConsumer().isStopOnDeserializationError()) { + CommonDelegatingErrorHandler delegatingErrorHandler = + new CommonDelegatingErrorHandler(new DefaultErrorHandler()); + delegatingErrorHandler.addDelegate( + DeserializationException.class, new CommonContainerStoppingErrorHandler()); + factory.setCommonErrorHandler(delegatingErrorHandler); + } + log.info( String.format( "Event-based KafkaListenerContainerFactory built successfully. Consumer concurrency = %s", From b80d2f471c559cd31cedb47a79cf07e779b065b9 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 21 Dec 2023 13:35:34 -0500 Subject: [PATCH 056/540] fix(ingest/fivetran): only materialize upstream lineage (#9490) --- .../ingestion/source/fivetran/fivetran.py | 19 +++++++---- .../integration/fivetran/fivetran_golden.json | 32 ------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran.py b/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran.py index c0395b4e4e7963..12e362fa8a3e3f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran.py +++ b/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran.py @@ -7,6 +7,7 @@ DataProcessInstance, InstanceRunResult, ) +from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.decorators import ( SourceCapability, @@ -248,13 +249,17 @@ def _get_connector_workunits( # Map Fivetran's connector entity with Datahub's datajob entity datajob = self._generate_datajob_from_connector(connector) - for mcp in datajob.generate_mcp(materialize_iolets=True): - if mcp.entityType == "dataset" and isinstance(mcp.aspect, StatusClass): - # While we "materialize" the referenced datasets, we don't want them - # to be tracked by stateful ingestion. - yield mcp.as_workunit(is_primary_source=False) - else: - yield mcp.as_workunit() + for mcp in datajob.generate_mcp(materialize_iolets=False): + yield mcp.as_workunit() + + # Materialize the upstream referenced datasets. + # We assume that the downstreams are materialized by other ingestion sources. + for iolet in datajob.inlets: + # We don't want these to be tracked by stateful ingestion. + yield MetadataChangeProposalWrapper( + entityUrn=str(iolet), + aspect=StatusClass(removed=False), + ).as_workunit(is_primary_source=False) # Map Fivetran's job/sync history entity with Datahub's data process entity for job in connector.jobs: diff --git a/metadata-ingestion/tests/integration/fivetran/fivetran_golden.json b/metadata-ingestion/tests/integration/fivetran/fivetran_golden.json index a72c960a722969..b8f05fa6e93aad 100644 --- a/metadata-ingestion/tests/integration/fivetran/fivetran_golden.json +++ b/metadata-ingestion/tests/integration/fivetran/fivetran_golden.json @@ -178,38 +178,6 @@ "lastRunId": "no-run-id-provided" } }, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD)", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1654621200000, - "runId": "powerbi-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD)", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1654621200000, - "runId": "powerbi-test", - "lastRunId": "no-run-id-provided" - } -}, { "entityType": "dataJob", "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", From a18c72083d763b08282b67146881d4f918b257de Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 21 Dec 2023 13:50:39 -0500 Subject: [PATCH 057/540] feat(ingest): handle multiline string coercion (#9484) --- docs-website/download_historical_versions.py | 4 +- docs/developers.md | 6 +-- .../src/datahub/configuration/git.py | 12 +---- .../validate_multiline_string.py | 31 ++++++++++++ .../ingestion/source/bigquery_v2/lineage.py | 2 +- .../ingestion/source/looker/lookml_source.py | 7 ++- .../source_config/usage/bigquery_usage.py | 3 ++ .../src/datahub/utilities/logging_manager.py | 1 + .../unit/config/test_pydantic_validators.py | 50 +++++++++++++++---- 9 files changed, 86 insertions(+), 30 deletions(-) create mode 100644 metadata-ingestion/src/datahub/configuration/validate_multiline_string.py diff --git a/docs-website/download_historical_versions.py b/docs-website/download_historical_versions.py index 53ee9cf1e63ef5..7493210ffa2a5f 100644 --- a/docs-website/download_historical_versions.py +++ b/docs-website/download_historical_versions.py @@ -37,9 +37,9 @@ def fetch_urls( except Exception as e: if attempt < max_retries: print(f"Attempt {attempt + 1}/{max_retries}: {e}") - time.sleep(retry_delay) + time.sleep(retry_delay * 2**attempt) else: - print(f"Max retries reached. Unable to fetch data.") + print("Max retries reached. Unable to fetch data.") raise diff --git a/docs/developers.md b/docs/developers.md index 60d31f5e4523f7..fe007a56ddc68f 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -17,10 +17,8 @@ title: "Local Development" On macOS, these can be installed using [Homebrew](https://brew.sh/). ```shell -# Install Java 8 and 11 -brew tap homebrew/cask-versions -brew install java11 -brew install --cask zulu8 +# Install Java +brew install openjdk@17 # Install Python brew install python@3.10 # you may need to add this to your PATH diff --git a/metadata-ingestion/src/datahub/configuration/git.py b/metadata-ingestion/src/datahub/configuration/git.py index a5f88744661a4a..3c76c8da0d5717 100644 --- a/metadata-ingestion/src/datahub/configuration/git.py +++ b/metadata-ingestion/src/datahub/configuration/git.py @@ -1,4 +1,3 @@ -import os import pathlib from typing import Any, Dict, Optional, Union @@ -6,6 +5,7 @@ from datahub.configuration.common import ConfigModel from datahub.configuration.validate_field_rename import pydantic_renamed_field +from datahub.configuration.validate_multiline_string import pydantic_multiline_string _GITHUB_PREFIX = "https://github.com/" _GITLAB_PREFIX = "https://gitlab.com/" @@ -92,15 +92,7 @@ class GitInfo(GitReference): description="The url to call `git clone` on. We infer this for github and gitlab repos, but it is required for other hosts.", ) - @validator("deploy_key_file") - def deploy_key_file_should_be_readable( - cls, v: Optional[FilePath] - ) -> Optional[FilePath]: - if v is not None: - # pydantic does existence checks, we just need to check if we can read it - if not os.access(v, os.R_OK): - raise ValueError(f"Unable to read deploy key file {v}") - return v + _fix_deploy_key_newlines = pydantic_multiline_string("deploy_key") @validator("deploy_key", pre=True, always=True) def deploy_key_filled_from_deploy_key_file( diff --git a/metadata-ingestion/src/datahub/configuration/validate_multiline_string.py b/metadata-ingestion/src/datahub/configuration/validate_multiline_string.py new file mode 100644 index 00000000000000..0baaf4f0264b99 --- /dev/null +++ b/metadata-ingestion/src/datahub/configuration/validate_multiline_string.py @@ -0,0 +1,31 @@ +from typing import Optional, Type, Union + +import pydantic + + +def pydantic_multiline_string(field: str) -> classmethod: + """If the field is present and contains an escaped newline, replace it with a real newline. + + This makes the assumption that the field value is never supposed to have a + r"\n" in it, and instead should only have newline characters. This is generally + a safe assumption for SSH keys and similar. + + The purpose of this helper is to make us more forgiving of small formatting issues + in recipes, without sacrificing correctness across the board. + """ + + def _validate_field( + cls: Type, v: Union[None, str, pydantic.SecretStr] + ) -> Optional[str]: + if v is not None: + if isinstance(v, pydantic.SecretStr): + v = v.get_secret_value() + v = v.replace(r"\n", "\n") + + return v + + # Hack: Pydantic maintains unique list of validators by referring its __name__. + # https://github.com/pydantic/pydantic/blob/v1.10.9/pydantic/main.py#L264 + # This hack ensures that multiple field deprecated do not overwrite each other. + _validate_field.__name__ = f"{_validate_field.__name__}_{field}" + return pydantic.validator(field, pre=True, allow_reuse=True)(_validate_field) diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py index eddd08c92b808d..b44b06feb95af2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py @@ -175,7 +175,7 @@ def make_lineage_edges_from_parsing_result( table_name = str( BigQueryTableRef.from_bigquery_table( BigqueryTableIdentifier.from_string_name( - DatasetUrn.create_from_string(table_urn).get_dataset_name() + DatasetUrn.from_string(table_urn).name ) ) ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py index b76bef49a7e6f0..33079f3fd9ac17 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py @@ -2060,10 +2060,9 @@ def get_internal_workunits(self) -> Iterable[MetadataWorkUnit]: # noqa: C901 ) logger.debug("Failed to process explore", exc_info=e) - processed_view_files = processed_view_map.get(model.connection) - if processed_view_files is None: - processed_view_map[model.connection] = set() - processed_view_files = processed_view_map[model.connection] + processed_view_files = processed_view_map.setdefault( + model.connection, set() + ) project_name = self.get_project_name(model_name) logger.debug(f"Model: {model_name}; Includes: {model.resolved_includes}") diff --git a/metadata-ingestion/src/datahub/ingestion/source_config/usage/bigquery_usage.py b/metadata-ingestion/src/datahub/ingestion/source_config/usage/bigquery_usage.py index 5eb9c83236e4f9..13abe73cc4e098 100644 --- a/metadata-ingestion/src/datahub/ingestion/source_config/usage/bigquery_usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source_config/usage/bigquery_usage.py @@ -11,6 +11,7 @@ from datahub.configuration.common import AllowDenyPattern, ConfigurationError from datahub.configuration.source_common import EnvConfigMixin from datahub.configuration.validate_field_removal import pydantic_removed_field +from datahub.configuration.validate_multiline_string import pydantic_multiline_string from datahub.ingestion.source.usage.usage_common import BaseUsageConfig from datahub.ingestion.source_config.bigquery import BigQueryBaseConfig @@ -44,6 +45,8 @@ class BigQueryCredential(ConfigModel): description="If not set it will be default to https://www.googleapis.com/robot/v1/metadata/x509/client_email", ) + _fix_private_key_newlines = pydantic_multiline_string("private_key") + @pydantic.root_validator(skip_on_failure=True) def validate_config(cls, values: Dict[str, Any]) -> Dict[str, Any]: if values.get("client_x509_cert_url") is None: diff --git a/metadata-ingestion/src/datahub/utilities/logging_manager.py b/metadata-ingestion/src/datahub/utilities/logging_manager.py index a8eacb0a9938df..62aa1ca7ab7918 100644 --- a/metadata-ingestion/src/datahub/utilities/logging_manager.py +++ b/metadata-ingestion/src/datahub/utilities/logging_manager.py @@ -199,6 +199,7 @@ def configure_logging(debug: bool, log_file: Optional[str] = None) -> Iterator[N for handler in handlers: root_logger.removeHandler(handler) for lib in DATAHUB_PACKAGES: + lib_logger = logging.getLogger(lib) lib_logger.removeHandler(handler) lib_logger.propagate = True diff --git a/metadata-ingestion/tests/unit/config/test_pydantic_validators.py b/metadata-ingestion/tests/unit/config/test_pydantic_validators.py index 399245736805cc..f687a2776f6e2d 100644 --- a/metadata-ingestion/tests/unit/config/test_pydantic_validators.py +++ b/metadata-ingestion/tests/unit/config/test_pydantic_validators.py @@ -1,12 +1,14 @@ from typing import Optional +import pydantic import pytest from pydantic import ValidationError -from datahub.configuration.common import ConfigModel +from datahub.configuration.common import ConfigModel, ConfigurationWarning from datahub.configuration.validate_field_deprecation import pydantic_field_deprecated from datahub.configuration.validate_field_removal import pydantic_removed_field from datahub.configuration.validate_field_rename import pydantic_renamed_field +from datahub.configuration.validate_multiline_string import pydantic_multiline_string from datahub.utilities.global_warning_util import ( clear_global_warnings, get_global_warnings, @@ -22,8 +24,9 @@ class TestModel(ConfigModel): v = TestModel.parse_obj({"b": "original"}) assert v.b == "original" - v = TestModel.parse_obj({"a": "renamed"}) - assert v.b == "renamed" + with pytest.warns(ConfigurationWarning, match="a is deprecated"): + v = TestModel.parse_obj({"a": "renamed"}) + assert v.b == "renamed" with pytest.raises(ValidationError): TestModel.parse_obj({"a": "foo", "b": "bar"}) @@ -44,9 +47,10 @@ class TestModel(ConfigModel): assert v.b == "original" assert v.b1 == "original" - v = TestModel.parse_obj({"a": "renamed", "a1": "renamed"}) - assert v.b == "renamed" - assert v.b1 == "renamed" + with pytest.warns(ConfigurationWarning, match=r"a.* is deprecated"): + v = TestModel.parse_obj({"a": "renamed", "a1": "renamed"}) + assert v.b == "renamed" + assert v.b1 == "renamed" with pytest.raises(ValidationError): TestModel.parse_obj({"a": "foo", "b": "bar", "b1": "ok"}) @@ -74,8 +78,9 @@ class TestModel(ConfigModel): v = TestModel.parse_obj({"b": "original"}) assert v.b == "original" - v = TestModel.parse_obj({"b": "original", "r1": "removed", "r2": "removed"}) - assert v.b == "original" + with pytest.warns(ConfigurationWarning, match=r"r\d was removed"): + v = TestModel.parse_obj({"b": "original", "r1": "removed", "r2": "removed"}) + assert v.b == "original" def test_field_deprecated(): @@ -92,7 +97,10 @@ class TestModel(ConfigModel): v = TestModel.parse_obj({"b": "original"}) assert v.b == "original" - v = TestModel.parse_obj({"b": "original", "d1": "deprecated", "d2": "deprecated"}) + with pytest.warns(ConfigurationWarning, match=r"d\d.+ deprecated"): + v = TestModel.parse_obj( + {"b": "original", "d1": "deprecated", "d2": "deprecated"} + ) assert v.b == "original" assert v.d1 == "deprecated" assert v.d2 == "deprecated" @@ -100,3 +108,27 @@ class TestModel(ConfigModel): assert any(["d2 is deprecated" in warning for warning in get_global_warnings()]) clear_global_warnings() + + +def test_multiline_string_fixer(): + class TestModel(ConfigModel): + s: str + m: Optional[pydantic.SecretStr] = None + + _validate_s = pydantic_multiline_string("s") + _validate_m = pydantic_multiline_string("m") + + v = TestModel.parse_obj({"s": "foo\nbar"}) + assert v.s == "foo\nbar" + + v = TestModel.parse_obj({"s": "foo\\nbar"}) + assert v.s == "foo\nbar" + + v = TestModel.parse_obj({"s": "normal", "m": "foo\\nbar"}) + assert v.s == "normal" + assert v.m + assert v.m.get_secret_value() == "foo\nbar" + + v = TestModel.parse_obj({"s": "normal", "m": pydantic.SecretStr("foo\\nbar")}) + assert v.m + assert v.m.get_secret_value() == "foo\nbar" From cfc641f0d03408b85ae75c2e4830c5f307ce6a68 Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Thu, 21 Dec 2023 20:32:51 +0100 Subject: [PATCH 058/540] fix(ingest/databricks): Pinning databricks sdk to not fail on mypy issues (#9500) --- metadata-ingestion/setup.py | 4 +++- .../src/datahub/ingestion/source/aws/aws_common.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 13c9d3c99aaca1..0dcac7a7fc1b41 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -274,7 +274,9 @@ databricks = { # 0.1.11 appears to have authentication issues with azure databricks - "databricks-sdk>=0.9.0", + # 0.16.0 added py.typed support which caused mypy to fail. The databricks sdk is pinned until we resolve mypy issues. + # https://github.com/databricks/databricks-sdk-py/pull/483 + "databricks-sdk>=0.9.0,<0.16.0", "pyspark~=3.3.0", "requests", # Version 2.4.0 includes sqlalchemy dialect, 2.8.0 includes some bug fixes diff --git a/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py b/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py index 0fb211a5d7b162..421991a0966c3a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py @@ -167,7 +167,7 @@ def get_session(self) -> Session: return session - def get_credentials(self) -> Dict[str, str]: + def get_credentials(self) -> Dict[str, Optional[str]]: credentials = self.get_session().get_credentials() if credentials is not None: return { From ca518d6c78d994d59879b29f5afa8ffd1cff56df Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 21 Dec 2023 20:28:45 -0500 Subject: [PATCH 059/540] feat(ingest): remove librdkafka hacks (#9507) --- docker/datahub-ingestion-base/Dockerfile | 9 ----- .../base-requirements.txt | 2 +- metadata-ingestion/developing.md | 3 +- .../scripts/datahub_preflight.sh | 19 +++------- metadata-ingestion/scripts/install_deps.sh | 5 ++- metadata-ingestion/setup.py | 35 ++++--------------- 6 files changed, 14 insertions(+), 59 deletions(-) diff --git a/docker/datahub-ingestion-base/Dockerfile b/docker/datahub-ingestion-base/Dockerfile index 81fec61ea50733..558a5afe2c2cf9 100644 --- a/docker/datahub-ingestion-base/Dockerfile +++ b/docker/datahub-ingestion-base/Dockerfile @@ -30,9 +30,6 @@ ARG DEBIAN_REPO_URL ARG PIP_MIRROR_URL ARG GITHUB_REPO_URL -ENV LIBRDKAFKA_VERSION=1.6.2 -ENV CONFLUENT_KAFKA_VERSION=1.6.1 - ENV DEBIAN_FRONTEND noninteractive # Optionally set corporate mirror for apk and pip @@ -40,7 +37,6 @@ RUN if [ "${DEBIAN_REPO_URL}" != "http://deb.debian.org/debian" ] ; then sed -i RUN if [ "${PIP_MIRROR_URL}" != "null" ] ; then pip config set global.index-url ${PIP_MIRROR_URL} ; fi RUN apt-get update && apt-get install -y -qq \ - make \ python3-ldap \ libldap2-dev \ libsasl2-dev \ @@ -53,11 +49,6 @@ RUN apt-get update && apt-get install -y -qq \ unzip \ ldap-utils \ && python -m pip install --no-cache --upgrade pip wheel setuptools \ - && wget -q ${GITHUB_REPO_URL}/edenhill/librdkafka/archive/v${LIBRDKAFKA_VERSION}.tar.gz -O - | \ - tar -xz -C /root \ - && cd /root/librdkafka-${LIBRDKAFKA_VERSION} \ - && ./configure --prefix /usr && make && make install && cd .. && rm -rf /root/librdkafka-${LIBRDKAFKA_VERSION} \ - && apt-get remove -y make \ && rm -rf /var/lib/apt/lists/* /var/cache/apk/* # compiled against newer golang for security fixes diff --git a/docker/datahub-ingestion-base/base-requirements.txt b/docker/datahub-ingestion-base/base-requirements.txt index eb082d50b3020f..141382466ab9f6 100644 --- a/docker/datahub-ingestion-base/base-requirements.txt +++ b/docker/datahub-ingestion-base/base-requirements.txt @@ -65,7 +65,7 @@ colorlog==4.8.0 comm==0.1.4 confection==0.1.3 ConfigUpdater==3.1.1 -confluent-kafka==1.8.2 +confluent-kafka==2.3.0 connexion==2.14.2 cron-descriptor==1.4.0 croniter==2.0.1 diff --git a/metadata-ingestion/developing.md b/metadata-ingestion/developing.md index d5f834936cdcfc..d1eef21974f1df 100644 --- a/metadata-ingestion/developing.md +++ b/metadata-ingestion/developing.md @@ -11,8 +11,7 @@ Also take a look at the guide to [adding a source](./adding-source.md). 1. Python 3.7+ must be installed in your host environment. 2. Java8 (gradle won't work with newer versions) -3. On MacOS: `brew install librdkafka` -4. On Debian/Ubuntu: `sudo apt install librdkafka-dev python3-dev python3-venv` +4. On Debian/Ubuntu: `sudo apt install python3-dev python3-venv` 5. On Fedora (if using LDAP source integration): `sudo yum install openldap-devel` ### Set up your Python environment diff --git a/metadata-ingestion/scripts/datahub_preflight.sh b/metadata-ingestion/scripts/datahub_preflight.sh index e82be9d7b27b71..9676964f4d49d1 100755 --- a/metadata-ingestion/scripts/datahub_preflight.sh +++ b/metadata-ingestion/scripts/datahub_preflight.sh @@ -45,8 +45,6 @@ arm64_darwin_preflight() { pip3 install --no-use-pep517 scipy fi - printf "✨ Setting up librdkafka prerequisities\n" - brew_install "librdkafka" "1.9.1" brew_install "openssl@1.1" brew install "postgresql@14" @@ -69,25 +67,16 @@ arm64_darwin_preflight() { export GRPC_PYTHON_BUILD_SYSTEM_OPENSSL GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1 export GRPC_PYTHON_BUILD_SYSTEM_ZLIB - CPPFLAGS="-I$(brew --prefix openssl@1.1)/include -I$(brew --prefix librdkafka)/include" + CPPFLAGS="-I$(brew --prefix openssl@1.1)/include" export CPPFLAGS - LDFLAGS="-L$(brew --prefix openssl@1.1)/lib -L$(brew --prefix librdkafka)/lib" + LDFLAGS="-L$(brew --prefix openssl@1.1)/lib" export LDFLAGS - CPATH="$(brew --prefix librdkafka)/include" - export CPATH - C_INCLUDE_PATH="$(brew --prefix librdkafka)/include" - export C_INCLUDE_PATH - LIBRARY_PATH="$(brew --prefix librdkafka)/lib" - export LIBRARY_PATH cat << EOF export GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 export GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1 - export CPPFLAGS="-I$(brew --prefix openssl@1.1)/include -I$(brew --prefix librdkafka)/include" - export LDFLAGS="-L$(brew --prefix openssl@1.1)/lib -L$(brew --prefix librdkafka)/lib -L$(brew --prefix postgresql@14)/lib/postgresql@14" - export CPATH="$(brew --prefix librdkafka)/include" - export C_INCLUDE_PATH="$(brew --prefix librdkafka)/include" - export LIBRARY_PATH="$(brew --prefix librdkafka)/lib" + export CPPFLAGS="-I$(brew --prefix openssl@1.1)/include" + export LDFLAGS="-L$(brew --prefix openssl@1.1)/lib -L$(brew --prefix postgresql@14)/lib/postgresql@14" EOF diff --git a/metadata-ingestion/scripts/install_deps.sh b/metadata-ingestion/scripts/install_deps.sh index 7e6b6956d8bb84..bae0278056ebbd 100755 --- a/metadata-ingestion/scripts/install_deps.sh +++ b/metadata-ingestion/scripts/install_deps.sh @@ -2,7 +2,8 @@ set -euxo pipefail if [ "$(uname)" == "Darwin" ]; then - brew install librdkafka + # None + true else sudo_cmd="" if command -v sudo; then @@ -11,7 +12,6 @@ else if command -v yum; then $sudo_cmd yum install -y \ - librdkafka-devel \ openldap-devel \ cyrus-sasl-devel \ openldap-clients \ @@ -21,7 +21,6 @@ else libxslt-devel else $sudo_cmd apt-get update && $sudo_cmd apt-get install -y \ - librdkafka-dev \ python3-ldap \ libldap2-dev \ libsasl2-dev \ diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 0dcac7a7fc1b41..c834700388d627 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -69,35 +69,12 @@ rest_common = {"requests", "requests_file"} kafka_common = { - # The confluent_kafka package provides a number of pre-built wheels for - # various platforms and architectures. However, it does not provide wheels - # for arm64 (including M1 Macs) or aarch64 (Docker's linux/arm64). This has - # remained an open issue on the confluent_kafka project for a year: - # - https://github.com/confluentinc/confluent-kafka-python/issues/1182 - # - https://github.com/confluentinc/confluent-kafka-python/pull/1161 - # - # When a wheel is not available, we must build from source instead. - # Building from source requires librdkafka to be installed. - # Most platforms have an easy way to install librdkafka: - # - MacOS: `brew install librdkafka` gives latest, which is 1.9.x or newer. - # - Debian: `apt install librdkafka` gives 1.6.0 (https://packages.debian.org/bullseye/librdkafka-dev). - # - Ubuntu: `apt install librdkafka` gives 1.8.0 (https://launchpad.net/ubuntu/+source/librdkafka). - # - # Moreover, confluent_kafka 1.9.0 introduced a hard compatibility break, and - # requires librdkafka >=1.9.0. As such, installing confluent_kafka 1.9.x on - # most arm64 Linux machines will fail, since it will build from source but then - # fail because librdkafka is too old. Hence, we have added an extra requirement - # that requires confluent_kafka<1.9.0 on non-MacOS arm64/aarch64 machines, which - # should ideally allow the builds to succeed in default conditions. We still - # want to allow confluent_kafka >= 1.9.0 for M1 Macs, which is why we can't - # broadly restrict confluent_kafka to <1.9.0. - # - # Note that this is somewhat of a hack, since we don't actually require the - # older version of confluent_kafka on those machines. Additionally, we will - # need monitor the Debian/Ubuntu PPAs and modify this rule if they start to - # support librdkafka >= 1.9.0. - "confluent_kafka>=1.5.0", - 'confluent_kafka<1.9.0; platform_system != "Darwin" and (platform_machine == "aarch64" or platform_machine == "arm64")', + # Note that confluent_kafka 1.9.0 introduced a hard compatibility break, and + # requires librdkafka >=1.9.0. This is generally not an issue, since they + # now provide prebuilt wheels for most platforms, including M1 Macs and + # Linux aarch64 (e.g. Docker's linux/arm64). Installing confluent_kafka + # from source remains a pain. + "confluent_kafka>=1.9.0", # We currently require both Avro libraries. The codegen uses avro-python3 (above) # schema parsers at runtime for generating and reading JSON into Python objects. # At the same time, we use Kafka's AvroSerializer, which internally relies on From be329986ab4b177899d16990fec31597ae765c58 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 21 Dec 2023 20:30:36 -0500 Subject: [PATCH 060/540] feat(ingest): rename custom package path from models to metadata (#9502) --- docs/modeling/extending-the-metadata-model.md | 8 ++++++++ metadata-ingestion/scripts/custom_package_codegen.py | 10 +++++----- metadata-ingestion/src/datahub/telemetry/telemetry.py | 5 +++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/modeling/extending-the-metadata-model.md b/docs/modeling/extending-the-metadata-model.md index dc4edd3306f95c..8b308fb65d243c 100644 --- a/docs/modeling/extending-the-metadata-model.md +++ b/docs/modeling/extending-the-metadata-model.md @@ -289,6 +289,14 @@ Alternatively, publish it to PyPI with `twine upload custom-package/my-company-d This will generate some Python build artifacts, which you can distribute within your team or publish to PyPI. The command output contains additional details and exact CLI commands you can use. +Once this package is installed, you can use the DataHub CLI as normal, and it will use your custom models. +You'll also be able to import those models, with IDE support, by changing your imports. + +```diff +- from datahub.metadata.schema_classes import DatasetPropertiesClass ++ from my_company_datahub_models.metadata.schema_classes import DatasetPropertiesClass +``` + diff --git a/metadata-ingestion/scripts/custom_package_codegen.py b/metadata-ingestion/scripts/custom_package_codegen.py index 3f59fdf2cc548d..714728087d4b6d 100644 --- a/metadata-ingestion/scripts/custom_package_codegen.py +++ b/metadata-ingestion/scripts/custom_package_codegen.py @@ -62,7 +62,7 @@ def generate( entity_registry=entity_registry, pdl_path=pdl_path, schemas_path=schemas_path, - outdir=str(src_path / "models"), + outdir=str(src_path / "metadata"), enable_custom_loader=False, ) @@ -91,13 +91,13 @@ def generate( ], package_data={{ "{python_package_name}": ["py.typed"], - "{python_package_name}.models": ["schema.avsc"], - "{python_package_name}.models.schemas": ["*.avsc"], + "{python_package_name}.metadata": ["schema.avsc"], + "{python_package_name}.metadata.schemas": ["*.avsc"], }}, entry_points={{ "datahub.custom_packages": [ - "models={python_package_name}.models.schema_classes", - "urns={python_package_name}.models._urns.urn_defs", + "models={python_package_name}.metadata.schema_classes", + "urns={python_package_name}.metadata._urns.urn_defs", ], }}, ) diff --git a/metadata-ingestion/src/datahub/telemetry/telemetry.py b/metadata-ingestion/src/datahub/telemetry/telemetry.py index 615be00d5455f5..c399f2e1a27e55 100644 --- a/metadata-ingestion/src/datahub/telemetry/telemetry.py +++ b/metadata-ingestion/src/datahub/telemetry/telemetry.py @@ -16,6 +16,7 @@ from datahub.cli.cli_utils import DATAHUB_ROOT_FOLDER, get_boolean_env_variable from datahub.configuration.common import ExceptionWithProps from datahub.ingestion.graph.client import DataHubGraph +from datahub.metadata.schema_classes import _custom_package_path from datahub.utilities.perf_timer import PerfTimer logger = logging.getLogger(__name__) @@ -89,6 +90,10 @@ if any(var in os.environ for var in CI_ENV_VARS): ENV_ENABLED = False +# Also disable if a custom metadata model package is in use. +if _custom_package_path: + ENV_ENABLED = False + TIMEOUT = int(os.environ.get("DATAHUB_TELEMETRY_TIMEOUT", "10")) MIXPANEL_ENDPOINT = "track.datahubproject.io/mp" MIXPANEL_TOKEN = "5ee83d940754d63cacbf7d34daa6f44a" From 4fe1df6892a7e45fe59a26990b441a67dd4faf93 Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Fri, 22 Dec 2023 11:57:24 +0530 Subject: [PATCH 061/540] feat(ui): edit link option (#9498) --- .../Documentation/components/LinkList.tsx | 119 ++++++++++++++++-- 1 file changed, 110 insertions(+), 9 deletions(-) diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx index bcce994c3f0f80..1b5c3d54009da8 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx @@ -1,14 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components/macro'; -import { message, Button, List, Typography } from 'antd'; -import { LinkOutlined, DeleteOutlined } from '@ant-design/icons'; +import { message, Button, List, Typography, Modal, Form, Input } from 'antd'; +import { LinkOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { EntityType, InstitutionalMemoryMetadata } from '../../../../../../types.generated'; -import { useEntityData } from '../../../EntityContext'; +import { useEntityData, useMutationUrn } from '../../../EntityContext'; import { useEntityRegistry } from '../../../../../useEntityRegistry'; import { ANTD_GRAY } from '../../../constants'; import { formatDateString } from '../../../containers/profile/utils'; -import { useRemoveLinkMutation } from '../../../../../../graphql/mutations.generated'; +import { useAddLinkMutation, useRemoveLinkMutation } from '../../../../../../graphql/mutations.generated'; +import analytics, { EntityActionType, EventType } from '../../../../../analytics'; const LinkListItem = styled(List.Item)` border-radius: 5px; @@ -33,10 +34,15 @@ type LinkListProps = { }; export const LinkList = ({ refetch }: LinkListProps) => { - const { urn: entityUrn, entityData } = useEntityData(); + const [editModalVisble, setEditModalVisible] = useState(false); + const [linkDetails, setLinkDetails] = useState(undefined); + const { urn: entityUrn, entityData, entityType } = useEntityData(); const entityRegistry = useEntityRegistry(); const [removeLinkMutation] = useRemoveLinkMutation(); const links = entityData?.institutionalMemory?.elements || []; + const [form] = Form.useForm(); + const [addLinkMutation] = useAddLinkMutation(); + const mutationUrn = useMutationUrn(); const handleDeleteLink = async (metadata: InstitutionalMemoryMetadata) => { try { @@ -53,8 +59,98 @@ export const LinkList = ({ refetch }: LinkListProps) => { refetch?.(); }; + const handleEditLink = (metadata: InstitutionalMemoryMetadata) => { + form.setFieldsValue({ + url: metadata.url, + label: metadata.description, + }); + setLinkDetails(metadata); + setEditModalVisible(true); + }; + + const handleClose = () => { + form.resetFields(); + setEditModalVisible(false); + }; + + const handleEdit = async (formData: any) => { + if (!linkDetails) return; + try { + await removeLinkMutation({ + variables: { input: { linkUrl: linkDetails.url, resourceUrn: linkDetails.associatedUrn || entityUrn } }, + }); + await addLinkMutation({ + variables: { input: { linkUrl: formData.url, label: formData.label, resourceUrn: mutationUrn } }, + }); + + message.success({ content: 'Link Updated', duration: 2 }); + + analytics.event({ + type: EventType.EntityActionEvent, + entityType, + entityUrn: mutationUrn, + actionType: EntityActionType.UpdateLinks, + }); + + refetch?.(); + handleClose(); + } catch (e: unknown) { + message.destroy(); + + if (e instanceof Error) { + message.error({ content: `Error updating link: \n ${e.message || ''}`, duration: 2 }); + } + } + }; + return entityData ? ( <> + + Cancel + , + , + ]} + > +
+ + + + + + +
+
{links.length > 0 && ( { renderItem={(link) => ( handleDeleteLink(link)} type="text" shape="circle" danger> - - + <> + + + } > Date: Fri, 22 Dec 2023 02:18:22 -0500 Subject: [PATCH 062/540] feat(ingest): support CLL for redshift materialized views with auto refresh (#9508) --- metadata-ingestion/setup.py | 2 +- .../src/datahub/utilities/sqlglot_lineage.py | 122 ++++++++++++------ ...dshift_materialized_view_auto_refresh.json | 54 ++++++++ .../tests/unit/sql_parsing/test_sql_detach.py | 46 +++++++ .../unit/sql_parsing/test_sqlglot_lineage.py | 72 ++++------- 5 files changed, 207 insertions(+), 89 deletions(-) create mode 100644 metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_materialized_view_auto_refresh.json create mode 100644 metadata-ingestion/tests/unit/sql_parsing/test_sql_detach.py diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index c834700388d627..4632c20cd3b969 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -98,7 +98,7 @@ sqlglot_lib = { # Using an Acryl fork of sqlglot. # https://github.com/tobymao/sqlglot/compare/main...hsheth2:sqlglot:hsheth?expand=1 - "acryl-sqlglot==19.0.2.dev10", + "acryl-sqlglot==20.4.1.dev14", } sql_common = ( diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index fc3efef2ba5322..f84b3f8b94a2e0 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -5,7 +5,7 @@ import logging import pathlib from collections import defaultdict -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union import pydantic.dataclasses import sqlglot @@ -60,6 +60,8 @@ ), ) ) +# Quick check that the rules were loaded correctly. +assert 0 < len(RULES_BEFORE_TYPE_ANNOTATION) < len(sqlglot.optimizer.optimizer.RULES) class GraphQLSchemaField(TypedDict): @@ -150,12 +152,16 @@ class _TableName(_FrozenModel): def as_sqlglot_table(self) -> sqlglot.exp.Table: return sqlglot.exp.Table( - catalog=self.database, db=self.db_schema, this=self.table + catalog=sqlglot.exp.Identifier(this=self.database) + if self.database + else None, + db=sqlglot.exp.Identifier(this=self.db_schema) if self.db_schema else None, + this=sqlglot.exp.Identifier(this=self.table), ) def qualified( self, - dialect: str, + dialect: sqlglot.Dialect, default_db: Optional[str] = None, default_schema: Optional[str] = None, ) -> "_TableName": @@ -271,7 +277,9 @@ def make_from_error(cls, error: Exception) -> "SqlParsingResult": ) -def _parse_statement(sql: sqlglot.exp.ExpOrStr, dialect: str) -> sqlglot.Expression: +def _parse_statement( + sql: sqlglot.exp.ExpOrStr, dialect: sqlglot.Dialect +) -> sqlglot.Expression: statement: sqlglot.Expression = sqlglot.maybe_parse( sql, dialect=dialect, error_level=sqlglot.ErrorLevel.RAISE ) @@ -279,8 +287,7 @@ def _parse_statement(sql: sqlglot.exp.ExpOrStr, dialect: str) -> sqlglot.Express def _table_level_lineage( - statement: sqlglot.Expression, - dialect: str, + statement: sqlglot.Expression, dialect: sqlglot.Dialect ) -> Tuple[Set[_TableName], Set[_TableName]]: # Generate table-level lineage. modified = { @@ -482,6 +489,26 @@ def close(self) -> None: ] _SupportedColumnLineageTypesTuple = (sqlglot.exp.Subqueryable, sqlglot.exp.DerivedTable) +DIALECTS_WITH_CASE_INSENSITIVE_COLS = { + # Column identifiers are case-insensitive in BigQuery, so we need to + # do a normalization step beforehand to make sure it's resolved correctly. + "bigquery", + # Our snowflake source lowercases column identifiers, so we are forced + # to do fuzzy (case-insensitive) resolution instead of exact resolution. + "snowflake", + # Teradata column names are case-insensitive. + # A name, even when enclosed in double quotation marks, is not case sensitive. For example, CUSTOMER and Customer are the same. + # See more below: + # https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/acreldb/n0ejgx4895bofnn14rlguktfx5r3.htm + "teradata", +} +DIALECTS_WITH_DEFAULT_UPPERCASE_COLS = { + # In some dialects, column identifiers are effectively case insensitive + # because they are automatically converted to uppercase. Most other systems + # automatically lowercase unquoted identifiers. + "snowflake", +} + class UnsupportedStatementTypeError(TypeError): pass @@ -495,8 +522,8 @@ class SqlUnderstandingError(Exception): # TODO: Break this up into smaller functions. def _column_level_lineage( # noqa: C901 statement: sqlglot.exp.Expression, - dialect: str, - input_tables: Dict[_TableName, SchemaInfo], + dialect: sqlglot.Dialect, + table_schemas: Dict[_TableName, SchemaInfo], output_table: Optional[_TableName], default_db: Optional[str], default_schema: Optional[str], @@ -515,19 +542,9 @@ def _column_level_lineage( # noqa: C901 column_lineage: List[_ColumnLineageInfo] = [] - use_case_insensitive_cols = dialect in { - # Column identifiers are case-insensitive in BigQuery, so we need to - # do a normalization step beforehand to make sure it's resolved correctly. - "bigquery", - # Our snowflake source lowercases column identifiers, so we are forced - # to do fuzzy (case-insensitive) resolution instead of exact resolution. - "snowflake", - # Teradata column names are case-insensitive. - # A name, even when enclosed in double quotation marks, is not case sensitive. For example, CUSTOMER and Customer are the same. - # See more below: - # https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/acreldb/n0ejgx4895bofnn14rlguktfx5r3.htm - "teradata", - } + use_case_insensitive_cols = _is_dialect_instance( + dialect, DIALECTS_WITH_CASE_INSENSITIVE_COLS + ) sqlglot_db_schema = sqlglot.MappingSchema( dialect=dialect, @@ -537,14 +554,16 @@ def _column_level_lineage( # noqa: C901 table_schema_normalized_mapping: Dict[_TableName, Dict[str, str]] = defaultdict( dict ) - for table, table_schema in input_tables.items(): + for table, table_schema in table_schemas.items(): normalized_table_schema: SchemaInfo = {} for col, col_type in table_schema.items(): if use_case_insensitive_cols: col_normalized = ( # This is required to match Sqlglot's behavior. col.upper() - if dialect in {"snowflake"} + if _is_dialect_instance( + dialect, DIALECTS_WITH_DEFAULT_UPPERCASE_COLS + ) else col.lower() ) else: @@ -561,7 +580,7 @@ def _column_level_lineage( # noqa: C901 if use_case_insensitive_cols: def _sqlglot_force_column_normalizer( - node: sqlglot.exp.Expression, dialect: "sqlglot.DialectType" = None + node: sqlglot.exp.Expression, ) -> sqlglot.exp.Expression: if isinstance(node, sqlglot.exp.Column): node.this.set("quoted", False) @@ -572,9 +591,7 @@ def _sqlglot_force_column_normalizer( # "Prior to case normalization sql %s", # statement.sql(pretty=True, dialect=dialect), # ) - statement = statement.transform( - _sqlglot_force_column_normalizer, dialect, copy=False - ) + statement = statement.transform(_sqlglot_force_column_normalizer, copy=False) # logger.debug( # "Sql after casing normalization %s", # statement.sql(pretty=True, dialect=dialect), @@ -595,7 +612,8 @@ def _schema_aware_fuzzy_column_resolve( # Optimize the statement + qualify column references. logger.debug( - "Prior to qualification sql %s", statement.sql(pretty=True, dialect=dialect) + "Prior to column qualification sql %s", + statement.sql(pretty=True, dialect=dialect), ) try: # Second time running qualify, this time with: @@ -678,7 +696,7 @@ def _schema_aware_fuzzy_column_resolve( # Otherwise, we can't process it. continue - if dialect == "bigquery" and output_col.lower() in { + if _is_dialect_instance(dialect, "bigquery") and output_col.lower() in { "_partitiontime", "_partitiondate", }: @@ -923,7 +941,7 @@ def _translate_sqlglot_type( def _translate_internal_column_lineage( table_name_urn_mapping: Dict[_TableName, str], raw_column_lineage: _ColumnLineageInfo, - dialect: str, + dialect: sqlglot.Dialect, ) -> ColumnLineageInfo: downstream_urn = None if raw_column_lineage.downstream.table: @@ -956,18 +974,44 @@ def _translate_internal_column_lineage( ) -def _get_dialect(platform: str) -> str: +def _get_dialect_str(platform: str) -> str: # TODO: convert datahub platform names to sqlglot dialect if platform == "presto-on-hive": return "hive" - if platform == "mssql": + elif platform == "mssql": return "tsql" - if platform == "athena": + elif platform == "athena": return "trino" + elif platform == "mysql": + # In sqlglot v20+, MySQL is now case-sensitive by default, which is the + # default behavior on Linux. However, MySQL's default case sensitivity + # actually depends on the underlying OS. + # For us, it's simpler to just assume that it's case-insensitive, and + # let the fuzzy resolution logic handle it. + return "mysql, normalization_strategy = lowercase" else: return platform +def _get_dialect(platform: str) -> sqlglot.Dialect: + return sqlglot.Dialect.get_or_raise(_get_dialect_str(platform)) + + +def _is_dialect_instance( + dialect: sqlglot.Dialect, platforms: Union[str, Iterable[str]] +) -> bool: + if isinstance(platforms, str): + platforms = [platforms] + else: + platforms = list(platforms) + + dialects = [sqlglot.Dialect.get_or_raise(platform) for platform in platforms] + + if any(isinstance(dialect, dialect_class.__class__) for dialect_class in dialects): + return True + return False + + def _sqlglot_lineage_inner( sql: sqlglot.exp.ExpOrStr, schema_resolver: SchemaResolver, @@ -975,7 +1019,7 @@ def _sqlglot_lineage_inner( default_schema: Optional[str] = None, ) -> SqlParsingResult: dialect = _get_dialect(schema_resolver.platform) - if dialect == "snowflake": + if _is_dialect_instance(dialect, "snowflake"): # in snowflake, table identifiers must be uppercased to match sqlglot's behavior. if default_db: default_db = default_db.upper() @@ -1064,7 +1108,7 @@ def _sqlglot_lineage_inner( column_lineage = _column_level_lineage( select_statement, dialect=dialect, - input_tables=table_name_schema_mapping, + table_schemas=table_name_schema_mapping, output_table=downstream_table, default_db=default_db, default_schema=default_schema, @@ -1204,13 +1248,13 @@ def replace_cte_refs(node: sqlglot.exp.Expression) -> sqlglot.exp.Expression: full_new_name, dialect=dialect, into=sqlglot.exp.Table ) - # We expect node.parent to be a Table or Column. - # Either way, it should support catalog/db/name. parent = node.parent - if "catalog" in parent.arg_types: + # We expect node.parent to be a Table or Column, both of which support catalog/db/name. + # However, we check the parent's arg_types to be safe. + if "catalog" in parent.arg_types and table_expr.catalog: parent.set("catalog", table_expr.catalog) - if "db" in parent.arg_types: + if "db" in parent.arg_types and table_expr.db: parent.set("db", table_expr.db) new_node = sqlglot.exp.Identifier(this=table_expr.name) diff --git a/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_materialized_view_auto_refresh.json b/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_materialized_view_auto_refresh.json new file mode 100644 index 00000000000000..fce65056a32f7b --- /dev/null +++ b/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_materialized_view_auto_refresh.json @@ -0,0 +1,54 @@ +{ + "query_type": "CREATE", + "in_tables": [ + "urn:li:dataset:(urn:li:dataPlatform:redshift,customer,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:redshift,orders,PROD)" + ], + "out_tables": [ + "urn:li:dataset:(urn:li:dataPlatform:redshift,mv_total_orders,PROD)" + ], + "column_lineage": [ + { + "downstream": { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,mv_total_orders,PROD)", + "column": "cust_id", + "column_type": null, + "native_column_type": null + }, + "upstreams": [ + { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,customer,PROD)", + "column": "cust_id" + } + ] + }, + { + "downstream": { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,mv_total_orders,PROD)", + "column": "first_name", + "column_type": null, + "native_column_type": null + }, + "upstreams": [ + { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,customer,PROD)", + "column": "first_name" + } + ] + }, + { + "downstream": { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,mv_total_orders,PROD)", + "column": "total_amount", + "column_type": null, + "native_column_type": null + }, + "upstreams": [ + { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,orders,PROD)", + "column": "amount" + } + ] + } + ] +} \ No newline at end of file diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_sql_detach.py b/metadata-ingestion/tests/unit/sql_parsing/test_sql_detach.py new file mode 100644 index 00000000000000..c99b05c35e0f57 --- /dev/null +++ b/metadata-ingestion/tests/unit/sql_parsing/test_sql_detach.py @@ -0,0 +1,46 @@ +from datahub.utilities.sqlglot_lineage import detach_ctes + + +def test_detach_ctes_simple(): + original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 ON table2.id = __cte_0.id" + detached_expr = detach_ctes( + original, + platform="snowflake", + cte_mapping={"__cte_0": "_my_cte_table"}, + ) + detached = detached_expr.sql(dialect="snowflake") + + assert ( + detached + == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN _my_cte_table ON table2.id = _my_cte_table.id" + ) + + +def test_detach_ctes_with_alias(): + original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 AS tablealias ON table2.id = tablealias.id" + detached_expr = detach_ctes( + original, + platform="snowflake", + cte_mapping={"__cte_0": "_my_cte_table"}, + ) + detached = detached_expr.sql(dialect="snowflake") + + assert ( + detached + == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN _my_cte_table AS tablealias ON table2.id = tablealias.id" + ) + + +def test_detach_ctes_with_multipart_replacement(): + original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 ON table2.id = __cte_0.id" + detached_expr = detach_ctes( + original, + platform="snowflake", + cte_mapping={"__cte_0": "my_db.my_schema.my_table"}, + ) + detached = detached_expr.sql(dialect="snowflake") + + assert ( + detached + == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN my_db.my_schema.my_table ON table2.id = my_db.my_schema.my_table.id" + ) diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py index 7f69e358f8f119..eb1ba06669112f 100644 --- a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py +++ b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py @@ -3,59 +3,11 @@ import pytest from datahub.testing.check_sql_parser_result import assert_sql_result -from datahub.utilities.sqlglot_lineage import ( - _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT, - detach_ctes, -) +from datahub.utilities.sqlglot_lineage import _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT RESOURCE_DIR = pathlib.Path(__file__).parent / "goldens" -def test_detach_ctes_simple(): - original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 ON table2.id = __cte_0.id" - detached_expr = detach_ctes( - original, - platform="snowflake", - cte_mapping={"__cte_0": "_my_cte_table"}, - ) - detached = detached_expr.sql(dialect="snowflake") - - assert ( - detached - == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN _my_cte_table ON table2.id = _my_cte_table.id" - ) - - -def test_detach_ctes_with_alias(): - original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 AS tablealias ON table2.id = tablealias.id" - detached_expr = detach_ctes( - original, - platform="snowflake", - cte_mapping={"__cte_0": "_my_cte_table"}, - ) - detached = detached_expr.sql(dialect="snowflake") - - assert ( - detached - == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN _my_cte_table AS tablealias ON table2.id = tablealias.id" - ) - - -def test_detach_ctes_with_multipart_replacement(): - original = "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN __cte_0 ON table2.id = __cte_0.id" - detached_expr = detach_ctes( - original, - platform="snowflake", - cte_mapping={"__cte_0": "my_db.my_schema.my_table"}, - ) - detached = detached_expr.sql(dialect="snowflake") - - assert ( - detached - == "WITH __cte_0 AS (SELECT * FROM table1) SELECT * FROM table2 JOIN my_db.my_schema.my_table ON table2.id = my_db.my_schema.my_table.id" - ) - - def test_select_max(): # The COL2 should get normalized to col2. assert_sql_result( @@ -1023,3 +975,25 @@ def test_postgres_complex_update(): }, expected_file=RESOURCE_DIR / "test_postgres_complex_update.json", ) + + +def test_redshift_materialized_view_auto_refresh(): + # Example query from the redshift docs: https://docs.aws.amazon.com/prescriptive-guidance/latest/materialized-views-redshift/refreshing-materialized-views.html + assert_sql_result( + """ +CREATE MATERIALIZED VIEW mv_total_orders +AUTO REFRESH YES -- Add this clause to auto refresh the MV +AS + SELECT c.cust_id, + c.first_name, + sum(o.amount) as total_amount + FROM orders o + JOIN customer c + ON c.cust_id = o.customer_id + GROUP BY c.cust_id, + c.first_name; +""", + dialect="redshift", + expected_file=RESOURCE_DIR + / "test_redshift_materialized_view_auto_refresh.json", + ) From db55fadb734546b796352aeb38ec2719ce770cf9 Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Fri, 22 Dec 2023 19:48:30 +0530 Subject: [PATCH 063/540] feat(ui): add custom cron option for UI based ingestion (#9510) --- .../source/builder/CreateScheduleStep.tsx | 38 ++++++++++++++----- .../source/builder/SelectTemplateStep.tsx | 4 +- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/datahub-web-react/src/app/ingest/source/builder/CreateScheduleStep.tsx b/datahub-web-react/src/app/ingest/source/builder/CreateScheduleStep.tsx index 7a14b6a7941896..3745ee0f44dc01 100644 --- a/datahub-web-react/src/app/ingest/source/builder/CreateScheduleStep.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/CreateScheduleStep.tsx @@ -1,4 +1,4 @@ -import { Button, Form, Switch, Typography } from 'antd'; +import { Button, Checkbox, Form, Input, Switch, Typography } from 'antd'; import React, { useMemo, useState } from 'react'; import { Cron } from 'react-js-cron'; import 'react-js-cron/dist/styles.css'; @@ -31,6 +31,10 @@ const CronText = styled(Typography.Paragraph)` color: ${ANTD_GRAY[7]}; `; +const AdvancedCheckBox = styled(Typography.Text)` + margin-right: 10px; + margin-bottom: 8px; +`; const CronSuccessCheck = styled(CheckCircleOutlined)` color: ${REDESIGN_COLORS.BLUE}; margin-right: 4px; @@ -68,8 +72,8 @@ export const CreateScheduleStep = ({ state, updateState, goTo, prev }: StepProps const { schedule } = state; const interval = schedule?.interval?.replaceAll(', ', ' ') || DAILY_MIDNIGHT_CRON_INTERVAL; const timezone = schedule?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; - const [scheduleEnabled, setScheduleEnabled] = useState(!!schedule); + const [advancedCronCheck, setAdvancedCronCheck] = useState(false); const [scheduleCronInterval, setScheduleCronInterval] = useState(interval); const [scheduleTimezone, setScheduleTimezone] = useState(timezone); @@ -137,13 +141,29 @@ export const CreateScheduleStep = ({ state, updateState, goTo, prev }: StepProps )}
Schedule}> - +
+ Advanced + setAdvancedCronCheck(event.target.checked)} + /> +
+ {advancedCronCheck ? ( + setScheduleCronInterval(e.target.value)} + /> + ) : ( + + )} {cronAsText.error && <>Invalid cron schedule. Cron must be of UNIX form:} {!cronAsText.text && ( diff --git a/datahub-web-react/src/app/ingest/source/builder/SelectTemplateStep.tsx b/datahub-web-react/src/app/ingest/source/builder/SelectTemplateStep.tsx index 8aaa4f3448686f..6b771d459c4ef9 100644 --- a/datahub-web-react/src/app/ingest/source/builder/SelectTemplateStep.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/SelectTemplateStep.tsx @@ -70,7 +70,9 @@ export const SelectTemplateStep = ({ state, updateState, goTo, cancel, ingestion }; const filteredSources = ingestionSources.filter( - (source) => source.displayName.includes(searchFilter) || source.name.includes(searchFilter), + (source) => + source.displayName.toLocaleLowerCase().includes(searchFilter.toLocaleLowerCase()) || + source.name.toLocaleLowerCase().includes(searchFilter.toLocaleLowerCase()), ); return ( From 0d8568e087b5489b49161423ed299dec84e32f1e Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 22 Dec 2023 14:59:14 -0500 Subject: [PATCH 064/540] fix(ingest): update dbt type inference (#9512) --- .../integration/dbt/dbt_enabled_with_schemas_mces_golden.json | 2 +- .../integration/dbt/dbt_test_column_meta_mapping_golden.json | 2 +- .../dbt/dbt_test_with_complex_owner_patterns_mces_golden.json | 2 +- .../dbt/dbt_test_with_data_platform_instance_mces_golden.json | 2 +- .../dbt/dbt_test_with_non_incremental_lineage_mces_golden.json | 2 +- .../dbt/dbt_test_with_target_platform_instance_mces_golden.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json index 4deb725ed2b444..fa26a93479a4f8 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_enabled_with_schemas_mces_golden.json @@ -153,7 +153,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json index 588470ef416314..f2208fd98c2030 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_column_meta_mapping_golden.json @@ -87,7 +87,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json index 926e8b8c8ed84b..a27eeb37759608 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_complex_owner_patterns_mces_golden.json @@ -117,7 +117,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json index 3727603266f252..43336ca585bcc3 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_data_platform_instance_mces_golden.json @@ -118,7 +118,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json index ec879e6af766ac..27ea568d010fa1 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_non_incremental_lineage_mces_golden.json @@ -118,7 +118,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, diff --git a/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json b/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json index e25c5e4faf6afd..07296e175d9ec6 100644 --- a/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json +++ b/metadata-ingestion/tests/integration/dbt/dbt_test_with_target_platform_instance_mces_golden.json @@ -118,7 +118,7 @@ "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "VARCHAR", + "nativeDataType": "TEXT", "recursive": false, "isPartOfKey": false }, From ed5bdfc5aec65978145a72d2701941ed21b35554 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 22 Dec 2023 17:12:31 -0500 Subject: [PATCH 065/540] feat(ingest/redshift): merge CLL instead of overwriting (#9513) --- .../ingestion/source/redshift/lineage.py | 74 ++++++++++++------- .../src/datahub/utilities/sqlglot_lineage.py | 5 +- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py index abed8505f168bf..8135e1d44c1021 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py @@ -41,6 +41,7 @@ UpstreamLineageClass, ) from datahub.utilities import memory_footprint +from datahub.utilities.dedup_list import deduplicate_list from datahub.utilities.urns import dataset_urn logger: logging.Logger = logging.getLogger(__name__) @@ -85,6 +86,30 @@ def __post_init__(self): else: self.dataset_lineage_type = DatasetLineageTypeClass.TRANSFORMED + def merge_lineage( + self, + upstreams: Set[LineageDataset], + cll: Optional[List[sqlglot_l.ColumnLineageInfo]], + ) -> None: + self.upstreams = self.upstreams.union(upstreams) + + # Merge CLL using the output column name as the merge key. + self.cll = self.cll or [] + existing_cll: Dict[str, sqlglot_l.ColumnLineageInfo] = { + c.downstream.column: c for c in self.cll + } + for c in cll or []: + if c.downstream.column in existing_cll: + # Merge using upstream + column name as the merge key. + existing_cll[c.downstream.column].upstreams = deduplicate_list( + [*existing_cll[c.downstream.column].upstreams, *c.upstreams] + ) + else: + # New output column, just add it as is. + self.cll.append(c) + + self.cll = self.cll or None + class RedshiftLineageExtractor: def __init__( @@ -161,7 +186,12 @@ def _get_sources_from_query( ) sources.append(source) - return sources, parsed_result.column_lineage + return ( + sources, + parsed_result.column_lineage + if self.config.include_view_column_lineage + else None, + ) def _build_s3_path_from_row(self, filename: str) -> str: path = filename.strip() @@ -208,7 +238,7 @@ def _get_sources( "Only s3 source supported with copy. The source was: {path}." ) self.report.num_lineage_dropped_not_support_copy_path += 1 - return sources, cll + return [], None path = strip_s3_prefix(self._get_s3_path(path)) urn = make_dataset_urn_with_platform_instance( platform=platform.value, @@ -284,7 +314,6 @@ def _populate_lineage_map( ddl=lineage_row.ddl, filename=lineage_row.filename, ) - target.cll = cll target.upstreams.update( self._get_upstream_lineages( @@ -294,13 +323,13 @@ def _populate_lineage_map( raw_db_name=raw_db_name, ) ) + target.cll = cll - # Merging downstreams if dataset already exists and has downstreams + # Merging upstreams if dataset already exists and has upstreams if target.dataset.urn in self._lineage_map: - self._lineage_map[target.dataset.urn].upstreams = self._lineage_map[ - target.dataset.urn - ].upstreams.union(target.upstreams) - + self._lineage_map[target.dataset.urn].merge_lineage( + upstreams=target.upstreams, cll=target.cll + ) else: self._lineage_map[target.dataset.urn] = target @@ -420,7 +449,10 @@ def populate_lineage( ) -> None: populate_calls: List[Tuple[str, LineageCollectorType]] = [] - if self.config.table_lineage_mode == LineageMode.STL_SCAN_BASED: + if self.config.table_lineage_mode in { + LineageMode.STL_SCAN_BASED, + LineageMode.MIXED, + }: # Populate table level lineage by getting upstream tables from stl_scan redshift table query = RedshiftQuery.stl_scan_based_lineage_query( self.config.database, @@ -428,15 +460,10 @@ def populate_lineage( self.end_time, ) populate_calls.append((query, LineageCollectorType.QUERY_SCAN)) - elif self.config.table_lineage_mode == LineageMode.SQL_BASED: - # Populate table level lineage by parsing table creating sqls - query = RedshiftQuery.list_insert_create_queries_sql( - db_name=database, - start_time=self.start_time, - end_time=self.end_time, - ) - populate_calls.append((query, LineageCollectorType.QUERY_SQL_PARSER)) - elif self.config.table_lineage_mode == LineageMode.MIXED: + if self.config.table_lineage_mode in { + LineageMode.SQL_BASED, + LineageMode.MIXED, + }: # Populate table level lineage by parsing table creating sqls query = RedshiftQuery.list_insert_create_queries_sql( db_name=database, @@ -445,15 +472,7 @@ def populate_lineage( ) populate_calls.append((query, LineageCollectorType.QUERY_SQL_PARSER)) - # Populate table level lineage by getting upstream tables from stl_scan redshift table - query = RedshiftQuery.stl_scan_based_lineage_query( - db_name=database, - start_time=self.start_time, - end_time=self.end_time, - ) - populate_calls.append((query, LineageCollectorType.QUERY_SCAN)) - - if self.config.include_views: + if self.config.include_views and self.config.include_view_lineage: # Populate table level lineage for views query = RedshiftQuery.view_lineage_query() populate_calls.append((query, LineageCollectorType.VIEW)) @@ -540,7 +559,6 @@ def get_lineage( dataset_urn: str, schema: RedshiftSchema, ) -> Optional[Tuple[UpstreamLineageClass, Dict[str, str]]]: - upstream_lineage: List[UpstreamClass] = [] cll_lineage: List[FineGrainedLineage] = [] diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index f84b3f8b94a2e0..b43c8de4c8f3d8 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -193,7 +193,7 @@ class _ColumnRef(_FrozenModel): column: str -class ColumnRef(_ParserBaseModel): +class ColumnRef(_FrozenModel): table: Urn column: str @@ -929,6 +929,7 @@ def _translate_sqlglot_type( TypeClass = ArrayTypeClass elif sqlglot_type in { sqlglot.exp.DataType.Type.UNKNOWN, + sqlglot.exp.DataType.Type.NULL, }: return None else: @@ -1090,7 +1091,7 @@ def _sqlglot_lineage_inner( table_schemas_resolved=total_schemas_resolved, ) logger.debug( - f"Resolved {len(table_name_schema_mapping)} of {len(tables)} table schemas" + f"Resolved {total_schemas_resolved} of {total_tables_discovered} table schemas" ) # Simplify the input statement for column-level lineage generation. From 4448cf1f2d777c82d913e5ee0aeabd0e2785fad3 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Tue, 26 Dec 2023 16:30:24 +0530 Subject: [PATCH 066/540] fix(ui/ingestion): add debounce on search on ingestion listing page (#9516) --- .../entity/shared/tabs/Dataset/Queries/utils/constants.ts | 1 + datahub-web-react/src/app/ingest/secret/SecretsList.tsx | 8 +++++++- .../src/app/ingest/source/IngestionSourceList.tsx | 8 +++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/utils/constants.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/utils/constants.ts index 5176c1207874c9..025705abc580ea 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/utils/constants.ts +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/utils/constants.ts @@ -16,5 +16,6 @@ export const DEFAULT_MAX_RECENT_QUERIES = 9; */ export const MAX_ROWS_BEFORE_DEBOUNCE = 50; export const HALF_SECOND_IN_MS = 500; +export const ONE_SECOND_IN_MS = 1000; export const ADD_UNAUTHORIZED_MESSAGE = 'You are not authorized to add Queries to this entity.'; diff --git a/datahub-web-react/src/app/ingest/secret/SecretsList.tsx b/datahub-web-react/src/app/ingest/secret/SecretsList.tsx index 2728fff0ccba34..1a960997e6beeb 100644 --- a/datahub-web-react/src/app/ingest/secret/SecretsList.tsx +++ b/datahub-web-react/src/app/ingest/secret/SecretsList.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { Button, Empty, message, Modal, Pagination, Typography } from 'antd'; +import { debounce } from 'lodash'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import * as QueryString from 'query-string'; import { useLocation } from 'react-router'; @@ -18,6 +19,7 @@ import { SearchBar } from '../../search/SearchBar'; import { useEntityRegistry } from '../../useEntityRegistry'; import { scrollToTop } from '../../shared/searchUtils'; import { addSecretToListSecretsCache, removeSecretFromListSecretsCache } from './cacheUtils'; +import { ONE_SECOND_IN_MS } from '../../entity/shared/tabs/Dataset/Queries/utils/constants'; const DeleteButtonContainer = styled.div` display: flex; @@ -84,6 +86,10 @@ export const SecretsList = () => { setPage(newPage); }; + const debouncedSetQuery = debounce((newQuery: string | undefined) => { + setQuery(newQuery); + }, ONE_SECOND_IN_MS); + const onSubmit = (state: SecretBuilderState, resetBuilderState: () => void) => { createSecretMutation({ variables: { @@ -199,7 +205,7 @@ export const SecretsList = () => { onSearch={() => null} onQueryChange={(q) => { setPage(1); - setQuery(q); + debouncedSetQuery(q); }} entityRegistry={entityRegistry} hideRecommendations diff --git a/datahub-web-react/src/app/ingest/source/IngestionSourceList.tsx b/datahub-web-react/src/app/ingest/source/IngestionSourceList.tsx index 6188845694f9eb..e6db6bfcc9a61b 100644 --- a/datahub-web-react/src/app/ingest/source/IngestionSourceList.tsx +++ b/datahub-web-react/src/app/ingest/source/IngestionSourceList.tsx @@ -1,5 +1,6 @@ import { PlusOutlined, RedoOutlined } from '@ant-design/icons'; import React, { useCallback, useEffect, useState } from 'react'; +import { debounce } from 'lodash'; import * as QueryString from 'query-string'; import { useLocation } from 'react-router'; import { Button, message, Modal, Pagination, Select } from 'antd'; @@ -30,6 +31,7 @@ import { INGESTION_CREATE_SOURCE_ID, INGESTION_REFRESH_SOURCES_ID, } from '../../onboarding/config/IngestionOnboardingConfig'; +import { ONE_SECOND_IN_MS } from '../../entity/shared/tabs/Dataset/Queries/utils/constants'; const PLACEHOLDER_URN = 'placeholder-urn'; @@ -133,6 +135,10 @@ export const IngestionSourceList = () => { setLastRefresh(new Date().getTime()); }, [refetch]); + const debouncedSetQuery = debounce((newQuery: string | undefined) => { + setQuery(newQuery); + }, ONE_SECOND_IN_MS); + function hasActiveExecution() { return !!filteredSources.find((source) => source.executions?.executionRequests.find((request) => isExecutionRequestActive(request)), @@ -401,7 +407,7 @@ export const IngestionSourceList = () => { onSearch={() => null} onQueryChange={(q) => { setPage(1); - setQuery(q); + debouncedSetQuery(q); }} entityRegistry={entityRegistry} hideRecommendations From d399a530576974da9beb1af24d7ea5f98922b6d3 Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Tue, 26 Dec 2023 18:26:40 +0530 Subject: [PATCH 067/540] fix(ui): correct the color of edit links (#9517) --- .../entity/shared/tabs/Documentation/components/LinkList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx index 1b5c3d54009da8..9f94a830ac1cfa 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/LinkList.tsx @@ -159,7 +159,7 @@ export const LinkList = ({ refetch }: LinkListProps) => { - + + } + trigger={['click']} + > + + ), }, From b7a0bbcb3d6000d3d9827ab19f13c3118d0bfc19 Mon Sep 17 00:00:00 2001 From: Fernando Marino` Date: Thu, 28 Dec 2023 01:24:25 +0100 Subject: [PATCH 073/540] feat(ingest/openapi): support proxies and alternate auth schemes (#9492) Co-authored-by: Fernando Marino Co-authored-by: Harshal Sheth --- .../src/datahub/ingestion/source/openapi.py | 41 +++++++++++++++---- .../ingestion/source/openapi_parser.py | 26 ++++++++---- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/openapi.py b/metadata-ingestion/src/datahub/ingestion/source/openapi.py index 3925ba51c16dd9..ad62ef7362aebd 100755 --- a/metadata-ingestion/src/datahub/ingestion/source/openapi.py +++ b/metadata-ingestion/src/datahub/ingestion/source/openapi.py @@ -52,6 +52,13 @@ class OpenApiConfig(ConfigModel): ignore_endpoints: list = Field(default=[], description="") username: str = Field(default="", description="") password: str = Field(default="", description="") + proxies: Optional[dict] = Field( + default=None, + description="Eg. " + "`{'http': 'http://10.10.1.10:3128', 'https': 'http://10.10.1.10:1080'}`." + "If authentication is required, add it to the proxy url directly e.g. " + "`http://user:pass@10.10.1.10:3128/`.", + ) forced_examples: dict = Field(default={}, description="") token: Optional[str] = Field(default=None, description="") get_token: dict = Field(default={}, description="") @@ -87,9 +94,13 @@ def get_swagger(self) -> Dict: password=self.password, tok_url=url4req, method=self.get_token["request_type"], + proxies=self.proxies, ) sw_dict = get_swag_json( - self.url, token=self.token, swagger_file=self.swagger_file + self.url, + token=self.token, + swagger_file=self.swagger_file, + proxies=self.proxies, ) # load the swagger file else: # using basic auth for accessing endpoints @@ -98,6 +109,7 @@ def get_swagger(self) -> Dict: username=self.username, password=self.password, swagger_file=self.swagger_file, + proxies=self.proxies, ) return sw_dict @@ -258,10 +270,15 @@ def get_workunits_internal(self) -> Iterable[ApiWorkUnit]: # noqa: C901 tot_url = clean_url(config.url + self.url_basepath + endpoint_k) if config.token: - response = request_call(tot_url, token=config.token) + response = request_call( + tot_url, token=config.token, proxies=config.proxies + ) else: response = request_call( - tot_url, username=config.username, password=config.password + tot_url, + username=config.username, + password=config.password, + proxies=config.proxies, ) if response.status_code == 200: fields2add, root_dataset_samples[dataset_name] = extract_fields( @@ -281,10 +298,15 @@ def get_workunits_internal(self) -> Iterable[ApiWorkUnit]: # noqa: C901 url_guess = try_guessing(endpoint_k, root_dataset_samples) tot_url = clean_url(config.url + self.url_basepath + url_guess) if config.token: - response = request_call(tot_url, token=config.token) + response = request_call( + tot_url, token=config.token, proxies=config.proxies + ) else: response = request_call( - tot_url, username=config.username, password=config.password + tot_url, + username=config.username, + password=config.password, + proxies=config.proxies, ) if response.status_code == 200: fields2add, _ = extract_fields(response, dataset_name) @@ -304,10 +326,15 @@ def get_workunits_internal(self) -> Iterable[ApiWorkUnit]: # noqa: C901 ) tot_url = clean_url(config.url + self.url_basepath + composed_url) if config.token: - response = request_call(tot_url, token=config.token) + response = request_call( + tot_url, token=config.token, proxies=config.proxies + ) else: response = request_call( - tot_url, username=config.username, password=config.password + tot_url, + username=config.username, + password=config.password, + proxies=config.proxies, ) if response.status_code == 200: fields2add, _ = extract_fields(response, dataset_name) diff --git a/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py b/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py index 1ab40bc8be73d4..84bb3ad4526117 100755 --- a/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py +++ b/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py @@ -51,6 +51,7 @@ def request_call( token: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, + proxies: Optional[dict] = None, ) -> requests.Response: headers = {"accept": "application/json"} @@ -60,8 +61,8 @@ def request_call( ) elif token is not None: - headers["Authorization"] = f"Bearer {token}" - return requests.get(url, headers=headers) + headers["Authorization"] = f"{token}" + return requests.get(url, proxies=proxies, headers=headers) else: return requests.get(url, headers=headers) @@ -72,12 +73,15 @@ def get_swag_json( username: Optional[str] = None, password: Optional[str] = None, swagger_file: str = "", + proxies: Optional[dict] = None, ) -> Dict: tot_url = url + swagger_file if token is not None: - response = request_call(url=tot_url, token=token) + response = request_call(url=tot_url, token=token, proxies=proxies) else: - response = request_call(url=tot_url, username=username, password=password) + response = request_call( + url=tot_url, username=username, password=password, proxies=proxies + ) if response.status_code != 200: raise Exception(f"Unable to retrieve {tot_url}, error {response.status_code}") @@ -251,7 +255,7 @@ def compose_url_attr(raw_url: str, attr_list: list) -> str: attr_list=["2",]) asd2 == "http://asd.com/2" """ - splitted = re.split(r"\{[^}]+\}", raw_url) + splitted = re.split(r"\{[^}]+}", raw_url) if splitted[-1] == "": # it can happen that the last element is empty splitted = splitted[:-1] composed_url = "" @@ -265,7 +269,7 @@ def compose_url_attr(raw_url: str, attr_list: list) -> str: def maybe_theres_simple_id(url: str) -> str: - dets = re.findall(r"(\{[^}]+\})", url) # searching the fields between parenthesis + dets = re.findall(r"(\{[^}]+})", url) # searching the fields between parenthesis if len(dets) == 0: return url dets_w_id = [det for det in dets if "id" in det] # the fields containing "id" @@ -349,6 +353,7 @@ def get_tok( password: str = "", tok_url: str = "", method: str = "post", + proxies: Optional[dict] = None, ) -> str: """ Trying to post username/password to get auth. @@ -357,12 +362,15 @@ def get_tok( url4req = url + tok_url if method == "post": # this will make a POST call with username and password - data = {"username": username, "password": password} + data = {"username": username, "password": password, "maxDuration": True} # url2post = url + "api/authenticate/" - response = requests.post(url4req, data=data) + response = requests.post(url4req, proxies=proxies, json=data) if response.status_code == 200: cont = json.loads(response.content) - token = cont["tokens"]["access"] + if "token" in cont: # other authentication scheme + token = cont["token"] + else: # works only for bearer authentication scheme + token = f"Bearer {cont['tokens']['access']}" elif method == "get": # this will make a GET call with username and password response = requests.get(url4req) From 754d8814477d050e907aeca6c561d98372b60dc5 Mon Sep 17 00:00:00 2001 From: cburroughs Date: Wed, 27 Dec 2023 19:33:41 -0500 Subject: [PATCH 074/540] build(ingest/feast): upgrade to latest feast version (#9439) --- metadata-ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 4632c20cd3b969..32d49ffc73fa34 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -316,7 +316,7 @@ # https://github.com/elastic/elasticsearch-py/issues/1639#issuecomment-883587433 "elasticsearch": {"elasticsearch==7.13.4"}, "feast": { - "feast~=0.31.1", + "feast~=0.34.1", "flask-openid>=1.3.0", # typeguard 3.x, released on 2023-03-14, seems to cause issues with Feast. "typeguard<3", From 9f79f44dd69a5a86864ccc31473305bdf1c2f4bb Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 27 Dec 2023 20:05:17 -0500 Subject: [PATCH 075/540] build: enable gradle caching (#9525) --- .github/workflows/airflow-plugin.yml | 1 + .github/workflows/build-and-test.yml | 1 + .github/workflows/check-datahub-jars.yml | 1 + .github/workflows/docker-unified.yml | 27 ++++++++++++--------- .github/workflows/documentation.yml | 1 + .github/workflows/metadata-ingestion.yml | 1 + .github/workflows/metadata-io.yml | 2 ++ .github/workflows/metadata-model.yml | 2 ++ .github/workflows/publish-datahub-jars.yml | 2 ++ .github/workflows/spark-smoke-test.yml | 2 ++ gradle.properties | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 61624 -> 61608 bytes gradlew | 4 +-- 13 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index 70816e5f093d13..97a0da8546ed17 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -55,6 +55,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index dab64cf2dca5e6..6daf1904ba3ae3 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -42,6 +42,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/check-datahub-jars.yml b/.github/workflows/check-datahub-jars.yml index 46d97ffec88618..556cd87f12df04 100644 --- a/.github/workflows/check-datahub-jars.yml +++ b/.github/workflows/check-datahub-jars.yml @@ -33,6 +33,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index 7cef38b1cd47ce..454e7661402459 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -84,6 +84,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -145,6 +146,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -206,6 +208,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -267,6 +270,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -328,6 +332,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - name: Pre-build artifacts for docker image @@ -567,6 +572,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - uses: dorny/paths-filter@v2 @@ -653,6 +659,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - uses: dorny/paths-filter@v2 @@ -731,12 +738,13 @@ jobs: strategy: fail-fast: false matrix: - test_strategy: [ - "no_cypress_suite0", - "no_cypress_suite1", - "cypress_suite1", - "cypress_rest" - ] + test_strategy: + [ + "no_cypress_suite0", + "no_cypress_suite1", + "cypress_suite1", + "cypress_rest", + ] needs: [ setup, @@ -760,6 +768,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" @@ -904,11 +913,7 @@ jobs: deploy_datahub_head: name: Deploy to Datahub HEAD runs-on: ubuntu-latest - needs: - [ - setup, - smoke_test - ] + needs: [setup, smoke_test] steps: - uses: aws-actions/configure-aws-credentials@v1 if: ${{ needs.setup.outputs.publish != 'false' && github.repository_owner == 'datahub-project' && needs.setup.outputs.repository_name == 'datahub' }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 29953b8b70d911..e1671cc0219198 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -32,6 +32,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/metadata-ingestion.yml b/.github/workflows/metadata-ingestion.yml index 4e04fef3b3980b..af73db483f9aeb 100644 --- a/.github/workflows/metadata-ingestion.yml +++ b/.github/workflows/metadata-ingestion.yml @@ -49,6 +49,7 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index 2188fcb07c77a6..96229642244b67 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -34,9 +34,11 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" + cache: "pip" - name: Gradle build (and test) # there is some race condition in gradle build, which makes gradle never terminate in ~30% of the runs # running build first without datahub-web-react:yarnBuild and then with it is 100% stable diff --git a/.github/workflows/metadata-model.yml b/.github/workflows/metadata-model.yml index d0112f1b14e7af..265a66aa236ae9 100644 --- a/.github/workflows/metadata-model.yml +++ b/.github/workflows/metadata-model.yml @@ -34,10 +34,12 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.10" + cache: "pip" - name: Install dependencies run: ./metadata-ingestion/scripts/install_deps.sh - name: Run model generation diff --git a/.github/workflows/publish-datahub-jars.yml b/.github/workflows/publish-datahub-jars.yml index 24d1c5436b3156..0a311be33cd30a 100644 --- a/.github/workflows/publish-datahub-jars.yml +++ b/.github/workflows/publish-datahub-jars.yml @@ -54,9 +54,11 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" + cache: "pip" - name: checkout upstream repo run: | git remote add upstream https://github.com/datahub-project/datahub.git diff --git a/.github/workflows/spark-smoke-test.yml b/.github/workflows/spark-smoke-test.yml index 60e183cce5179c..94692bd3c2336a 100644 --- a/.github/workflows/spark-smoke-test.yml +++ b/.github/workflows/spark-smoke-test.yml @@ -35,9 +35,11 @@ jobs: with: distribution: "zulu" java-version: 17 + - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 with: python-version: "3.10" + cache: "pip" - name: Install dependencies run: ./metadata-ingestion/scripts/install_deps.sh - name: Remove images diff --git a/gradle.properties b/gradle.properties index 1cd349344b432d..f410ff01bf397d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.daemon=false org.gradle.configureondemand=true org.gradle.parallel=true -org.gradle.caching=false +org.gradle.caching=true # Increase gradle JVM memory to 3GB to allow tests to run locally org.gradle.jvmargs=-Xmx3000m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index afba109285af78dbd2a1d187e33ac4f87c76e392..ccebba7710deaf9f98673a68957ea02138b60d0a 100644 GIT binary patch delta 11632 zcmZvCWmsHIvn}otoWWsmcPF@o;2JczyX!!3mq9{scP9i11PcU$4GzKGB{&IihkW_o zbKd)$`O#H-t*YJKyL-C(sjk*_`0{mlL^UON1bi?o20=0j9xOh%ei@J~uLo722sQ!? z42-jzH3vM*|5z;${D%Z1z>-6?+!a8R2&^_5R82;A{zEDJH88;dK(!yMfTk1-S2$Sw zCIswy1gQ9kmj(+JN(3g)qKZ%!32Jsub^v`?A}@l6ieT!WPuco!LiX35zyZJ<06|Me)};q$8UbE@JzIV7 zjabmR_!-#~61M#3PnCX3B*N=plNEo@)#a7bm0u@^k2l!XFA*mqTmj$fmF4iiq}LnC zF*Umt9<5P>#-!gN=S1d=2veDI!b$hC1Ln*>#i7yq^5KWPfkp2dxeSgIp8vhCpfFTE zV1wZQzExKaNRfI@`aAs-h+&iccdh_M%o_IV1>F#1(UCqNF2(lopwF*!G|`Yi+*}KY z_^VrRSQ!Go{X8FQ)7(c)FxfA3b^cwoy{7NHo*g_DAfbd4l;(a`|9s2tSzN_Vx?Em; z;hF;K(bxSLnqQ=w$d`>%SU^n@^Pi|(mMvuh0UNbZ*3pPEqIl6{lDJInhNCldqRNw9 z=$)@$*s!12V~KYN7t(143=hwuLM(2DGf-$GhYeQgp>5QzNMHH6(LkJ>v>BOt=Oi#|f+HvN*zP?|OMLfmXfJOvg~^ z+P0-<6_#rYiv&UYihhcOFRn*&!j^szdCaBEt|z&sf|%3)F<{;T^hTp{k+(<;eKdg;pqg8c@Q` zvCZht=B@Tg0J)?P!+-rXNJTM^34+xq)yspMn|7Anogw~^%`&!pux&Y%yDi%Mb7G(m zjY7wbTtyllcE!Hrs5L07C!{8~cWPum|1DckPu~UHMdQq}>5oNYcGnd@$)0$-K z#}DDT47{IUYHZ~MA|y6n4;#JRzjWeiFK9xX-#%#B(dX|(jG1efSmt#Sd(tTAct61T z)lZ?Q%2zprzKM^&ZMOUre~z3cju$$pHN_ya<43#01FE66$%`B`Hi|iNYsJIiZI5{G zv1T@cX`#C_P44Iwdju!pycPl!wR10ll2wz+!%2g0Toe{r2#O|WVb!mSl7B^25B2i~ zqHg!|n_0MZ3abhl!52%AOp^trBq>g1{glL>jFeQRZ9K&aA6aoSs90x1D%)HFi5_vl!N2ggjGZ#w)G+DyCm7Cdn zMp32|$lo&_%lM`T$)rI(^D?Ux(fSdVJe1*(NWW<|#aOr5Brdj@BBp^L%CL%W@88nJ z$5O#=5Tr(Ds4-!gcli+TZJ-c>V&~LYj0f5JnKAC}} zo4;Tk!k;E25$qg9CrG~x3%6E>n3ROQ?5Td7+>=MS35r`Ne&$x)>M`tnjcN9wzpdUk@Fq^Ro zEY4~4y-m&6j=jeH^V5$MH(L*=21{y3s_a+<&Kol< z#C{>*WrRxAJ_83`ePJ4_3xKX%BWo?OZL|fx~xpRi-Q73L-#oVqhw14kdH6 zanOGujOl8pv3Ko{)%?paa|;Y1N==Molf3)Z%D_kocrd;@J1};jBc*E{pYj*~AV%D4 z6BbLqXd-)Y@Z#xieFLC#-<36^E62EO6c_wt;(ETD^c(nR^KL5Ret6zggSP-pU3`bn z>fsguY(YTsXYV`{LvA_{9Djz@w*Li#bGLDt&_%1BH|tMgcTkI_28q*WQ6R1LEUsmR zo;Phy#X?-Dm@>dXfr>SAAQS|v?*W>~t=pe{=WQmT;`v+w?zSWOtDV^a|JSzuxr4wa z2p$H87zqZ39(-;`2GX~6(R8qNVK;X$b$1WfQu3aX!+)f21b)@=LDA?fsvx0I#81+v z3a8~ol(Ml^_IV#hUmP3FBPeY-lr~VjUz+t8eT(hUSLk8twy4>Dns~5JzJNe9A{m?6 zme{uHO<4qbeAuK5zPPOo7JUbou!)D4r!VNdAq7jsr5zkH4!Yb=3IFbw*TPx!V8vz# z`MInAzUeGHyh^{zYP~(&7hQmr72gHV1Z$0DX|lAAEx^36e*z>cr!mS!yovW6qwUT| zn)8TbNP(5#?vTc}dro>%u%xxO&oWnm%{x`-Ba>zXL?c@()UH=R3Wqlp10KMXEF+4c zW7$bIe9zh!0!kGpq-B?3UNGL`YY(#ed>JObE%~j;PE98|uCfWwxeA40_;-$ROHboQ z+Ntw}U*wc&CcCV&Ip$TbzNMgXH1Mls>Pk{{dAZ&%@igsP3o#XqT4oh>b4x%; z=iLLvEjM@gLwXm1q)Ll+w~r(>lBL%V!98)BCcqDRE$1)FUI*BlBUbTKgcy$2=xV#H z&K*3dJAsHklpeWePq&)AKY>KZmk78Yi6M#^DDc=1dlcyC{+Q0zI+s!ZK20BbRoOJX zb&_?FCCU^_NkUT@<$%D7?6tBneYk(kH#AcqFE8}3XGHJ?}g&)N=vHD78 zoJ7>3)EALmaLa@^ef|nfT`YHi%wh(RSFr$Ifih9Z8>&m+L_2>yb<9*)cuYIKaVl z6$=UE-2g(iI?>&Vv-aN`WMrAxBXl8*bV;ztb3@(x!ng3%qB2%dT)D|Ljb0)V6gk4- zZ;=g;Fe|g-vm=CT;POEWc~`BJ^k;To=D>A!LEL1PgA zz=?m2z*Jzpg?WkQdL#oUMPxLxG??w~dD3}tF?T}=lWQj4&FxgP;T0^>dT9P*P>fPJ zB+Yu!=Eg98)-DglC(^ePp>|-gTwv;4V!_)jiEAqALdJ?=@_;*+K}=vW93*i&Ol*nG z^9Da=y-4s=pN);>hl)LL8aTQU5-aK*ZtlYRabLtXr_o>451$GwqnzFC#PSkMX<2-+ zS2CvMOlUcYQPkD6h~a;HlbUJAQh&{n`YMi0m-z{ZitrF%ho?=0*fNIarq(!rm8{tK znd(rlO+EL%mC_8kgF2uZ4dgJUe_-Wimy+DL96E-~U6a`P0{cuYKX82j@c@+CMV?&| zhBtrPQPD`PUx;|kp_jpyZ3iB352>T$`GIck$dORnCCCj6VkJj+$vCXJm<^^EePm!y z%(IVe3pAJxya?=&@W`$B^idQyMKFACi}udo}EqNUCI_{_C^E zsm|pN26tJ;3LkH!H|3mb5p#nvmq5MZ;W~YbOSrK_s;=u9>A#0`*o`3&6%hso0}Td- z?eCD{gZ{Gu$LkpA;_2c)Vky4o#4x5olMKfU53(xRTj+2>>~_~&(gc{pym84`bbcqHy- z`b`TAE{+4YGMe&7DdDnzf8X~RgOZT;GaHDS4j3m3&~?!s;QI_rrvpe9TN(~xHn1YQ zYAUQ?(g4@f!?*xBddcS$E6#(C0aWXKR9fpc+N^IHwrE`dfi!-^_P9SWhCiFSuvApF zj5zjOVd2T;by%&!gV=KtQVlX`-WkC|JzSJF;|1H z%)UACPG#~|-?~CuhgXcWVhMPBy?DOICRmZQgn2TLez4loup9bT>;7H2mSeHo0EidU zWvy~YX1L&-Ut>JY+UuxqfY0riUd>AM%B7V%F6<(omjo?Z)kM|lS9J41KMcUqKwqgf&yPcGMII@*lJ?0LAb_xM=ls8~lf z1J+$Fk85i3EjB#;|xIr*Xybc096dJ7(kBml}>Z! zm2@E}AAeQyds@R6NGG}p{npJ>h{OPq>S?|w>1199ij~52`gwdBh<Jf>sij;7IoV z@^tO(yvaa8y|_nf+=K-c845Y|;aD7p2d~jU!_ce?%Y|sdr1#ut{gZ$P4@!Ly(GiM; zej;8!o{coegW297K8=FL7}IANaSkLK8?0XH37P^SY!5o>bHc&*Sr(|9{R-&5%o_xL z7s+{wuP4T-#C>DdLA=Iv%Z?uF7{{S~U^Hz^1`kFt3(m&)zL0AJLYtkT}Q zaoP*W#Rz+EX0qBie?pnucfs=)p_hAsQ7aImOUo9BiJS%3Ba!e%;=>n-m|rSlW{FF< zG0pxcFpSY&XcY9g5r*IJ*cFnk!j2+bV0e6DH40sv+^BxxP-MHKKOr31!nPppF5jC_ z4NIg>9W9APBY9EIKJ+0zH~aZ$&0O~N zf5*bCl`L21p`kGl8w?E9pY^m*uP9u78QR2|iV?&v#00W|_@ zL~vM1Nc(46j-hX?t~;2tlZ!F4ogZsHvdu2Hcr?_M)4r{=`Pgb;?_D#mZ*OootL>=W z_0;BU#jQvI_xMubB=D5o`XSGKyuc2EAz@+lk;kK_a{dtTIW^G+hk@l>wYEF_Gd1R2;Rx=IvTiJ&ocJF7}PAo49=yUuQOXXr}l*O2$rj zB;E0V&x+oTMvmK-J`;|`4y3E&T)pZt7Sld}d3c!ayrlN2Wj)(-hE#`(U^>s*L4X-3 zSKZkNc@<`Uto^#renX1G%8{FfX=+fH#}{~h?Zwl>Tk$g2rp`f?xCW=mO}=;Uh!-#I z$_`f~u;!e+L177craEY@carrpZuKD1k>`_%Qwy*!Fuu6=6UtdQ7`Ps zmhc~%sC{UI=Gt^EQ=q={R}Imvg>>Gz6p~o614C2l@&}Ts5G%Q;#$Q$ zN%fw`7331JG|<=_?_+!KpeXxxg)Bssi9(^IpUIDS!;QRtCL3gDkEgfeXf(EUz}dk` z5#js;zwa~SJBt`)D%+M~T+^x!H_QoCn_84lS&Hb*hK_Kdy{vQXde*~m3{z3Bn)$r@ zennVvZijwmgPjVa)?m|3i7FyxE3*7SQlz>5deffw@tvGM?<37r>#-Hh(rk`>&(*nL zlIH+bO(gSiR0c0drEGhLK8o`t5eCbMg%Zt@?XUS|5*3Lb>imHt^2~>Q!x%wXmz6&F z5u^f~Z`(z9B=N|aU56wZGIVTzmjv-mtylHGigDj1!qz@|9|LUsSboea*kjY6AtxhA zCk{}h(|=9yqHm;DrRgiT!Au;7nK^DHQqFm5RlEGeyAt3h5SC#j#Qg0fu~Q;IhZ%OK zDM8XciVr5W*mlMX)<2!tB_}f~0<0Jf)4%rCPK{T!UXWVEDRkJu$eE3o0KEh-t7-5dl-X5dWB2nYh*z;m- z`_l&;%}K}Jo|k&%beab~A*ntWA3Y>;!cgBPE*zvX7zTI9)pdWZm`60V2}m)|-uCM@ zAE=h`o<$w*p4X3N)nVZ|K#<$1*ZhEGZ7~(zuN<+m2+DRHOb zNf1j3{6Xw6Z04&Is7RQ4vf+cVfM)vsCB9hAl*zt!4n;n)=RG|1UWM9Erfht&MA+zc z#@@8jFVFUvFK`14T35rRbq5}ZMio<*9&H0AQ@$Vple&w!JEVB$)Ql{Tu1sgholX}b z-(F;XV9SdR<=r25q-xZ<(zMY7+A{=<*&dJdAoNTcZ+?a-N!a$E6pt-qtLvu+zr`M7 z#=OsNlIyd-6UT${FR<@aa&CSa39pXcC^V_=Qglh~8v7;hFHNO=?pe|R3KlU60q02s zz&#+nCA7kgk8v)nHvR1ltRb61>_RS9_56W4$TdFom<*B=N4*Uc`&eURwTHRU%q1qR zA4hjh^>!ZuBzFDVRCg)Qjb2-mYe(^(((3hxLK5ji#%|6ZNGPZ8V1truuNwFW=K!eG zA7;e%>d{j{BiQ>Mq|3DzeZTQ?uzQM^8Wcgpr7p3<8$CW*Q?qZgV0T+`4e?oG_+Xb3 z>-*-Nkg}9{2%F<<%J6Y|$W5!vt<+x!8qpR?R`u0Syzc=dtYY!J%ZV1`*I$<0 zgRp&eh|(#h7UmJd4n-_LIhj^A;4{nmhnB0R8KhM5VJ zTOZq&S*XYPNdl{|3mq{wMYiF!6y_^WX3cAt*$lRQVV>g8aNIq1+N@Za6L&aWuvhBh zU9)9oI)Zjqhs3Du^6J``)~g3Hy?g2S)feWMg=2{qc*5dLs;WI99(rcIt7Hi!n|dgs z@yr`G1oJQ+)?Lh^Y~&EZSM+necNdN14*` zRB`d;)-vab?Ic%p9$=4*AByKT<$98OyDq+w{>jrXOaxPVn+}n*LnUS$CB4Ez8h>5nG#}f z%ehQGu|h+NuIZ|3Jh(EMzHMOlS9TwgXP-yXKWxGc1K}L zR5c>?qP8L#5-(W7)WcDrgw9>0e+SI@&=6DP2r{Ov} zakYS6{E03GaI>^GYd8&7=?vrv3#sRt=N<)n_+Yw_>i&q}o$6vMyX5>%a zxgvlrm|4#rurzD>Rg>mCN8%t`STLjw3Gq#lB`xcDw*u3zgLo~BuaagTLy{}TK8ZZ2 zr_5NZ-2ZT!__;av)jl_|UB=C@A(U-fs=o`lp-I!zotr}OO=WEL6Yjg122x`kk?!+%b?G$#IcP z4`8#FRE|uxkqLXRFw0D`griN1Z`C83q>?WIa+wo3K&WrF5^<-|LI+^z6 zutc8neP67R$LPT-r8!zsG8AYNW&1q+9ylvMZHNfIeMQVg-y)R0%7AQFetB0bBaUQW zS6BrJ!g9`oMHB$t+m>{unk>pcmcBDB+J_iV-ayVK8v~2e(occTwqIY8H0ZkCiY`WP z$%>GQu-LMWhG4kcGLIH%+P*Xj&D6-E4-Dy2D9)9k*HNX>&h^7>GbDb%HHyVawi3Tn zl>X9O9Vu5U$x~TyNkI}438D7xUY16rPV_LBGLFN>_W1>}anQ8wCOu2d(7x0##g6w6 z9#|Dp7?YiFf7$xLxi8)akJ)wt(2LXNh0(A}%b--y&P2~9JcH+a>t zk7UD->bIgGU^>IZo9Q$F&T#5+67YiFsoj=-x?1nx+UXndM8vz+e5*{U?7)0jqKH|G zcmgTflkWm0l}+S$^C;J%`1slUg!3-=RSI-YLl&)-Vid9*PtIWRvpJxcls5K7@xe~N znNXzmrbNv0+uuqW{Mc=xPY&bq&B*6ApQ8xymcYq-V?Dzr?(! zkHdo*b%s#9Z^Rp4jVRz-gnJQ)+_cet*jU?^(1k7ytd`jJ_lQh+(71p30K+XXv41J8 z<{J77q|pw(ez#&Y&y&~;q&l3jDU;jeYd0wJ*DDGPbFQsm;X61Flqfo{RZm(i+9#R$ ztkj3x+DMet48M(H<0tc|;WII%<{E6pdB)k2KOab$#@jYbdZW1?#N2_$v;9q1z`WAz zEcFm(bOb1&HSsx*nX;$wO(@|||B*-<#zyQuC^hj~fA7K4CV(6f_Pd5eV@wxTjdT9T z;Egtzb-q_7bf&Xbj%cOTxmevTzWHwK%SwUu3;XrI6FCn{e{t5ad>>wVc#roZo_5Y3 ze|m4N|Hg)-TAt=6bYC!#EUsUSk07$Vz4>#-PS_Fw?tAyTvW4!w&!dPJd?h+>%)zAa z=pd3TQG$5o?#gn&&r3*eX>eHvA^B34Y);MoanfEEFGe{rdLkNqXTM(FW<$=H=oHXt1Z z^{Kcz8PAqne|sRt3IG1h6QM%sdsv@jJjEb(pNuBrO6?H>p?E>c9{4seCbz{L1_^Y@ z4592bZEz51ySawkmNvdiywbQLaoQ`|R$lp4UOJ>*4`<9#9zMQ)pBCTg(Vo+(OYIN5 zm_9B|3v6z$NF1|Cxw$vipHjl4wz&9MeL7nVDy=iOsKtUk>Y!Jq^Kiuv%Snr&d>Q)mC!c)IDUVr@t;X8*7=>u-rXBKm! z3Z-BT^05gV8?|s~5aKK$d9#yM5Q8Mdd6kS#%vg9@AbOX-@zz6)EMDy)Y z*-l4!Nu>VaceD`-yAGf~+%M2fin!#CZtC^s5u0tB=iY-qL9y@8xeVTQ+SAhLtY5FDGRFa5Mo`9BH$@N<)$KM@4eL;U&Xqf$+6bc=j_= zcFgC{zlk>95lwN)K|Q#J%cA1mFSpdu0uxs;eBz_Wpr7m^Jg&{t!<1 zF1Q+={Dz!C!J2r@31~)+!R~U^(|SwhdhA(j0V2C=Oby7lm}7dWH&?3}fI_Lb3}9G6 zdkOTh_%JTPup`uT=fZ%NMHY6(*k2mTHJ^E{9w+G=2 z^-2`8%EzT!cgs;3;-Tj7lmp|w#|Q2aWCD+E zJ@?=c3M>gi0U=~HC0ICo_~(odAl#7%7$PDJ#@>3)#%zHC`(l5b5O$zh9pDBD0GM+J z4Q^B74`{jb{H6kF05}T@PD%f%?}s$bU-yk2ARMFIACOay;xEXl2*7|QZ~plehnOis zX&TJ`Tr2*!9Ywo9I7CA**&Yq}U>5^g!|^qtEmE zAIE=P$NtL^+ZF1_@lU6_4uEjxUQmbtdg1Wm9~k=(2q)+L7e)mKc+>qu1wpAf|9>dV z0OEhBH7NBR^p`?O2}b&d;Wz@q?WX*ff!e~@LbhJ*Pz{-a|fC?-U znB(|4?dR1o5Uwx>ELlbfp2)%a$2mUte@~CNT&Mv#cTv+|U@C(G9N?EHNdId{ z{&n^7uWxf&0W~N4|E3q_7XY~W1Pu=H%bzBVo;=U&wG$v*L(d-|_!JY&a7qCMbNT>a zlT$Rfw7$Q@b0+hLQy|>(@E>6M^mzrig#yRpe}MQiWU$cLvz6u<5Kd+4FG!6B#Dh+} I<>$8jKcJOiWB>pF delta 11565 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 Date: Thu, 28 Dec 2023 04:06:41 -0500 Subject: [PATCH 076/540] chore(build): update base-requirements + add script for regeneration (#9524) --- .../base-requirements.txt | 317 +++++++++--------- .../regenerate-base-requirements.sh | 37 ++ 2 files changed, 195 insertions(+), 159 deletions(-) create mode 100755 docker/datahub-ingestion-base/regenerate-base-requirements.sh diff --git a/docker/datahub-ingestion-base/base-requirements.txt b/docker/datahub-ingestion-base/base-requirements.txt index 141382466ab9f6..90928759027942 100644 --- a/docker/datahub-ingestion-base/base-requirements.txt +++ b/docker/datahub-ingestion-base/base-requirements.txt @@ -1,149 +1,147 @@ -# Excluded for slim -# pyspark==3.0.3 -# pydeequ==1.0.1 - +# Generated requirements file. Run ./regenerate-base-requirements.sh to regenerate. acryl-datahub-classify==0.0.8 -acryl-PyHive==0.6.14 -acryl-sqlglot==18.5.2.dev45 +acryl-PyHive==0.6.16 +acryl-sqlglot==20.4.1.dev14 aenum==3.1.15 -aiohttp==3.8.6 +aiohttp==3.9.1 aiosignal==1.3.1 -alembic==1.12.0 +alembic==1.13.1 altair==4.2.0 +annotated-types==0.6.0 anyio==3.7.1 -apache-airflow==2.7.2 -apache-airflow-providers-common-sql==1.7.2 -apache-airflow-providers-ftp==3.5.2 -apache-airflow-providers-http==4.5.2 -apache-airflow-providers-imap==3.3.2 -apache-airflow-providers-sqlite==3.4.3 -apispec==6.3.0 +apache-airflow==2.7.3 +apache-airflow-providers-common-sql==1.9.0 +apache-airflow-providers-ftp==3.7.0 +apache-airflow-providers-http==4.8.0 +apache-airflow-providers-imap==3.5.0 +apache-airflow-providers-sqlite==3.6.0 +apispec==6.3.1 appdirs==1.4.4 appnope==0.1.3 -argcomplete==3.1.2 +argcomplete==3.2.1 argon2-cffi==23.1.0 argon2-cffi-bindings==21.2.0 asgiref==3.7.2 asn1crypto==1.5.1 -asttokens==2.4.0 +asttokens==2.4.1 async-timeout==4.0.3 -asynch==0.2.2 +asynch==0.2.3 attrs==23.1.0 -avro==1.10.2 +avro==1.11.3 avro-gen3==0.7.11 -Babel==2.13.0 -backcall==0.2.0 +Babel==2.14.0 backoff==2.2.1 beautifulsoup4==4.12.2 bleach==6.1.0 -blinker==1.6.3 +blinker==1.7.0 blis==0.7.11 -boto3==1.28.62 -botocore==1.31.62 +boto3==1.34.8 +botocore==1.34.8 bowler==0.9.0 bracex==2.4 cached-property==1.5.2 cachelib==0.9.0 -cachetools==5.3.1 +cachetools==5.3.2 catalogue==2.0.10 -cattrs==23.1.2 -certifi==2023.7.22 +cattrs==23.2.3 +certifi==2023.11.17 cffi==1.16.0 chardet==5.2.0 -charset-normalizer==3.3.0 -ciso8601==2.3.0 +charset-normalizer==3.3.2 +ciso8601==2.3.1 click==8.1.7 click-default-group==1.2.4 click-spinner==0.1.10 clickclick==20.10.2 -clickhouse-cityhash==1.0.2.4 clickhouse-driver==0.2.6 clickhouse-sqlalchemy==0.2.4 -cloudpickle==2.2.1 +cloudpickle==3.0.0 colorama==0.4.6 colorlog==4.8.0 -comm==0.1.4 -confection==0.1.3 -ConfigUpdater==3.1.1 +comm==0.2.0 +confection==0.1.4 +ConfigUpdater==3.2 confluent-kafka==2.3.0 connexion==2.14.2 cron-descriptor==1.4.0 croniter==2.0.1 -cryptography==41.0.4 +cryptography==41.0.7 cx-Oracle==8.3.0 cymem==2.0.8 -dask==2023.9.3 +dask==2023.12.1 databricks-cli==0.18.0 databricks-dbapi==0.6.0 -databricks-sdk==0.10.0 +databricks-sdk==0.15.0 +databricks-sql-connector==2.9.3 debugpy==1.8.0 decorator==5.1.1 defusedxml==0.7.1 -deltalake==0.11.0 +deltalake==0.14.0 Deprecated==1.2.14 dill==0.3.7 dnspython==2.4.2 -docker==6.1.3 +docker==7.0.0 docutils==0.20.1 ecdsa==0.18.0 elasticsearch==7.13.4 email-validator==1.3.1 entrypoints==0.4 et-xmlfile==1.1.0 -exceptiongroup==1.1.3 -executing==2.0.0 -expandvars==0.11.0 -fastapi==0.103.2 -fastavro==1.8.4 -fastjsonschema==2.18.1 +exceptiongroup==1.2.0 +executing==2.0.1 +expandvars==0.12.0 +fastapi==0.108.0 +fastavro==1.9.2 +fastjsonschema==2.19.0 feast==0.31.1 -filelock==3.12.4 +filelock==3.13.1 fissix==21.11.13 Flask==2.2.5 flatdict==4.0.1 -frozenlist==1.4.0 -fsspec==2023.9.2 +frozenlist==1.4.1 +fsspec==2023.12.2 future==0.18.3 -GeoAlchemy2==0.14.1 -gitdb==4.0.10 -GitPython==3.1.37 -google-api-core==2.12.0 -google-auth==2.23.3 -google-cloud-appengine-logging==1.3.2 +GeoAlchemy2==0.14.3 +gitdb==4.0.11 +GitPython==3.1.40 +google-api-core==2.15.0 +google-auth==2.25.2 +google-cloud-appengine-logging==1.4.0 google-cloud-audit-log==0.2.5 -google-cloud-bigquery==3.12.0 -google-cloud-core==2.3.3 +google-cloud-bigquery==3.14.1 +google-cloud-core==2.4.1 google-cloud-datacatalog-lineage==0.2.2 google-cloud-logging==3.5.0 google-crc32c==1.5.0 google-re2==1.1 -google-resumable-media==2.6.0 -googleapis-common-protos==1.60.0 +google-resumable-media==2.7.0 +googleapis-common-protos==1.62.0 gql==3.4.1 graphql-core==3.2.3 graphviz==0.20.1 great-expectations==0.15.50 -greenlet==3.0.0 -grpc-google-iam-v1==0.12.6 -grpcio==1.59.0 -grpcio-reflection==1.59.0 -grpcio-status==1.59.0 -grpcio-tools==1.59.0 +greenlet==3.0.3 +grpc-google-iam-v1==0.13.0 +grpcio==1.60.0 +grpcio-reflection==1.60.0 +grpcio-status==1.60.0 +grpcio-tools==1.60.0 gssapi==1.8.3 gunicorn==21.2.0 h11==0.14.0 -httpcore==0.18.0 -httptools==0.6.0 -httpx==0.25.0 +hdbcli==2.19.20 +httpcore==1.0.2 +httptools==0.6.1 +httpx==0.26.0 humanfriendly==10.0 -idna==3.4 +idna==3.6 ijson==3.2.3 -importlib-metadata==6.8.0 -importlib-resources==6.1.0 +importlib-metadata==6.11.0 +importlib-resources==6.1.1 inflection==0.5.1 ipaddress==1.0.23 ipykernel==6.17.1 -ipython==8.16.1 +ipython==8.19.0 ipython-genutils==0.2.0 ipywidgets==8.1.1 iso3166==2.1.1 @@ -152,34 +150,34 @@ itsdangerous==2.1.2 jedi==0.19.1 Jinja2==3.1.2 jmespath==1.0.1 -JPype1==1.4.1 +JPype1==1.5.0 jsonlines==4.0.0 jsonpatch==1.33 jsonpointer==2.4 jsonref==1.1.0 -jsonschema==4.19.1 -jsonschema-specifications==2023.7.1 +jsonschema==4.20.0 +jsonschema-specifications==2023.12.1 jupyter-server==1.24.0 jupyter_client==7.4.9 jupyter_core==4.12.0 -jupyterlab-pygments==0.2.2 jupyterlab-widgets==3.0.9 +jupyterlab_pygments==0.3.0 langcodes==3.3.0 lark==1.1.4 -lazy-object-proxy==1.9.0 +lazy-object-proxy==1.10.0 leb128==1.0.5 -limits==3.6.0 +limits==3.7.0 linear-tsv==1.1.0 linkify-it-py==2.0.2 -lkml==1.3.1 +lkml==1.3.3 locket==1.0.0 lockfile==0.12.2 looker-sdk==23.0.0 -lxml==4.9.3 +lxml==4.9.4 lz4==4.3.2 -makefun==1.15.1 -Mako==1.2.4 -Markdown==3.5 +makefun==1.15.2 +Mako==1.3.0 +Markdown==3.5.1 markdown-it-py==3.0.0 MarkupSafe==2.1.3 marshmallow==3.20.1 @@ -190,26 +188,26 @@ mdit-py-plugins==0.4.0 mdurl==0.1.2 mistune==3.0.2 mixpanel==4.10.0 -mlflow-skinny==2.7.1 +mlflow-skinny==2.9.2 mmh3==4.0.1 mmhash3==3.0.1 more-itertools==10.1.0 moreorless==0.4.0 -moto==4.2.5 +moto==4.2.12 msal==1.22.0 multidict==6.0.4 murmurhash==1.0.10 -mypy==1.6.0 +mypy==1.8.0 mypy-extensions==1.0.0 nbclassic==1.0.0 nbclient==0.6.3 -nbconvert==7.9.2 +nbconvert==7.13.1 nbformat==5.9.1 nest-asyncio==1.5.8 -networkx==3.1 +networkx==3.2.1 notebook==6.5.6 notebook_shim==0.2.3 -numpy==1.26.0 +numpy==1.26.2 oauthlib==3.2.2 okta==1.7.0 openlineage-airflow==1.2.0 @@ -217,110 +215,107 @@ openlineage-integration-common==1.2.0 openlineage-python==1.2.0 openlineage_sql==1.2.0 openpyxl==3.1.2 -opentelemetry-api==1.20.0 -opentelemetry-exporter-otlp==1.20.0 -opentelemetry-exporter-otlp-proto-common==1.20.0 -opentelemetry-exporter-otlp-proto-grpc==1.20.0 -opentelemetry-exporter-otlp-proto-http==1.20.0 -opentelemetry-proto==1.20.0 -opentelemetry-sdk==1.20.0 -opentelemetry-semantic-conventions==0.41b0 +opentelemetry-api==1.22.0 +opentelemetry-exporter-otlp==1.22.0 +opentelemetry-exporter-otlp-proto-common==1.22.0 +opentelemetry-exporter-otlp-proto-grpc==1.22.0 +opentelemetry-exporter-otlp-proto-http==1.22.0 +opentelemetry-proto==1.22.0 +opentelemetry-sdk==1.22.0 +opentelemetry-semantic-conventions==0.43b0 ordered-set==4.1.0 -oscrypto==1.3.0 packaging==23.2 pandas==1.5.3 pandavro==1.5.2 pandocfilters==1.5.0 -parse==1.19.1 +parse==1.20.0 parso==0.8.3 partd==1.4.1 -pathspec==0.11.2 -pathy==0.10.2 +pathspec==0.12.1 +pathy==0.10.3 pendulum==2.1.2 -pexpect==4.8.0 +pexpect==4.9.0 phonenumbers==8.13.0 -pickleshare==0.7.5 platformdirs==3.11.0 pluggy==1.3.0 preshed==3.0.9 prison==0.2.1 -progressbar2==4.2.0 -prometheus-client==0.17.1 -prompt-toolkit==3.0.39 -proto-plus==1.22.3 -protobuf==4.24.4 -psutil==5.9.5 +progressbar2==4.3.2 +prometheus-client==0.19.0 +prompt-toolkit==3.0.43 +proto-plus==1.23.0 +protobuf==4.25.1 +psutil==5.9.7 psycopg2-binary==2.9.9 ptyprocess==0.7.0 pure-eval==0.2.2 pure-sasl==0.6.2 -py-partiql-parser==0.3.7 +py-partiql-parser==0.5.0 pyarrow==11.0.0 -pyasn1==0.5.0 +pyasn1==0.5.1 pyasn1-modules==0.3.0 -pyathena==2.4.1 -pycountry==22.3.5 +pyathena==2.25.2 +pycountry==23.12.11 pycparser==2.21 pycryptodome==3.19.0 -pycryptodomex==3.19.0 pydantic==1.10.13 +pydantic_core==2.14.6 pydash==7.0.6 -pydruid==0.6.5 -Pygments==2.16.1 +pydruid==0.6.6 +Pygments==2.17.2 pyiceberg==0.4.0 -pymongo==4.5.0 +pymongo==4.6.1 PyMySQL==1.1.0 -pyOpenSSL==23.2.0 +pyOpenSSL==23.3.0 pyparsing==3.0.9 pyspnego==0.10.2 python-daemon==3.0.1 python-dateutil==2.8.2 python-dotenv==1.0.0 python-jose==3.3.0 -python-ldap==3.4.3 +python-ldap==3.4.4 python-nvd3==0.15.0 python-slugify==8.0.1 python-stdnum==1.19 -python-tds==1.13.0 +python-tds==1.14.0 python-utils==3.8.1 python3-openid==3.2.0 pytz==2023.3.post1 pytzdata==2020.1 PyYAML==6.0.1 pyzmq==24.0.1 -ratelimiter==1.2.0.post0 redash-toolbelt==0.1.9 -redshift-connector==2.0.914 -referencing==0.30.2 -regex==2023.10.3 +redshift-connector==2.0.918 +referencing==0.32.0 +regex==2023.12.25 requests==2.31.0 requests-file==1.5.1 requests-gssapi==1.2.3 requests-ntlm==1.2.0 requests-toolbelt==0.10.1 -responses==0.23.3 +responses==0.24.1 rfc3339-validator==0.1.4 rfc3986==2.0.0 -rich==13.6.0 -rich-argparse==1.3.0 -rpds-py==0.10.6 +rich==13.7.0 +rich-argparse==1.4.0 +rpds-py==0.15.2 rsa==4.9 ruamel.yaml==0.17.17 ruamel.yaml.clib==0.2.8 -s3transfer==0.7.0 -schwifty==2023.9.0 -scipy==1.11.3 +s3transfer==0.10.0 +schwifty==2023.11.2 +scipy==1.11.4 scramp==1.4.4 Send2Trash==1.8.2 -sentry-sdk==1.32.0 +sentry-sdk==1.39.1 setproctitle==1.3.3 simple-salesforce==1.12.5 six==1.16.0 smart-open==6.4.0 smmap==5.0.1 sniffio==1.3.0 -snowflake-connector-python==3.2.1 -snowflake-sqlalchemy==1.5.0 +snowflake-connector-python==3.6.0 +snowflake-sqlalchemy==1.5.1 sortedcontainers==2.4.0 soupsieve==2.5 spacy==3.4.3 @@ -328,67 +323,71 @@ spacy-legacy==3.0.12 spacy-loggers==1.0.5 sql-metadata==2.2.2 SQLAlchemy==1.4.44 -sqlalchemy-bigquery==1.8.0 -SQLAlchemy-JSONField==1.0.1.post0 +sqlalchemy-bigquery==1.9.0 +sqlalchemy-hana==1.1.1 +SQLAlchemy-JSONField==1.0.2 sqlalchemy-pytds==0.3.5 sqlalchemy-redshift==0.8.14 SQLAlchemy-Utils==0.41.1 -sqlalchemy2-stubs==0.0.2a35 +sqlalchemy2-stubs==0.0.2a37 sqllineage==1.3.8 sqlparse==0.4.4 srsly==2.4.8 stack-data==0.6.3 -starlette==0.27.0 +starlette==0.32.0.post1 strictyaml==1.7.3 tableauserverclient==0.25 tableschema==1.20.2 tabulate==0.9.0 tabulator==1.53.5 tenacity==8.2.3 -termcolor==2.3.0 -terminado==0.17.1 +teradatasql==20.0.0.2 +teradatasqlalchemy==17.20.0.0 +termcolor==2.4.0 +terminado==0.18.0 text-unidecode==1.3 thinc==8.1.12 -thrift==0.13.0 +thrift==0.16.0 thrift-sasl==0.4.3 tinycss2==1.2.1 toml==0.10.2 tomli==2.0.1 -tomlkit==0.12.1 +tomlkit==0.12.3 toolz==0.12.0 -tornado==6.3.3 +tornado==6.4 tqdm==4.66.1 traitlets==5.2.1.post0 trino==0.327.0 typeguard==2.13.3 typer==0.7.0 -types-PyYAML==6.0.12.12 typing-inspect==0.9.0 -typing_extensions==4.8.0 -tzlocal==5.1 +typing_extensions==4.9.0 +tzlocal==5.2 uc-micro-py==1.0.2 -ujson==5.8.0 +ujson==5.9.0 unicodecsv==0.14.1 -urllib3==1.26.17 -uvicorn==0.23.2 -uvloop==0.17.0 -vertica-python==1.3.5 -vertica-sqlalchemy-dialect==0.0.8 +universal-pathlib==0.1.4 +urllib3==1.26.18 +uvicorn==0.25.0 +uvloop==0.19.0 +vertica-python==1.3.8 +vertica-sqlalchemy-dialect==0.0.8.1 vininfo==1.7.0 volatile==2.1.0 wasabi==0.10.1 -watchfiles==0.20.0 +watchfiles==0.21.0 wcmatch==8.5 -wcwidth==0.2.8 +wcwidth==0.2.12 webencodings==0.5.1 -websocket-client==1.6.4 -websockets==11.0.3 +websocket-client==1.7.0 +websockets==12.0 Werkzeug==2.2.3 widgetsnbextension==4.0.9 -wrapt==1.15.0 -WTForms==3.1.0 +wrapt==1.16.0 +WTForms==3.0.1 xlrd==2.0.1 xmltodict==0.13.0 -yarl==1.9.2 +yarl==1.9.4 zeep==4.2.1 -zstd==1.5.5.1 \ No newline at end of file +zipp==3.17.0 +zstd==1.5.5.1 diff --git a/docker/datahub-ingestion-base/regenerate-base-requirements.sh b/docker/datahub-ingestion-base/regenerate-base-requirements.sh new file mode 100755 index 00000000000000..6fb331afa484a3 --- /dev/null +++ b/docker/datahub-ingestion-base/regenerate-base-requirements.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# This script is used to regenerate the base-requirements.txt file + +set -euxo pipefail +cd "$( dirname "${BASH_SOURCE[0]}" )" + +SCRIPT_NAME=$(basename "$0") +DATAHUB_DIR=$(pwd)/../.. + +# Create a virtualenv. +VENV_DIR=$(mktemp -d) +python -c "import sys; assert sys.version_info >= (3, 9), 'Python 3.9 or higher is required.'" +python -m venv $VENV_DIR +source $VENV_DIR/bin/activate +pip install --upgrade pip setuptools wheel +echo "Using virtualenv at $VENV_DIR" + +# Install stuff. +pushd $DATAHUB_DIR/metadata-ingestion +pip install -e . +pip install -e '../metadata-ingestion-modules/airflow-plugin/[plugin-v2]' +pip install -e '.[all]' +popd + +# Generate the requirements file. +# Removing Flask deps due as per https://github.com/datahub-project/datahub/pull/6867/files +# Removing py4j and PyJWT due to https://github.com/datahub-project/datahub/pull/6868/files +# Removing pyspark and pydeequ because we don't want them in the slim image, so they can be added separately. +# TODO: It's unclear if these removals are still actually needed. +echo "# Generated requirements file. Run ./$SCRIPT_NAME to regenerate." > base-requirements.txt +pip freeze \ + | grep -v -E "^-e" \ + | grep -v "Flask-" \ + | grep -v -E "(py4j|PyJWT)==" \ + | grep -v -E "(pyspark|pydeequ)==" \ + >> base-requirements.txt From 4efa46f8c91dfdedc21b7081143d196c7a0be0da Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Thu, 28 Dec 2023 15:05:14 +0530 Subject: [PATCH 077/540] test(cypress/users): add automatic reset password test (#9515) --- .../src/app/identity/user/UserListItem.tsx | 17 ++- .../app/identity/user/ViewResetTokenModal.tsx | 7 +- .../cypress/e2e/mutations/add_users.js | 135 +++++++++++++----- 3 files changed, 114 insertions(+), 45 deletions(-) diff --git a/datahub-web-react/src/app/identity/user/UserListItem.tsx b/datahub-web-react/src/app/identity/user/UserListItem.tsx index 69b8a6c2d1355f..8ad3d7d93d6573 100644 --- a/datahub-web-react/src/app/identity/user/UserListItem.tsx +++ b/datahub-web-react/src/app/identity/user/UserListItem.tsx @@ -98,8 +98,8 @@ export default function UserListItem({ user, canManageUserCredentials, selectRol
{displayName}
-
- {user.username} +
+ {user.username}
{userStatus && ( @@ -121,8 +121,12 @@ export default function UserListItem({ user, canManageUserCredentials, selectRol trigger={['click']} overlay={ - setIsViewingResetToken(true)}> -   Reset user password + setIsViewingResetToken(true)} + data-testid="reset-menu-item" + > +   Reset user password  Delete @@ -130,7 +134,10 @@ export default function UserListItem({ user, canManageUserCredentials, selectRol } > - + Generate a new reset link! Note, any old links will cease to be active. - + diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/add_users.js b/smoke-test/tests/cypress/cypress/e2e/mutations/add_users.js index e19c6065d42743..ba225ba37884ba 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/add_users.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/add_users.js @@ -1,47 +1,104 @@ const tryToSignUp = () => { - let number = Math.floor(Math.random() * 100000); - let name = `Example Name ${number}`; - cy.enterTextInTestId("email", `example${number}@example.com`); - cy.enterTextInTestId("name", name); - cy.enterTextInTestId("password", "Example password"); - cy.enterTextInTestId("confirmPassword", "Example password"); - - cy.mouseover("#title").click(); - cy.waitTextVisible("Other").click(); - - cy.get("[type=submit]").click(); - return name; + let number = Math.floor(Math.random() * 100000); + let name = `Example Name ${number}`; + let email = `example${number}@example.com`; + cy.enterTextInTestId("email", email); + cy.enterTextInTestId("name", name); + cy.enterTextInTestId("password", "Example password"); + cy.enterTextInTestId("confirmPassword", "Example password"); + + cy.mouseover("#title").click(); + cy.waitTextVisible("Other").click(); + + cy.get("[type=submit]").click(); + return { name, email }; }; describe("add_user", () => { - it("go to user link and invite a user", () => { - cy.login(); + let registeredEmail = ""; + it("go to user link and invite a user", () => { + cy.login(); + + cy.visit("/settings/identities/users"); + cy.waitTextVisible("Invite Users"); + cy.clickOptionWithText("Invite Users"); + + cy.waitTextVisible(/signup\?invite_token=\w{32}/) + .then(($elem) => { + const inviteLink = $elem.text(); + cy.log(inviteLink); cy.visit("/settings/identities/users"); - cy.waitTextVisible("Invite Users"); - - cy.clickOptionWithText("Invite Users"); - - cy.waitTextVisible(/signup\?invite_token=\w{32}/).then(($elem) => { - const inviteLink = $elem.text(); - cy.log(inviteLink); - cy.visit("/settings/identities/users"); - cy.logout(); - cy.visit(inviteLink); - let name = tryToSignUp(); - cy.waitTextVisible("Welcome to DataHub"); - cy.hideOnboardingTour(); - cy.waitTextVisible(name); - }).then(() => { - cy.logout(); - cy.visit("/signup?invite_token=bad_token"); - tryToSignUp(); - cy.waitTextVisible("Failed to log in! An unexpected error occurred."); - }); + cy.logout(); + cy.visit(inviteLink); + const { name, email } = tryToSignUp(); + registeredEmail = email; + cy.waitTextVisible("Welcome to DataHub"); + cy.hideOnboardingTour(); + cy.waitTextVisible(name); + }) + .then(() => { + cy.logout(); + cy.visit("/signup?invite_token=bad_token"); + tryToSignUp(); + cy.waitTextVisible("Failed to log in! An unexpected error occurred."); + }); + }); + + it("Verify you can’t generate a reset password link for a non-native user", () => { + cy.login(); + cy.visit("/settings/identities/users"); + cy.waitTextVisible("Invite Users"); + cy.get("[data-testid=userItem-non-native]").first().click(); + cy.get('[data-testid="reset-menu-item"]').should( + "have.attr", + "aria-disabled", + "true" + ); + }); + + it("Generate a reset password link for a native user", () => { + cy.login(); + cy.visit("/settings/identities/users"); + cy.waitTextVisible("Invite Users"); + cy.get(`[data-testid="email-native"]`) + .contains(registeredEmail) + .should("exist") + .parents(".ant-list-item") + .find('[data-testid="userItem-native"]') + .should("be.visible") + .click(); + + cy.get("[data-testid=resetButton]").first().click(); + cy.get("[data-testid=refreshButton]").click(); + cy.waitTextVisible("Generated new link to reset credentials"); + + cy.window().then((win) => { + cy.stub(win, "prompt"); }); -}); + cy.get(".ant-typography-copy").should("be.visible").click(); + cy.get(".ant-modal-close").should("be.visible").click(); -// Verify you can’t generate a reset password link for a non-native user (root, for example) -// Generate a reset password link for a native user -// Log out, then verify that using a bad reset token in the URL doesn’t allow you to reset password -// Use the correct reset link to reset native user credentials \ No newline at end of file + cy.waitTextVisible(/reset\?reset_token=\w{32}/) + .then(($elem) => { + const inviteLink = $elem.text(); + cy.logout(); + cy.visit(inviteLink); + cy.enterTextInTestId("email", registeredEmail); + cy.enterTextInTestId("password", "Example Reset Password"); + cy.enterTextInTestId("confirmPassword", "Example Reset Password"); + cy.get("[type=submit]").click(); + cy.waitTextVisible("Welcome back"); + cy.hideOnboardingTour(); + }) + .then(() => { + cy.logout(); + cy.visit("/reset?reset_token=bad_token"); + cy.enterTextInTestId("email", registeredEmail); + cy.enterTextInTestId("password", "Example Reset Password"); + cy.enterTextInTestId("confirmPassword", "Example Reset Password"); + cy.get("[type=submit]").click(); + cy.waitTextVisible("Failed to log in!"); + }); + }); +}); From 3635c1c2213cfb8421d89b7cc106ab236d72c7ec Mon Sep 17 00:00:00 2001 From: Shubham Jagtap <132359390+shubhamjagtap639@users.noreply.github.com> Date: Thu, 28 Dec 2023 15:24:26 +0530 Subject: [PATCH 078/540] feat(ingestion/bigquery): Use sqlglot_lineage for usage and add more perf timers (#9247) Co-authored-by: Andrew Sikowitz --- metadata-ingestion/setup.py | 2 - .../ingestion/source/bigquery_v2/bigquery.py | 22 +- .../source/bigquery_v2/bigquery_audit.py | 16 +- .../source/bigquery_v2/bigquery_config.py | 5 + .../source/bigquery_v2/bigquery_report.py | 12 +- .../ingestion/source/bigquery_v2/usage.py | 86 ++--- .../datahub/utilities/bigquery_sql_parser.py | 92 ----- .../src/datahub/utilities/sqlglot_lineage.py | 8 +- .../bigquery/test_bigquery_usage.py | 8 +- .../tests/unit/test_bigquery_sql_lineage.py | 66 +++- .../tests/unit/test_bigquery_sql_parser.py | 327 ------------------ .../tests/unit/test_bigquery_usage.py | 14 +- .../unit/test_bigqueryv2_usage_source.py | 6 +- 13 files changed, 159 insertions(+), 505 deletions(-) delete mode 100644 metadata-ingestion/src/datahub/utilities/bigquery_sql_parser.py delete mode 100644 metadata-ingestion/tests/unit/test_bigquery_sql_parser.py diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 32d49ffc73fa34..8e4791e253c7cf 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -295,8 +295,6 @@ "bigquery": sql_common | bigquery_common | { - # TODO: I doubt we need all three sql parsing libraries. - *sqllineage_lib, *sqlglot_lib, "sqlalchemy-bigquery>=1.4.1", "google-cloud-datacatalog-lineage==0.2.2", diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py index 9813945683289c..3704eae96aece0 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py @@ -221,6 +221,7 @@ def __init__(self, ctx: PipelineContext, config: BigQueryV2Config): self.bigquery_data_dictionary = BigQuerySchemaApi( self.report.schema_api_perf, self.config.get_bigquery_client() ) + self.sql_parser_schema_resolver = self._init_schema_resolver() redundant_lineage_run_skip_handler: Optional[ RedundantLineageRunSkipHandler @@ -253,6 +254,7 @@ def __init__(self, ctx: PipelineContext, config: BigQueryV2Config): self.usage_extractor = BigQueryUsageExtractor( config, self.report, + schema_resolver=self.sql_parser_schema_resolver, dataset_urn_builder=self.gen_dataset_urn_from_ref, redundant_run_skip_handler=redundant_usage_run_skip_handler, ) @@ -283,8 +285,6 @@ def __init__(self, ctx: PipelineContext, config: BigQueryV2Config): # Maps view ref -> actual sql self.view_definitions: FileBackedDict[str] = FileBackedDict() - self.sql_parser_schema_resolver = self._init_schema_resolver() - self.add_config_to_report() atexit.register(cleanup, config) @@ -371,7 +371,10 @@ def usage_capability_test( report: BigQueryV2Report, ) -> CapabilityReport: usage_extractor = BigQueryUsageExtractor( - connection_conf, report, lambda ref: "" + connection_conf, + report, + schema_resolver=SchemaResolver(platform="bigquery"), + dataset_urn_builder=lambda ref: "", ) for project_id in project_ids: try: @@ -447,7 +450,9 @@ def _init_schema_resolver(self) -> SchemaResolver: self.config.lineage_parse_view_ddl or self.config.lineage_use_sql_parser ) schema_ingestion_enabled = ( - self.config.include_views and self.config.include_tables + self.config.include_schema_metadata + and self.config.include_tables + and self.config.include_views ) if schema_resolution_required and not schema_ingestion_enabled: @@ -545,10 +550,11 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: if not projects: return - for project_id in projects: - self.report.set_ingestion_stage(project_id.id, METADATA_EXTRACTION) - logger.info(f"Processing project: {project_id.id}") - yield from self._process_project(project_id) + if self.config.include_schema_metadata: + for project_id in projects: + self.report.set_ingestion_stage(project_id.id, METADATA_EXTRACTION) + logger.info(f"Processing project: {project_id.id}") + yield from self._process_project(project_id) if self.config.include_usage_statistics: yield from self.usage_extractor.get_usage_workunits( diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_audit.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_audit.py index 55366d6c57cf83..8cef10ca234481 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_audit.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_audit.py @@ -12,6 +12,7 @@ get_first_missing_key, get_first_missing_key_any, ) +from datahub.utilities.urns.dataset_urn import DatasetUrn AuditLogEntry = Any @@ -178,6 +179,17 @@ def from_string_name(cls, ref: str) -> "BigQueryTableRef": raise ValueError(f"invalid BigQuery table reference: {ref}") return cls(BigqueryTableIdentifier(parts[1], parts[3], parts[5])) + @classmethod + def from_urn(cls, urn: str) -> "BigQueryTableRef": + """Raises: ValueError if urn is not a valid BigQuery table URN.""" + dataset_urn = DatasetUrn.create_from_string(urn) + split = dataset_urn.get_dataset_name().rsplit(".", 3) + if len(split) == 3: + project, dataset, table = split + else: + _, project, dataset, table = split + return cls(BigqueryTableIdentifier(project, dataset, table)) + def is_temporary_table(self, prefixes: List[str]) -> bool: for prefix in prefixes: if self.table_identifier.dataset.startswith(prefix): @@ -566,7 +578,7 @@ def from_query_event( query_event: QueryEvent, debug_include_full_payloads: bool = False, ) -> "ReadEvent": - readEvent = ReadEvent( + return ReadEvent( actor_email=query_event.actor_email, timestamp=query_event.timestamp, resource=read_resource, @@ -577,8 +589,6 @@ def from_query_event( from_query=True, ) - return readEvent - @classmethod def from_exported_bigquery_audit_metadata( cls, row: BigQueryAuditMetadata, debug_include_full_payloads: bool = False diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py index c13b08a6d9656b..58f2a600c2ff7d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py @@ -94,6 +94,11 @@ class BigQueryV2Config( description="Regex patterns for project_id to filter in ingestion.", ) + include_schema_metadata: bool = Field( + default=True, + description="Whether to ingest the BigQuery schema, i.e. projects, schemas, tables, and views.", + ) + usage: BigQueryUsageConfig = Field( default=BigQueryUsageConfig(), description="Usage related configs" ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py index 9d92b011ee2856..69913b383af874 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py @@ -33,6 +33,13 @@ class BigQueryAuditLogApiPerfReport(Report): list_log_entries: PerfTimer = field(default_factory=PerfTimer) +@dataclass +class BigQueryProcessingPerfReport(Report): + sql_parsing_sec: PerfTimer = field(default_factory=PerfTimer) + store_usage_event_sec: PerfTimer = field(default_factory=PerfTimer) + usage_state_size: Optional[str] = None + + @dataclass class BigQueryV2Report(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowReport): num_total_lineage_entries: TopKDict[str, int] = field(default_factory=TopKDict) @@ -120,8 +127,6 @@ class BigQueryV2Report(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowR read_reasons_stat: Counter[str] = field(default_factory=collections.Counter) operation_types_stat: Counter[str] = field(default_factory=collections.Counter) - usage_state_size: Optional[str] = None - exclude_empty_projects: Optional[bool] = None schema_api_perf: BigQuerySchemaApiPerfReport = field( @@ -130,6 +135,9 @@ class BigQueryV2Report(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowR audit_log_api_perf: BigQueryAuditLogApiPerfReport = field( default_factory=BigQueryAuditLogApiPerfReport ) + processing_perf: BigQueryProcessingPerfReport = field( + default_factory=BigQueryProcessingPerfReport + ) lineage_start_time: Optional[datetime] = None lineage_end_time: Optional[datetime] = None diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/usage.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/usage.py index 65b559550ffc59..ccc64184f3346f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/usage.py @@ -35,7 +35,6 @@ AuditEvent, AuditLogEntry, BigQueryAuditMetadata, - BigqueryTableIdentifier, BigQueryTableRef, QueryEvent, ReadEvent, @@ -60,9 +59,9 @@ USAGE_EXTRACTION_USAGE_AGGREGATION, ) from datahub.metadata.schema_classes import OperationClass, OperationTypeClass -from datahub.utilities.bigquery_sql_parser import BigQuerySQLParser from datahub.utilities.file_backed_collections import ConnectionWrapper, FileBackedDict from datahub.utilities.perf_timer import PerfTimer +from datahub.utilities.sqlglot_lineage import SchemaResolver, sqlglot_lineage logger: logging.Logger = logging.getLogger(__name__) @@ -284,7 +283,7 @@ def delete_original_read_events_for_view_query_events(self) -> None: ) def report_disk_usage(self, report: BigQueryV2Report) -> None: - report.usage_state_size = str( + report.processing_perf.usage_state_size = str( { "main": humanfriendly.format_size(os.path.getsize(self.conn.filename)), "queries": humanfriendly.format_size( @@ -310,11 +309,14 @@ def __init__( self, config: BigQueryV2Config, report: BigQueryV2Report, + *, + schema_resolver: SchemaResolver, dataset_urn_builder: Callable[[BigQueryTableRef], str], redundant_run_skip_handler: Optional[RedundantUsageRunSkipHandler] = None, ): self.config: BigQueryV2Config = config self.report: BigQueryV2Report = report + self.schema_resolver = schema_resolver self.dataset_urn_builder = dataset_urn_builder # Replace hash of query with uuid if there are hash conflicts self.uuid_to_query: Dict[str, str] = {} @@ -415,10 +417,11 @@ def generate_read_events_from_query( ) -> Iterable[AuditEvent]: try: tables = self.get_tables_from_query( - query_event_on_view.project_id, query_event_on_view.query, + default_project=query_event_on_view.project_id, + default_dataset=query_event_on_view.default_dataset, ) - assert tables is not None and len(tables) != 0 + assert len(tables) != 0 for table in tables: yield AuditEvent.create( ReadEvent.from_query_event(table, query_event_on_view) @@ -462,12 +465,15 @@ def _ingest_events( self.report.num_view_query_events += 1 for new_event in self.generate_read_events_from_query(query_event): - num_generated += self._store_usage_event( - new_event, usage_state, table_refs - ) - num_aggregated += self._store_usage_event( - audit_event, usage_state, table_refs - ) + with self.report.processing_perf.store_usage_event_sec: + num_generated += self._store_usage_event( + new_event, usage_state, table_refs + ) + with self.report.processing_perf.store_usage_event_sec: + num_aggregated += self._store_usage_event( + audit_event, usage_state, table_refs + ) + except Exception as e: logger.warning( f"Unable to store usage event {audit_event}", exc_info=True @@ -905,54 +911,38 @@ def _generate_filter(self, corrected_start_time, corrected_end_time): ) def get_tables_from_query( - self, default_project: str, query: str - ) -> Optional[List[BigQueryTableRef]]: + self, query: str, default_project: str, default_dataset: Optional[str] = None + ) -> List[BigQueryTableRef]: """ This method attempts to parse bigquery objects read in the query """ if not query: - return None + return [] - parsed_tables = set() try: - parser = BigQuerySQLParser( - query, - self.config.sql_parser_use_external_process, - use_raw_names=self.config.lineage_sql_parser_use_raw_names, - ) - tables = parser.get_tables() - except Exception as ex: + with self.report.processing_perf.sql_parsing_sec: + result = sqlglot_lineage( + query, + self.schema_resolver, + default_db=default_project, + default_schema=default_dataset, + ) + except Exception: logger.debug( - f"Sql parsing failed on this query on view: {query}. " - f"Usage won't be added. The error was {ex}." + f"Sql parsing failed on this query on view: {query}. Usage won't be added." ) - return None + logger.debug(result.debug_info) + return [] - for table in tables: - parts = table.split(".") - if len(parts) == 2: - parsed_tables.add( - BigQueryTableRef( - BigqueryTableIdentifier( - project_id=default_project, dataset=parts[0], table=parts[1] - ) - ).get_sanitized_table_ref() - ) - elif len(parts) == 3: - parsed_tables.add( - BigQueryTableRef( - BigqueryTableIdentifier( - project_id=parts[0], dataset=parts[1], table=parts[2] - ) - ).get_sanitized_table_ref() - ) - else: - logger.debug( - f"Invalid table identifier {table} when parsing query on view {query}" - ) + parsed_table_refs = [] + for urn in result.in_tables: + try: + parsed_table_refs.append(BigQueryTableRef.from_urn(urn)) + except ValueError: + logger.debug(f"Invalid urn {urn} when parsing query on view {query}") self.report.num_view_query_events_failed_table_identification += 1 - return list(parsed_tables) + return parsed_table_refs def _report_error( self, label: str, e: Exception, group: Optional[str] = None diff --git a/metadata-ingestion/src/datahub/utilities/bigquery_sql_parser.py b/metadata-ingestion/src/datahub/utilities/bigquery_sql_parser.py deleted file mode 100644 index 4ad41f1fe23c9a..00000000000000 --- a/metadata-ingestion/src/datahub/utilities/bigquery_sql_parser.py +++ /dev/null @@ -1,92 +0,0 @@ -import re -from typing import List - -import sqlparse - -from datahub.utilities.sql_parser import SqlLineageSQLParser, SQLParser - - -class BigQuerySQLParser(SQLParser): - parser: SQLParser - - def __init__( - self, - sql_query: str, - use_external_process: bool = False, - use_raw_names: bool = False, - ) -> None: - super().__init__(sql_query) - - self._parsed_sql_query = self.parse_sql_query(sql_query) - self.parser = SqlLineageSQLParser( - self._parsed_sql_query, use_external_process, use_raw_names - ) - - def parse_sql_query(self, sql_query: str) -> str: - sql_query = BigQuerySQLParser._parse_bigquery_comment_sign(sql_query) - sql_query = BigQuerySQLParser._escape_keyword_from_as_field_name(sql_query) - sql_query = BigQuerySQLParser._escape_cte_name_after_keyword_with(sql_query) - - sql_query = sqlparse.format( - sql_query.strip(), - reindent_aligned=True, - strip_comments=True, - ) - - sql_query = BigQuerySQLParser._escape_table_or_view_name_at_create_statement( - sql_query - ) - sql_query = BigQuerySQLParser._escape_object_name_after_keyword_from(sql_query) - sql_query = BigQuerySQLParser._remove_comma_before_from(sql_query) - - return sql_query - - @staticmethod - def _parse_bigquery_comment_sign(sql_query: str) -> str: - return re.sub(r"#(.*)", r"-- \1", sql_query, flags=re.IGNORECASE) - - @staticmethod - def _escape_keyword_from_as_field_name(sql_query: str) -> str: - return re.sub(r"(\w*\.from)", r"`\1`", sql_query, flags=re.IGNORECASE) - - @staticmethod - def _escape_cte_name_after_keyword_with(sql_query: str) -> str: - """ - Escape the first cte name in case it is one of reserved words - """ - return re.sub(r"(with\s)([^`\s()]+)", r"\1`\2`", sql_query, flags=re.IGNORECASE) - - @staticmethod - def _escape_table_or_view_name_at_create_statement(sql_query: str) -> str: - """ - Reason: in case table name contains hyphens which breaks sqllineage later on - """ - return re.sub( - r"(create.*\s)(table\s|view\s)([^`\s()]+)(?=\sas)", - r"\1\2`\3`", - sql_query, - flags=re.IGNORECASE, - ) - - @staticmethod - def _remove_comma_before_from(sql_query: str) -> str: - return re.sub(r",(\s*?)(?=from)", r" ", sql_query, flags=re.IGNORECASE) - - @staticmethod - def _escape_object_name_after_keyword_from(sql_query: str) -> str: - """ - Reason: in case table name contains hyphens which breaks sqllineage later on - Note: ignore cases of having keyword FROM as part of datetime function EXTRACT - """ - return re.sub( - r"(? List[str]: - return self.parser.get_tables() - - def get_columns(self) -> List[str]: - return self.parser.get_columns() diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index b43c8de4c8f3d8..0f84871d6c96ac 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -333,6 +333,9 @@ def _table_level_lineage( return tables, modified +TABLE_CASE_SENSITIVE_PLATFORMS = {"bigquery"} + + class SchemaResolver(Closeable): def __init__( self, @@ -402,7 +405,10 @@ def resolve_table(self, table: _TableName) -> Tuple[str, Optional[SchemaInfo]]: if schema_info: return urn_lower, schema_info - return urn_lower, None + if self.platform in TABLE_CASE_SENSITIVE_PLATFORMS: + return urn, None + else: + return urn_lower, None def _resolve_schema_info(self, urn: str) -> Optional[SchemaInfo]: if urn in self._schema_cache: diff --git a/metadata-ingestion/tests/performance/bigquery/test_bigquery_usage.py b/metadata-ingestion/tests/performance/bigquery/test_bigquery_usage.py index bbc3378450bffd..9bbe9c45887a8f 100644 --- a/metadata-ingestion/tests/performance/bigquery/test_bigquery_usage.py +++ b/metadata-ingestion/tests/performance/bigquery/test_bigquery_usage.py @@ -14,6 +14,7 @@ from datahub.ingestion.source.bigquery_v2.bigquery_report import BigQueryV2Report from datahub.ingestion.source.bigquery_v2.usage import BigQueryUsageExtractor from datahub.utilities.perf_timer import PerfTimer +from datahub.utilities.sqlglot_lineage import SchemaResolver from tests.performance.bigquery.bigquery_events import generate_events, ref_from_table from tests.performance.data_generation import ( NormalDistribution, @@ -47,7 +48,10 @@ def run_test(): usage_extractor = BigQueryUsageExtractor( config, report, - lambda ref: make_dataset_urn("bigquery", str(ref.table_identifier)), + schema_resolver=SchemaResolver(platform="bigquery"), + dataset_urn_builder=lambda ref: make_dataset_urn( + "bigquery", str(ref.table_identifier) + ), ) report.set_ingestion_stage("All", "Event Generation") @@ -83,7 +87,7 @@ def run_test(): print( f"Peak Memory Used: {humanfriendly.format_size(peak_memory_usage - pre_mem_usage)}" ) - print(f"Disk Used: {report.usage_state_size}") + print(f"Disk Used: {report.processing_perf.usage_state_size}") print(f"Hash collisions: {report.num_usage_query_hash_collisions}") diff --git a/metadata-ingestion/tests/unit/test_bigquery_sql_lineage.py b/metadata-ingestion/tests/unit/test_bigquery_sql_lineage.py index f807be747a193a..755e9081dda390 100644 --- a/metadata-ingestion/tests/unit/test_bigquery_sql_lineage.py +++ b/metadata-ingestion/tests/unit/test_bigquery_sql_lineage.py @@ -1,4 +1,35 @@ -from datahub.utilities.bigquery_sql_parser import BigQuerySQLParser +from typing import List + +from datahub.ingestion.source.bigquery_v2.bigquery_audit import BigQueryTableRef +from datahub.utilities.sqlglot_lineage import SchemaResolver, sqlglot_lineage + + +class BigQuerySQLParser: + def __init__(self, sql_query: str, schema_resolver: SchemaResolver) -> None: + self.result = sqlglot_lineage(sql_query, schema_resolver) + + def get_tables(self) -> List[str]: + ans = [] + for urn in self.result.in_tables: + table_ref = BigQueryTableRef.from_urn(urn) + ans.append(str(table_ref.table_identifier)) + return ans + + def get_columns(self) -> List[str]: + ans = [] + for col_info in self.result.column_lineage or []: + for col_ref in col_info.upstreams: + ans.append(col_ref.column) + return ans + + +def test_bigquery_sql_lineage_basic(): + parser = BigQuerySQLParser( + sql_query="""SELECT * FROM project_1.database_1.view_1""", + schema_resolver=SchemaResolver(platform="bigquery"), + ) + + assert parser.get_tables() == ["project_1.database_1.view_1"] def test_bigquery_sql_lineage_hash_as_comment_sign_is_accepted(): @@ -14,7 +45,8 @@ def test_bigquery_sql_lineage_hash_as_comment_sign_is_accepted(): -- this comment will not break sqllineage either # this comment will not break sqllineage either FROM `project.dataset.src_tbl` - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.dataset.src_tbl"] @@ -39,7 +71,7 @@ def test_bigquery_sql_lineage_camel_case_table(): # this comment will not break sqllineage either FROM `project.dataset.CamelCaseTable` """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.dataset.CamelCaseTable"] @@ -64,7 +96,7 @@ def test_bigquery_sql_lineage_camel_case_dataset(): # this comment will not break sqllineage either FROM `project.DataSet.table` """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.DataSet.table"] @@ -89,7 +121,7 @@ def test_bigquery_sql_lineage_camel_case_table_and_dataset(): # this comment will not break sqllineage either FROM `project.DataSet.CamelTable` """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.DataSet.CamelTable"] @@ -117,7 +149,7 @@ def test_bigquery_sql_lineage_camel_case_table_and_dataset_subquery(): SELECT * FROM `project.DataSet.CamelTable` ) """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.DataSet.CamelTable"] @@ -146,7 +178,7 @@ def test_bigquery_sql_lineage_camel_case_table_and_dataset_joins(): LEFT JOIN `project.DataSet3.CamelTable3` on c.id = b.id """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == [ @@ -179,7 +211,7 @@ def test_bigquery_sql_lineage_camel_case_table_and_dataset_joins_and_subquery(): LEFT JOIN (SELECT * FROM `project.DataSet3.CamelTable3`) c ON c.id = b.id """, - use_raw_names=True, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == [ @@ -199,7 +231,8 @@ def test_bigquery_sql_lineage_keyword_data_is_accepted(): FROM `project.example_dataset.example_table` ) SELECT * FROM data - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.example_dataset.example_table"] @@ -213,7 +246,8 @@ def test_bigquery_sql_lineage_keyword_admin_is_accepted(): FROM `project.example_dataset.example_table` ) SELECT * FROM admin - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.example_dataset.example_table"] @@ -238,7 +272,8 @@ def test_bigquery_sql_lineage_cte_alias_as_keyword_is_accepted(): ) SELECT * FROM map - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == [ @@ -255,7 +290,8 @@ def test_bigquery_sql_lineage_create_or_replace_view_name_with_hyphens_is_accept FROM project.dataset.src_table_a UNION SELECT * FROM `project.dataset.src_table_b` - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == [ @@ -270,7 +306,8 @@ def test_bigquery_sql_lineage_source_table_name_with_hyphens_is_accepted(): CREATE OR REPLACE VIEW `project.dataset.test_view` AS SELECT * FROM test-project.dataset.src_table - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["test-project.dataset.src_table"] @@ -282,7 +319,8 @@ def test_bigquery_sql_lineage_from_as_column_name_is_accepted(): CREATE OR REPLACE VIEW `project.dataset.test_view` AS SELECT x.from AS col FROM project.dataset.src_table AS x - """ + """, + schema_resolver=SchemaResolver(platform="bigquery"), ) assert parser.get_tables() == ["project.dataset.src_table"] diff --git a/metadata-ingestion/tests/unit/test_bigquery_sql_parser.py b/metadata-ingestion/tests/unit/test_bigquery_sql_parser.py deleted file mode 100644 index 2a73bfc5e8b686..00000000000000 --- a/metadata-ingestion/tests/unit/test_bigquery_sql_parser.py +++ /dev/null @@ -1,327 +0,0 @@ -import pytest - -from datahub.utilities.bigquery_sql_parser import BigQuerySQLParser - - -def test_bigquery_sql_parser_comments_are_removed(): - parser = BigQuerySQLParser( - sql_query=""" -/* -HERE IS A STANDARD COMMENT BLOCK -THIS WILL NOT BREAK sqllineage -*/ -CREATE OR REPLACE TABLE `project.dataset.test_view` AS -#This, comment will not break sqllineage -SELECT foo --- this comment will not break sqllineage either -# this comment will not break sqllineage either - FROM `project.dataset.src_table` -""" - ) - - assert ( - parser._parsed_sql_query - == """CREATE OR REPLACE TABLE `project.dataset.test_view` AS SELECT foo - FROM `project.dataset.src_table`""" - ) - - assert parser.get_tables() == ["project.dataset.src_table"] - - -def test_bigquery_sql_parser_formats_input_sql(): - parser = BigQuerySQLParser( - sql_query=""" -CREATE OR REPLACE TABLE `project.dataset.test_view` AS -SELECT foo FROM `project.dataset.src_table_a` AS a -INNER JOIN `project.dataset.src_table_b` AS b ON a.key_field = b.key_field -""" - ) - - assert ( - parser._parsed_sql_query - == """CREATE OR REPLACE TABLE `project.dataset.test_view` AS SELECT foo - FROM `project.dataset.src_table_a` AS a - INNER JOIN `project.dataset.src_table_b` AS b - ON a.key_field = b.key_field""" - ) - - assert parser.get_tables() == [ - "project.dataset.src_table_a", - "project.dataset.src_table_b", - ] - - -def test_remove_comma_before_from(): - assert ( - BigQuerySQLParser._remove_comma_before_from( - """ -select a, b,from `project.dataset.table_name_1` -""" - ) - == """ -select a, b from `project.dataset.table_name_1` -""" - ) - - assert ( - BigQuerySQLParser._remove_comma_before_from( - """ -select a, b from `project.dataset.table_name_1` -""" - ) - == """ -select a, b from `project.dataset.table_name_1` -""" - ) - - assert ( - BigQuerySQLParser._remove_comma_before_from( - """ -select - a, - b, -from `project.dataset.table_name_1` -""" - ) - == """ -select - a, - b from `project.dataset.table_name_1` -""" - ) - - -def test_bigquery_sql_parser_subquery(): - parser = BigQuerySQLParser( - sql_query=""" - create or replace table smoke_test_db.table_from_view_and_table - as (select b.date_utc, v.revenue from smoke_test_db.base_table b, smoke_test_db.view_from_table v - """ - ) - assert parser.get_tables() == [ - "smoke_test_db.base_table", - "smoke_test_db.view_from_table", - ] - - -def test_bigquery_sql_parser_comment_sign_switched_correctly(): - sql_query = BigQuerySQLParser._parse_bigquery_comment_sign( - """ -#upper comment -SELECT * FROM hello -# lower comment -""" - ) - - assert ( - sql_query - == """ --- upper comment -SELECT * FROM hello --- lower comment -""" - ) - - -def test_bigquery_sql_parser_keyword_from_is_escaped_if_used_as_fieldname(): - sql_query = BigQuerySQLParser._escape_keyword_from_as_field_name( - """ -SELECT hello.from AS col FROM hello -""" - ) - - assert ( - sql_query - == """ -SELECT `hello.from` AS col FROM hello -""" - ) - - -def test_bigquery_sql_parser_first_cte_name_is_escaped(): - sql_query = BigQuerySQLParser._escape_cte_name_after_keyword_with( - """ -CREATE OR REPLACE VIEW `test_view` AS -WITH cte_1 AS ( - SELECT * FROM foo -), -cte_2 AS ( - SELECT * FROM bar -) -SELECT * FROM cte_1 UNION ALL -SELECT * FROM cte_2 -""" - ) - - assert ( - sql_query - == """ -CREATE OR REPLACE VIEW `test_view` AS -WITH `cte_1` AS ( - SELECT * FROM foo -), -cte_2 AS ( - SELECT * FROM bar -) -SELECT * FROM cte_1 UNION ALL -SELECT * FROM cte_2 -""" - ) - - -def test_bigquery_sql_parser_table_name_is_escaped_at_create_statement(): - sql_query_create = BigQuerySQLParser._escape_table_or_view_name_at_create_statement( - """ -CREATE TABLE project.dataset.test_table AS -col_1 STRING, -col_2 STRING -""" - ) - - sql_query_create_or_replace = BigQuerySQLParser._escape_table_or_view_name_at_create_statement( - """ -CREATE OR REPLACE TABLE project.dataset.test_table AS -col_1 STRING, -col_2 STRING -""" - ) - - assert ( - sql_query_create - == """ -CREATE TABLE `project.dataset.test_table` AS -col_1 STRING, -col_2 STRING -""" - ) - assert ( - sql_query_create_or_replace - == """ -CREATE OR REPLACE TABLE `project.dataset.test_table` AS -col_1 STRING, -col_2 STRING -""" - ) - - -def test_bigquery_sql_parser_view_name_is_escaped_at_create_statement(): - sql_query_create = BigQuerySQLParser._escape_table_or_view_name_at_create_statement( - """ -CREATE VIEW project.dataset.test_view AS -SELECT * FROM project.dataset.src_table -""" - ) - - sql_query_create_or_replace = BigQuerySQLParser._escape_table_or_view_name_at_create_statement( - """ -CREATE OR REPLACE VIEW project.dataset.test_view AS -SELECT * FROM project.dataset.src_table -""" - ) - - assert ( - sql_query_create - == """ -CREATE VIEW `project.dataset.test_view` AS -SELECT * FROM project.dataset.src_table -""" - ) - assert ( - sql_query_create_or_replace - == """ -CREATE OR REPLACE VIEW `project.dataset.test_view` AS -SELECT * FROM project.dataset.src_table -""" - ) - - -def test_bigquery_sql_parser_object_name_is_escaped_after_keyword_from(): - sql_query = BigQuerySQLParser._escape_object_name_after_keyword_from( - """ -CREATE OR REPLACE VIEW `project.dataset.test_view` AS -SELECT * FROM src-project.dataset.src_table_a UNION ALL -SELECT * FROM project.dataset.src_table_b -""" - ) - - assert ( - sql_query - == """ -CREATE OR REPLACE VIEW `project.dataset.test_view` AS -SELECT * FROM `src-project.dataset.src_table_a` UNION ALL -SELECT * FROM `project.dataset.src_table_b` -""" - ) - - -def test_bigquery_sql_parser_field_name_is_not_escaped_after_keyword_from_in_datetime_functions(): - sql_query = BigQuerySQLParser._escape_object_name_after_keyword_from( - """ -CREATE OR REPLACE VIEW `project.dataset.test_view` AS -SELECT -EXTRACT(MICROSECOND FROM time_field) AS col_1, -EXTRACT(MILLISECOND FROM time_field) AS col_2, -EXTRACT(SECOND FROM time_field) AS col_3, -EXTRACT(MINUTE FROM time_field) AS col_4, -EXTRACT(HOUR FROM time_field) AS col_5, -EXTRACT(DAYOFWEEK FROM time_field) AS col_6, -EXTRACT(DAY FROM time_field) AS col_7, -EXTRACT(DAYOFYEAR FROM time_field) AS col_8, -EXTRACT(WEEK FROM time_field) AS col_9, -EXTRACT(WEEK FROM time_field) AS col_10, -EXTRACT(ISOWEEK FROM time_field) AS col_11, -EXTRACT(MONTH FROM time_field) AS col_12, -EXTRACT(QUARTER FROM time_field) AS col_13, -EXTRACT(YEAR FROM time_field) AS col_14, -EXTRACT(ISOYEAR FROM time_field) AS col_15, -EXTRACT(DATE FROM time_field) AS col_16, -EXTRACT(TIME FROM time_field) AS col_17 -FROM src-project.dataset.src_table_a -""" - ) - - assert ( - sql_query - == """ -CREATE OR REPLACE VIEW `project.dataset.test_view` AS -SELECT -EXTRACT(MICROSECOND FROM time_field) AS col_1, -EXTRACT(MILLISECOND FROM time_field) AS col_2, -EXTRACT(SECOND FROM time_field) AS col_3, -EXTRACT(MINUTE FROM time_field) AS col_4, -EXTRACT(HOUR FROM time_field) AS col_5, -EXTRACT(DAYOFWEEK FROM time_field) AS col_6, -EXTRACT(DAY FROM time_field) AS col_7, -EXTRACT(DAYOFYEAR FROM time_field) AS col_8, -EXTRACT(WEEK FROM time_field) AS col_9, -EXTRACT(WEEK FROM time_field) AS col_10, -EXTRACT(ISOWEEK FROM time_field) AS col_11, -EXTRACT(MONTH FROM time_field) AS col_12, -EXTRACT(QUARTER FROM time_field) AS col_13, -EXTRACT(YEAR FROM time_field) AS col_14, -EXTRACT(ISOYEAR FROM time_field) AS col_15, -EXTRACT(DATE FROM time_field) AS col_16, -EXTRACT(TIME FROM time_field) AS col_17 -FROM `src-project.dataset.src_table_a` -""" - ) - - -def test_bigquery_sql_parser_with_semicolon_in_from(): - sql_query = """CREATE VIEW `acryl-staging.smoke_test_db.view_from_table`\nAS select * from smoke_test_db.base_table;""" - - table_list = BigQuerySQLParser(sql_query).get_tables() - table_list.sort() - assert table_list == ["smoke_test_db.base_table"] - - -@pytest.mark.xfail -def test_bigquery_sql_parser_with_parenthesis_in_from(): - sql_query = """ - CREATE VIEW `acryl-staging.smoke_test_db.view_from_table` AS - select * from smoke_test_db.base_table LEFT JOIN UNNEST(my_array) ON day1 = day2; - """ - - table_list = BigQuerySQLParser(sql_query).get_tables() - table_list.sort() - assert table_list == ["smoke_test_db.base_table"] diff --git a/metadata-ingestion/tests/unit/test_bigquery_usage.py b/metadata-ingestion/tests/unit/test_bigquery_usage.py index c0055763bc15b4..664d3112810ff0 100644 --- a/metadata-ingestion/tests/unit/test_bigquery_usage.py +++ b/metadata-ingestion/tests/unit/test_bigquery_usage.py @@ -35,6 +35,7 @@ TimeWindowSizeClass, ) from datahub.testing.compare_metadata_json import diff_metadata_json +from datahub.utilities.sqlglot_lineage import SchemaResolver from tests.performance.bigquery.bigquery_events import generate_events, ref_from_table from tests.performance.data_generation import generate_data, generate_queries from tests.performance.data_model import Container, FieldAccess, Query, Table, View @@ -202,7 +203,10 @@ def usage_extractor(config: BigQueryV2Config) -> BigQueryUsageExtractor: return BigQueryUsageExtractor( config, report, - lambda ref: make_dataset_urn("bigquery", str(ref.table_identifier)), + schema_resolver=SchemaResolver(platform="bigquery"), + dataset_urn_builder=lambda ref: make_dataset_urn( + "bigquery", str(ref.table_identifier) + ), ) @@ -961,21 +965,21 @@ def test_operational_stats( def test_get_tables_from_query(usage_extractor): assert usage_extractor.get_tables_from_query( - PROJECT_1, "SELECT * FROM project-1.database_1.view_1" + "SELECT * FROM project-1.database_1.view_1", default_project=PROJECT_1 ) == [ BigQueryTableRef(BigqueryTableIdentifier("project-1", "database_1", "view_1")) ] assert usage_extractor.get_tables_from_query( - PROJECT_1, "SELECT * FROM database_1.view_1" + "SELECT * FROM database_1.view_1", default_project=PROJECT_1 ) == [ BigQueryTableRef(BigqueryTableIdentifier("project-1", "database_1", "view_1")) ] assert sorted( usage_extractor.get_tables_from_query( - PROJECT_1, "SELECT v.id, v.name, v.total, t.name as name1 FROM database_1.view_1 as v inner join database_1.table_1 as t on v.id=t.id", + default_project=PROJECT_1, ) ) == [ BigQueryTableRef(BigqueryTableIdentifier("project-1", "database_1", "table_1")), @@ -984,8 +988,8 @@ def test_get_tables_from_query(usage_extractor): assert sorted( usage_extractor.get_tables_from_query( - PROJECT_1, "CREATE TABLE database_1.new_table AS SELECT v.id, v.name, v.total, t.name as name1 FROM database_1.view_1 as v inner join database_1.table_1 as t on v.id=t.id", + default_project=PROJECT_1, ) ) == [ BigQueryTableRef(BigqueryTableIdentifier("project-1", "database_1", "table_1")), diff --git a/metadata-ingestion/tests/unit/test_bigqueryv2_usage_source.py b/metadata-ingestion/tests/unit/test_bigqueryv2_usage_source.py index 44fd840f28d594..25e849a5092938 100644 --- a/metadata-ingestion/tests/unit/test_bigqueryv2_usage_source.py +++ b/metadata-ingestion/tests/unit/test_bigqueryv2_usage_source.py @@ -10,6 +10,7 @@ from datahub.ingestion.source.bigquery_v2.bigquery_config import BigQueryV2Config from datahub.ingestion.source.bigquery_v2.bigquery_report import BigQueryV2Report from datahub.ingestion.source.bigquery_v2.usage import BigQueryUsageExtractor +from datahub.utilities.sqlglot_lineage import SchemaResolver FROZEN_TIME = "2021-07-20 00:00:00" @@ -114,7 +115,10 @@ def test_bigqueryv2_filters(): corrected_start_time = config.start_time - config.max_query_duration corrected_end_time = config.end_time + config.max_query_duration filter: str = BigQueryUsageExtractor( - config, BigQueryV2Report(), lambda x: "" + config, + BigQueryV2Report(), + schema_resolver=SchemaResolver(platform="bigquery"), + dataset_urn_builder=lambda x: "", )._generate_filter(corrected_start_time, corrected_end_time) assert filter == expected_filter From 60347d6735ea2136d721bbf6644ae82df6519d9c Mon Sep 17 00:00:00 2001 From: Diego Reiriz Cores Date: Thu, 28 Dec 2023 12:09:10 +0100 Subject: [PATCH 079/540] fix(ingest/mongodb): support disabling schemaSamplingSize (#9295) Co-authored-by: Harshal Sheth --- .../src/datahub/ingestion/source/mongodb.py | 8 +++++--- .../tests/integration/mongodb/test_mongodb.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/mongodb.py b/metadata-ingestion/src/datahub/ingestion/source/mongodb.py index 2aa8b1d37d4776..283ab652f23c62 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/mongodb.py +++ b/metadata-ingestion/src/datahub/ingestion/source/mongodb.py @@ -102,7 +102,7 @@ class MongoDBConfig( ) schemaSamplingSize: Optional[PositiveInt] = Field( default=1000, - description="Number of documents to use when inferring schema size. If set to `0`, all documents will be scanned.", + description="Number of documents to use when inferring schema size. If set to `null`, all documents will be scanned.", ) useRandomSampling: bool = Field( default=True, @@ -225,13 +225,15 @@ def construct_schema_pymongo( ] if use_random_sampling: # get sample documents in collection - aggregations.append({"$sample": {"size": sample_size}}) + if sample_size: + aggregations.append({"$sample": {"size": sample_size}}) documents = collection.aggregate( aggregations, allowDiskUse=True, ) else: - aggregations.append({"$limit": sample_size}) + if sample_size: + aggregations.append({"$limit": sample_size}) documents = collection.aggregate(aggregations, allowDiskUse=True) return construct_schema(list(documents), delimiter) diff --git a/metadata-ingestion/tests/integration/mongodb/test_mongodb.py b/metadata-ingestion/tests/integration/mongodb/test_mongodb.py index 56fb471d4c9f1b..0a0ba55ff5b802 100644 --- a/metadata-ingestion/tests/integration/mongodb/test_mongodb.py +++ b/metadata-ingestion/tests/integration/mongodb/test_mongodb.py @@ -26,6 +26,7 @@ def test_mongodb_ingest(docker_compose_runner, pytestconfig, tmp_path, mock_time "password": "examplepass", "maxDocumentSize": 25000, "platform_instance": "instance", + "schemaSamplingSize": None, }, }, "sink": { From 2cd38a469d5ac607bd510a0ca045d151b4657afd Mon Sep 17 00:00:00 2001 From: Tony Ouyang Date: Thu, 28 Dec 2023 03:09:30 -0800 Subject: [PATCH 080/540] fix(ingest): Fix mongodb ingestion when platform_instance is missing from recipe (#9486) Co-authored-by: Harshal Sheth --- metadata-ingestion/src/datahub/ingestion/source/mongodb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/mongodb.py b/metadata-ingestion/src/datahub/ingestion/source/mongodb.py index 283ab652f23c62..577da91ee82da9 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/mongodb.py +++ b/metadata-ingestion/src/datahub/ingestion/source/mongodb.py @@ -379,6 +379,8 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: platform_instance=self.config.platform_instance, ) + # Initialize data_platform_instance with a default value + data_platform_instance = None if self.config.platform_instance: data_platform_instance = DataPlatformInstanceClass( platform=make_data_platform_urn(platform), From e343b69ce4881ceefdf4af0cafea29188092de52 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Thu, 28 Dec 2023 16:50:13 +0530 Subject: [PATCH 081/540] fix(ingest/snowflake): explicit set schema if public schema is absent (#9526) --- .../source/snowflake/snowflake_profiler.py | 14 ++++++++++++++ .../ingestion/source/snowflake/snowflake_query.py | 4 ++++ .../source/state/stateful_ingestion_base.py | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py index 4bda7da422e9d6..9a37f779bbcd58 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_profiler.py @@ -24,6 +24,8 @@ logger = logging.getLogger(__name__) +PUBLIC_SCHEMA = "PUBLIC" + class SnowflakeProfiler(GenericProfiler, SnowflakeCommonMixin): def __init__( @@ -36,6 +38,7 @@ def __init__( self.config: SnowflakeV2Config = config self.report: SnowflakeV2Report = report self.logger = logger + self.database_default_schema: Dict[str, str] = dict() def get_workunits( self, database: SnowflakeDatabase, db_tables: Dict[str, List[SnowflakeTable]] @@ -47,6 +50,10 @@ def get_workunits( "max_overflow", self.config.profiling.max_workers ) + if PUBLIC_SCHEMA not in db_tables: + # If PUBLIC schema is absent, we use any one of schemas as default schema + self.database_default_schema[database.name] = list(db_tables.keys())[0] + profile_requests = [] for schema in database.schemas: for table in db_tables[schema.name]: @@ -136,9 +143,16 @@ def get_profiler_instance( ) def callable_for_db_connection(self, db_name: str) -> Callable: + schema_name = self.database_default_schema.get(db_name) + def get_db_connection(): conn = self.config.get_connection() conn.cursor().execute(SnowflakeQuery.use_database(db_name)) + + # As mentioned here - https://docs.snowflake.com/en/sql-reference/sql/use-database#usage-notes + # no schema is selected if PUBLIC schema is absent. We need to explicitly call `USE SCHEMA ` + if schema_name: + conn.cursor().execute(SnowflakeQuery.use_schema(schema_name)) return conn return get_db_connection diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py index 267f7cf0749099..724e4392f1d612 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py @@ -80,6 +80,10 @@ def show_tags() -> str: def use_database(db_name: str) -> str: return f'use database "{db_name}"' + @staticmethod + def use_schema(schema_name: str) -> str: + return f'use schema "{schema_name}"' + @staticmethod def get_databases(db_name: Optional[str]) -> str: db_clause = f'"{db_name}".' if db_name is not None else "" diff --git a/metadata-ingestion/src/datahub/ingestion/source/state/stateful_ingestion_base.py b/metadata-ingestion/src/datahub/ingestion/source/state/stateful_ingestion_base.py index 8a448f40e95b4b..61d39b18f523d2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/state/stateful_ingestion_base.py +++ b/metadata-ingestion/src/datahub/ingestion/source/state/stateful_ingestion_base.py @@ -98,7 +98,7 @@ class StatefulIngestionConfigBase(GenericModel, Generic[CustomConfig]): ) -class StatefulLineageConfigMixin: +class StatefulLineageConfigMixin(ConfigModel): enable_stateful_lineage_ingestion: bool = Field( default=True, description="Enable stateful lineage ingestion." From 4de2c24249697fa68831f880fda216ddb46fba3d Mon Sep 17 00:00:00 2001 From: Sumit Patil <91715217+sumitappt@users.noreply.github.com> Date: Thu, 28 Dec 2023 21:37:57 +0530 Subject: [PATCH 082/540] style(search): Border is too thick for sidebar (#9528) --- .../src/app/search/sidebar/BrowseSidebar.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx b/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx index c16bcdcaf6c727..1731727c14cfc1 100644 --- a/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx +++ b/datahub-web-react/src/app/search/sidebar/BrowseSidebar.tsx @@ -9,7 +9,6 @@ import useSidebarEntities from './useSidebarEntities'; import { ANTD_GRAY_V2 } from '../../entity/shared/constants'; import { ProfileSidebarResizer } from '../../entity/shared/containers/profile/sidebar/ProfileSidebarResizer'; - export const MAX_BROWSER_WIDTH = 500; export const MIN_BROWSWER_WIDTH = 200; @@ -18,7 +17,6 @@ export const SidebarWrapper = styled.div<{ visible: boolean; width: number }>` width: ${(props) => (props.visible ? `${props.width}px` : '0')}; min-width: ${(props) => (props.visible ? `${props.width}px` : '0')}; transition: width 250ms ease-in-out; - border-right: 1px solid ${(props) => props.theme.styles['border-color-base']}; background-color: ${ANTD_GRAY_V2[1]}; background: white; `; @@ -53,7 +51,12 @@ const BrowseSidebar = ({ visible }: Props) => { return ( <> - + Navigate From 5321352852a511bf92685290fc8a4371faaed876 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Fri, 29 Dec 2023 12:53:58 +0530 Subject: [PATCH 083/540] style(ui): humanise duration shown on ingestion page (#9530) --- .../executions/IngestionExecutionTable.tsx | 8 +++---- .../src/app/shared/formatDuration.ts | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 datahub-web-react/src/app/shared/formatDuration.ts diff --git a/datahub-web-react/src/app/ingest/source/executions/IngestionExecutionTable.tsx b/datahub-web-react/src/app/ingest/source/executions/IngestionExecutionTable.tsx index 8c81cc36ae3f99..a9d9283ef1377e 100644 --- a/datahub-web-react/src/app/ingest/source/executions/IngestionExecutionTable.tsx +++ b/datahub-web-react/src/app/ingest/source/executions/IngestionExecutionTable.tsx @@ -4,6 +4,7 @@ import { StyledTable } from '../../../entity/shared/components/styled/StyledTabl import { ExecutionRequest } from '../../../../types.generated'; import { ButtonsColumn, SourceColumn, StatusColumn, TimeColumn } from './IngestionExecutionTableColumns'; import { SUCCESS } from '../utils'; +import { formatDuration } from '../../../shared/formatDuration'; interface Props { executionRequests: ExecutionRequest[]; @@ -34,13 +35,10 @@ export default function IngestionExecutionTable({ render: TimeColumn, }, { - title: 'Duration (s)', + title: 'Duration', dataIndex: 'duration', key: 'duration', - render: (durationMs: number) => { - const seconds = (durationMs && `${durationMs / 1000}s`) || 'None'; - return seconds; - }, + render: (durationMs: number) => formatDuration(durationMs), }, { title: 'Status', diff --git a/datahub-web-react/src/app/shared/formatDuration.ts b/datahub-web-react/src/app/shared/formatDuration.ts new file mode 100644 index 00000000000000..1028b46f70b316 --- /dev/null +++ b/datahub-web-react/src/app/shared/formatDuration.ts @@ -0,0 +1,21 @@ +export const formatDuration = (durationMs: number): string => { + if (!durationMs) return 'None'; + + const seconds = durationMs / 1000; + + if (seconds < 60) { + return `${seconds.toFixed(1)} s`; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + + if (minutes < 60) { + return `${minutes} min ${remainingSeconds} s`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = Math.round(minutes % 60); + + return `${hours} hr ${remainingMinutes} min`; +}; From 06bd9b988d3006d57350476ccec18b2a5e7aac37 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Fri, 29 Dec 2023 21:34:06 +0530 Subject: [PATCH 084/540] fix(cli): upsert for data product external url (#9534) --- metadata-ingestion/src/datahub/specific/dataproduct.py | 2 +- .../entities/dataproducts/golden_dataproduct_out_upsert.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/metadata-ingestion/src/datahub/specific/dataproduct.py b/metadata-ingestion/src/datahub/specific/dataproduct.py index 301a0ff63f2f05..bb49ac47b3ef86 100644 --- a/metadata-ingestion/src/datahub/specific/dataproduct.py +++ b/metadata-ingestion/src/datahub/specific/dataproduct.py @@ -152,7 +152,7 @@ def set_external_url(self, external_url: str) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, "replace", - path="/external_url", + path="/externalUrl", value=external_url, ) return self diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json index ca4aafe848f60c..97c2330f58bc75 100644 --- a/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json @@ -5,7 +5,7 @@ "changeType": "PATCH", "aspectName": "dataProductProperties", "aspect": { - "value": "[{\"op\": \"replace\", \"path\": \"/name\", \"value\": \"Pet of the Week Campaign\"}, {\"op\": \"replace\", \"path\": \"/assets\", \"value\": [{\"destinationUrn\": \"urn:li:container:DATABASE\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:container:SCHEMA\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes)\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}]}, {\"op\": \"replace\", \"path\": \"/customProperties\", \"value\": {\"version\": \"2.0\", \"classification\": \"pii\"}}, {\"op\": \"replace\", \"path\": \"/external_url\", \"value\": \"https://github.com/datahub-project/datahub\"}]", + "value": "[{\"op\": \"replace\", \"path\": \"/name\", \"value\": \"Pet of the Week Campaign\"}, {\"op\": \"replace\", \"path\": \"/assets\", \"value\": [{\"destinationUrn\": \"urn:li:container:DATABASE\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:container:SCHEMA\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes)\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}]}, {\"op\": \"replace\", \"path\": \"/customProperties\", \"value\": {\"version\": \"2.0\", \"classification\": \"pii\"}}, {\"op\": \"replace\", \"path\": \"/externalUrl\", \"value\": \"https://github.com/datahub-project/datahub\"}]", "contentType": "application/json-patch+json" } }, From 31f9c796763677a4d452066d9b49b4088e65da19 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:22:22 +0530 Subject: [PATCH 085/540] fix posts are failing to be created as Admin user (#9533) --- datahub-web-react/src/app/settings/posts/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/settings/posts/utils.ts b/datahub-web-react/src/app/settings/posts/utils.ts index ce48c7400738ce..9958a0e8d9f0e8 100644 --- a/datahub-web-react/src/app/settings/posts/utils.ts +++ b/datahub-web-react/src/app/settings/posts/utils.ts @@ -16,7 +16,7 @@ export const addToListPostCache = (client, newPost, pageSize) => { }); // Add our new post into the existing list. - const newPosts = [newPost, ...(currData?.listPosts?.posts || [])]; + const newPosts = [...(currData?.listPosts?.posts || [])]; // Write our data back to the cache. client.writeQuery({ From 0bb838b904807c8fdc8266b6395023079b4dce4f Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:45:55 +0530 Subject: [PATCH 086/540] fix(ui): while creating secrets via UI validate validate characters (#9548) --- datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx b/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx index 30f04d61b8fc90..c099d9a580efab 100644 --- a/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx +++ b/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx @@ -81,7 +81,7 @@ export const SecretBuilderModal = ({ initialState, visible, onSubmit, onCancel } }, { whitespace: false }, { min: 1, max: 50 }, - { pattern: /^[^\s\t${}\\,'"]+$/, message: 'This secret name is not allowed.' }, + { pattern: /^[a-zA-Z_]+[a-zA-Z0-9_]*$/, message: 'Please start the secret name with a letter, followed by letters, digits, or underscores only.' }, ]} hasFeedback > From 6d72640e9149343363885ec275d89fb48d9a9626 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Wed, 3 Jan 2024 09:47:58 +0530 Subject: [PATCH 087/540] feat(ui): add databricks logo (#9473) --- datahub-web-react/src/app/ingest/source/builder/constants.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/datahub-web-react/src/app/ingest/source/builder/constants.ts b/datahub-web-react/src/app/ingest/source/builder/constants.ts index 08538729de40b5..bd792d78856d5c 100644 --- a/datahub-web-react/src/app/ingest/source/builder/constants.ts +++ b/datahub-web-react/src/app/ingest/source/builder/constants.ts @@ -103,6 +103,8 @@ export const CUSTOM = 'custom'; export const CUSTOM_URN = `urn:li:dataPlatform:${CUSTOM}`; export const UNITY_CATALOG = 'unity-catalog'; export const UNITY_CATALOG_URN = `urn:li:dataPlatform:${UNITY_CATALOG}`; +export const DATABRICKS = 'databricks'; +export const DATABRICKS_URN = `urn:li:dataPlatform:${DATABRICKS}`; export const DBT_CLOUD = 'dbt-cloud'; export const DBT_CLOUD_URN = `urn:li:dataPlatform:dbt`; export const VERTICA = 'vertica'; @@ -143,6 +145,7 @@ export const PLATFORM_URN_TO_LOGO = { [TRINO_URN]: trinoLogo, [SUPERSET_URN]: supersetLogo, [UNITY_CATALOG_URN]: databricksLogo, + [DATABRICKS_URN]: databricksLogo, [VERTICA_URN]: verticaLogo, [FIVETRAN_URN]: fivetranLogo, [CSV_URN]: csvLogo, From 29f2142a2c128f7f165f9011eff3bc647ae92185 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Wed, 3 Jan 2024 09:48:43 +0530 Subject: [PATCH 088/540] feat(databricks): add hive metastore analyze profiling (#9511) --- metadata-ingestion/setup.py | 4 +- .../ingestion/source/sql/sql_config.py | 8 +- .../datahub/ingestion/source/unity/config.py | 16 +- .../source/unity/hive_metastore_proxy.py | 109 ++++++++- .../datahub/ingestion/source/unity/proxy.py | 125 +++++++--- .../ingestion/source/unity/proxy_profiling.py | 50 ++-- .../ingestion/source/unity/proxy_types.py | 24 +- .../datahub/ingestion/source/unity/report.py | 6 + .../datahub/ingestion/source/unity/source.py | 27 ++- .../datahub/ingestion/source/unity/usage.py | 5 +- .../unity/test_unity_catalog_ingest.py | 104 +++++++- .../unity/unity_catalog_mces_golden.json | 228 ++++++++++++++++-- 12 files changed, 600 insertions(+), 106 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 8e4791e253c7cf..10db019b513812 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -251,9 +251,7 @@ databricks = { # 0.1.11 appears to have authentication issues with azure databricks - # 0.16.0 added py.typed support which caused mypy to fail. The databricks sdk is pinned until we resolve mypy issues. - # https://github.com/databricks/databricks-sdk-py/pull/483 - "databricks-sdk>=0.9.0,<0.16.0", + "databricks-sdk>=0.9.0", "pyspark~=3.3.0", "requests", # Version 2.4.0 includes sqlalchemy dialect, 2.8.0 includes some bug fixes diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_config.py b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_config.py index 54edab6f3b84ba..c0dc70301ba341 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/sql_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/sql_config.py @@ -112,7 +112,13 @@ def ensure_profiling_pattern_is_passed_to_profiling( cls, values: Dict[str, Any] ) -> Dict[str, Any]: profiling: Optional[GEProfilingConfig] = values.get("profiling") - if profiling is not None and profiling.enabled: + # Note: isinstance() check is required here as unity-catalog source reuses + # SQLCommonConfig with different profiling config than GEProfilingConfig + if ( + profiling is not None + and isinstance(profiling, GEProfilingConfig) + and profiling.enabled + ): profiling._allow_deny_patterns = values["profile_pattern"] return values diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py index 96971faeea69f4..df36153af9d83b 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py @@ -95,14 +95,6 @@ class UnityCatalogAnalyzeProfilerConfig(UnityCatalogProfilerConfig): description="Number of worker threads to use for profiling. Set to 1 to disable.", ) - @pydantic.root_validator(skip_on_failure=True) - def warehouse_id_required_for_profiling( - cls, values: Dict[str, Any] - ) -> Dict[str, Any]: - if values.get("enabled") and not values.get("warehouse_id"): - raise ValueError("warehouse_id must be set when profiling is enabled.") - return values - @property def include_columns(self): return not self.profile_table_level_only @@ -254,6 +246,7 @@ class UnityCatalogSourceConfig( description="Generate usage statistics.", ) + # TODO: Remove `type:ignore` by refactoring config profiling: Union[UnityCatalogGEProfilerConfig, UnityCatalogAnalyzeProfilerConfig] = Field( # type: ignore default=UnityCatalogGEProfilerConfig(), description="Data profiling configuration", @@ -316,7 +309,9 @@ def include_metastore_warning(cls, v: bool) -> bool: @pydantic.root_validator(skip_on_failure=True) def set_warehouse_id_from_profiling(cls, values: Dict[str, Any]) -> Dict[str, Any]: - profiling: Optional[UnityCatalogProfilerConfig] = values.get("profiling") + profiling: Optional[ + Union[UnityCatalogGEProfilerConfig, UnityCatalogAnalyzeProfilerConfig] + ] = values.get("profiling") if not values.get("warehouse_id") and profiling and profiling.warehouse_id: values["warehouse_id"] = profiling.warehouse_id if ( @@ -337,6 +332,9 @@ def set_warehouse_id_from_profiling(cls, values: Dict[str, Any]) -> Dict[str, An if values.get("warehouse_id") and profiling and not profiling.warehouse_id: profiling.warehouse_id = values["warehouse_id"] + if profiling and profiling.enabled and not profiling.warehouse_id: + raise ValueError("warehouse_id must be set when profiling is enabled.") + return values @pydantic.validator("schema_pattern", always=True) diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py index 99b2ff998662cb..814d86a2f3234b 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py @@ -12,11 +12,14 @@ from datahub.ingestion.source.unity.proxy_types import ( Catalog, Column, + ColumnProfile, CustomCatalogType, HiveTableType, Metastore, Schema, Table, + TableProfile, + TableReference, ) logger = logging.getLogger(__name__) @@ -38,6 +41,18 @@ "binary": ColumnTypeName.BINARY, } +NUM_NULLS = "num_nulls" +DISTINCT_COUNT = "distinct_count" +MIN = "min" +MAX = "max" +AVG_COL_LEN = "avg_col_len" +MAX_COL_LEN = "max_col_len" +VERSION = "version" + +ROWS = "rows" +BYTES = "bytes" +TABLE_STAT_LIST = {ROWS, BYTES} + class HiveMetastoreProxy(Closeable): # TODO: Support for view lineage using SQL parsing @@ -67,7 +82,7 @@ def get_inspector(sqlalchemy_url: str, options: dict) -> Inspector: def hive_metastore_catalog(self, metastore: Optional[Metastore]) -> Catalog: return Catalog( - id=HIVE_METASTORE, + id=f"{metastore.id}.{HIVE_METASTORE}" if metastore else HIVE_METASTORE, name=HIVE_METASTORE, comment=None, metastore=metastore, @@ -95,9 +110,14 @@ def hive_metastore_tables(self, schema: Schema) -> Iterable[Table]: continue yield self._get_table(schema, table_name, False) - def _get_table(self, schema: Schema, table_name: str, is_view: bool) -> Table: + def _get_table( + self, + schema: Schema, + table_name: str, + is_view: bool = False, + ) -> Table: columns = self._get_columns(schema, table_name) - detailed_info = self._get_table_info(schema, table_name) + detailed_info = self._get_table_info(schema.name, table_name) comment = detailed_info.pop("Comment", None) storage_location = detailed_info.pop("Location", None) @@ -129,6 +149,74 @@ def _get_table(self, schema: Schema, table_name: str, is_view: bool) -> Table: comment=comment, ) + def get_table_profile( + self, ref: TableReference, include_column_stats: bool = False + ) -> TableProfile: + columns = self._get_columns( + Schema( + id=ref.schema, + name=ref.schema, + # This is okay, as none of this is used in profiling + catalog=self.hive_metastore_catalog(None), + comment=None, + owner=None, + ), + ref.table, + ) + detailed_info = self._get_table_info(ref.schema, ref.table) + + table_stats = ( + self._get_cached_table_statistics(detailed_info["Statistics"]) + if detailed_info.get("Statistics") + else {} + ) + + return TableProfile( + num_rows=int(table_stats[ROWS]) + if table_stats.get(ROWS) is not None + else None, + total_size=int(table_stats[BYTES]) + if table_stats.get(BYTES) is not None + else None, + num_columns=len(columns), + column_profiles=[ + self._get_column_profile(column.name, ref) for column in columns + ] + if include_column_stats + else [], + ) + + def _get_column_profile(self, column: str, ref: TableReference) -> ColumnProfile: + + props = self._column_describe_extended(ref.schema, ref.table, column) + col_stats = {} + for prop in props: + col_stats[prop[0]] = prop[1] + return ColumnProfile( + name=column, + null_count=int(col_stats[NUM_NULLS]) + if col_stats.get(NUM_NULLS) is not None + else None, + distinct_count=int(col_stats[DISTINCT_COUNT]) + if col_stats.get(DISTINCT_COUNT) is not None + else None, + min=col_stats.get(MIN), + max=col_stats.get(MAX), + avg_len=col_stats.get(AVG_COL_LEN), + max_len=col_stats.get(MAX_COL_LEN), + version=col_stats.get(VERSION), + ) + + def _get_cached_table_statistics(self, statistics: str) -> dict: + # statistics is in format "xx bytes" OR "1382 bytes, 2 rows" + table_stats = dict() + for prop in statistics.split(","): + value_key_list = prop.strip().split(" ") # value_key_list -> [value, key] + if len(value_key_list) == 2 and value_key_list[1] in TABLE_STAT_LIST: + table_stats[value_key_list[1]] = value_key_list[0] + + return table_stats + def _get_created_at(self, created_at: Optional[str]) -> Optional[datetime]: return ( datetime.strptime(created_at, "%a %b %d %H:%M:%S %Z %Y") @@ -171,8 +259,8 @@ def _get_table_type(self, type: Optional[str]) -> HiveTableType: else: return HiveTableType.UNKNOWN - def _get_table_info(self, schema: Schema, table_name: str) -> dict: - rows = self._describe_extended(schema.name, table_name) + def _get_table_info(self, schema_name: str, table_name: str) -> dict: + rows = self._describe_extended(schema_name, table_name) index = rows.index(("# Detailed Table Information", "", "")) rows = rows[index + 1 :] @@ -235,6 +323,17 @@ def _describe_extended(self, schema_name: str, table_name: str) -> List[Row]: """ return self._execute_sql(f"DESCRIBE EXTENDED `{schema_name}`.`{table_name}`") + def _column_describe_extended( + self, schema_name: str, table_name: str, column_name: str + ) -> List[Row]: + """ + Rows are structured as shown in examples here + https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-aux-describe-table.html#examples + """ + return self._execute_sql( + f"DESCRIBE EXTENDED `{schema_name}`.`{table_name}` {column_name}" + ) + def _execute_sql(self, sql: str) -> List[Row]: return self.inspector.bind.execute(sql).fetchall() diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py index 13baa8b57a639d..b414f3f188c23c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy.py @@ -4,7 +4,7 @@ import dataclasses import logging from datetime import datetime, timezone -from typing import Any, Dict, Iterable, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union, cast from unittest.mock import patch from databricks.sdk import WorkspaceClient @@ -49,16 +49,19 @@ logger: logging.Logger = logging.getLogger(__name__) +@dataclasses.dataclass class TableInfoWithGeneration(TableInfo): generation: Optional[int] = None - @classmethod def as_dict(self) -> dict: return {**super().as_dict(), "generation": self.generation} @classmethod def from_dict(cls, d: Dict[str, Any]) -> "TableInfoWithGeneration": - table_info = super().from_dict(d) + table_info: TableInfoWithGeneration = cast( + TableInfoWithGeneration, + super().from_dict(d), + ) table_info.generation = d.get("generation") return table_info @@ -72,7 +75,10 @@ def as_dict(self) -> dict: @classmethod def from_dict(cls, d: Dict[str, Any]) -> "QueryFilterWithStatementTypes": - v = super().from_dict(d) + v: QueryFilterWithStatementTypes = cast( + QueryFilterWithStatementTypes, + super().from_dict(d), + ) v.statement_types = d["statement_types"] return v @@ -104,7 +110,7 @@ def __init__( def check_basic_connectivity(self) -> bool: return bool(self._workspace_client.catalogs.list()) - def assigned_metastore(self) -> Metastore: + def assigned_metastore(self) -> Optional[Metastore]: response = self._workspace_client.metastores.summary() return self._create_metastore(response) @@ -117,7 +123,9 @@ def catalogs(self, metastore: Optional[Metastore]) -> Iterable[Catalog]: logger.info("Catalogs not found") return [] for catalog in response: - yield self._create_catalog(metastore, catalog) + optional_catalog = self._create_catalog(metastore, catalog) + if optional_catalog: + yield optional_catalog def catalog( self, catalog_name: str, metastore: Optional[Metastore] @@ -126,7 +134,11 @@ def catalog( if not response: logger.info(f"Catalog {catalog_name} not found") return None - return self._create_catalog(metastore, response) + optional_catalog = self._create_catalog(metastore, response) + if optional_catalog: + return optional_catalog + + return None def schemas(self, catalog: Catalog) -> Iterable[Schema]: if ( @@ -140,7 +152,9 @@ def schemas(self, catalog: Catalog) -> Iterable[Schema]: logger.info(f"Schemas not found for catalog {catalog.id}") return [] for schema in response: - yield self._create_schema(catalog, schema) + optional_schema = self._create_schema(catalog, schema) + if optional_schema: + yield optional_schema def tables(self, schema: Schema) -> Iterable[Table]: if ( @@ -158,28 +172,38 @@ def tables(self, schema: Schema) -> Iterable[Table]: return [] for table in response: try: - yield self._create_table(schema, table) + optional_table = self._create_table( + schema, cast(TableInfoWithGeneration, table) + ) + if optional_table: + yield optional_table except Exception as e: logger.warning(f"Error parsing table: {e}") self.report.report_warning("table-parse", str(e)) def service_principals(self) -> Iterable[ServicePrincipal]: for principal in self._workspace_client.service_principals.list(): - yield self._create_service_principal(principal) + optional_sp = self._create_service_principal(principal) + if optional_sp: + yield optional_sp def workspace_notebooks(self) -> Iterable[Notebook]: for obj in self._workspace_client.workspace.list("/", recursive=True): - if obj.object_type == ObjectType.NOTEBOOK: + if obj.object_type == ObjectType.NOTEBOOK and obj.object_id and obj.path: yield Notebook( id=obj.object_id, path=obj.path, language=obj.language, created_at=datetime.fromtimestamp( obj.created_at / 1000, tz=timezone.utc - ), + ) + if obj.created_at + else None, modified_at=datetime.fromtimestamp( obj.modified_at / 1000, tz=timezone.utc - ), + ) + if obj.modified_at + else None, ) def query_history( @@ -204,7 +228,9 @@ def query_history( ) for query_info in self._query_history(filter_by=filter_by): try: - yield self._create_query(query_info) + optional_query = self._create_query(query_info) + if optional_query: + yield optional_query except Exception as e: logger.warning(f"Error parsing query: {e}") self.report.report_warning("query-parse", str(e)) @@ -229,15 +255,16 @@ def _query_history( "max_results": max_results, # Max batch size } - response: dict = self._workspace_client.api_client.do( + response: dict = self._workspace_client.api_client.do( # type: ignore method, path, body={**body, "filter_by": filter_by.as_dict()} ) + # we use default raw=False in above request, therefore will always get dict while True: if "res" not in response or not response["res"]: return for v in response["res"]: yield QueryInfo.from_dict(v) - response = self._workspace_client.api_client.do( + response = self._workspace_client.api_client.do( # type: ignore method, path, body={**body, "page_token": response["next_page_token"]} ) @@ -245,7 +272,7 @@ def list_lineages_by_table( self, table_name: str, include_entity_lineage: bool ) -> dict: """List table lineage by table name.""" - return self._workspace_client.api_client.do( + return self._workspace_client.api_client.do( # type: ignore method="GET", path="/api/2.0/lineage-tracking/table-lineage", body={ @@ -256,7 +283,7 @@ def list_lineages_by_table( def list_lineages_by_column(self, table_name: str, column_name: str) -> dict: """List column lineage by table name and column name.""" - return self._workspace_client.api_client.do( + return self._workspace_client.api_client.do( # type: ignore "GET", "/api/2.0/lineage-tracking/column-lineage", body={"table_name": table_name, "column_name": column_name}, @@ -325,7 +352,9 @@ def _escape_sequence(value: str) -> str: @staticmethod def _create_metastore( obj: Union[GetMetastoreSummaryResponse, MetastoreInfo] - ) -> Metastore: + ) -> Optional[Metastore]: + if not obj.name: + return None return Metastore( name=obj.name, id=UnityCatalogApiProxy._escape_sequence(obj.name), @@ -339,7 +368,10 @@ def _create_metastore( def _create_catalog( self, metastore: Optional[Metastore], obj: CatalogInfo - ) -> Catalog: + ) -> Optional[Catalog]: + if not obj.name: + self.report.num_catalogs_missing_name += 1 + return None catalog_name = self._escape_sequence(obj.name) return Catalog( name=obj.name, @@ -350,7 +382,10 @@ def _create_catalog( type=obj.catalog_type, ) - def _create_schema(self, catalog: Catalog, obj: SchemaInfo) -> Schema: + def _create_schema(self, catalog: Catalog, obj: SchemaInfo) -> Optional[Schema]: + if not obj.name: + self.report.num_schemas_missing_name += 1 + return None return Schema( name=obj.name, id=f"{catalog.id}.{self._escape_sequence(obj.name)}", @@ -359,11 +394,14 @@ def _create_schema(self, catalog: Catalog, obj: SchemaInfo) -> Schema: owner=obj.owner, ) - def _create_column(self, table_id: str, obj: ColumnInfo) -> Column: + def _create_column(self, table_id: str, obj: ColumnInfo) -> Optional[Column]: + if not obj.name: + self.report.num_columns_missing_name += 1 + return None return Column( name=obj.name, id=f"{table_id}.{self._escape_sequence(obj.name)}", - type_text=obj.type_text, + type_text=obj.type_text or "", type_name=obj.type_name, type_scale=obj.type_scale, type_precision=obj.type_precision, @@ -372,7 +410,12 @@ def _create_column(self, table_id: str, obj: ColumnInfo) -> Column: comment=obj.comment, ) - def _create_table(self, schema: Schema, obj: TableInfoWithGeneration) -> Table: + def _create_table( + self, schema: Schema, obj: TableInfoWithGeneration + ) -> Optional[Table]: + if not obj.name: + self.report.num_tables_missing_name += 1 + return None table_id = f"{schema.id}.{self._escape_sequence(obj.name)}" return Table( name=obj.name, @@ -381,26 +424,40 @@ def _create_table(self, schema: Schema, obj: TableInfoWithGeneration) -> Table: schema=schema, storage_location=obj.storage_location, data_source_format=obj.data_source_format, - columns=[ - self._create_column(table_id, column) for column in obj.columns or [] - ], + columns=list(self._extract_columns(obj.columns, table_id)) + if obj.columns + else [], view_definition=obj.view_definition or None, properties=obj.properties or {}, owner=obj.owner, generation=obj.generation, - created_at=datetime.fromtimestamp(obj.created_at / 1000, tz=timezone.utc), + created_at=datetime.fromtimestamp(obj.created_at / 1000, tz=timezone.utc) + if obj.created_at + else None, created_by=obj.created_by, updated_at=datetime.fromtimestamp(obj.updated_at / 1000, tz=timezone.utc) if obj.updated_at + else None + if obj.updated_at else None, updated_by=obj.updated_by, table_id=obj.table_id, comment=obj.comment, ) + def _extract_columns( + self, columns: List[ColumnInfo], table_id: str + ) -> Iterable[Column]: + for column in columns: + optional_column = self._create_column(table_id, column) + if optional_column: + yield optional_column + def _create_service_principal( self, obj: DatabricksServicePrincipal - ) -> ServicePrincipal: + ) -> Optional[ServicePrincipal]: + if not obj.display_name or not obj.application_id: + return None return ServicePrincipal( id=f"{obj.id}.{self._escape_sequence(obj.display_name)}", display_name=obj.display_name, @@ -408,8 +465,14 @@ def _create_service_principal( active=obj.active, ) - @staticmethod - def _create_query(info: QueryInfo) -> Query: + def _create_query(self, info: QueryInfo) -> Optional[Query]: + if ( + not info.query_text + or not info.query_start_time_ms + or not info.query_end_time_ms + ): + self.report.num_queries_missing_info += 1 + return None return Query( query_id=info.query_id, query_text=info.query_text, diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_profiling.py b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_profiling.py index ab38119d01a9b6..5992f103ccac35 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_profiling.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_profiling.py @@ -14,6 +14,10 @@ StatementStatus, ) +from datahub.ingestion.source.unity.hive_metastore_proxy import ( + HIVE_METASTORE, + HiveMetastoreProxy, +) from datahub.ingestion.source.unity.proxy_types import ( ColumnProfile, TableProfile, @@ -30,6 +34,7 @@ class UnityCatalogProxyProfilingMixin: _workspace_client: WorkspaceClient report: UnityCatalogReport warehouse_id: str + hive_metastore_proxy: Optional[HiveMetastoreProxy] def check_profiling_connectivity(self): self._workspace_client.warehouses.get(self.warehouse_id) @@ -136,6 +141,8 @@ def _analyze_table( def _check_analyze_table_statement_status( self, execute_response: ExecuteStatementResponse, max_wait_secs: int ) -> bool: + if not execute_response.statement_id or not execute_response.status: + return False statement_id: str = execute_response.statement_id status: StatementStatus = execute_response.status @@ -152,13 +159,15 @@ def _check_analyze_table_statement_status( statement_id ) self._raise_if_error(response, "get-statement") - status = response.status + status = response.status # type: ignore return status.state == StatementState.SUCCEEDED def _get_table_profile( self, ref: TableReference, include_columns: bool ) -> TableProfile: + if self.hive_metastore_proxy and ref.catalog == HIVE_METASTORE: + return self.hive_metastore_proxy.get_table_profile(ref, include_columns) table_info = self._workspace_client.tables.get(ref.qualified_table_name) return self._create_table_profile(table_info, include_columns=include_columns) @@ -166,7 +175,12 @@ def _create_table_profile( self, table_info: TableInfo, include_columns: bool ) -> TableProfile: # Warning: this implementation is brittle -- dependent on properties that can change - columns_names = [column.name for column in table_info.columns] + columns_names = ( + [column.name for column in table_info.columns if column.name] + if table_info.columns + else [] + ) + return TableProfile( num_rows=self._get_int(table_info, "spark.sql.statistics.numRows"), total_size=self._get_int(table_info, "spark.sql.statistics.totalSize"), @@ -182,6 +196,7 @@ def _create_table_profile( def _create_column_profile( self, column: str, table_info: TableInfo ) -> ColumnProfile: + tblproperties = table_info.properties or {} return ColumnProfile( name=column, null_count=self._get_int( @@ -190,25 +205,18 @@ def _create_column_profile( distinct_count=self._get_int( table_info, f"spark.sql.statistics.colStats.{column}.distinctCount" ), - min=table_info.properties.get( - f"spark.sql.statistics.colStats.{column}.min" - ), - max=table_info.properties.get( - f"spark.sql.statistics.colStats.{column}.max" - ), - avg_len=table_info.properties.get( - f"spark.sql.statistics.colStats.{column}.avgLen" - ), - max_len=table_info.properties.get( - f"spark.sql.statistics.colStats.{column}.maxLen" - ), - version=table_info.properties.get( + min=tblproperties.get(f"spark.sql.statistics.colStats.{column}.min"), + max=tblproperties.get(f"spark.sql.statistics.colStats.{column}.max"), + avg_len=tblproperties.get(f"spark.sql.statistics.colStats.{column}.avgLen"), + max_len=tblproperties.get(f"spark.sql.statistics.colStats.{column}.maxLen"), + version=tblproperties.get( f"spark.sql.statistics.colStats.{column}.version" ), ) def _get_int(self, table_info: TableInfo, field: str) -> Optional[int]: - value = table_info.properties.get(field) + tblproperties = table_info.properties or {} + value = tblproperties.get(field) if value is not None: try: return int(value) @@ -223,14 +231,18 @@ def _get_int(self, table_info: TableInfo, field: str) -> Optional[int]: def _raise_if_error( response: Union[ExecuteStatementResponse, GetStatementResponse], key: str ) -> None: - if response.status.state in [ + if response.status and response.status.state in [ StatementState.FAILED, StatementState.CANCELED, StatementState.CLOSED, ]: raise DatabricksError( - response.status.error.message, - error_code=response.status.error.error_code.value, + response.status.error.message + if response.status.error and response.status.error.message + else "Unknown Error", + error_code=response.status.error.error_code.value + if response.status.error and response.status.error.error_code + else "Unknown Error Code", status=response.status.state.value, context=key, ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py index e5951cb0fa4ffc..c66189d99f738f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/proxy_types.py @@ -96,8 +96,8 @@ class CommonProperty: @dataclass class Metastore(CommonProperty): - global_metastore_id: str # Global across clouds and regions - metastore_id: str + global_metastore_id: Optional[str] # Global across clouds and regions + metastore_id: Optional[str] owner: Optional[str] cloud: Optional[str] region: Optional[str] @@ -107,7 +107,7 @@ class Metastore(CommonProperty): class Catalog(CommonProperty): metastore: Optional[Metastore] owner: Optional[str] - type: Union[CatalogType, CustomCatalogType] + type: Optional[Union[CatalogType, CustomCatalogType]] @dataclass @@ -224,14 +224,14 @@ class Table(CommonProperty): columns: List[Column] storage_location: Optional[str] data_source_format: Optional[DataSourceFormat] - table_type: Union[TableType, HiveTableType] + table_type: Optional[Union[TableType, HiveTableType]] owner: Optional[str] generation: Optional[int] created_at: Optional[datetime] created_by: Optional[str] updated_at: Optional[datetime] updated_by: Optional[str] - table_id: str + table_id: Optional[str] view_definition: Optional[str] properties: Dict[str, str] upstreams: Dict[TableReference, Dict[str, List[str]]] = field(default_factory=dict) @@ -252,16 +252,16 @@ def __post_init__(self): @dataclass class Query: - query_id: str + query_id: Optional[str] query_text: str - statement_type: QueryStatementType + statement_type: Optional[QueryStatementType] start_time: datetime end_time: datetime # User who ran the query - user_id: int + user_id: Optional[int] user_name: Optional[str] # Email or username # User whose credentials were used to run the query - executed_as_user_id: int + executed_as_user_id: Optional[int] executed_as_user_name: Optional[str] @@ -310,9 +310,9 @@ def __bool__(self): class Notebook: id: NotebookId path: str - language: Language - created_at: datetime - modified_at: datetime + language: Optional[Language] + created_at: Optional[datetime] + modified_at: Optional[datetime] upstreams: FrozenSet[TableReference] = field(default_factory=frozenset) diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/report.py b/metadata-ingestion/src/datahub/ingestion/source/unity/report.py index 0770d9d27055c0..02eedb67f4cc22 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/report.py @@ -39,3 +39,9 @@ class UnityCatalogReport(IngestionStageReport, ProfilingSqlReport): num_profile_missing_size_in_bytes: int = 0 num_profile_failed_unsupported_column_type: int = 0 num_profile_failed_int_casts: int = 0 + + num_catalogs_missing_name: int = 0 + num_schemas_missing_name: int = 0 + num_tables_missing_name: int = 0 + num_columns_missing_name: int = 0 + num_queries_missing_info: int = 0 diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py index 43c5e244393772..1bc47c63078490 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py @@ -304,22 +304,28 @@ def process_notebooks(self) -> Iterable[MetadataWorkUnit]: yield from self._gen_notebook_workunits(notebook) def _gen_notebook_workunits(self, notebook: Notebook) -> Iterable[MetadataWorkUnit]: + + properties = {"path": notebook.path} + if notebook.language: + properties["language"] = notebook.language.value + mcps = MetadataChangeProposalWrapper.construct_many( entityUrn=self.gen_notebook_urn(notebook), aspects=[ DatasetPropertiesClass( name=notebook.path.rsplit("/", 1)[-1], - customProperties={ - "path": notebook.path, - "language": notebook.language.value, - }, + customProperties=properties, externalUrl=urljoin( self.config.workspace_url, f"#notebook/{notebook.id}" ), - created=TimeStampClass(int(notebook.created_at.timestamp() * 1000)), + created=TimeStampClass(int(notebook.created_at.timestamp() * 1000)) + if notebook.created_at + else None, lastModified=TimeStampClass( int(notebook.modified_at.timestamp() * 1000) - ), + ) + if notebook.modified_at + else None, ), SubTypesClass(typeNames=[DatasetSubTypes.NOTEBOOK]), BrowsePathsClass(paths=notebook.path.split("/")), @@ -352,6 +358,9 @@ def process_metastores(self) -> Iterable[MetadataWorkUnit]: metastore: Optional[Metastore] = None if self.config.include_metastore: metastore = self.unity_catalog_api_proxy.assigned_metastore() + if not metastore: + self.report.report_failure("Metastore", "Not found") + return yield from self.gen_metastore_containers(metastore) yield from self.process_catalogs(metastore) if metastore and self.config.include_metastore: @@ -705,13 +714,15 @@ def _create_table_property_aspect(self, table: Table) -> DatasetPropertiesClass: if table.generation is not None: custom_properties["generation"] = str(table.generation) - custom_properties["table_type"] = table.table_type.value + if table.table_type: + custom_properties["table_type"] = table.table_type.value if table.created_by: custom_properties["created_by"] = table.created_by if table.properties: custom_properties.update({k: str(v) for k, v in table.properties.items()}) - custom_properties["table_id"] = table.table_id + if table.table_id: + custom_properties["table_id"] = table.table_id if table.owner: custom_properties["owner"] = table.owner if table.updated_by: diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py b/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py index ab21c1a3186596..f07e7a92d87626 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py @@ -117,7 +117,10 @@ def _get_workunits_internal( def _generate_operation_workunit( self, query: Query, table_info: QueryTableInfo ) -> Iterable[MetadataWorkUnit]: - if query.statement_type not in OPERATION_STATEMENT_TYPES: + if ( + not query.statement_type + or query.statement_type not in OPERATION_STATEMENT_TYPES + ): return None # Not sure about behavior when there are multiple target tables. This is a best attempt. diff --git a/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py b/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py index aab7630d57f460..05f1db0b932f81 100644 --- a/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py +++ b/metadata-ingestion/tests/integration/unity/test_unity_catalog_ingest.py @@ -186,6 +186,8 @@ def register_mock_data(workspace_client): "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", }, "generation": 2, "metastore_id": "2c983545-d403-4f87-9063-5b7e3b6d3736", @@ -200,6 +202,57 @@ def register_mock_data(workspace_client): ) ] + workspace_client.tables.get = lambda *args, **kwargs: databricks.sdk.service.catalog.TableInfo.from_dict( + { + "name": "quickstart_table", + "catalog_name": "quickstart_catalog", + "schema_name": "quickstart_schema", + "table_type": "MANAGED", + "data_source_format": "DELTA", + "columns": [ + { + "name": "columnA", + "type_text": "int", + "type_json": '{"name":"columnA","type":"integer","nullable":true,"metadata":{}}', + "type_name": "INT", + "type_precision": 0, + "type_scale": 0, + "position": 0, + "nullable": True, + }, + { + "name": "columnB", + "type_text": "string", + "type_json": '{"name":"columnB","type":"string","nullable":true,"metadata":{}}', + "type_name": "STRING", + "type_precision": 0, + "type_scale": 0, + "position": 1, + "nullable": True, + }, + ], + "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "owner": "account users", + "properties": { + "delta.lastCommitTimestamp": "1666185711000", + "delta.lastUpdateVersion": "1", + "delta.minReaderVersion": "1", + "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", + }, + "generation": 2, + "metastore_id": "2c983545-d403-4f87-9063-5b7e3b6d3736", + "full_name": "quickstart_catalog.quickstart_schema.quickstart_table", + "data_access_configuration_id": "00000000-0000-0000-0000-000000000000", + "created_at": 1666185698688, + "created_by": "abc@acryl.io", + "updated_at": 1666186049633, + "updated_by": "abc@acryl.io", + "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", + } + ) + workspace_client.service_principals.list.return_value = [ ServicePrincipal.from_dict(d) for d in [ @@ -220,7 +273,50 @@ def register_mock_data(workspace_client): def mock_hive_sql(query): - if query == "DESCRIBE EXTENDED `bronze_kambi`.`bet`": + + if query == "DESCRIBE EXTENDED `bronze_kambi`.`bet` betStatusId": + return [ + ("col_name", "betStatusId"), + ("data_type", "bigint"), + ("comment", None), + ("min", None), + ("max", None), + ("num_nulls", 0), + ("distinct_count", 1), + ("avg_col_len", 8), + ("max_col_len", 8), + ("histogram", None), + ] + elif query == "DESCRIBE EXTENDED `bronze_kambi`.`bet` channelId": + return [ + ("col_name", "channelId"), + ("data_type", "bigint"), + ("comment", None), + ("min", None), + ("max", None), + ("num_nulls", 0), + ("distinct_count", 1), + ("avg_col_len", 8), + ("max_col_len", 8), + ("histogram", None), + ] + elif query == "DESCRIBE EXTENDED `bronze_kambi`.`bet` combination": + return [ + ("col_name", "combination"), + ( + "data_type", + "struct>,eventId:bigint,eventName:string,eventStartDate:string,live:boolean,odds:double,outcomeIds:array,outcomeLabel:string,sportId:string,status:string,voidReason:string>>,payout:double,rewardExtraPayout:double,stake:double>", + ), + ("comment", None), + ("min", None), + ("max", None), + ("num_nulls", None), + ("distinct_count", None), + ("avg_col_len", None), + ("max_col_len", None), + ("histogram", None), + ] + elif query == "DESCRIBE EXTENDED `bronze_kambi`.`bet`": return [ ("betStatusId", "bigint", None), ("channelId", "bigint", None), @@ -237,6 +333,7 @@ def mock_hive_sql(query): ("Created Time", "Wed Jun 22 05:14:56 UTC 2022", ""), ("Last Access", "UNKNOWN", ""), ("Created By", "Spark 3.2.1", ""), + ("Statistics", "1024 bytes, 3 rows", ""), ("Type", "MANAGED", ""), ("Location", "dbfs:/user/hive/warehouse/bronze_kambi.db/bet", ""), ("Provider", "delta", ""), @@ -312,6 +409,11 @@ def test_ingestion(pytestconfig, tmp_path, requests_mock): "include_ownership": True, "include_hive_metastore": True, "warehouse_id": "test", + "profiling": { + "enabled": True, + "method": "analyze", + "call_analyze": False, + }, }, }, "sink": { diff --git a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json index 98a6615dd2b52c..383f94144ffdcc 100644 --- a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json +++ b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json @@ -504,7 +504,7 @@ "Last Access": "UNKNOWN", "Created By": "Spark 3.2.1", "Owner": "root", - "table_id": "hive_metastore.bronze_kambi.view1", + "table_id": "acryl_metastore.hive_metastore.bronze_kambi.view1", "created_at": "2022-06-22 05:14:56" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/view1", @@ -638,7 +638,7 @@ "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "hive_metastore.bronze_kambi.view1", + "schemaName": "acryl_metastore.hive_metastore.bronze_kambi.view1", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1172,10 +1172,11 @@ "Table": "bet", "Last Access": "UNKNOWN", "Created By": "Spark 3.2.1", + "Statistics": "1024 bytes, 3 rows", "Owner": "root", "Is_managed_location": "true", "Table Properties": "[delta.autoOptimize.autoCompact=true,delta.autoOptimize.optimizeWrite=true,delta.minReaderVersion=1,delta.minWriterVersion=2]", - "table_id": "hive_metastore.bronze_kambi.bet", + "table_id": "acryl_metastore.hive_metastore.bronze_kambi.bet", "created_at": "2022-06-22 05:14:56" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/bet", @@ -1275,7 +1276,7 @@ "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "hive_metastore.bronze_kambi.bet", + "schemaName": "acryl_metastore.hive_metastore.bronze_kambi.bet", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1731,15 +1732,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default/quickstart_table", "name": "quickstart_table", @@ -2061,15 +2064,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema/quickstart_table", "name": "quickstart_table", @@ -2527,15 +2532,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default/quickstart_table", "name": "quickstart_table", @@ -2857,15 +2864,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema/quickstart_table", "name": "quickstart_table", @@ -3323,15 +3332,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default/quickstart_table", "name": "quickstart_table", @@ -3653,15 +3664,17 @@ "generation": "2", "table_type": "MANAGED", "created_by": "abc@acryl.io", - "created_at": "2022-10-19 13:21:38.688000+00:00", "delta.lastCommitTimestamp": "1666185711000", "delta.lastUpdateVersion": "1", "delta.minReaderVersion": "1", "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", "owner": "account users", "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00" + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/quickstart_schema/quickstart_table", "name": "quickstart_table", @@ -3813,6 +3826,69 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580920011, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703581191932, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 3, + "columnCount": 3, + "fieldProfiles": [ + { + "fieldPath": "betStatusId", + "uniqueCount": 1, + "uniqueProportion": 0.3333333333333333, + "nullCount": 0, + "nullProportion": 0.0 + }, + { + "fieldPath": "channelId", + "uniqueCount": 1, + "uniqueProportion": 0.3333333333333333, + "nullCount": 0, + "nullProportion": 0.0 + } + ], + "sizeInBytes": 1024 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", @@ -3829,6 +3905,30 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580406273, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", @@ -3845,6 +3945,78 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580920008, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580920011, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580920012, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", @@ -3877,6 +4049,30 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", + "aspect": { + "json": { + "timestampMillis": 1703580920010, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", From 2d302fe754969a4ec64b678d6a4002558eee66b3 Mon Sep 17 00:00:00 2001 From: Kunal-kankriya <127090035+Kunal-kankriya@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:59:20 +0530 Subject: [PATCH 089/540] fix(cypress): make setting manage policy test not flaky (#9547) --- .../cypress/e2e/settings/manage_policies.js | 247 ++++++++---------- 1 file changed, 104 insertions(+), 143 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/settings/manage_policies.js b/smoke-test/tests/cypress/cypress/e2e/settings/manage_policies.js index 6515d92285e2e7..0e69a4e7f287a2 100644 --- a/smoke-test/tests/cypress/cypress/e2e/settings/manage_policies.js +++ b/smoke-test/tests/cypress/cypress/e2e/settings/manage_policies.js @@ -4,149 +4,110 @@ const platform_policy_edited = `Platform test policy ${test_id} EDITED`; const metadata_policy_name = `Metadata test policy ${test_id}`; const metadata_policy_edited = `Metadata test policy ${test_id} EDITED`; + + +function searchAndToggleMetadataPolicyStatus(metadataPolicyName, targetStatus) { + cy.get('[data-testid="search-input"]').should('be.visible'); + cy.get('[data-testid="search-input"]').eq(1).type(metadataPolicyName); + cy.contains('tr', metadataPolicyName).as('metadataPolicyRow'); + cy.contains(targetStatus).click(); +} + +function clickFocusAndType(Id, text) { + cy.clickOptionWithTestId(Id) + .focused().clear() + .type(text); +} + +function updateAndSave(Id, groupName, text) { + cy.clickOptionWithTestId(Id).type(groupName); + cy.get(`[title='${text}']`).click(); + cy.focused().blur(); +} + +function clickOnButton(saveButton) { + cy.get(`#${saveButton}`).click(); +} + +function createPolicy(decription, policyName) { + clickFocusAndType("policy-description", decription) + clickOnButton("nextButton"); + updateAndSave("privileges", "All", "All Privileges", "nextButton") + clickOnButton("nextButton"); + updateAndSave("users", "All", "All Users") + updateAndSave("groups", "All", "All Groups") + clickOnButton("saveButton"); + cy.waitTextVisible("Successfully saved policy."); + cy.waitTextVisible(policyName); +} + +function editPolicy(policyName, editPolicy, description, policyEdited, visibleDiscription) { + searchAndToggleMetadataPolicyStatus(policyName, 'EDIT') + cy.clickOptionWithTestId("policy-name") + cy.focused().clear().type(editPolicy); + cy.clickOptionWithTestId("policy-description"); + cy.focused().clear().type(description); + clickOnButton("nextButton"); + clickOnButton("nextButton"); + clickOnButton("saveButton"); + cy.waitTextVisible("Successfully saved policy."); + cy.waitTextVisible(policyEdited); + cy.waitTextVisible(visibleDiscription);; +} + +function deletePolicy(policyEdited, deletePolicy) { + searchAndToggleMetadataPolicyStatus(policyEdited, 'DEACTIVATE') + cy.waitTextVisible("Successfully deactivated policy.") + cy.contains('DEACTIVATE').should('not.exist') + cy.contains('ACTIVATE').click(); + cy.waitTextVisible("Successfully activated policy.") + cy.get("[data-icon='delete']").click(); + cy.waitTextVisible(deletePolicy); + cy.clickOptionWithText("Yes"); + cy.waitTextVisible("Successfully removed policy."); + cy.ensureTextNotPresent(policyEdited); +} + describe("create and manage platform and metadata policies", () => { + beforeEach(() => { + cy.loginWithCredentials(); + cy.visit("/settings/permissions/policies"); + }); + + it("create platform policy", () => { + cy.waitTextVisible("Manage Permissions"); + cy.clickOptionWithText("Create new policy"); + clickFocusAndType("policy-name", platform_policy_name) + cy.get('[data-testid="policy-type"] [title="Metadata"]').click(); + cy.clickOptionWithTestId("platform"); + createPolicy(`Platform policy description ${test_id}`, platform_policy_name) + }); + + it("edit platform policy", () => { + editPolicy(`${platform_policy_name}`, platform_policy_edited, + `Platform policy description ${test_id} EDITED`, + platform_policy_edited, `Platform policy description ${test_id} EDITED`) + }); + + it("deactivate and activate platform policy", () => { + deletePolicy(`${platform_policy_edited}`, `Delete ${platform_policy_edited}`, `${platform_policy_edited}`) + }); + + it("create metadata policy", () => { + cy.clickOptionWithText("Create new policy"); + clickFocusAndType("policy-name", metadata_policy_name) + cy.get('[data-testid="policy-type"]').should('have.text', 'Metadata'); + createPolicy(`Metadata policy description ${test_id}`, metadata_policy_name) + }); + + it("edit metadata policy", () => { + editPolicy(`${metadata_policy_name}`, metadata_policy_edited, + `Metadata policy description ${test_id} EDITED`, + metadata_policy_edited, `Metadata policy description ${test_id} EDITED`) + }); + + it("deactivate and activate metadata policy", () => { + deletePolicy(`${metadata_policy_name}`, `Delete ${metadata_policy_name}`, `${metadata_policy_edited}`) + }); - it("create platform policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.waitTextVisible("Manage Permissions"); - cy.clickOptionWithText("Create new policy"); - cy.clickOptionWithTestId("policy-name") - .focused() - .type(platform_policy_name); - cy.get('[data-testid="policy-type"] [title="Metadata"]').click(); - cy.clickOptionWithTestId("platform"); - cy.clickOptionWithTestId("policy-description") - .focused() - .type(`Platform policy description ${test_id}`); - cy.get("#nextButton").click(); - cy.get('[data-testid="privileges"]').type("All"); - cy.clickOptionWithText("All Privileges").focused().blur(); - cy.get("#nextButton").click(); - cy.get('[data-testid="users"]').type("All"); - cy.get("[title='All Users']").click(); - cy.focused().blur(); - cy.get('[data-testid="groups"]').type("All"); - cy.get("[title='All Groups']").click(); - cy.focused().blur(); - cy.get("#saveButton").click(); - cy.waitTextVisible("Successfully saved policy."); - cy.waitTextVisible(platform_policy_name); - }); - - it("edit platform policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.contains('tr', `${platform_policy_name}` ) - .contains('EDIT') - .click(); - cy.clickOptionWithTestId("policy-name"); - cy.focused().clear().type(platform_policy_edited); - cy.clickOptionWithTestId("policy-description"); - cy.focused().clear().type(`Platform policy description ${test_id} EDITED`); - cy.get("#nextButton").click(); - cy.get("#nextButton").click(); - cy.get("#saveButton").click(); - cy.waitTextVisible("Successfully saved policy."); - cy.waitTextVisible(platform_policy_edited); - cy.waitTextVisible(`Platform policy description ${test_id} EDITED`); - }); - - it("deactivate and activate platform policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.contains('tr', `${platform_policy_edited}` ) - .contains('DEACTIVATE') - .click(); - cy.waitTextVisible("Successfully deactivated policy.") - cy.contains('tr', `${platform_policy_edited}` ) - .contains('INACTIVE') - .should("be.visible"); - cy.contains('tr', `${platform_policy_edited}` ) - .contains('ACTIVATE') - .click(); - cy.waitTextVisible("Successfully activated policy.") - cy.contains('tr', `${platform_policy_edited}` ) - .contains('ACTIVE') - .should("be.visible"); - cy.contains('tr', `${platform_policy_edited}` ) - .find("[data-icon='delete']") - .click(); - cy.waitTextVisible(`Delete ${platform_policy_edited}`); - cy.clickOptionWithText("Yes"); - cy.waitTextVisible("Successfully removed policy."); - cy.ensureTextNotPresent(`${platform_policy_edited}`); - - }); - - it("create metadata policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.clickOptionWithText("Create new policy"); - cy.clickOptionWithTestId("policy-name") - .focused() - .type(metadata_policy_name); - cy.get('[data-testid="policy-type"]').should('have.text', 'Metadata'); - cy.clickOptionWithTestId("policy-description") - .focused() - .type(`Metadata policy description ${test_id}`); - cy.get("#nextButton").click(); - cy.get('[data-testid="privileges"]').type("All"); - cy.clickOptionWithText("All Privileges").focused().blur(); - cy.get("#nextButton").click(); - cy.get('[data-testid="users"]').type("All"); - cy.get("[title='All Users']").click(); - cy.focused().blur(); - cy.get('[data-testid="groups"]').type("All"); - cy.get("[title='All Groups']").click(); - cy.focused().blur(); - cy.get("#saveButton").click(); - cy.waitTextVisible("Successfully saved policy."); - cy.waitTextVisible(metadata_policy_name); - }); - - it("edit metadata policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.contains('tr', `${metadata_policy_name}` ) - .contains('EDIT') - .click(); - cy.clickOptionWithTestId("policy-name") - cy.focused().clear().type(metadata_policy_edited); - cy.clickOptionWithTestId("policy-description"); - cy.focused().clear().type(`Metadata policy description ${test_id} EDITED`); - cy.get("#nextButton").click(); - cy.get("#nextButton").click(); - cy.get("#saveButton").click(); - cy.waitTextVisible("Successfully saved policy."); - cy.waitTextVisible(metadata_policy_edited); - cy.waitTextVisible(`Metadata policy description ${test_id} EDITED`); - }); - - it("deactivate and activate metadata policy", () => { - cy.loginWithCredentials(); - cy.visit("/settings/permissions/policies"); - cy.contains('tr', `${metadata_policy_edited}` ) - .contains('DEACTIVATE') - .click(); - cy.waitTextVisible("Successfully deactivated policy.") - cy.contains('tr', `${metadata_policy_edited}` ) - .contains('INACTIVE') - .should("be.visible"); - cy.contains('tr', `${metadata_policy_edited}` ) - .contains('ACTIVATE') - .click(); - cy.waitTextVisible("Successfully activated policy.") - cy.contains('tr', `${metadata_policy_edited}` ) - .contains('ACTIVE') - .should("be.visible"); - cy.contains('tr', `${metadata_policy_edited}` ) - .find("[data-icon='delete']") - .click(); - cy.waitTextVisible(`Delete ${metadata_policy_edited}`); - cy.clickOptionWithText("Yes"); - cy.waitTextVisible("Successfully removed policy."); - cy.ensureTextNotPresent(`${metadata_policy_edited}`); - }); - }); \ No newline at end of file From c395d86139c773cd374fa6a52587614787580192 Mon Sep 17 00:00:00 2001 From: kushagra-apptware <81357546+kushagra-apptware@users.noreply.github.com> Date: Wed, 3 Jan 2024 14:00:28 +0530 Subject: [PATCH 090/540] fix(ui): search user incorrect role shown (#9532) --- datahub-web-react/src/app/identity/user/SelectRole.tsx | 6 +++++- datahub-web-react/src/app/identity/user/UserList.tsx | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/datahub-web-react/src/app/identity/user/SelectRole.tsx b/datahub-web-react/src/app/identity/user/SelectRole.tsx index 011eae0fbd8b32..deaa85f14b0883 100644 --- a/datahub-web-react/src/app/identity/user/SelectRole.tsx +++ b/datahub-web-react/src/app/identity/user/SelectRole.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { UserOutlined } from '@ant-design/icons'; import { Select } from 'antd'; import { useApolloClient } from '@apollo/client'; @@ -49,6 +49,10 @@ export default function SelectRole({ user, userRoleUrn, selectRoleOptions, refet const [currentRoleUrn, setCurrentRoleUrn] = useState(defaultRoleUrn); const [isViewingAssignRole, setIsViewingAssignRole] = useState(false); + useEffect(() => { + setCurrentRoleUrn(defaultRoleUrn); + }, [defaultRoleUrn]); + const onSelectRole = (roleUrn: string) => { setCurrentRoleUrn(roleUrn); setIsViewingAssignRole(true); diff --git a/datahub-web-react/src/app/identity/user/UserList.tsx b/datahub-web-react/src/app/identity/user/UserList.tsx index 8e2bc21f0693f7..22b44e5f2d6258 100644 --- a/datahub-web-react/src/app/identity/user/UserList.tsx +++ b/datahub-web-react/src/app/identity/user/UserList.tsx @@ -52,6 +52,7 @@ export const UserList = () => { const params = QueryString.parse(location.search, { arrayFormat: 'comma' }); const paramsQuery = (params?.query as string) || undefined; const [query, setQuery] = useState(undefined); + const [usersList, setUsersList] = useState>([]); useEffect(() => setQuery(paramsQuery), [paramsQuery]); const [page, setPage] = useState(1); @@ -81,8 +82,9 @@ export const UserList = () => { }); const totalUsers = usersData?.listUsers?.total || 0; - const users = usersData?.listUsers?.users || []; - + useEffect(()=> { + setUsersList(usersData?.listUsers?.users || []); + }, [usersData]); const onChangePage = (newPage: number) => { scrollToTop(); setPage(newPage); @@ -145,6 +147,7 @@ export const UserList = () => { onQueryChange={(q) => { setPage(1); setQuery(q); + setUsersList([]); }} entityRegistry={entityRegistry} hideRecommendations @@ -155,7 +158,7 @@ export const UserList = () => { locale={{ emptyText: , }} - dataSource={users} + dataSource={usersList} renderItem={(item: any) => ( handleDelete(item.urn as string)} From 21075e606707df42f25c4ab2d37ef6b2d97daf0d Mon Sep 17 00:00:00 2001 From: Shirshanka Das Date: Wed, 3 Jan 2024 00:39:58 -0800 Subject: [PATCH 091/540] fix(ci): make test flexible to allow sha-based cli versions (#9551) --- smoke-test/tests/read_only/test_services_up.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/smoke-test/tests/read_only/test_services_up.py b/smoke-test/tests/read_only/test_services_up.py index cbe92625f4689a..b1b3b1d6f4bd79 100644 --- a/smoke-test/tests/read_only/test_services_up.py +++ b/smoke-test/tests/read_only/test_services_up.py @@ -2,6 +2,7 @@ import pytest import requests +import re from tests.utils import get_gms_url, wait_for_healthcheck_util @@ -13,6 +14,8 @@ def test_services_up(): wait_for_healthcheck_util() +def looks_like_a_short_sha(sha: str) -> bool: + return len(sha) == 7 and re.match(r"[0-9a-f]{7}", sha) is not None @pytest.mark.read_only def test_gms_config_accessible(): @@ -30,4 +33,4 @@ def test_gms_config_accessible(): default_cli_version: str = gms_config["managedIngestion"]["defaultCliVersion"] print(f"Default CLI version: {default_cli_version}") assert not default_cli_version.startswith("@") - assert "." in default_cli_version + assert "." in default_cli_version or looks_like_a_short_sha(default_cli_version), "Default CLI version does not look like a version string" From 2e3141e1db5be0b24c343812a885dc494168a7de Mon Sep 17 00:00:00 2001 From: Kunal-kankriya <127090035+Kunal-kankriya@users.noreply.github.com> Date: Wed, 3 Jan 2024 18:59:16 +0530 Subject: [PATCH 092/540] tests(cypress): add navigation in search test (#9545) --- .../e2e/search/query_and_filter_search.js | 156 ++++++++++++------ 1 file changed, 102 insertions(+), 54 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/search/query_and_filter_search.js b/smoke-test/tests/cypress/cypress/e2e/search/query_and_filter_search.js index 4637310b864968..59105be587803b 100644 --- a/smoke-test/tests/cypress/cypress/e2e/search/query_and_filter_search.js +++ b/smoke-test/tests/cypress/cypress/e2e/search/query_and_filter_search.js @@ -1,57 +1,105 @@ +const datasetNames = { + dashboardsType: "Baz Dashboard", + pipelinesType: "Users", + MlmoduleType: "cypress-model", + glossaryTermsType: "CypressColumnInfoType", + tags: "some-cypress-feature-1", + hivePlatform: "cypress_logging_events", + airflowPlatform: "User Creations", + awsPlatform: "project/root/events/logging_events_bckp", + hdfsPlatform: "SampleHdfsDataset" +}; + +const searchToExecute = (value) => { + cy.get("input[data-testid=search-input]").eq(0).type(`${value}{enter}`); + cy.waitTextPresent("Type"); +}; + +const selectFilteredEntity = (textToClick, entity, url) => { + cy.get(`[data-testid=filter-dropdown-${textToClick}]`).click({ force: true }); + cy.get(`[data-testid="filter-option-${entity}"]`).click({ force: true }); + cy.get("[data-testid=update-filters]").click({ force: true }); + cy.url().should("include", `${url}`); + cy.get("[data-testid=update-filters]").should("not.be.visible"); + cy.get('.ant-pagination-next').scrollIntoView().should('be.visible'); +}; + +const verifyFilteredEntity = (text) => { + cy.get('.ant-typography').contains(text).should('be.visible'); +}; + describe("auto-complete dropdown, filter plus query search test", () => { + + beforeEach(() => { + cy.loginWithCredentials(); + cy.visit('/'); + }); + + it.skip("Verify the 'filter by type' section + query", () => { + + //Dashboard + searchToExecute("*"); + selectFilteredEntity("Type", "Dashboards", "filter__entityType"); + cy.clickOptionWithText(datasetNames.dashboardsType); + verifyFilteredEntity('Dashboard'); + + //Ml Models + searchToExecute("*"); + selectFilteredEntity("Type", "ML Models", "filter__entityType"); + cy.clickOptionWithText(datasetNames.MlmoduleType); + verifyFilteredEntity('ML Model'); + + //Piplines + searchToExecute("*"); + selectFilteredEntity("Type", "Pipelines", "filter__entityType"); + cy.clickOptionWithText(datasetNames.pipelinesType); + verifyFilteredEntity('Pipeline'); + + }); + + it("Verify the 'filter by Glossary term' section + query", () => { + + //Glossary Term + searchToExecute("*"); + selectFilteredEntity("Type", "Glossary Terms", "filter__entityType"); + cy.clickOptionWithText(datasetNames.glossaryTermsType); + verifyFilteredEntity('Glossary Term'); +}); + + it("Verify the 'filter by platform' section + query", () => { + + //Hive + searchToExecute("*"); + selectFilteredEntity("Platform", "Hive", "filter_platform"); + cy.clickOptionWithText(datasetNames.hivePlatform); + verifyFilteredEntity('Hive'); + + //AWS S3 + searchToExecute("*"); + selectFilteredEntity("Platform", "AWS S3", "filter_platform"); + cy.clickOptionWithText(datasetNames.awsPlatform); + verifyFilteredEntity('AWS S3'); + + //HDFS + searchToExecute("*"); + selectFilteredEntity("Platform", "HDFS", "filter_platform"); + cy.clickOptionWithText(datasetNames.hdfsPlatform); + verifyFilteredEntity('HDFS'); + + //Airflow + searchToExecute("*"); + selectFilteredEntity("Platform", "Airflow", "filter_platform"); + cy.clickOptionWithText(datasetNames.airflowPlatform); + verifyFilteredEntity('Airflow'); + }); - const platformQuerySearch = (query,test_id,active_filter) => { - cy.visit("/"); - cy.get("input[data-testid=search-input]").type(query); - cy.get(`[data-testid="quick-filter-urn:li:dataPlatform:${test_id}"]`).click(); - cy.focused().type("{enter}").wait(3000); - cy.url().should( - "include", - `?filter_platform___false___EQUAL___0=urn%3Ali%3AdataPlatform%3A${test_id}` - ); - cy.get('[data-testid="search-input"]').should("have.value", query); - cy.get(`[data-testid="active-filter-${active_filter}"]`).should("be.visible"); - cy.contains("of 0 results").should("not.exist"); - cy.contains(/of [0-9]+ results/); - } - - const entityQuerySearch = (query,test_id,active_filter) => { - cy.visit("/"); - cy.get("input[data-testid=search-input]").type(query); - cy.get(`[data-testid="quick-filter-${test_id}"]`).click(); - cy.focused().type("{enter}").wait(3000); - cy.url().should( - "include", - `?filter__entityType___false___EQUAL___0=${test_id}` - ); - cy.get('[data-testid="search-input"]').should("have.value", query); - cy.get(`[data-testid="active-filter-${active_filter}"]`).should("be.visible"); - cy.contains("of 0 results").should("not.exist"); - cy.contains(/of [0-9]+ results/); - } - - it("verify the 'filter by' section + query (result in search page with query applied + filter applied)", () => { - // Platform query plus filter test - cy.loginWithCredentials(); - // Airflow - platformQuerySearch ("cypress","airflow","Airflow"); - // BigQuery - platformQuerySearch ("cypress","bigquery","BigQuery"); - // dbt - platformQuerySearch ("cypress","dbt","dbt"); - // Hive - platformQuerySearch ("cypress","hive","Hive"); - - // Entity type query plus filter test - // Datasets - entityQuerySearch ("cypress","DATASET","Datasets"); - // Dashboards - entityQuerySearch ("cypress","DASHBOARD","Dashboards"); - // Pipelines - entityQuerySearch ("cypress","DATA_FLOW","Pipelines"); - // Domains - entityQuerySearch ("Marketing","DOMAIN","Domains"); - // Glossary Terms - entityQuerySearch ("cypress","GLOSSARY_TERM","Glossary Terms"); + it("Verify the 'filter by tag' section + query", () => { + + //CypressFeatureTag + searchToExecute("*"); + selectFilteredEntity("Tag", "CypressFeatureTag", "filter_tags"); + cy.clickOptionWithText(datasetNames.tags); + cy.mouseover('[data-testid="tag-CypressFeatureTag"]'); + verifyFilteredEntity('Feature'); }); -}); \ No newline at end of file +}); From ff78e3c172fee880cdbe1aa3333cf4a73926c910 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Wed, 3 Jan 2024 19:47:19 +0530 Subject: [PATCH 093/540] docs(acryl cloud): release notes for 0.2.14.1 (#9554) --- docs-website/sidebars.js | 1 + docs/managed-datahub/release-notes/v_0_2_14.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 docs/managed-datahub/release-notes/v_0_2_14.md diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index 5d7c6b06adad41..2b8873c6787783 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -177,6 +177,7 @@ module.exports = { }, { "Managed DataHub Release History": [ + "docs/managed-datahub/release-notes/v_0_2_14", "docs/managed-datahub/release-notes/v_0_2_13", "docs/managed-datahub/release-notes/v_0_2_12", "docs/managed-datahub/release-notes/v_0_2_11", diff --git a/docs/managed-datahub/release-notes/v_0_2_14.md b/docs/managed-datahub/release-notes/v_0_2_14.md new file mode 100644 index 00000000000000..8ad1f19503e066 --- /dev/null +++ b/docs/managed-datahub/release-notes/v_0_2_14.md @@ -0,0 +1,17 @@ +# v0.2.14.1 +--- + +Release Availability Date +--- +02-Jan-2023 + +Recommended CLI/SDK +--- +- `v0.12.1.3` with release notes at https://github.com/acryldata/datahub/releases/tag/v0.12.1.3 + +If you are using an older CLI/SDK version then please upgrade it. This applies for all CLI/SDK usages, if you are using it through your terminal, github actions, airflow, in python SDK somewhere, Java SKD etc. This is a strong recommendation to upgrade as we keep on pushing fixes in the CLI and it helps us support you better. + +## Release Changelog +--- +- Since `v0.2.13` these changes from OSS DataHub https://github.com/datahub-project/datahub/compare/d9de854d276c118afc55264ecc9e2712b91b4ab2...31f9c796763677a4d452066d9b49b4088e65da19 have been pulled in. + From c3c4bef1ad746a57a1a6cff821a732fe8114f695 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Wed, 3 Jan 2024 22:59:39 +0530 Subject: [PATCH 094/540] ci(doc): tweak build rule to avoid docker build for docs (#9555) --- .github/workflows/docker-unified.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index 454e7661402459..8afce059572c7d 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -4,12 +4,14 @@ on: branches: - master paths-ignore: + - "docs-website/**" - "docs/**" - "**.md" pull_request: branches: - "**" paths-ignore: + - "docs-website/**" - "docs/**" - "**.md" release: From c9613043c86e169a888d5ac60f0efdcd1551a2b0 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 3 Jan 2024 14:28:22 -0500 Subject: [PATCH 095/540] fix(ingest): improve kafka-connect test stability (#9519) --- .../tests/integration/kafka/docker-compose.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/metadata-ingestion/tests/integration/kafka/docker-compose.yml b/metadata-ingestion/tests/integration/kafka/docker-compose.yml index 43f30cbe1e6651..0a4422e07515cf 100644 --- a/metadata-ingestion/tests/integration/kafka/docker-compose.yml +++ b/metadata-ingestion/tests/integration/kafka/docker-compose.yml @@ -1,5 +1,5 @@ --- -version: '3.8' +version: "3.8" services: zookeeper: image: confluentinc/cp-zookeeper:7.2.2 @@ -9,7 +9,8 @@ services: ports: - "52181" volumes: - - test_zkdata:/var/opt/zookeeper + - test_zkdata:/var/lib/zookeeper/data + - test_zklogs:/var/lib/zookeeper/log broker: image: confluentinc/cp-kafka:7.2.2 @@ -34,3 +35,4 @@ services: volumes: test_zkdata: + test_zklogs: From 83b904e379b0e9a13d22659e483c6d3d4c9b29ba Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 3 Jan 2024 14:28:32 -0500 Subject: [PATCH 096/540] fix(ingest/looker): add user stats to report (#9505) --- .../ingestion/source/looker/looker_common.py | 5 +++++ .../ingestion/source/looker/looker_config.py | 5 ----- .../ingestion/source/looker/looker_source.py | 13 +++++-------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py index 53533a8d27c9b5..94a56bb9281cb5 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_common.py @@ -1059,6 +1059,7 @@ class LookerDashboardSourceReport(StaleEntityRemovalSourceReport): dashboards_scanned_for_usage: int = 0 charts_scanned_for_usage: int = 0 charts_with_activity: LossySet[str] = dataclasses_field(default_factory=LossySet) + accessed_dashboards: int = 0 dashboards_with_activity: LossySet[str] = dataclasses_field( default_factory=LossySet ) @@ -1066,6 +1067,10 @@ class LookerDashboardSourceReport(StaleEntityRemovalSourceReport): _looker_explore_registry: Optional[LookerExploreRegistry] = None total_explores: int = 0 explores_scanned: int = 0 + + resolved_user_ids: int = 0 + email_ids_missing: int = 0 # resolved users with missing email addresses + _looker_api: Optional[LookerAPI] = None query_latency: Dict[str, datetime.timedelta] = dataclasses_field( default_factory=dict diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_config.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_config.py index 514f22b4f21580..52a21e8f122597 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_config.py @@ -160,11 +160,6 @@ class LookerDashboardSourceConfig( description="When enabled, extracts ownership from Looker directly. When disabled, ownership is left empty " "for dashboards and charts.", ) - actor: Optional[str] = Field( - None, - description="This config is deprecated in favor of `extract_owners`. Previously, was the actor to use in " - "ownership properties of ingested metadata.", - ) strip_user_ids_from_email: bool = Field( False, description="When enabled, converts Looker user emails of the form name@domain.com to urn:li:corpuser:name " diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py index 7e8fbfde120420..0cce267bf5579c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py @@ -129,9 +129,6 @@ class LookerDashboardSource(TestableSource, StatefulIngestionSourceBase): source_config: LookerDashboardSourceConfig reporter: LookerDashboardSourceReport user_registry: LookerUserRegistry - accessed_dashboards: int = 0 - resolved_user_ids: int = 0 - email_ids_missing: int = 0 # resolved users with missing email addresses reachable_look_registry: Set[ str ] # Keep track of look-id which are reachable from Dashboard @@ -866,7 +863,7 @@ def _get_folder_path(self, folder: FolderBase, client: LookerAPI) -> str: def _get_looker_dashboard( self, dashboard: Dashboard, client: LookerAPI ) -> LookerDashboard: - self.accessed_dashboards += 1 + self.reporter.accessed_dashboards += 1 if dashboard.folder is None: logger.debug(f"{dashboard.id} has no folder") dashboard_folder_path = None @@ -928,9 +925,9 @@ def _get_looker_user(self, user_id: Optional[str]) -> Optional[LookerUser]: if user is not None and self.source_config.extract_owners: # Keep track of how many user ids we were able to resolve - self.resolved_user_ids += 1 + self.reporter.resolved_user_ids += 1 if user.email is None: - self.email_ids_missing += 1 + self.reporter.email_ids_missing += 1 return user @@ -1313,8 +1310,8 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: if ( self.source_config.extract_owners - and self.resolved_user_ids > 0 - and self.email_ids_missing == self.resolved_user_ids + and self.reporter.resolved_user_ids > 0 + and self.reporter.email_ids_missing == self.reporter.resolved_user_ids ): # Looks like we tried to extract owners and could not find their email addresses. This is likely a permissions issue self.reporter.report_warning( From 186b6f942d3fa7f0ce379add72cbcb57bccd4bb0 Mon Sep 17 00:00:00 2001 From: Shirshanka Das Date: Wed, 3 Jan 2024 12:21:06 -0800 Subject: [PATCH 097/540] perf(lineage): Rewrite lineage query for Elastic graph store (#9552) --- .../graph/elastic/ESGraphQueryDAO.java | 82 ++++--- .../graph/search/ESGraphQueryDAOTest.java | 94 ++++++- ...1.json => lineage_query_filters_full.json} | 98 ++++---- ...eage_query_filters_full_empty_filters.json | 60 +++++ ...e_query_filters_full_multiple_filters.json | 229 ++++++++++++++++++ .../lineage_query_filters_limited.json | 32 +++ 6 files changed, 508 insertions(+), 87 deletions(-) rename metadata-io/src/test/resources/elasticsearch/sample_filters/{lineage_query_filters_1.json => lineage_query_filters_full.json} (81%) create mode 100644 metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_empty_filters.json create mode 100644 metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_multiple_filters.json create mode 100644 metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_limited.json diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java index 92960bc9222ab4..97cb186ce948ce 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java @@ -336,17 +336,10 @@ private List getLineageRelationships( Collectors.toMap( Function.identity(), entityType -> lineageRegistry.getLineageRelationships(entityType, direction))); - BoolQueryBuilder finalQuery = QueryBuilders.boolQuery(); - // Get all relation types relevant to the set of urns to hop from - urnsPerEntityType.forEach( - (entityType, urns) -> - finalQuery.should( - getQueryForLineage( - urns, - edgesPerEntityType.getOrDefault(entityType, Collections.emptyList()), - graphFilters, - startTimeMillis, - endTimeMillis))); + + QueryBuilder finalQuery = + getLineageQuery( + urnsPerEntityType, edgesPerEntityType, graphFilters, startTimeMillis, endTimeMillis); SearchResponse response = executeSearchQuery(finalQuery, 0, graphQueryConfiguration.getMaxResult()); Set entityUrnSet = new HashSet<>(entityUrns); @@ -361,18 +354,53 @@ private List getLineageRelationships( entityUrnSet, response, validEdges, visitedEntities, numHops, existingPaths); } - // Get search query for given list of edges and source urns @VisibleForTesting - public static QueryBuilder getQueryForLineage( - @Nonnull List urns, - @Nonnull List lineageEdges, + public static QueryBuilder getLineageQuery( + @Nonnull Map> urnsPerEntityType, + @Nonnull Map> edgesPerEntityType, @Nonnull GraphFilters graphFilters, @Nullable Long startTimeMillis, @Nullable Long endTimeMillis) { - BoolQueryBuilder query = QueryBuilders.boolQuery(); - if (lineageEdges.isEmpty()) { - return query; + BoolQueryBuilder entityTypeQueries = QueryBuilders.boolQuery(); + // Get all relation types relevant to the set of urns to hop from + urnsPerEntityType.forEach( + (entityType, urns) -> { + if (edgesPerEntityType.containsKey(entityType) + && !edgesPerEntityType.get(entityType).isEmpty()) { + entityTypeQueries.should( + getLineageQueryForEntityType( + urns, edgesPerEntityType.get(entityType), graphFilters)); + } + }); + + BoolQueryBuilder finalQuery = QueryBuilders.boolQuery(); + + finalQuery.filter(entityTypeQueries); + finalQuery.filter(buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), SOURCE)); + finalQuery.filter(buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), DESTINATION)); + + /* + * Optional - Add edge filtering based on time windows. + */ + if (startTimeMillis != null && endTimeMillis != null) { + finalQuery.filter(TimeFilterUtils.getEdgeTimeFilterQuery(startTimeMillis, endTimeMillis)); + } else { + log.debug( + String.format( + "Empty time filter range provided: start time %s, end time: %s. Skipping application of time filters", + startTimeMillis, endTimeMillis)); } + + return finalQuery; + } + + // Get search query for given list of edges and source urns + @VisibleForTesting + public static QueryBuilder getLineageQueryForEntityType( + @Nonnull List urns, + @Nonnull List lineageEdges, + @Nonnull GraphFilters graphFilters) { + BoolQueryBuilder query = QueryBuilders.boolQuery(); Map> edgesByDirection = lineageEdges.stream().collect(Collectors.groupingBy(EdgeInfo::getDirection)); @@ -388,18 +416,6 @@ public static QueryBuilder getQueryForLineage( query.should(getIncomingEdgeQuery(urns, incomingEdges, graphFilters)); } - /* - * Optional - Add edge filtering based on time windows. - */ - if (startTimeMillis != null && endTimeMillis != null) { - query.must(TimeFilterUtils.getEdgeTimeFilterQuery(startTimeMillis, endTimeMillis)); - } else { - log.debug( - String.format( - "Empty time filter range provided: start time %s, end time: %s. Skipping application of time filters", - startTimeMillis, endTimeMillis)); - } - return query; } @@ -601,9 +617,6 @@ private static BoolQueryBuilder getOutGoingEdgeQuery( BoolQueryBuilder outgoingEdgeQuery = QueryBuilders.boolQuery(); outgoingEdgeQuery.must(buildUrnFilters(urns, SOURCE)); outgoingEdgeQuery.must(buildEdgeFilters(outgoingEdges)); - outgoingEdgeQuery.must(buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), SOURCE)); - outgoingEdgeQuery.must( - buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), DESTINATION)); return outgoingEdgeQuery; } @@ -612,9 +625,6 @@ private static BoolQueryBuilder getIncomingEdgeQuery( BoolQueryBuilder incomingEdgeQuery = QueryBuilders.boolQuery(); incomingEdgeQuery.must(buildUrnFilters(urns, DESTINATION)); incomingEdgeQuery.must(buildEdgeFilters(incomingEdges)); - incomingEdgeQuery.must(buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), SOURCE)); - incomingEdgeQuery.must( - buildEntityTypesFilter(graphFilters.getAllowedEntityTypes(), DESTINATION)); return incomingEdgeQuery; } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/ESGraphQueryDAOTest.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/ESGraphQueryDAOTest.java index 9fc9490bfd7ef1..5b7f880e6d83ab 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/ESGraphQueryDAOTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/ESGraphQueryDAOTest.java @@ -23,16 +23,40 @@ public class ESGraphQueryDAOTest { - private static final String TEST_QUERY_FILE = - "elasticsearch/sample_filters/lineage_query_filters_1.json"; + private static final String TEST_QUERY_FILE_LIMITED = + "elasticsearch/sample_filters/lineage_query_filters_limited.json"; + private static final String TEST_QUERY_FILE_FULL = + "elasticsearch/sample_filters/lineage_query_filters_full.json"; + private static final String TEST_QUERY_FILE_FULL_EMPTY_FILTERS = + "elasticsearch/sample_filters/lineage_query_filters_full_empty_filters.json"; + private static final String TEST_QUERY_FILE_FULL_MULTIPLE_FILTERS = + "elasticsearch/sample_filters/lineage_query_filters_full_multiple_filters.json"; @Test private static void testGetQueryForLineageFullArguments() throws Exception { - URL url = Resources.getResource(TEST_QUERY_FILE); - String expectedQuery = Resources.toString(url, StandardCharsets.UTF_8); - - List urns = new ArrayList<>(); + URL urlLimited = Resources.getResource(TEST_QUERY_FILE_LIMITED); + String expectedQueryLimited = Resources.toString(urlLimited, StandardCharsets.UTF_8); + URL urlFull = Resources.getResource(TEST_QUERY_FILE_FULL); + String expectedQueryFull = Resources.toString(urlFull, StandardCharsets.UTF_8); + URL urlFullEmptyFilters = Resources.getResource(TEST_QUERY_FILE_FULL_EMPTY_FILTERS); + String expectedQueryFullEmptyFilters = + Resources.toString(urlFullEmptyFilters, StandardCharsets.UTF_8); + URL urlFullMultipleFilters = Resources.getResource(TEST_QUERY_FILE_FULL_MULTIPLE_FILTERS); + String expectedQueryFullMultipleFilters = + Resources.toString(urlFullMultipleFilters, StandardCharsets.UTF_8); + + List urns = List.of(Urn.createFromString("urn:li:dataset:test-urn")); + List urnsMultiple1 = + ImmutableList.of( + UrnUtils.getUrn("urn:li:dataset:test-urn"), + UrnUtils.getUrn("urn:li:dataset:test-urn2"), + UrnUtils.getUrn("urn:li:dataset:test-urn3")); + List urnsMultiple2 = + ImmutableList.of( + UrnUtils.getUrn("urn:li:chart:test-urn"), + UrnUtils.getUrn("urn:li:chart:test-urn2"), + UrnUtils.getUrn("urn:li:chart:test-urn3")); List edgeInfos = new ArrayList<>( ImmutableList.of( @@ -40,14 +64,64 @@ private static void testGetQueryForLineageFullArguments() throws Exception { "DownstreamOf", RelationshipDirection.INCOMING, Constants.DATASET_ENTITY_NAME))); + List edgeInfosMultiple1 = + ImmutableList.of( + new LineageRegistry.EdgeInfo( + "DownstreamOf", RelationshipDirection.OUTGOING, Constants.DATASET_ENTITY_NAME), + new LineageRegistry.EdgeInfo( + "Consumes", RelationshipDirection.OUTGOING, Constants.DATASET_ENTITY_NAME)); + List edgeInfosMultiple2 = + ImmutableList.of( + new LineageRegistry.EdgeInfo( + "DownstreamOf", RelationshipDirection.OUTGOING, Constants.DATA_JOB_ENTITY_NAME), + new LineageRegistry.EdgeInfo( + "Consumes", RelationshipDirection.OUTGOING, Constants.DATA_JOB_ENTITY_NAME)); + String entityType = "testEntityType"; + Map> urnsPerEntityType = Map.of(entityType, urns); + Map> urnsPerEntityTypeMultiple = + Map.of( + Constants.DATASET_ENTITY_NAME, + urnsMultiple1, + Constants.CHART_ENTITY_NAME, + urnsMultiple2); + Map> edgesPerEntityType = Map.of(entityType, edgeInfos); + Map> edgesPerEntityTypeMultiple = + Map.of( + Constants.DATASET_ENTITY_NAME, edgeInfosMultiple1, + Constants.DATA_JOB_ENTITY_NAME, edgeInfosMultiple2); GraphFilters graphFilters = new GraphFilters(ImmutableList.of(Constants.DATASET_ENTITY_NAME)); + GraphFilters graphFiltersMultiple = + new GraphFilters( + ImmutableList.of( + Constants.DATASET_ENTITY_NAME, + Constants.DASHBOARD_ENTITY_NAME, + Constants.DATA_JOB_ENTITY_NAME)); Long startTime = 0L; Long endTime = 1L; - QueryBuilder builder = - ESGraphQueryDAO.getQueryForLineage(urns, edgeInfos, graphFilters, startTime, endTime); - - Assert.assertEquals(builder.toString(), expectedQuery); + QueryBuilder limitedBuilder = + ESGraphQueryDAO.getLineageQueryForEntityType(urns, edgeInfos, graphFilters); + + QueryBuilder fullBuilder = + ESGraphQueryDAO.getLineageQuery( + urnsPerEntityType, edgesPerEntityType, graphFilters, startTime, endTime); + + QueryBuilder fullBuilderEmptyFilters = + ESGraphQueryDAO.getLineageQuery( + urnsPerEntityType, edgesPerEntityType, GraphFilters.emptyGraphFilters, null, null); + + QueryBuilder fullBuilderMultipleFilters = + ESGraphQueryDAO.getLineageQuery( + urnsPerEntityTypeMultiple, + edgesPerEntityTypeMultiple, + graphFiltersMultiple, + startTime, + endTime); + + Assert.assertEquals(limitedBuilder.toString(), expectedQueryLimited); + Assert.assertEquals(fullBuilder.toString(), expectedQueryFull); + Assert.assertEquals(fullBuilderEmptyFilters.toString(), expectedQueryFullEmptyFilters); + Assert.assertEquals(fullBuilderMultipleFilters.toString(), expectedQueryFullMultipleFilters); } @Test diff --git a/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_1.json b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full.json similarity index 81% rename from metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_1.json rename to metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full.json index eb84638f0ccd08..0a1cee08414a9d 100644 --- a/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_1.json +++ b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full.json @@ -1,6 +1,62 @@ { "bool" : { - "must" : [ + "filter" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "must" : [ + { + "terms" : { + "destination.urn" : [ + "urn:li:dataset:test-urn" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "relationshipType" : [ + "DownstreamOf" + ], + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "terms" : { + "source.entityType" : [ + "dataset" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "destination.entityType" : [ + "dataset" + ], + "boost" : 1.0 + } + }, { "bool" : { "should" : [ @@ -160,46 +216,6 @@ } } ], - "should" : [ - { - "bool" : { - "must" : [ - { - "terms" : { - "destination.urn" : [ ], - "boost" : 1.0 - } - }, - { - "terms" : { - "relationshipType" : [ - "DownstreamOf" - ], - "boost" : 1.0 - } - }, - { - "terms" : { - "source.entityType" : [ - "dataset" - ], - "boost" : 1.0 - } - }, - { - "terms" : { - "destination.entityType" : [ - "dataset" - ], - "boost" : 1.0 - } - } - ], - "adjust_pure_negative" : true, - "boost" : 1.0 - } - } - ], "adjust_pure_negative" : true, "boost" : 1.0 } diff --git a/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_empty_filters.json b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_empty_filters.json new file mode 100644 index 00000000000000..ab2841d6602d82 --- /dev/null +++ b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_empty_filters.json @@ -0,0 +1,60 @@ +{ + "bool" : { + "filter" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "must" : [ + { + "terms" : { + "destination.urn" : [ + "urn:li:dataset:test-urn" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "relationshipType" : [ + "DownstreamOf" + ], + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "terms" : { + "source.entityType" : [ ], + "boost" : 1.0 + } + }, + { + "terms" : { + "destination.entityType" : [ ], + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } +} \ No newline at end of file diff --git a/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_multiple_filters.json b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_multiple_filters.json new file mode 100644 index 00000000000000..39f595e0e8dd2d --- /dev/null +++ b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_full_multiple_filters.json @@ -0,0 +1,229 @@ +{ + "bool" : { + "filter" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "must" : [ + { + "terms" : { + "source.urn" : [ + "urn:li:dataset:test-urn", + "urn:li:dataset:test-urn2", + "urn:li:dataset:test-urn3" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "relationshipType" : [ + "DownstreamOf", + "Consumes" + ], + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "terms" : { + "source.entityType" : [ + "dataset", + "dashboard", + "dataJob" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "destination.entityType" : [ + "dataset", + "dashboard", + "dataJob" + ], + "boost" : 1.0 + } + }, + { + "bool" : { + "should" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "must" : [ + { + "exists" : { + "field" : "createdOn", + "boost" : 1.0 + } + }, + { + "range" : { + "createdOn" : { + "from" : 0, + "to" : 1, + "include_lower" : true, + "include_upper" : true, + "boost" : 1.0 + } + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "bool" : { + "must" : [ + { + "exists" : { + "field" : "updatedOn", + "boost" : 1.0 + } + }, + { + "range" : { + "updatedOn" : { + "from" : 0, + "to" : 1, + "include_lower" : true, + "include_upper" : true, + "boost" : 1.0 + } + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "bool" : { + "must" : [ + { + "bool" : { + "should" : [ + { + "bool" : { + "must_not" : [ + { + "exists" : { + "field" : "createdOn", + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "bool" : { + "must" : [ + { + "term" : { + "createdOn" : { + "value" : 0, + "boost" : 1.0 + } + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "bool" : { + "should" : [ + { + "bool" : { + "must_not" : [ + { + "exists" : { + "field" : "updatedOn", + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "bool" : { + "must" : [ + { + "term" : { + "updatedOn" : { + "value" : 0, + "boost" : 1.0 + } + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + }, + { + "term" : { + "properties.source" : { + "value" : "UI", + "boost" : 1.0 + } + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } +} \ No newline at end of file diff --git a/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_limited.json b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_limited.json new file mode 100644 index 00000000000000..95d468ec3dac8e --- /dev/null +++ b/metadata-io/src/test/resources/elasticsearch/sample_filters/lineage_query_filters_limited.json @@ -0,0 +1,32 @@ +{ + "bool" : { + "should" : [ + { + "bool" : { + "must" : [ + { + "terms" : { + "destination.urn" : [ + "urn:li:dataset:test-urn" + ], + "boost" : 1.0 + } + }, + { + "terms" : { + "relationshipType" : [ + "DownstreamOf" + ], + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } + } + ], + "adjust_pure_negative" : true, + "boost" : 1.0 + } +} \ No newline at end of file From f06b5c782099ace00116fd33dda73af5a48e4184 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 3 Jan 2024 15:30:11 -0500 Subject: [PATCH 098/540] feat(ingest): improve config loading helpers (#9477) --- .../datahub/configuration/config_loader.py | 48 ++++++++------ .../datahub/ingestion/run/pipeline_config.py | 3 +- .../src/datahub/secret/__init__.py | 0 .../datahub/secret/datahub_secret_store.py | 66 +++++++++++++++++++ .../datahub/secret/datahub_secrets_client.py | 45 +++++++++++++ .../src/datahub/secret/secret_common.py | 59 +++++++++++++++++ .../src/datahub/secret/secret_store.py | 43 ++++++++++++ 7 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 metadata-ingestion/src/datahub/secret/__init__.py create mode 100644 metadata-ingestion/src/datahub/secret/datahub_secret_store.py create mode 100644 metadata-ingestion/src/datahub/secret/datahub_secrets_client.py create mode 100644 metadata-ingestion/src/datahub/secret/secret_common.py create mode 100644 metadata-ingestion/src/datahub/secret/secret_store.py diff --git a/metadata-ingestion/src/datahub/configuration/config_loader.py b/metadata-ingestion/src/datahub/configuration/config_loader.py index 2f41af6f7286e6..4266bac0c79abd 100644 --- a/metadata-ingestion/src/datahub/configuration/config_loader.py +++ b/metadata-ingestion/src/datahub/configuration/config_loader.py @@ -1,56 +1,59 @@ import io +import os import pathlib import re import sys import tempfile import unittest.mock -from typing import Any, Dict, Set, Union +from typing import Any, Dict, Mapping, Optional, Set, Union from urllib import parse import requests -from expandvars import UnboundVariable, expandvars +from expandvars import UnboundVariable, expand from datahub.configuration.common import ConfigurationError, ConfigurationMechanism from datahub.configuration.json_loader import JsonConfigurationMechanism from datahub.configuration.toml import TomlConfigurationMechanism from datahub.configuration.yaml import YamlConfigurationMechanism +Environ = Mapping[str, str] -def _resolve_element(element: str) -> str: + +def _resolve_element(element: str, environ: Environ) -> str: if re.search(r"(\$\{).+(\})", element): - return expandvars(element, nounset=True) + return expand(element, nounset=True, environ=environ) elif element.startswith("$"): try: - return expandvars(element, nounset=True) + return expand(element, nounset=True, environ=environ) except UnboundVariable: return element else: return element -def _resolve_list(ele_list: list) -> list: +def _resolve_list(ele_list: list, environ: Environ) -> list: new_v: list = [] for ele in ele_list: if isinstance(ele, str): - new_v.append(_resolve_element(ele)) + new_v.append(_resolve_element(ele, environ=environ)) elif isinstance(ele, list): - new_v.append(_resolve_list(ele)) + new_v.append(_resolve_list(ele, environ=environ)) elif isinstance(ele, dict): - new_v.append(resolve_env_variables(ele)) + new_v.append(resolve_env_variables(ele, environ=environ)) else: new_v.append(ele) return new_v -def resolve_env_variables(config: dict) -> dict: +def resolve_env_variables(config: dict, environ: Environ) -> dict: new_dict: Dict[Any, Any] = {} for k, v in config.items(): if isinstance(v, dict): - new_dict[k] = resolve_env_variables(v) + new_dict[k] = resolve_env_variables(v, environ=environ) elif isinstance(v, list): - new_dict[k] = _resolve_list(v) + new_dict[k] = _resolve_list(v, environ=environ) elif isinstance(v, str): - new_dict[k] = _resolve_element(v) + new_dict[k] = _resolve_element(v, environ=environ) else: new_dict[k] = v return new_dict @@ -60,13 +63,20 @@ def list_referenced_env_variables(config: dict) -> Set[str]: # This is a bit of a hack, but expandvars does a bunch of escaping # and other logic that we don't want to duplicate here. - with unittest.mock.patch("expandvars.getenv") as mock_getenv: - mock_getenv.return_value = "mocked_value" + vars = set() + + def mock_get_env(key: str, default: Optional[str] = None) -> str: + vars.add(key) + if default is not None: + return default + return "mocked_value" + + mock = unittest.mock.MagicMock() + mock.get.side_effect = mock_get_env - resolve_env_variables(config) + resolve_env_variables(config, environ=mock) - calls = mock_getenv.mock_calls - return set([call[1][0] for call in calls]) + return vars WRITE_TO_FILE_DIRECTIVE_PREFIX = "__DATAHUB_TO_FILE_" @@ -147,7 +157,7 @@ def load_config_file( config = raw_config.copy() if resolve_env_vars: - config = resolve_env_variables(config) + config = resolve_env_variables(config, environ=os.environ) if process_directives: config = _process_directives(config) diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py index f22f94c9e93514..c0f6add6df0063 100644 --- a/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py +++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py @@ -1,5 +1,6 @@ import datetime import logging +import os import uuid from typing import Any, Dict, List, Optional @@ -112,7 +113,7 @@ def default_sink_is_datahub_rest(cls, values: Dict[str, Any]) -> Any: } # resolve env variables if present default_sink_config = config_loader.resolve_env_variables( - default_sink_config + default_sink_config, environ=os.environ ) values["sink"] = default_sink_config diff --git a/metadata-ingestion/src/datahub/secret/__init__.py b/metadata-ingestion/src/datahub/secret/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/metadata-ingestion/src/datahub/secret/datahub_secret_store.py b/metadata-ingestion/src/datahub/secret/datahub_secret_store.py new file mode 100644 index 00000000000000..8301ff2d9dc1a6 --- /dev/null +++ b/metadata-ingestion/src/datahub/secret/datahub_secret_store.py @@ -0,0 +1,66 @@ +import logging +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, validator + +from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph +from datahub.secret.datahub_secrets_client import DataHubSecretsClient +from datahub.secret.secret_store import SecretStore + +logger = logging.getLogger(__name__) + + +class DataHubSecretStoreConfig(BaseModel): + graph_client: Optional[DataHubGraph] = None + graph_client_config: Optional[DatahubClientConfig] = None + + class Config: + arbitrary_types_allowed = True + + @validator("graph_client") + def check_graph_connection(cls, v: DataHubGraph) -> DataHubGraph: + if v is not None: + v.test_connection() + return v + + +# An implementation of SecretStore that fetches secrets from DataHub +class DataHubSecretStore(SecretStore): + # Client for fetching secrets from DataHub GraphQL API + client: DataHubSecretsClient + + def __init__(self, config: DataHubSecretStoreConfig): + # Attempt to establish an outbound connection to DataHub and create a client. + if config.graph_client is not None: + self.client = DataHubSecretsClient(graph=config.graph_client) + elif config.graph_client_config is not None: + graph = DataHubGraph(config.graph_client_config) + self.client = DataHubSecretsClient(graph) + else: + raise Exception( + "Invalid configuration provided: unable to construct DataHub Graph Client." + ) + + def get_secret_values(self, secret_names: List[str]) -> Dict[str, Union[str, None]]: + # Fetch the secret from DataHub, using the credentials provided in the configuration. + # Use the GraphQL API. + try: + return self.client.get_secret_values(secret_names) + except Exception: + # Failed to resolve secrets, return empty. + logger.exception( + f"Caught exception while attempting to fetch secrets from DataHub. Secret names: {secret_names}" + ) + return {} + + def get_secret_value(self, secret_name: str) -> Union[str, None]: + secret_value_dict = self.get_secret_values([secret_name]) + return secret_value_dict.get(secret_name) + + def get_id(self) -> str: + return "datahub" + + @classmethod + def create(cls, config: Any) -> "DataHubSecretStore": + config = DataHubSecretStoreConfig.parse_obj(config) + return cls(config) diff --git a/metadata-ingestion/src/datahub/secret/datahub_secrets_client.py b/metadata-ingestion/src/datahub/secret/datahub_secrets_client.py new file mode 100644 index 00000000000000..c60aeff5db2f3f --- /dev/null +++ b/metadata-ingestion/src/datahub/secret/datahub_secrets_client.py @@ -0,0 +1,45 @@ +from typing import Dict, List, Optional + +from datahub.ingestion.graph.client import DataHubGraph + + +class DataHubSecretsClient: + """Class used to fetch secrets from DataHub.""" + + graph: DataHubGraph + + def __init__(self, graph: DataHubGraph): + self.graph = graph + + def get_secret_values(self, secret_names: List[str]) -> Dict[str, Optional[str]]: + if len(secret_names) == 0: + return {} + + request_json = { + "query": """query getSecretValues($input: GetSecretValuesInput!) {\n + getSecretValues(input: $input) {\n + name\n + value\n + }\n + }""", + "variables": {"input": {"secrets": secret_names}}, + } + # TODO: Use graph.execute_graphql() instead. + + # Fetch secrets using GraphQL API f + response = self.graph._session.post( + f"{self.graph.config.server}/api/graphql", json=request_json + ) + response.raise_for_status() + + # Verify response + res_data = response.json() + if "errors" in res_data: + raise Exception("Failed to retrieve secrets from DataHub.") + + # Convert list of name, value secret pairs into a dict and return + secret_value_list = res_data["data"]["getSecretValues"] + secret_value_dict = dict() + for secret_value in secret_value_list: + secret_value_dict[secret_value["name"]] = secret_value["value"] + return secret_value_dict diff --git a/metadata-ingestion/src/datahub/secret/secret_common.py b/metadata-ingestion/src/datahub/secret/secret_common.py new file mode 100644 index 00000000000000..2f7a584d875380 --- /dev/null +++ b/metadata-ingestion/src/datahub/secret/secret_common.py @@ -0,0 +1,59 @@ +import json +import logging +from typing import List + +from datahub.configuration.config_loader import ( + list_referenced_env_variables, + resolve_env_variables, +) +from datahub.secret.secret_store import SecretStore + +logger = logging.getLogger(__name__) + + +def resolve_secrets(secret_names: List[str], secret_stores: List[SecretStore]) -> dict: + # Attempt to resolve secret using by checking each configured secret store. + final_secret_values = dict({}) + + for secret_store in secret_stores: + try: + # Retrieve secret values from the store. + secret_values_dict = secret_store.get_secret_values(secret_names) + # Overlay secret values from each store, if not None. + for secret_name, secret_value in secret_values_dict.items(): + if secret_value is not None: + # HACK: We previously, incorrectly replaced newline characters with + # a r'\n' string. This was a lossy conversion, since we can no longer + # distinguish between a newline character and the literal '\n' in + # the secret value. For now, we assume that all r'\n' strings are + # actually newline characters. This will break if a secret value + # genuinely contains the string r'\n'. + # Once this PR https://github.com/datahub-project/datahub/pull/9484 + # has baked for a while, we should be able to remove this hack. + # TODO: This logic should live in the DataHub secret client/store, + # not the general secret resolution logic. + secret_value = secret_value.replace(r"\n", "\n") + + final_secret_values[secret_name] = secret_value + except Exception: + logger.exception( + f"Failed to fetch secret values from secret store with id {secret_store.get_id()}" + ) + return final_secret_values + + +def resolve_recipe(recipe: str, secret_stores: List[SecretStore]) -> dict: + json_recipe_raw = json.loads(recipe) + + # 1. Extract all secrets needing resolved. + secrets_to_resolve = list_referenced_env_variables(json_recipe_raw) + + # 2. Resolve secret values + secret_values_dict = resolve_secrets(list(secrets_to_resolve), secret_stores) + + # 3. Substitute secrets into recipe file + json_recipe_resolved = resolve_env_variables( + json_recipe_raw, environ=secret_values_dict + ) + + return json_recipe_resolved diff --git a/metadata-ingestion/src/datahub/secret/secret_store.py b/metadata-ingestion/src/datahub/secret/secret_store.py new file mode 100644 index 00000000000000..d6d61d8c3c9241 --- /dev/null +++ b/metadata-ingestion/src/datahub/secret/secret_store.py @@ -0,0 +1,43 @@ +from abc import abstractmethod +from typing import Dict, List, Optional + +from datahub.configuration.common import ConfigModel + + +class SecretStoreConfig(ConfigModel): + type: str + config: Dict + + +class SecretStore: + """ + Abstract base class for a Secret Store, or a class that resolves "secret" values by name. + """ + + @classmethod + @abstractmethod + def create(cls, configs: dict) -> "SecretStore": + pass + + @abstractmethod + def get_secret_values(self, secret_names: List[str]) -> Dict[str, Optional[str]]: + """ + Attempt to fetch a group of secrets, returning a Dictionary of the secret of None if one + cannot be resolved by the store. + """ + + def get_secret_value(self, secret_name: str) -> Optional[str]: + secret_value_dict = self.get_secret_values([secret_name]) + return secret_value_dict.get(secret_name) + + @abstractmethod + def get_id(self) -> str: + """ + Get a unique name or id associated with the Secret Store. + """ + + @abstractmethod + def close(self) -> None: + """ + Wraps up the task + """ From 822d0eb014080fef030cdee84731878787c38c61 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Wed, 3 Jan 2024 15:11:07 -0600 Subject: [PATCH 099/540] feat(patch): add dashboardInfo and chartInfo support for patch (#9536) --- .../registry/SnapshotEntityRegistry.java | 4 + .../template/AspectTemplateEngine.java | 4 +- .../template/chart/ChartInfoTemplate.java | 82 ++++ .../dashboard/DashboardInfoTemplate.java | 105 +++++ .../datajob/DataJobInputOutputTemplate.java | 2 - .../registry/patch/ChartInfoTemplateTest.java | 41 ++ .../patch/DashboardInfoTemplateTest.java | 41 ++ .../UpstreamLineageTemplateTest.java | 2 +- .../src/datahub/specific/chart.py | 316 ++++++++++++++ .../src/datahub/specific/dashboard.py | 410 ++++++++++++++++++ .../src/datahub/specific/datajob.py | 12 +- .../src/datahub/specific/dataproduct.py | 10 +- .../src/datahub/specific/dataset.py | 8 +- .../src/datahub/specific/ownership.py | 2 +- .../golden_dataproduct_out_upsert.json | 2 +- .../unit/patch/complex_dataset_patch.json | 2 +- .../tests/unit/patch/test_patch_builder.py | 47 +- .../patch/chart/ChartInfoPatchBuilder.java | 41 ++ .../client/patch/common/PatchUtil.java | 84 ++++ .../dashboard/DashboardInfoPatchBuilder.java | 103 +++++ .../DataJobInputOutputPatchBuilder.java | 73 +--- .../java/datahub/client/patch/PatchTest.java | 89 ++++ 22 files changed, 1385 insertions(+), 95 deletions(-) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java create mode 100644 entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/ChartInfoTemplateTest.java create mode 100644 entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java rename entity-registry/src/test/java/com/linkedin/metadata/models/registry/{ => patch}/UpstreamLineageTemplateTest.java (99%) create mode 100644 metadata-ingestion/src/datahub/specific/chart.py create mode 100644 metadata-ingestion/src/datahub/specific/dashboard.py create mode 100644 metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java create mode 100644 metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java create mode 100644 metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java index cfc2c0901ce0da..bb0113abc9ed6c 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java @@ -12,9 +12,11 @@ import com.linkedin.metadata.models.EventSpec; import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.metadata.models.registry.template.Template; +import com.linkedin.metadata.models.registry.template.chart.ChartInfoTemplate; import com.linkedin.metadata.models.registry.template.common.GlobalTagsTemplate; import com.linkedin.metadata.models.registry.template.common.GlossaryTermsTemplate; import com.linkedin.metadata.models.registry.template.common.OwnershipTemplate; +import com.linkedin.metadata.models.registry.template.dashboard.DashboardInfoTemplate; import com.linkedin.metadata.models.registry.template.dataflow.DataFlowInfoTemplate; import com.linkedin.metadata.models.registry.template.datajob.DataJobInfoTemplate; import com.linkedin.metadata.models.registry.template.datajob.DataJobInputOutputTemplate; @@ -79,6 +81,8 @@ private AspectTemplateEngine populateTemplateEngine(Map aspe aspectSpecTemplateMap.put(DATA_JOB_INFO_ASPECT_NAME, new DataJobInfoTemplate()); aspectSpecTemplateMap.put( DATA_PRODUCT_PROPERTIES_ASPECT_NAME, new DataProductPropertiesTemplate()); + aspectSpecTemplateMap.put(CHART_INFO_ASPECT_NAME, new ChartInfoTemplate()); + aspectSpecTemplateMap.put(DASHBOARD_INFO_ASPECT_NAME, new DashboardInfoTemplate()); aspectSpecTemplateMap.put(DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, new DataJobInputOutputTemplate()); return new AspectTemplateEngine(aspectSpecTemplateMap); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java index 95849a94bae29c..029eb688c5291a 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java @@ -32,7 +32,9 @@ public class AspectTemplateEngine { DATA_FLOW_INFO_ASPECT_NAME, DATA_JOB_INFO_ASPECT_NAME, DATA_PRODUCT_PROPERTIES_ASPECT_NAME, - DATA_JOB_INPUT_OUTPUT_ASPECT_NAME) + DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, + CHART_INFO_ASPECT_NAME, + DASHBOARD_INFO_ASPECT_NAME) .collect(Collectors.toSet()); private final Map> _aspectTemplateMap; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java new file mode 100644 index 00000000000000..654f923e7322d0 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java @@ -0,0 +1,82 @@ +package com.linkedin.metadata.models.registry.template.chart; + +import static com.linkedin.metadata.Constants.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.chart.ChartDataSourceTypeArray; +import com.linkedin.chart.ChartInfo; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.ChangeAuditStamps; +import com.linkedin.common.EdgeArray; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import java.util.Collections; +import javax.annotation.Nonnull; + +public class ChartInfoTemplate implements ArrayMergingTemplate { + + private static final String INPUT_EDGES_FIELD_NAME = "inputEdges"; + private static final String INPUTS_FIELD_NAME = "inputs"; + private static final String DESTINATION_URN_FIELD_NAME = "destinationUrn"; + + @Override + public ChartInfo getSubtype(RecordTemplate recordTemplate) throws ClassCastException { + if (recordTemplate instanceof ChartInfo) { + return (ChartInfo) recordTemplate; + } + throw new ClassCastException("Unable to cast RecordTemplate to DataJobInputOutput"); + } + + @Override + public Class getTemplateType() { + return ChartInfo.class; + } + + @Nonnull + @Override + public ChartInfo getDefault() { + ChartInfo chartInfo = new ChartInfo(); + chartInfo.setDescription(""); + chartInfo.setTitle(""); + ChangeAuditStamps changeAuditStamps = new ChangeAuditStamps(); + AuditStamp auditStamp = + new AuditStamp() + .setActor(UrnUtils.getUrn(SYSTEM_ACTOR)) + .setTime(System.currentTimeMillis()); + changeAuditStamps.setCreated(auditStamp).setLastModified(auditStamp); + chartInfo.setLastModified(changeAuditStamps); + chartInfo.setInputEdges(new EdgeArray()); + + // Deprecated fields + chartInfo.setInputs(new ChartDataSourceTypeArray()); + + return chartInfo; + } + + @Nonnull + @Override + public JsonNode transformFields(JsonNode baseNode) { + JsonNode transformedNode = + arrayFieldToMap( + baseNode, + INPUT_EDGES_FIELD_NAME, + Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + transformedNode = arrayFieldToMap(transformedNode, INPUTS_FIELD_NAME, Collections.emptyList()); + + return transformedNode; + } + + @Nonnull + @Override + public JsonNode rebaseFields(JsonNode patched) { + JsonNode rebasedNode = + transformedMapToArray( + patched, INPUT_EDGES_FIELD_NAME, Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + rebasedNode = transformedMapToArray(rebasedNode, INPUTS_FIELD_NAME, Collections.emptyList()); + + return rebasedNode; + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java new file mode 100644 index 00000000000000..eae04b5285adf4 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java @@ -0,0 +1,105 @@ +package com.linkedin.metadata.models.registry.template.dashboard; + +import static com.linkedin.metadata.Constants.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.ChangeAuditStamps; +import com.linkedin.common.ChartUrnArray; +import com.linkedin.common.EdgeArray; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.dashboard.DashboardInfo; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import java.util.Collections; +import javax.annotation.Nonnull; + +public class DashboardInfoTemplate implements ArrayMergingTemplate { + + private static final String CHART_EDGES_FIELD_NAME = "chartEdges"; + private static final String DATASET_EDGES_FIELD_NAME = "datasetEdges"; + private static final String DATASETS_FIELD_NAME = "datasets"; + private static final String CHARTS_FIELD_NAME = "charts"; + private static final String DESTINATION_URN_FIELD_NAME = "destinationUrn"; + + @Override + public DashboardInfo getSubtype(RecordTemplate recordTemplate) throws ClassCastException { + if (recordTemplate instanceof DashboardInfo) { + return (DashboardInfo) recordTemplate; + } + throw new ClassCastException("Unable to cast RecordTemplate to DataJobInputOutput"); + } + + @Override + public Class getTemplateType() { + return DashboardInfo.class; + } + + @Nonnull + @Override + public DashboardInfo getDefault() { + DashboardInfo dashboardInfo = new DashboardInfo(); + dashboardInfo.setTitle(""); + dashboardInfo.setDescription(""); + ChangeAuditStamps changeAuditStamps = new ChangeAuditStamps(); + AuditStamp auditStamp = + new AuditStamp() + .setActor(UrnUtils.getUrn(SYSTEM_ACTOR)) + .setTime(System.currentTimeMillis()); + changeAuditStamps.setCreated(auditStamp).setLastModified(auditStamp); + dashboardInfo.setLastModified(changeAuditStamps); + dashboardInfo.setChartEdges(new EdgeArray()); + dashboardInfo.setDatasetEdges(new EdgeArray()); + + // Deprecated fields + dashboardInfo.setDatasets(new UrnArray()); + dashboardInfo.setCharts(new ChartUrnArray()); + + return dashboardInfo; + } + + @Nonnull + @Override + public JsonNode transformFields(JsonNode baseNode) { + JsonNode transformedNode = + arrayFieldToMap( + baseNode, + CHART_EDGES_FIELD_NAME, + Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + transformedNode = + arrayFieldToMap( + transformedNode, + DATASET_EDGES_FIELD_NAME, + Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + transformedNode = + arrayFieldToMap(transformedNode, DATASETS_FIELD_NAME, Collections.emptyList()); + + transformedNode = arrayFieldToMap(transformedNode, CHARTS_FIELD_NAME, Collections.emptyList()); + + return transformedNode; + } + + @Nonnull + @Override + public JsonNode rebaseFields(JsonNode patched) { + JsonNode rebasedNode = + transformedMapToArray( + patched, + DATASET_EDGES_FIELD_NAME, + Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + rebasedNode = + transformedMapToArray( + rebasedNode, + CHART_EDGES_FIELD_NAME, + Collections.singletonList(DESTINATION_URN_FIELD_NAME)); + + rebasedNode = transformedMapToArray(rebasedNode, DATASETS_FIELD_NAME, Collections.emptyList()); + rebasedNode = transformedMapToArray(rebasedNode, CHARTS_FIELD_NAME, Collections.emptyList()); + + return rebasedNode; + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java index 889297734e9777..6761892b1b31b4 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java @@ -23,8 +23,6 @@ public class DataJobInputOutputTemplate implements ArrayMergingTemplate patchOperations = new ArrayList<>(); + ObjectNode edgeNode = instance.objectNode(); + edgeNode.put( + "destinationUrn", "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"); + JsonPatchOperation operation = + new AddOperation( + new JsonPointer( + "/inputEdges/urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"), + edgeNode); + patchOperations.add(operation); + JsonPatch patch = new JsonPatch(patchOperations); + ChartInfo result = chartInfoTemplate.applyPatch(dashboardInfo, patch); + + Assert.assertEquals( + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"), + result.getInputEdges().get(0).getDestinationUrn()); + } +} diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java new file mode 100644 index 00000000000000..962ff1d40d873c --- /dev/null +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java @@ -0,0 +1,41 @@ +package com.linkedin.metadata.models.registry.patch; + +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonpatch.AddOperation; +import com.github.fge.jsonpatch.JsonPatch; +import com.github.fge.jsonpatch.JsonPatchOperation; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.dashboard.DashboardInfo; +import com.linkedin.metadata.models.registry.template.dashboard.DashboardInfoTemplate; +import java.util.ArrayList; +import java.util.List; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class DashboardInfoTemplateTest { + + @Test + public void testDashboardInfoTemplate() throws Exception { + DashboardInfoTemplate dashboardInfoTemplate = new DashboardInfoTemplate(); + DashboardInfo dashboardInfo = dashboardInfoTemplate.getDefault(); + List patchOperations = new ArrayList<>(); + ObjectNode edgeNode = instance.objectNode(); + edgeNode.put( + "destinationUrn", "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"); + JsonPatchOperation operation = + new AddOperation( + new JsonPointer( + "/datasetEdges/urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"), + edgeNode); + patchOperations.add(operation); + JsonPatch patch = new JsonPatch(patchOperations); + DashboardInfo result = dashboardInfoTemplate.applyPatch(dashboardInfo, patch); + + Assert.assertEquals( + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)"), + result.getDatasetEdges().get(0).getDestinationUrn()); + } +} diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java similarity index 99% rename from entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java rename to entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java index 07982a87be56cb..8f410ae8da0857 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/UpstreamLineageTemplateTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java @@ -1,4 +1,4 @@ -package com.linkedin.metadata.models.registry; +package com.linkedin.metadata.models.registry.patch; import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; diff --git a/metadata-ingestion/src/datahub/specific/chart.py b/metadata-ingestion/src/datahub/specific/chart.py new file mode 100644 index 00000000000000..5dc394e8ebe0fb --- /dev/null +++ b/metadata-ingestion/src/datahub/specific/chart.py @@ -0,0 +1,316 @@ +import time +from typing import Dict, List, Optional, TypeVar, Union +from urllib.parse import quote + +from datahub.emitter.mcp_patch_builder import MetadataPatchProposal +from datahub.metadata.schema_classes import ( + AuditStampClass, + ChartInfoClass as ChartInfo, + EdgeClass as Edge, + GlobalTagsClass as GlobalTags, + GlossaryTermAssociationClass as Term, + GlossaryTermsClass as GlossaryTerms, + KafkaAuditHeaderClass, + OwnerClass as Owner, + OwnershipTypeClass, + SystemMetadataClass, + TagAssociationClass as Tag, +) +from datahub.specific.custom_properties import CustomPropertiesPatchHelper +from datahub.specific.ownership import OwnershipPatchHelper +from datahub.utilities.urns.tag_urn import TagUrn +from datahub.utilities.urns.urn import Urn + +T = TypeVar("T", bound=MetadataPatchProposal) + + +class ChartPatchBuilder(MetadataPatchProposal): + def __init__( + self, + urn: str, + system_metadata: Optional[SystemMetadataClass] = None, + audit_header: Optional[KafkaAuditHeaderClass] = None, + ) -> None: + """ + Initializes a ChartPatchBuilder instance. + + Args: + urn: The URN of the chart + system_metadata: The system metadata of the chart (optional). + audit_header: The Kafka audit header of the chart (optional). + """ + super().__init__( + urn, "chart", system_metadata=system_metadata, audit_header=audit_header + ) + self.custom_properties_patch_helper = CustomPropertiesPatchHelper( + self, ChartInfo.ASPECT_NAME + ) + self.ownership_patch_helper = OwnershipPatchHelper(self) + + def _mint_auditstamp(self, message: Optional[str] = None) -> AuditStampClass: + """ + Creates an AuditStampClass instance with the current timestamp and other default values. + + Args: + message: The message associated with the audit stamp (optional). + + Returns: + An instance of AuditStampClass. + """ + return AuditStampClass( + time=int(time.time() * 1000.0), + actor="urn:li:corpuser:datahub", + message=message, + ) + + def _ensure_urn_type( + self, entity_type: str, edges: List[Edge], context: str + ) -> None: + """ + Ensures that the destination URNs in the given edges have the specified entity type. + + Args: + entity_type: The entity type to check against. + edges: A list of Edge objects. + context: The context or description of the operation. + + Raises: + ValueError: If any of the destination URNs is not of the specified entity type. + """ + for e in edges: + urn = Urn.create_from_string(e.destinationUrn) + if not urn.get_type() == entity_type: + raise ValueError( + f"{context}: {e.destinationUrn} is not of type {entity_type}" + ) + + def add_owner(self, owner: Owner) -> "ChartPatchBuilder": + """ + Adds an owner to the ChartPatchBuilder. + + Args: + owner: The Owner object to add. + + Returns: + The ChartPatchBuilder instance. + """ + self.ownership_patch_helper.add_owner(owner) + return self + + def remove_owner( + self, owner: str, owner_type: Optional[OwnershipTypeClass] = None + ) -> "ChartPatchBuilder": + """ + Removes an owner from the ChartPatchBuilder. + + Args: + owner: The owner to remove. + owner_type: The ownership type of the owner (optional). + + Returns: + The ChartPatchBuilder instance. + + Notes: + `owner_type` is optional. + """ + self.ownership_patch_helper.remove_owner(owner, owner_type) + return self + + def set_owners(self, owners: List[Owner]) -> "ChartPatchBuilder": + """ + Sets the owners of the ChartPatchBuilder. + + Args: + owners: A list of Owner objects. + + Returns: + The ChartPatchBuilder instance. + """ + self.ownership_patch_helper.set_owners(owners) + return self + + def add_input_edge(self, input: Union[Edge, Urn, str]) -> "ChartPatchBuilder": + """ + Adds an input to the ChartPatchBuilder. + + Args: + input: The input, which can be an Edge object, Urn object, or a string. + + Returns: + The ChartPatchBuilder instance. + + Notes: + If `input` is an Edge object, it is used directly. If `input` is a Urn object or string, + it is converted to an Edge object and added with default audit stamps. + """ + if isinstance(input, Edge): + input_urn: str = input.destinationUrn + input_edge: Edge = input + elif isinstance(input, (Urn, str)): + input_urn = str(input) + + input_edge = Edge( + destinationUrn=input_urn, + created=self._mint_auditstamp(), + lastModified=self._mint_auditstamp(), + ) + + self._ensure_urn_type("dataset", [input_edge], "add_dataset") + self._add_patch( + ChartInfo.ASPECT_NAME, + "add", + path=f"/inputEdges/{quote(input_urn, safe='')}", + value=input_urn, + ) + return self + + def remove_input_edge(self, input: Union[str, Urn]) -> "ChartPatchBuilder": + """ + Removes an input from the ChartPatchBuilder. + + Args: + input: The input to remove, specified as a string or Urn object. + + Returns: + The ChartPatchBuilder instance. + """ + self._add_patch( + ChartInfo.ASPECT_NAME, + "remove", + path=f"/inputEdges/{input}", + value={}, + ) + return self + + def set_input_edges(self, inputs: List[Edge]) -> "ChartPatchBuilder": + """ + Sets the input edges for the ChartPatchBuilder. + + Args: + inputs: A list of Edge objects representing the input edges. + + Returns: + The ChartPatchBuilder instance. + + Notes: + This method replaces all existing inputs with the given inputs. + """ + self._add_patch( + ChartInfo.ASPECT_NAME, + "add", + path="/inputEdges", + value=inputs, + ) + return self + + def add_tag(self, tag: Tag) -> "ChartPatchBuilder": + """ + Adds a tag to the ChartPatchBuilder. + + Args: + tag: The Tag object representing the tag to be added. + + Returns: + The ChartPatchBuilder instance. + """ + self._add_patch( + GlobalTags.ASPECT_NAME, "add", path=f"/tags/{tag.tag}", value=tag + ) + return self + + def remove_tag(self, tag: Union[str, Urn]) -> "ChartPatchBuilder": + """ + Removes a tag from the ChartPatchBuilder. + + Args: + tag: The tag to remove, specified as a string or Urn object. + + Returns: + The ChartPatchBuilder instance. + """ + if isinstance(tag, str) and not tag.startswith("urn:li:tag:"): + tag = TagUrn.create_from_id(tag) + self._add_patch(GlobalTags.ASPECT_NAME, "remove", path=f"/tags/{tag}", value={}) + return self + + def add_term(self, term: Term) -> "ChartPatchBuilder": + """ + Adds a glossary term to the ChartPatchBuilder. + + Args: + term: The Term object representing the glossary term to be added. + + Returns: + The ChartPatchBuilder instance. + """ + self._add_patch( + GlossaryTerms.ASPECT_NAME, "add", path=f"/terms/{term.urn}", value=term + ) + return self + + def remove_term(self, term: Union[str, Urn]) -> "ChartPatchBuilder": + """ + Removes a glossary term from the ChartPatchBuilder. + + Args: + term: The term to remove, specified as a string or Urn object. + + Returns: + The ChartPatchBuilder instance. + """ + if isinstance(term, str) and not term.startswith("urn:li:glossaryTerm:"): + term = "urn:li:glossaryTerm:" + term + self._add_patch( + GlossaryTerms.ASPECT_NAME, "remove", path=f"/terms/{term}", value={} + ) + return self + + def set_custom_properties( + self, custom_properties: Dict[str, str] + ) -> "ChartPatchBuilder": + """ + Sets the custom properties for the ChartPatchBuilder. + + Args: + custom_properties: A dictionary containing the custom properties to be set. + + Returns: + The ChartPatchBuilder instance. + + Notes: + This method replaces all existing custom properties with the given dictionary. + """ + self._add_patch( + ChartInfo.ASPECT_NAME, + "add", + path="/customProperties", + value=custom_properties, + ) + return self + + def add_custom_property(self, key: str, value: str) -> "ChartPatchBuilder": + """ + Adds a custom property to the ChartPatchBuilder. + + Args: + key: The key of the custom property. + value: The value of the custom property. + + Returns: + The ChartPatchBuilder instance. + """ + self.custom_properties_patch_helper.add_property(key, value) + return self + + def remove_custom_property(self, key: str) -> "ChartPatchBuilder": + """ + Removes a custom property from the ChartPatchBuilder. + + Args: + key: The key of the custom property to remove. + + Returns: + The ChartPatchBuilder instance. + """ + self.custom_properties_patch_helper.remove_property(key) + return self diff --git a/metadata-ingestion/src/datahub/specific/dashboard.py b/metadata-ingestion/src/datahub/specific/dashboard.py new file mode 100644 index 00000000000000..855dcc5685cea4 --- /dev/null +++ b/metadata-ingestion/src/datahub/specific/dashboard.py @@ -0,0 +1,410 @@ +import time +from typing import Dict, List, Optional, TypeVar, Union +from urllib.parse import quote + +from datahub.emitter.mcp_patch_builder import MetadataPatchProposal +from datahub.metadata.schema_classes import ( + AuditStampClass, + DashboardInfoClass as DashboardInfo, + EdgeClass as Edge, + GlobalTagsClass as GlobalTags, + GlossaryTermAssociationClass as Term, + GlossaryTermsClass as GlossaryTerms, + KafkaAuditHeaderClass, + OwnerClass as Owner, + OwnershipTypeClass, + SystemMetadataClass, + TagAssociationClass as Tag, +) +from datahub.specific.custom_properties import CustomPropertiesPatchHelper +from datahub.specific.ownership import OwnershipPatchHelper +from datahub.utilities.urns.tag_urn import TagUrn +from datahub.utilities.urns.urn import Urn + +T = TypeVar("T", bound=MetadataPatchProposal) + + +class DashboardPatchBuilder(MetadataPatchProposal): + def __init__( + self, + urn: str, + system_metadata: Optional[SystemMetadataClass] = None, + audit_header: Optional[KafkaAuditHeaderClass] = None, + ) -> None: + """ + Initializes a DashboardPatchBuilder instance. + + Args: + urn: The URN of the dashboard + system_metadata: The system metadata of the dashboard (optional). + audit_header: The Kafka audit header of the dashboard (optional). + """ + super().__init__( + urn, "dashboard", system_metadata=system_metadata, audit_header=audit_header + ) + self.custom_properties_patch_helper = CustomPropertiesPatchHelper( + self, DashboardInfo.ASPECT_NAME + ) + self.ownership_patch_helper = OwnershipPatchHelper(self) + + def _mint_auditstamp(self, message: Optional[str] = None) -> AuditStampClass: + """ + Creates an AuditStampClass instance with the current timestamp and other default values. + + Args: + message: The message associated with the audit stamp (optional). + + Returns: + An instance of AuditStampClass. + """ + return AuditStampClass( + time=int(time.time() * 1000.0), + actor="urn:li:corpuser:datahub", + message=message, + ) + + def _ensure_urn_type( + self, entity_type: str, edges: List[Edge], context: str + ) -> None: + """ + Ensures that the destination URNs in the given edges have the specified entity type. + + Args: + entity_type: The entity type to check against. + edges: A list of Edge objects. + context: The context or description of the operation. + + Raises: + ValueError: If any of the destination URNs is not of the specified entity type. + """ + for e in edges: + urn = Urn.create_from_string(e.destinationUrn) + if not urn.get_type() == entity_type: + raise ValueError( + f"{context}: {e.destinationUrn} is not of type {entity_type}" + ) + + def add_owner(self, owner: Owner) -> "DashboardPatchBuilder": + """ + Adds an owner to the DashboardPatchBuilder. + + Args: + owner: The Owner object to add. + + Returns: + The DashboardPatchBuilder instance. + """ + self.ownership_patch_helper.add_owner(owner) + return self + + def remove_owner( + self, owner: str, owner_type: Optional[OwnershipTypeClass] = None + ) -> "DashboardPatchBuilder": + """ + Removes an owner from the DashboardPatchBuilder. + + Args: + owner: The owner to remove. + owner_type: The ownership type of the owner (optional). + + Returns: + The DashboardPatchBuilder instance. + + Notes: + `owner_type` is optional. + """ + self.ownership_patch_helper.remove_owner(owner, owner_type) + return self + + def set_owners(self, owners: List[Owner]) -> "DashboardPatchBuilder": + """ + Sets the owners of the DashboardPatchBuilder. + + Args: + owners: A list of Owner objects. + + Returns: + The DashboardPatchBuilder instance. + """ + self.ownership_patch_helper.set_owners(owners) + return self + + def add_dataset_edge( + self, dataset: Union[Edge, Urn, str] + ) -> "DashboardPatchBuilder": + """ + Adds an dataset to the DashboardPatchBuilder. + + Args: + dataset: The dataset, which can be an Edge object, Urn object, or a string. + + Returns: + The DashboardPatchBuilder instance. + + Raises: + ValueError: If the dataset is not a Dataset urn. + + Notes: + If `dataset` is an Edge object, it is used directly. If `dataset` is a Urn object or string, + it is converted to an Edge object and added with default audit stamps. + """ + if isinstance(dataset, Edge): + dataset_urn: str = dataset.destinationUrn + dataset_edge: Edge = dataset + elif isinstance(dataset, (Urn, str)): + dataset_urn = str(dataset) + if not dataset_urn.startswith("urn:li:dataset:"): + raise ValueError(f"Input {dataset} is not a Dataset urn") + + dataset_edge = Edge( + destinationUrn=dataset_urn, + created=self._mint_auditstamp(), + lastModified=self._mint_auditstamp(), + ) + + self._ensure_urn_type("dataset", [dataset_edge], "add_dataset") + self._add_patch( + DashboardInfo.ASPECT_NAME, + "add", + path=f"/datasetEdges/{quote(dataset_urn, safe='')}", + value=dataset_edge, + ) + return self + + def remove_dataset_edge(self, dataset: Union[str, Urn]) -> "DashboardPatchBuilder": + """ + Removes a dataset edge from the DashboardPatchBuilder. + + Args: + dataset: The dataset to remove, specified as a string or Urn object. + + Returns: + The DashboardPatchBuilder instance. + """ + self._add_patch( + DashboardInfo.ASPECT_NAME, + "remove", + path=f"/datasetEdges/{dataset}", + value={}, + ) + return self + + def set_dataset_edges(self, datasets: List[Edge]) -> "DashboardPatchBuilder": + """ + Sets the dataset edges for the DashboardPatchBuilder. + + Args: + datasets: A list of Edge objects representing the dataset edges. + + Returns: + The DashboardPatchBuilder instance. + + Raises: + ValueError: If any of the input edges are not of type 'Datset'. + + Notes: + This method replaces all existing datasets with the given inputs. + """ + self._ensure_urn_type("dataset", datasets, "dataset edges") + self._add_patch( + DashboardInfo.ASPECT_NAME, + "add", + path="/datasetEdges", + value=datasets, + ) + return self + + def add_chart_edge(self, chart: Union[Edge, Urn, str]) -> "DashboardPatchBuilder": + """ + Adds a chart edge to the DashboardPatchBuilder. + + Args: + chart: The dataset, which can be an Edge object, Urn object, or a string. + + Returns: + The DashboardPatchBuilder instance. + + Raises: + ValueError: If the edge is not a Chart urn. + + Notes: + If `chart` is an Edge object, it is used directly. If `chart` is a Urn object or string, + it is converted to an Edge object and added with default audit stamps. + """ + if isinstance(chart, Edge): + chart_urn: str = chart.destinationUrn + chart_edge: Edge = chart + elif isinstance(chart, (Urn, str)): + chart_urn = str(chart) + if not chart_urn.startswith("urn:li:chart:"): + raise ValueError(f"Input {chart} is not a Chart urn") + + chart_edge = Edge( + destinationUrn=chart_urn, + created=self._mint_auditstamp(), + lastModified=self._mint_auditstamp(), + ) + + self._ensure_urn_type("dataset", [chart_edge], "add_chart_edge") + self._add_patch( + DashboardInfo.ASPECT_NAME, + "add", + path=f"/chartEdges/{quote(chart_urn, safe='')}", + value=chart_edge, + ) + return self + + def remove_chart_edge(self, chart: Union[str, Urn]) -> "DashboardPatchBuilder": + """ + Removes an chart edge from the DashboardPatchBuilder. + + Args: + chart: The chart to remove, specified as a string or Urn object. + + Returns: + The DashboardPatchBuilder instance. + """ + self._add_patch( + DashboardInfo.ASPECT_NAME, + "remove", + path=f"/chartEdges/{chart}", + value={}, + ) + return self + + def set_chart_edges(self, charts: List[Edge]) -> "DashboardPatchBuilder": + """ + Sets the chart edges for the DashboardPatchBuilder. + + Args: + charts: A list of Edge objects representing the chart edges. + + Returns: + The DashboardPatchBuilder instance. + + Raises: + ValueError: If any of the edges are not of type 'chart'. + + Notes: + This method replaces all existing charts with the given charts. + """ + self._ensure_urn_type("chart", charts, "set_charts") + self._add_patch( + DashboardInfo.ASPECT_NAME, + "add", + path="/chartEdges", + value=charts, + ) + return self + + def add_tag(self, tag: Tag) -> "DashboardPatchBuilder": + """ + Adds a tag to the DashboardPatchBuilder. + + Args: + tag: The Tag object representing the tag to be added. + + Returns: + The DashboardPatchBuilder instance. + """ + self._add_patch( + GlobalTags.ASPECT_NAME, "add", path=f"/tags/{tag.tag}", value=tag + ) + return self + + def remove_tag(self, tag: Union[str, Urn]) -> "DashboardPatchBuilder": + """ + Removes a tag from the DashboardPatchBuilder. + + Args: + tag: The tag to remove, specified as a string or Urn object. + + Returns: + The DashboardPatchBuilder instance. + """ + if isinstance(tag, str) and not tag.startswith("urn:li:tag:"): + tag = TagUrn.create_from_id(tag) + self._add_patch(GlobalTags.ASPECT_NAME, "remove", path=f"/tags/{tag}", value={}) + return self + + def add_term(self, term: Term) -> "DashboardPatchBuilder": + """ + Adds a glossary term to the DashboardPatchBuilder. + + Args: + term: The Term object representing the glossary term to be added. + + Returns: + The DashboardPatchBuilder instance. + """ + self._add_patch( + GlossaryTerms.ASPECT_NAME, "add", path=f"/terms/{term.urn}", value=term + ) + return self + + def remove_term(self, term: Union[str, Urn]) -> "DashboardPatchBuilder": + """ + Removes a glossary term from the DashboardPatchBuilder. + + Args: + term: The term to remove, specified as a string or Urn object. + + Returns: + The DashboardPatchBuilder instance. + """ + if isinstance(term, str) and not term.startswith("urn:li:glossaryTerm:"): + term = "urn:li:glossaryTerm:" + term + self._add_patch( + GlossaryTerms.ASPECT_NAME, "remove", path=f"/terms/{term}", value={} + ) + return self + + def set_custom_properties( + self, custom_properties: Dict[str, str] + ) -> "DashboardPatchBuilder": + """ + Sets the custom properties for the DashboardPatchBuilder. + + Args: + custom_properties: A dictionary containing the custom properties to be set. + + Returns: + The DashboardPatchBuilder instance. + + Notes: + This method replaces all existing custom properties with the given dictionary. + """ + self._add_patch( + DashboardInfo.ASPECT_NAME, + "add", + path="/customProperties", + value=custom_properties, + ) + return self + + def add_custom_property(self, key: str, value: str) -> "DashboardPatchBuilder": + """ + Adds a custom property to the DashboardPatchBuilder. + + Args: + key: The key of the custom property. + value: The value of the custom property. + + Returns: + The DashboardPatchBuilder instance. + """ + self.custom_properties_patch_helper.add_property(key, value) + return self + + def remove_custom_property(self, key: str) -> "DashboardPatchBuilder": + """ + Removes a custom property from the DashboardPatchBuilder. + + Args: + key: The key of the custom property to remove. + + Returns: + The DashboardPatchBuilder instance. + """ + self.custom_properties_patch_helper.remove_property(key) + return self diff --git a/metadata-ingestion/src/datahub/specific/datajob.py b/metadata-ingestion/src/datahub/specific/datajob.py index 7ebaee6b918c12..0338a1320c15b8 100644 --- a/metadata-ingestion/src/datahub/specific/datajob.py +++ b/metadata-ingestion/src/datahub/specific/datajob.py @@ -207,7 +207,7 @@ def set_input_datajobs(self, inputs: List[Edge]) -> "DataJobPatchBuilder": self._ensure_urn_type("dataJob", inputs, "input datajobs") self._add_patch( DataJobInputOutput.ASPECT_NAME, - "replace", + "add", path="/inputDatajobEdges", value=inputs, ) @@ -290,7 +290,7 @@ def set_input_datasets(self, inputs: List[Edge]) -> "DataJobPatchBuilder": self._ensure_urn_type("dataset", inputs, "set_input_datasets") self._add_patch( DataJobInputOutput.ASPECT_NAME, - "replace", + "add", path="/inputDatasetEdges", value=inputs, ) @@ -375,7 +375,7 @@ def set_output_datasets(self, outputs: List[Edge]) -> "DataJobPatchBuilder": self._ensure_urn_type("dataset", outputs, "set_output_datasets") self._add_patch( DataJobInputOutput.ASPECT_NAME, - "replace", + "add", path="/outputDatasetEdges", value=outputs, ) @@ -463,7 +463,7 @@ def set_input_dataset_fields(self, inputs: List[Edge]) -> "DataJobPatchBuilder": self._ensure_urn_type("schemaField", inputs, "set_input_dataset_fields") self._add_patch( DataJobInputOutput.ASPECT_NAME, - "replace", + "add", path="/inputDatasetFields", value=inputs, ) @@ -551,7 +551,7 @@ def set_output_dataset_fields(self, outputs: List[Edge]) -> "DataJobPatchBuilder self._ensure_urn_type("schemaField", outputs, "set_output_dataset_fields") self._add_patch( DataJobInputOutput.ASPECT_NAME, - "replace", + "add", path="/outputDatasetFields", value=outputs, ) @@ -636,7 +636,7 @@ def set_custom_properties( """ self._add_patch( DataJobInfo.ASPECT_NAME, - "replace", + "add", path="/customProperties", value=custom_properties, ) diff --git a/metadata-ingestion/src/datahub/specific/dataproduct.py b/metadata-ingestion/src/datahub/specific/dataproduct.py index bb49ac47b3ef86..2c174e0c9a6cbb 100644 --- a/metadata-ingestion/src/datahub/specific/dataproduct.py +++ b/metadata-ingestion/src/datahub/specific/dataproduct.py @@ -85,7 +85,7 @@ def remove_term(self, term: Union[str, Urn]) -> "DataProductPatchBuilder": def set_name(self, name: str) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, - "replace", + "add", path="/name", value=name, ) @@ -94,7 +94,7 @@ def set_name(self, name: str) -> "DataProductPatchBuilder": def set_description(self, description: str) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, - "replace", + "add", path="/description", value=description, ) @@ -105,7 +105,7 @@ def set_custom_properties( ) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, - "replace", + "add", path="/customProperties", value=custom_properties, ) @@ -124,7 +124,7 @@ def set_assets( ) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, - "replace", + "add", path="/assets", value=assets, ) @@ -151,7 +151,7 @@ def remove_asset(self, asset_urn: str) -> "DataProductPatchBuilder": def set_external_url(self, external_url: str) -> "DataProductPatchBuilder": self._add_patch( DataProductProperties.ASPECT_NAME, - "replace", + "add", path="/externalUrl", value=external_url, ) diff --git a/metadata-ingestion/src/datahub/specific/dataset.py b/metadata-ingestion/src/datahub/specific/dataset.py index 294a80572669b8..62ee4fc57b61b6 100644 --- a/metadata-ingestion/src/datahub/specific/dataset.py +++ b/metadata-ingestion/src/datahub/specific/dataset.py @@ -143,7 +143,7 @@ def remove_upstream_lineage( def set_upstream_lineages(self, upstreams: List[Upstream]) -> "DatasetPatchBuilder": self._add_patch( - UpstreamLineage.ASPECT_NAME, "replace", path="/upstreams", value=upstreams + UpstreamLineage.ASPECT_NAME, "add", path="/upstreams", value=upstreams ) return self @@ -297,7 +297,7 @@ def set_description( DatasetProperties.ASPECT_NAME if not editable else EditableDatasetProperties.ASPECT_NAME, - "replace", + "add", path="/description", value=description, ) @@ -308,7 +308,7 @@ def set_custom_properties( ) -> "DatasetPatchBuilder": self._add_patch( DatasetProperties.ASPECT_NAME, - "replace", + "add", path="/customProperties", value=custom_properties, ) @@ -326,7 +326,7 @@ def set_display_name(self, display_name: str) -> "DatasetPatchBuilder": if display_name is not None: self._add_patch( DatasetProperties.ASPECT_NAME, - "replace", + "add", path="/name", value=display_name, ) diff --git a/metadata-ingestion/src/datahub/specific/ownership.py b/metadata-ingestion/src/datahub/specific/ownership.py index 334b45a67437f2..c2a3874a3a33f3 100644 --- a/metadata-ingestion/src/datahub/specific/ownership.py +++ b/metadata-ingestion/src/datahub/specific/ownership.py @@ -43,6 +43,6 @@ def remove_owner( def set_owners(self, owners: List[OwnerClass]) -> "OwnershipPatchHelper": self._parent._add_patch( - OwnershipClass.ASPECT_NAME, "replace", path="/owners", value=owners + OwnershipClass.ASPECT_NAME, "add", path="/owners", value=owners ) return self diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json index 97c2330f58bc75..66bc2ce0c2a0c0 100644 --- a/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_upsert.json @@ -5,7 +5,7 @@ "changeType": "PATCH", "aspectName": "dataProductProperties", "aspect": { - "value": "[{\"op\": \"replace\", \"path\": \"/name\", \"value\": \"Pet of the Week Campaign\"}, {\"op\": \"replace\", \"path\": \"/assets\", \"value\": [{\"destinationUrn\": \"urn:li:container:DATABASE\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:container:SCHEMA\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes)\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}]}, {\"op\": \"replace\", \"path\": \"/customProperties\", \"value\": {\"version\": \"2.0\", \"classification\": \"pii\"}}, {\"op\": \"replace\", \"path\": \"/externalUrl\", \"value\": \"https://github.com/datahub-project/datahub\"}]", + "value": "[{\"op\": \"add\", \"path\": \"/name\", \"value\": \"Pet of the Week Campaign\"}, {\"op\": \"add\", \"path\": \"/assets\", \"value\": [{\"destinationUrn\": \"urn:li:container:DATABASE\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:container:SCHEMA\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}, {\"destinationUrn\": \"urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes)\", \"created\": {\"time\": 1681455600000, \"actor\": \"urn:li:corpuser:datahub\", \"message\": \"yaml\"}}]}, {\"op\": \"add\", \"path\": \"/customProperties\", \"value\": {\"version\": \"2.0\", \"classification\": \"pii\"}}, {\"op\": \"add\", \"path\": \"/externalUrl\", \"value\": \"https://github.com/datahub-project/datahub\"}]", "contentType": "application/json-patch+json" } }, diff --git a/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json b/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json index ed5a7723ac2bf1..bcc619a09401e2 100644 --- a/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json +++ b/metadata-ingestion/tests/unit/patch/complex_dataset_patch.json @@ -7,7 +7,7 @@ "aspect": { "json": [ { - "op": "replace", + "op": "add", "path": "/description", "value": "test description" }, diff --git a/metadata-ingestion/tests/unit/patch/test_patch_builder.py b/metadata-ingestion/tests/unit/patch/test_patch_builder.py index f05c4978f8644e..e68f948be8aa0f 100644 --- a/metadata-ingestion/tests/unit/patch/test_patch_builder.py +++ b/metadata-ingestion/tests/unit/patch/test_patch_builder.py @@ -3,7 +3,12 @@ import pytest -from datahub.emitter.mce_builder import make_dataset_urn, make_tag_urn +from datahub.emitter.mce_builder import ( + make_chart_urn, + make_dashboard_urn, + make_dataset_urn, + make_tag_urn, +) from datahub.ingestion.sink.file import write_metadata_file from datahub.metadata.schema_classes import ( DatasetLineageTypeClass, @@ -15,6 +20,8 @@ TagAssociationClass, UpstreamClass, ) +from datahub.specific.chart import ChartPatchBuilder +from datahub.specific.dashboard import DashboardPatchBuilder from datahub.specific.dataset import DatasetPatchBuilder @@ -80,3 +87,41 @@ def test_complex_dataset_patch( pytestconfig.rootpath / "tests/unit/patch/complex_dataset_patch.json" ).read_text() ) + + +def test_basic_chart_patch_builder(): + patcher = ChartPatchBuilder( + make_chart_urn(platform="hive", name="fct_users_created") + ).add_tag(TagAssociationClass(tag=make_tag_urn("test_tag"))) + + assert patcher.build() == [ + MetadataChangeProposalClass( + entityType="chart", + entityUrn="urn:li:chart:(hive,fct_users_created)", + changeType="PATCH", + aspectName="globalTags", + aspect=GenericAspectClass( + value=b'[{"op": "add", "path": "/tags/urn:li:tag:test_tag", "value": {"tag": "urn:li:tag:test_tag"}}]', + contentType="application/json-patch+json", + ), + ), + ] + + +def test_basic_dashboard_patch_builder(): + patcher = DashboardPatchBuilder( + make_dashboard_urn(platform="hive", name="fct_users_created") + ).add_tag(TagAssociationClass(tag=make_tag_urn("test_tag"))) + + assert patcher.build() == [ + MetadataChangeProposalClass( + entityType="dashboard", + entityUrn="urn:li:dashboard:(hive,fct_users_created)", + changeType="PATCH", + aspectName="globalTags", + aspect=GenericAspectClass( + value=b'[{"op": "add", "path": "/tags/urn:li:tag:test_tag", "value": {"tag": "urn:li:tag:test_tag"}}]', + contentType="application/json-patch+json", + ), + ), + ] diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java new file mode 100644 index 00000000000000..0655d2b3eb8eb2 --- /dev/null +++ b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java @@ -0,0 +1,41 @@ +package datahub.client.patch.chart; + +import static com.linkedin.metadata.Constants.*; +import static datahub.client.patch.common.PatchUtil.*; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.linkedin.common.urn.Urn; +import datahub.client.patch.AbstractMultiFieldPatchBuilder; +import datahub.client.patch.PatchOperationType; +import javax.annotation.Nonnull; +import org.apache.commons.lang3.tuple.ImmutableTriple; + +public class ChartInfoPatchBuilder extends AbstractMultiFieldPatchBuilder { + private static final String INPUT_EDGES_PATH_START = "/inputEdges/"; + + // Simplified with just Urn + public ChartInfoPatchBuilder addInputEdge(@Nonnull Urn urn) { + ObjectNode value = createEdgeValue(urn); + + pathValues.add( + ImmutableTriple.of(PatchOperationType.ADD.getValue(), INPUT_EDGES_PATH_START + urn, value)); + return this; + } + + public ChartInfoPatchBuilder removeInputEdge(@Nonnull Urn urn) { + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), INPUT_EDGES_PATH_START + urn, null)); + return this; + } + + @Override + protected String getAspectName() { + return CHART_INFO_ASPECT_NAME; + } + + @Override + protected String getEntityType() { + return CHART_ENTITY_NAME; + } +} diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java new file mode 100644 index 00000000000000..69db36c6e038cf --- /dev/null +++ b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java @@ -0,0 +1,84 @@ +package datahub.client.patch.common; + +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; +import static com.linkedin.metadata.Constants.*; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.linkedin.common.Edge; +import com.linkedin.common.urn.Urn; +import javax.annotation.Nonnull; + +public class PatchUtil { + private PatchUtil() {} + + private static final String TIME_KEY = "time"; + private static final String ACTOR_KEY = "actor"; + private static final String IMPERSONATOR_KEY = "impersonator"; + private static final String MESSAGE_KEY = "message"; + private static final String LAST_MODIFIED_KEY = "lastModified"; + private static final String CREATED_KEY = "created"; + private static final String DESTINATION_URN_KEY = "destinationUrn"; + private static final String SOURCE_URN_KEY = "sourceUrn"; + + private static final String PROPERTIES_KEY = "properties"; + + public static ObjectNode createEdgeValue(@Nonnull Edge edge) { + ObjectNode value = instance.objectNode(); + + ObjectNode created = instance.objectNode(); + if (edge.getCreated() == null) { + created.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); + } else { + created + .put(TIME_KEY, edge.getCreated().getTime()) + .put(ACTOR_KEY, edge.getCreated().getActor().toString()); + if (edge.getCreated().getImpersonator() != null) { + created.put(IMPERSONATOR_KEY, edge.getCreated().getImpersonator().toString()); + } + if (edge.getCreated().getMessage() != null) { + created.put(MESSAGE_KEY, edge.getCreated().getMessage()); + } + } + value.set(CREATED_KEY, created); + + ObjectNode lastModified = instance.objectNode(); + if (edge.getLastModified() == null) { + lastModified.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); + } else { + lastModified + .put(TIME_KEY, edge.getLastModified().getTime()) + .put(ACTOR_KEY, edge.getLastModified().getActor().toString()); + if (edge.getLastModified().getImpersonator() != null) { + lastModified.put(IMPERSONATOR_KEY, edge.getLastModified().getImpersonator().toString()); + } + if (edge.getLastModified().getMessage() != null) { + lastModified.put(MESSAGE_KEY, edge.getLastModified().getMessage()); + } + } + value.set(LAST_MODIFIED_KEY, lastModified); + + if (edge.getProperties() != null) { + ObjectNode propertiesNode = instance.objectNode(); + edge.getProperties().forEach((k, v) -> propertiesNode.set(k, instance.textNode(v))); + value.set(PROPERTIES_KEY, propertiesNode); + } + + value.put(DESTINATION_URN_KEY, edge.getDestinationUrn().toString()); + if (edge.getSourceUrn() != null) { + value.put(SOURCE_URN_KEY, edge.getSourceUrn().toString()); + } + + return value; + } + + public static ObjectNode createEdgeValue(@Nonnull Urn urn) { + ObjectNode value = instance.objectNode(); + ObjectNode auditStamp = instance.objectNode(); + auditStamp.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); + + value.put(DESTINATION_URN_KEY, urn.toString()).set(LAST_MODIFIED_KEY, auditStamp); + value.set(CREATED_KEY, auditStamp); + + return value; + } +} diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java new file mode 100644 index 00000000000000..cadde582f1c64a --- /dev/null +++ b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java @@ -0,0 +1,103 @@ +package datahub.client.patch.dashboard; + +import static com.linkedin.metadata.Constants.*; +import static datahub.client.patch.common.PatchUtil.*; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.linkedin.common.Edge; +import com.linkedin.common.urn.ChartUrn; +import com.linkedin.common.urn.DatasetUrn; +import com.linkedin.common.urn.Urn; +import datahub.client.patch.AbstractMultiFieldPatchBuilder; +import datahub.client.patch.PatchOperationType; +import javax.annotation.Nonnull; +import org.apache.commons.lang3.tuple.ImmutableTriple; + +public class DashboardInfoPatchBuilder + extends AbstractMultiFieldPatchBuilder { + private static final String CHART_EDGES_PATH_START = "/chartEdges/"; + private static final String DATASET_EDGES_PATH_START = "/datasetEdges/"; + + // Simplified with just Urn + public DashboardInfoPatchBuilder addChartEdge(@Nonnull ChartUrn urn) { + ObjectNode value = createEdgeValue(urn); + + pathValues.add( + ImmutableTriple.of(PatchOperationType.ADD.getValue(), CHART_EDGES_PATH_START + urn, value)); + return this; + } + + public DashboardInfoPatchBuilder removeChartEdge(@Nonnull ChartUrn urn) { + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), CHART_EDGES_PATH_START + urn, null)); + return this; + } + + public DashboardInfoPatchBuilder addDatasetEdge(@Nonnull DatasetUrn urn) { + ObjectNode value = createEdgeValue(urn); + + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), DATASET_EDGES_PATH_START + urn, value)); + return this; + } + + public DashboardInfoPatchBuilder removeDatasetEdge(@Nonnull DatasetUrn urn) { + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), DATASET_EDGES_PATH_START + urn, null)); + return this; + } + + // Full Edge modification + public DashboardInfoPatchBuilder addEdge(@Nonnull Edge edge) { + ObjectNode value = createEdgeValue(edge); + String path = getEdgePath(edge); + + pathValues.add(ImmutableTriple.of(PatchOperationType.ADD.getValue(), path, value)); + return this; + } + + public DashboardInfoPatchBuilder removeEdge(@Nonnull Edge edge) { + String path = getEdgePath(edge); + + pathValues.add(ImmutableTriple.of(PatchOperationType.REMOVE.getValue(), path, null)); + return this; + } + + /** + * Determines Edge path based on supplied Urn, if not a valid entity type throws + * IllegalArgumentException + * + * @param edge + * @return + * @throws IllegalArgumentException if destinationUrn is an invalid entity type + */ + private String getEdgePath(@Nonnull Edge edge) { + Urn destinationUrn = edge.getDestinationUrn(); + + if (DATASET_ENTITY_NAME.equals(destinationUrn.getEntityType())) { + return DATASET_EDGES_PATH_START + destinationUrn; + } + + if (CHART_ENTITY_NAME.equals(destinationUrn.getEntityType())) { + return CHART_EDGES_PATH_START + destinationUrn; + } + + // TODO: Output Data Jobs not supported by aspect, add here if this changes + + throw new IllegalArgumentException( + String.format("Unsupported entity type: %s", destinationUrn.getEntityType())); + } + + @Override + protected String getAspectName() { + return DASHBOARD_INFO_ASPECT_NAME; + } + + @Override + protected String getEntityType() { + return DASHBOARD_ENTITY_NAME; + } +} diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java index 0fb0454533fc06..fc250daffe916d 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java +++ b/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java @@ -2,6 +2,7 @@ import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; import static com.linkedin.metadata.Constants.*; +import static datahub.client.patch.common.PatchUtil.*; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; @@ -20,21 +21,9 @@ public class DataJobInputOutputPatchBuilder private static final String INPUT_DATA_JOB_EDGES_PATH_START = "/inputDatajobEdges/"; private static final String INPUT_DATASET_EDGES_PATH_START = "/inputDatasetEdges/"; private static final String OUTPUT_DATASET_EDGES_PATH_START = "/outputDatasetEdges/"; - - private static final String DESTINATION_URN_KEY = "destinationUrn"; - private static final String SOURCE_URN_KEY = "sourceUrn"; - private static final String LAST_MODIFIED_KEY = "lastModified"; - private static final String CREATED_KEY = "created"; - private static final String PROPERTIES_KEY = "properties"; - private static final String INPUT_DATASET_FIELDS_PATH_START = "/inputDatasetFields/"; private static final String OUTPUT_DATASET_FIELDS_PATH_START = "/outputDatasetFields/"; - private static final String TIME_KEY = "time"; - private static final String ACTOR_KEY = "actor"; - private static final String IMPERSONATOR_KEY = "impersonator"; - private static final String MESSAGE_KEY = "message"; - // Simplified with just Urn public DataJobInputOutputPatchBuilder addInputDatajobEdge(@Nonnull DataJobUrn dataJobUrn) { ObjectNode value = createEdgeValue(dataJobUrn); @@ -144,66 +133,6 @@ public DataJobInputOutputPatchBuilder removeEdge( return this; } - private ObjectNode createEdgeValue(@Nonnull Urn urn) { - ObjectNode value = instance.objectNode(); - ObjectNode auditStamp = instance.objectNode(); - auditStamp.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); - - value.put(DESTINATION_URN_KEY, urn.toString()).set(LAST_MODIFIED_KEY, auditStamp); - value.set(CREATED_KEY, auditStamp); - - return value; - } - - private ObjectNode createEdgeValue(@Nonnull Edge edge) { - ObjectNode value = instance.objectNode(); - - ObjectNode created = instance.objectNode(); - if (edge.getCreated() == null) { - created.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); - } else { - created - .put(TIME_KEY, edge.getCreated().getTime()) - .put(ACTOR_KEY, edge.getCreated().getActor().toString()); - if (edge.getCreated().getImpersonator() != null) { - created.put(IMPERSONATOR_KEY, edge.getCreated().getImpersonator().toString()); - } - if (edge.getCreated().getMessage() != null) { - created.put(MESSAGE_KEY, edge.getCreated().getMessage()); - } - } - value.set(CREATED_KEY, created); - - ObjectNode lastModified = instance.objectNode(); - if (edge.getLastModified() == null) { - lastModified.put(TIME_KEY, System.currentTimeMillis()).put(ACTOR_KEY, UNKNOWN_ACTOR); - } else { - lastModified - .put(TIME_KEY, edge.getLastModified().getTime()) - .put(ACTOR_KEY, edge.getLastModified().getActor().toString()); - if (edge.getLastModified().getImpersonator() != null) { - lastModified.put(IMPERSONATOR_KEY, edge.getLastModified().getImpersonator().toString()); - } - if (edge.getLastModified().getMessage() != null) { - lastModified.put(MESSAGE_KEY, edge.getLastModified().getMessage()); - } - } - value.set(LAST_MODIFIED_KEY, lastModified); - - if (edge.getProperties() != null) { - ObjectNode propertiesNode = instance.objectNode(); - edge.getProperties().forEach((k, v) -> propertiesNode.set(k, instance.textNode(v))); - value.set(PROPERTIES_KEY, propertiesNode); - } - - value.put(DESTINATION_URN_KEY, edge.getDestinationUrn().toString()); - if (edge.getSourceUrn() != null) { - value.put(SOURCE_URN_KEY, edge.getSourceUrn().toString()); - } - - return value; - } - /** * Determines Edge path based on supplied Urn, if not a valid entity type throws * IllegalArgumentException diff --git a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java index 563742990f5468..5bd10245899e41 100644 --- a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java +++ b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java @@ -8,6 +8,7 @@ import com.linkedin.common.GlossaryTermAssociation; import com.linkedin.common.OwnershipType; import com.linkedin.common.TagAssociation; +import com.linkedin.common.urn.ChartUrn; import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.DataJobUrn; import com.linkedin.common.urn.DataPlatformUrn; @@ -22,7 +23,9 @@ import datahub.client.MetadataWriteResponse; import datahub.client.file.FileEmitter; import datahub.client.file.FileEmitterConfig; +import datahub.client.patch.chart.ChartInfoPatchBuilder; import datahub.client.patch.common.OwnershipPatchBuilder; +import datahub.client.patch.dashboard.DashboardInfoPatchBuilder; import datahub.client.patch.dataflow.DataFlowInfoPatchBuilder; import datahub.client.patch.datajob.DataJobInfoPatchBuilder; import datahub.client.patch.datajob.DataJobInputOutputPatchBuilder; @@ -551,4 +554,90 @@ public void testLocalDataJobInputAddEdge() { System.out.println(Arrays.asList(e.getStackTrace())); } } + + @Test + @Ignore + public void testLocalChartInfoAdd() { + RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); + try { + MetadataChangeProposal chartInfoPatch = + new ChartInfoPatchBuilder() + .urn(UrnUtils.getUrn("urn:li:chart:(dashboardTool,chartId)")) + .addInputEdge( + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleHiveDataset,PROD)")) + .build(); + Future response = restEmitter.emit(chartInfoPatch); + + System.out.println(response.get().getResponseContent()); + + } catch (URISyntaxException | IOException | ExecutionException | InterruptedException e) { + System.out.println(Arrays.asList(e.getStackTrace())); + } + } + + @Test + @Ignore + public void testLocalChartInfoRemove() { + RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); + try { + MetadataChangeProposal chartInfoPatch = + new ChartInfoPatchBuilder() + .urn(UrnUtils.getUrn("urn:li:chart:(dashboardTool,chartId)")) + .removeInputEdge( + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleHiveDataset,PROD)")) + .build(); + Future response = restEmitter.emit(chartInfoPatch); + + System.out.println(response.get().getResponseContent()); + + } catch (URISyntaxException | IOException | ExecutionException | InterruptedException e) { + System.out.println(Arrays.asList(e.getStackTrace())); + } + } + + @Test + @Ignore + public void testLocalDashboardInfoAdd() { + RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); + try { + MetadataChangeProposal dashboardInfoPatch = + new DashboardInfoPatchBuilder() + .urn(UrnUtils.getUrn("urn:li:dashboard:(dashboardTool,dashboardId)")) + .addDatasetEdge( + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleHiveDataset,PROD)")) + .addChartEdge(ChartUrn.createFromString("urn:li:chart:(dashboartTool, chartId)")) + .build(); + Future response = restEmitter.emit(dashboardInfoPatch); + + System.out.println(response.get().getResponseContent()); + + } catch (URISyntaxException | IOException | ExecutionException | InterruptedException e) { + System.out.println(Arrays.asList(e.getStackTrace())); + } + } + + @Test + @Ignore + public void testLocalDashboardInfoRemove() { + RestEmitter restEmitter = new RestEmitter(RestEmitterConfig.builder().build()); + try { + MetadataChangeProposal dashboardInfoPatch = + new DashboardInfoPatchBuilder() + .urn(UrnUtils.getUrn("urn:li:dashboard:(dashboardTool,dashboardId)")) + .removeDatasetEdge( + DatasetUrn.createFromString( + "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleHiveDataset,PROD)")) + .removeChartEdge(ChartUrn.createFromString("urn:li:chart:(dashboardTool, chartId)")) + .build(); + Future response = restEmitter.emit(dashboardInfoPatch); + + System.out.println(response.get().getResponseContent()); + + } catch (URISyntaxException | IOException | ExecutionException | InterruptedException e) { + System.out.println(Arrays.asList(e.getStackTrace())); + } + } } From 296e41dfed325116c2a5661c32ae27790b28aafd Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Wed, 3 Jan 2024 15:58:50 -0600 Subject: [PATCH 100/540] feat(docker): docker compose profiles updates (#9514) Co-authored-by: Harshal Sheth --- docker/build.gradle | 7 ++++++- docker/profiles/README.md | 2 +- docker/profiles/docker-compose.actions.yml | 2 +- docker/profiles/docker-compose.frontend.yml | 4 ++-- docker/profiles/docker-compose.gms.yml | 16 ++++++++-------- .../profiles/docker-compose.prerequisites.yml | 18 +++++++++--------- docs/developers.md | 2 +- docs/how/updating-datahub.md | 3 ++- 8 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docker/build.gradle b/docker/build.gradle index 190202620c382c..189c4959e04429 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -31,6 +31,11 @@ ext { pg_quickstart_modules = quickstart_modules - [':docker:mysql-setup'] + [':docker:postgres-setup'] } +tasks.register('minDockerCompose2.20', Exec) { + executable 'bash' + args '-c', 'echo -e "$(docker compose version --short)\n2.20"|sort --version-sort --check=quiet --reverse' +} + tasks.register('quickstart') {} tasks.register('quickstartSlim') {} tasks.register('quickstartDebug') {} @@ -118,9 +123,9 @@ tasks.getByName('quickstartDebugComposeUp').dependsOn( ) tasks.withType(ComposeUp).configureEach { shouldRunAfter('quickstartNuke') + dependsOn tasks.named("minDockerCompose2.20") } - task debugReload(type: Exec) { def cmd = ['docker compose -p datahub --profile debug'] + compose_args + ['restart'] + debug_reloadable commandLine 'bash', '-c', cmd.join(" ") diff --git a/docker/profiles/README.md b/docker/profiles/README.md index df09f15cd85cee..fb3c9e3c84a7a2 100644 --- a/docker/profiles/README.md +++ b/docker/profiles/README.md @@ -5,7 +5,7 @@ for quickstart use-cases as well as development use-cases. These configurations infrastructure configurations that DataHub can operate on. Requirements: -* Use the profiles requires a modern version of docker. +* Using profiles requires docker compose >= 2.20. * If using the debug/development profiles, you will need to have built the `debug` docker images locally. See the Development Profiles section for more details. ```bash diff --git a/docker/profiles/docker-compose.actions.yml b/docker/profiles/docker-compose.actions.yml index a509a6a67d2705..676a72bae32018 100644 --- a/docker/profiles/docker-compose.actions.yml +++ b/docker/profiles/docker-compose.actions.yml @@ -1,7 +1,7 @@ x-datahub-actions-service: &datahub-actions-service hostname: actions - image: ${DATAHUB_ACTIONS_IMAGE:-acryldata/datahub-actions}:${ACTIONS_VERSION:-head} + image: ${DATAHUB_ACTIONS_IMAGE:-${DATAHUB_ACTIONS_REPO:-acryldata}/datahub-actions}:${ACTIONS_VERSION:-head} env_file: datahub-actions/env/docker.env environment: ACTIONS_EXTRA_PACKAGES: ${ACTIONS_EXTRA_PACKAGES:-} diff --git a/docker/profiles/docker-compose.frontend.yml b/docker/profiles/docker-compose.frontend.yml index 80cb4e7b4b596d..6e1bbc0be70f5c 100644 --- a/docker/profiles/docker-compose.frontend.yml +++ b/docker/profiles/docker-compose.frontend.yml @@ -1,7 +1,7 @@ x-datahub-frontend-service: &datahub-frontend-service hostname: datahub-frontend-react - image: ${DATAHUB_FRONTEND_IMAGE:-linkedin/datahub-frontend-react}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_FRONTEND_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-frontend-react}:${DATAHUB_VERSION:-head} ports: - ${DATAHUB_MAPPED_FRONTEND_PORT:-9002}:9002 env_file: datahub-frontend/env/docker.env @@ -12,7 +12,7 @@ x-datahub-frontend-service: &datahub-frontend-service x-datahub-frontend-service-dev: &datahub-frontend-service-dev <<: *datahub-frontend-service - image: linkedin/datahub-frontend-react:debug + image: ${DATAHUB_FRONTEND_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-frontend-react}:debug ports: - ${DATAHUB_MAPPED_FRONTEND_DEBUG_PORT:-5002}:5002 - ${DATAHUB_MAPPED_FRONTEND_PORT:-9002}:9002 diff --git a/docker/profiles/docker-compose.gms.yml b/docker/profiles/docker-compose.gms.yml index 01602c8b906b91..93072a76d4041b 100644 --- a/docker/profiles/docker-compose.gms.yml +++ b/docker/profiles/docker-compose.gms.yml @@ -54,7 +54,7 @@ x-datahub-dev-telemetry-env: &datahub-dev-telemetry-env ################################# x-datahub-system-update-service: &datahub-system-update-service hostname: datahub-system-update - image: ${DATAHUB_UPGRADE_IMAGE:-acryldata/datahub-upgrade}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_UPGRADE_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-upgrade}:${DATAHUB_VERSION:-head} command: - -u - SystemUpdate @@ -67,7 +67,7 @@ x-datahub-system-update-service: &datahub-system-update-service x-datahub-system-update-service-dev: &datahub-system-update-service-dev <<: *datahub-system-update-service - image: ${DATAHUB_UPGRADE_IMAGE:-acryldata/datahub-upgrade}:debug + image: ${DATAHUB_UPGRADE_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-upgrade}:debug ports: - ${DATAHUB_MAPPED_UPGRADE_DEBUG_PORT:-5003}:5003 environment: &datahub-system-update-dev-env @@ -85,7 +85,7 @@ x-datahub-system-update-service-dev: &datahub-system-update-service-dev ################################# x-datahub-gms-service: &datahub-gms-service hostname: datahub-gms - image: ${DATAHUB_GMS_IMAGE:-linkedin/datahub-gms}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_GMS_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-gms}:${DATAHUB_VERSION:-head} ports: - ${DATAHUB_MAPPED_GMS_PORT:-8080}:8080 env_file: datahub-gms/env/docker.env @@ -102,7 +102,7 @@ x-datahub-gms-service: &datahub-gms-service x-datahub-gms-service-dev: &datahub-gms-service-dev <<: *datahub-gms-service - image: ${DATAHUB_GMS_IMAGE:-linkedin/datahub-gms}:debug + image: ${DATAHUB_GMS_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-gms}:debug ports: - ${DATAHUB_MAPPED_GMS_DEBUG_PORT:-5001}:5001 - ${DATAHUB_MAPPED_GMS_PORT:-8080}:8080 @@ -128,7 +128,7 @@ x-datahub-gms-service-dev: &datahub-gms-service-dev ################################# x-datahub-mae-consumer-service: &datahub-mae-consumer-service hostname: datahub-mae-consumer - image: ${DATAHUB_MAE_CONSUMER_IMAGE:-linkedin/datahub-mae-consumer}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_MAE_CONSUMER_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-mae-consumer}:${DATAHUB_VERSION:-head} ports: - 9091:9091 env_file: datahub-mae-consumer/env/docker.env @@ -137,7 +137,7 @@ x-datahub-mae-consumer-service: &datahub-mae-consumer-service x-datahub-mae-consumer-service-dev: &datahub-mae-consumer-service-dev <<: *datahub-mae-consumer-service - image: ${DATAHUB_MAE_CONSUMER_IMAGE:-linkedin/datahub-mae-consumer}:debug + image: ${DATAHUB_MAE_CONSUMER_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-mae-consumer}:debug environment: <<: [*datahub-dev-telemetry-env, *datahub-mae-consumer-env] volumes: @@ -151,7 +151,7 @@ x-datahub-mae-consumer-service-dev: &datahub-mae-consumer-service-dev ################################# x-datahub-mce-consumer-service: &datahub-mce-consumer-service hostname: datahub-mce-consumer - image: ${DATAHUB_MCE_CONSUMER_IMAGE:-linkedin/datahub-mce-consumer}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_MCE_CONSUMER_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-mce-consumer}:${DATAHUB_VERSION:-head} ports: - 9090:9090 env_file: datahub-mce-consumer/env/docker.env @@ -160,7 +160,7 @@ x-datahub-mce-consumer-service: &datahub-mce-consumer-service x-datahub-mce-consumer-service-dev: &datahub-mce-consumer-service-dev <<: *datahub-mce-consumer-service - image: ${DATAHUB_MCE_CONSUMER_IMAGE:-linkedin/datahub-mce-consumer}:debug + image: ${DATAHUB_MCE_CONSUMER_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-mce-consumer}:debug environment: <<: [*datahub-dev-telemetry-env, *datahub-mce-consumer-env] volumes: diff --git a/docker/profiles/docker-compose.prerequisites.yml b/docker/profiles/docker-compose.prerequisites.yml index d90d4a252f9935..232239c6c70d08 100644 --- a/docker/profiles/docker-compose.prerequisites.yml +++ b/docker/profiles/docker-compose.prerequisites.yml @@ -128,7 +128,7 @@ services: container_name: mysql-setup profiles: *mysql-profiles-quickstart hostname: mysql-setup - image: ${DATAHUB_MYSQL_SETUP_IMAGE:-acryldata/datahub-mysql-setup}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_MYSQL_SETUP_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-mysql-setup}:${DATAHUB_VERSION:-head} env_file: mysql-setup/env/docker.env depends_on: mysql: @@ -139,7 +139,7 @@ services: <<: *mysql-setup container_name: mysql-setup-dev profiles: *mysql-profiles-dev - image: ${DATAHUB_MYSQL_SETUP_IMAGE:-acryldata/datahub-mysql-setup}:debug + image: ${DATAHUB_MYSQL_SETUP_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-mysql-setup}:debug postgres: container_name: postgres profiles: *postgres-profiles @@ -162,7 +162,7 @@ services: container_name: postgres-setup profiles: *postgres-profiles-quickstart hostname: postgres-setup - image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-acryldata/datahub-postgres-setup}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-postgres-setup}:${DATAHUB_VERSION:-head} env_file: postgres-setup/env/docker.env depends_on: postgres: @@ -173,7 +173,7 @@ services: <<: *postgres-setup container_name: postgres-setup-dev profiles: *postgres-profiles-dev - image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-acryldata/datahub-postgres-setup}:debug + image: ${DATAHUB_POSTGRES_SETUP_IMAGE:-${DATAHUB_REPO:-acryldata}/datahub-postgres-setup}:debug cassandra: container_name: cassandra profiles: *cassandra-profiles @@ -267,7 +267,7 @@ services: container_name: kafka-setup profiles: *profiles-quickstart hostname: kafka-setup - image: ${DATAHUB_KAFKA_SETUP_IMAGE:-linkedin/datahub-kafka-setup}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_KAFKA_SETUP_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-kafka-setup}:${DATAHUB_VERSION:-head} env_file: kafka-setup/env/docker.env environment: &kafka-setup-env DATAHUB_PRECREATE_TOPICS: ${DATAHUB_PRECREATE_TOPICS:-false} @@ -285,7 +285,7 @@ services: environment: <<: *kafka-setup-env DATAHUB_PRECREATE_TOPICS: ${DATAHUB_PRECREATE_TOPICS:-true} - image: ${DATAHUB_KAFKA_SETUP_IMAGE:-linkedin/datahub-kafka-setup}:debug + image: ${DATAHUB_KAFKA_SETUP_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-kafka-setup}:debug elasticsearch: container_name: elasticsearch profiles: *elasticsearch-profiles @@ -311,7 +311,7 @@ services: - esdata:/usr/share/elasticsearch/data elasticsearch-setup-dev: &elasticsearch-setup-dev container_name: elasticsearch-setup-dev - image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:debug + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-elasticsearch-setup}:debug profiles: *elasticsearch-profiles hostname: elasticsearch-setup env_file: elasticsearch-setup/env/docker.env @@ -351,7 +351,7 @@ services: container_name: opensearch-setup profiles: *opensearch-profiles-quickstart hostname: opensearch-setup - image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:${DATAHUB_VERSION:-head} + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-elasticsearch-setup}:${DATAHUB_VERSION:-head} environment: <<: *search-datastore-environment USE_AWS_ELASTICSEARCH: ${USE_AWS_ELASTICSEARCH:-true} @@ -365,7 +365,7 @@ services: container_name: opensearch-setup-dev profiles: *opensearch-profiles-dev hostname: opensearch-setup-dev - image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-linkedin/datahub-elasticsearch-setup}:debug + image: ${DATAHUB_ELASTIC_SETUP_IMAGE:-${DATAHUB_REPO:-linkedin}/datahub-elasticsearch-setup}:debug environment: <<: *search-datastore-environment USE_AWS_ELASTICSEARCH: ${USE_AWS_ELASTICSEARCH:-true} diff --git a/docs/developers.md b/docs/developers.md index fe007a56ddc68f..4e31aceeb43821 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -9,7 +9,7 @@ title: "Local Development" - [Java 17 JDK](https://openjdk.org/projects/jdk/17/) - [Python 3.10](https://www.python.org/downloads/release/python-3100/) - [Docker](https://www.docker.com/) -- [Docker Compose](https://docs.docker.com/compose/) +- [Docker Compose >=2.20](https://docs.docker.com/compose/) - Docker engine with at least 8GB of memory to run tests. ::: diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index 61ad2d623d72a4..fb082bea7d1517 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -8,7 +8,8 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - Updating MySQL version for quickstarts to 8.2, may cause quickstart issues for existing instances. - Neo4j 5.x, may require migration from 4.x -- Build now requires JDK17 (Runtime Java 11) +- Build requires JDK17 (Runtime Java 11) +- Build requires Docker Compose > 2.20 ### Potential Downtime From 424057862790b520e6d6e7d9d0a04f52aa46e500 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 3 Jan 2024 17:16:16 -0500 Subject: [PATCH 101/540] feat(ui): switch to vite and vitest (#9451) --- .github/workflows/metadata-io.yml | 3 - .github/workflows/spark-smoke-test.yml | 3 +- build.gradle | 4 + datahub-frontend/build.gradle | 18 - datahub-frontend/conf/routes | 11 +- datahub-web-react/.env | 4 +- datahub-web-react/.eslintrc.js | 3 +- datahub-web-react/build.gradle | 66 +- datahub-web-react/craco.config.js | 75 - datahub-web-react/datahub-frontend.graphql | 389 - datahub-web-react/{public => }/index.html | 14 +- datahub-web-react/package.json | 59 +- .../public/{ => assets}/favicon.ico | Bin .../public/{ => assets}/logo.png | Bin datahub-web-react/public/manifest.json | 2 +- datahub-web-react/src/App.less | 5 +- datahub-web-react/src/App.test.tsx | 15 +- datahub-web-react/src/App.tsx | 35 +- datahub-web-react/src/Mocks.tsx | 12 + datahub-web-react/src/app/Routes.tsx | 4 +- .../src/app/analytics/analytics.ts | 2 +- .../src/app/domain/DomainIcon.tsx | 2 +- .../src/app/entity/dataJob/tabs/RunsTab.tsx | 2 +- .../entity/dataset/profile/OperationsTab.tsx | 2 +- .../dataset/profile/__tests__/Schema.test.tsx | 70 +- .../__tests__/SchemaDescriptionField.test.tsx | 4 +- .../__tests__/PlatformContent.test.tsx | 6 +- .../embed/UpstreamHealth/FailingEntity.tsx | 2 +- .../embed/UpstreamHealth/UpstreamHealth.tsx | 2 +- .../__tests__/DocumentationTab.test.tsx | 8 +- .../editor/__tests__/Editor.test.tsx | 2 +- .../Entity/__tests__/DataJobFlowTab.test.tsx | 6 +- .../entity/user/__tests__/UserHeader.test.tsx | 11 - .../ingest/source/builder/RecipeBuilder.tsx | 8 +- .../source/builder/RecipeForm/FormField.tsx | 8 +- .../source/builder/RecipeForm/RecipeForm.tsx | 8 +- .../RecipeForm/SecretField/SecretField.tsx | 16 +- .../TestConnection/TestConnectionModal.tsx | 2 +- .../app/ingest/source/builder/YamlEditor.tsx | 3 +- .../lineage/__tests__/LineageEdges.test.tsx | 22 +- .../__tests__/LineageEntityView.test.tsx | 2 +- .../lineage/__tests__/LineageTree.test.tsx | 12 +- .../policy/_tests_/policyUtils.test.tsx | 175 +- .../src/app/preview/DefaultPreviewCard.tsx | 4 +- .../__tests__/Recommendations.test.tsx | 1 + .../src/app/search/ToggleSidebarButton.tsx | 4 +- .../__tests__/FilterRendererRegistry.test.tsx | 6 +- .../src/app/search/filters/utils.tsx | 2 +- .../src/app/search/sidebar/EntityLink.tsx | 2 +- .../app/search/sorting/SearchSortSelect.tsx | 2 +- datahub-web-react/src/conf/Global.ts | 1 - .../src/conf/theme/global-variables.less | 26 +- .../src/graphql-mock/createServer.ts | 12 - datahub-web-react/src/graphql-mock/server.ts | 84 - datahub-web-react/src/index.tsx | 3 +- datahub-web-react/src/react-app-env.d.ts | 1 - datahub-web-react/src/setupProxy.js | 37 - datahub-web-react/src/setupTests.ts | 21 +- .../utils/test-utils/TestPageContainer.tsx | 2 +- datahub-web-react/src/vite-env.d.ts | 2 + datahub-web-react/tsconfig.json | 5 +- datahub-web-react/vite.config.ts | 100 + datahub-web-react/yarn.lock | 8860 +++-------------- smoke-test/tests/cypress/package-lock.json | 2031 ---- .../tests/read_only/test_services_up.py | 2 +- 65 files changed, 1905 insertions(+), 10400 deletions(-) delete mode 100644 datahub-web-react/craco.config.js delete mode 100644 datahub-web-react/datahub-frontend.graphql rename datahub-web-react/{public => }/index.html (66%) rename datahub-web-react/public/{ => assets}/favicon.ico (100%) rename datahub-web-react/public/{ => assets}/logo.png (100%) delete mode 100644 datahub-web-react/src/graphql-mock/createServer.ts delete mode 100644 datahub-web-react/src/graphql-mock/server.ts delete mode 100644 datahub-web-react/src/react-app-env.d.ts delete mode 100644 datahub-web-react/src/setupProxy.js create mode 100644 datahub-web-react/src/vite-env.d.ts create mode 100644 datahub-web-react/vite.config.ts delete mode 100644 smoke-test/tests/cypress/package-lock.json diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index 96229642244b67..c964352c3e129a 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -40,9 +40,6 @@ jobs: python-version: "3.10" cache: "pip" - name: Gradle build (and test) - # there is some race condition in gradle build, which makes gradle never terminate in ~30% of the runs - # running build first without datahub-web-react:yarnBuild and then with it is 100% stable - # datahub-frontend:unzipAssets depends on datahub-web-react:yarnBuild but gradle does not know about it run: | ./gradlew :metadata-io:test - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/spark-smoke-test.yml b/.github/workflows/spark-smoke-test.yml index 94692bd3c2336a..bd99905a513d67 100644 --- a/.github/workflows/spark-smoke-test.yml +++ b/.github/workflows/spark-smoke-test.yml @@ -51,8 +51,7 @@ jobs: -x :datahub-web-react:yarnLint \ -x :datahub-web-react:yarnGenerate \ -x :datahub-web-react:yarnInstall \ - -x :datahub-web-react:yarnQuickBuild \ - -x :datahub-web-react:copyAssets \ + -x :datahub-web-react:yarnBuild \ -x :datahub-web-react:distZip \ -x :datahub-web-react:jar - uses: actions/upload-artifact@v3 diff --git a/build.gradle b/build.gradle index bb01a15a7db8d6..4680598165d285 100644 --- a/build.gradle +++ b/build.gradle @@ -325,6 +325,10 @@ subprojects { } plugins.withType(JavaPlugin).configureEach { + if (project.name == 'datahub-web-react') { + return + } + dependencies { implementation externalDependency.annotationApi constraints { diff --git a/datahub-frontend/build.gradle b/datahub-frontend/build.gradle index 437c72e6394ea6..1174c5c5cfd5d1 100644 --- a/datahub-frontend/build.gradle +++ b/datahub-frontend/build.gradle @@ -1,5 +1,4 @@ plugins { - id "io.github.kobylynskyi.graphql.codegen" version "4.1.1" id 'scala' id 'com.palantir.docker' id 'org.gradle.playframework' @@ -39,23 +38,6 @@ artifacts { archives myTar } -graphqlCodegen { - // For options: https://github.com/kobylynskyi/graphql-java-codegen/blob/master/docs/codegen-options.md - graphqlSchemaPaths = ["$projectDir/conf/datahub-frontend.graphql".toString()] - outputDir = new File("$projectDir/app/graphql") - packageName = "generated" - generateApis = true - modelValidationAnnotation = "" - customTypesMapping = [ - Long: "Long", - ] -} - -tasks.withType(Checkstyle) { - exclude "**/generated/**" -} - - /* PLAY UPGRADE NOTE Generates the distribution jars under the expected names. The playFramework plugin only accepts certain name values diff --git a/datahub-frontend/conf/routes b/datahub-frontend/conf/routes index 3102c26497fedd..6b53a2789e7ccc 100644 --- a/datahub-frontend/conf/routes +++ b/datahub-frontend/conf/routes @@ -36,11 +36,14 @@ PUT /openapi/*path c HEAD /openapi/*path controllers.Application.proxy(path: String, request: Request) PATCH /openapi/*path controllers.Application.proxy(path: String, request: Request) -# Map static resources from the /public folder to the /assets URL path -GET /assets/*file controllers.Assets.at(path="/public", file) - # Analytics route POST /track controllers.TrackingController.track(request: Request) -# Wildcard route accepts any routes and delegates to serveAsset which in turn serves the React Bundle +# Known React asset routes +GET /assets/*file controllers.Assets.at(path="/public/assets", file) +GET /node_modules/*file controllers.Assets.at(path="/public/node_modules", file) +GET /manifest.json controllers.Assets.at(path="/public", file="manifest.json") +GET /robots.txt controllers.Assets.at(path="/public", file="robots.txt") + +# Wildcard route accepts any routes and delegates to serveAsset which in turn serves the React Bundle's index.html GET /*path controllers.Application.index(path) diff --git a/datahub-web-react/.env b/datahub-web-react/.env index e5529bbdaa56da..7c02340752104b 100644 --- a/datahub-web-react/.env +++ b/datahub-web-react/.env @@ -1,5 +1,3 @@ -PUBLIC_URL=/assets REACT_APP_THEME_CONFIG=theme_light.config.json SKIP_PREFLIGHT_CHECK=true -BUILD_PATH=build/yarn -REACT_APP_PROXY_TARGET=http://localhost:9002 \ No newline at end of file +REACT_APP_PROXY_TARGET=http://localhost:9002 diff --git a/datahub-web-react/.eslintrc.js b/datahub-web-react/.eslintrc.js index 2806942dd10531..e48dfdb23a4e79 100644 --- a/datahub-web-react/.eslintrc.js +++ b/datahub-web-react/.eslintrc.js @@ -5,7 +5,7 @@ module.exports = { 'airbnb-typescript', 'airbnb/hooks', 'plugin:@typescript-eslint/recommended', - 'plugin:jest/recommended', + 'plugin:vitest/recommended', 'prettier', ], plugins: ['@typescript-eslint'], @@ -46,6 +46,7 @@ module.exports = { argsIgnorePattern: '^_', }, ], + 'vitest/prefer-to-be': 'off', }, settings: { react: { diff --git a/datahub-web-react/build.gradle b/datahub-web-react/build.gradle index 72821d8b97dc0b..c0355b935137a4 100644 --- a/datahub-web-react/build.gradle +++ b/datahub-web-react/build.gradle @@ -19,7 +19,7 @@ node { version = '21.2.0' // Version of Yarn to use. - yarnVersion = '1.22.1' + yarnVersion = '1.22.21' // Base URL for fetching node distributions (set nodeDistBaseUrl if you have a mirror). if (project.hasProperty('nodeDistBaseUrl')) { @@ -44,10 +44,33 @@ node { */ task yarnInstall(type: YarnTask) { args = ['install'] + + // The node_modules directory can contain built artifacts, so + // it's not really safe to cache it. + outputs.cacheIf { false } + + inputs.files( + file('yarn.lock'), + file('package.json'), + ) + outputs.dir('node_modules') } task yarnGenerate(type: YarnTask, dependsOn: yarnInstall) { args = ['run', 'generate'] + + outputs.cacheIf { true } + + inputs.files( + yarnInstall.inputs.files, + file('codegen.yml'), + project.fileTree(dir: "../datahub-graphql-core/src/main/resources/", include: "*.graphql"), + project.fileTree(dir: "src", include: "**/*.graphql"), + ) + + outputs.files( + project.fileTree(dir: "src", include: "**/*.generated.ts"), + ) } task yarnServe(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { @@ -55,7 +78,8 @@ task yarnServe(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { } task yarnTest(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { - args = ['run', 'test', '--watchAll', 'false'] + // Explicitly runs in non-watch mode. + args = ['run', 'test', 'run'] } task yarnLint(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { @@ -68,13 +92,24 @@ task yarnLintFix(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { args = ['run', 'lint-fix'] } -task yarnBuild(type: YarnTask, dependsOn: [yarnInstall, yarnTest, yarnLint]) { - args = ['run', 'build'] -} - -task yarnQuickBuild(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { +task yarnBuild(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) { environment = [NODE_OPTIONS: "--max-old-space-size=3072 --openssl-legacy-provider"] args = ['run', 'build'] + + outputs.cacheIf { true } + inputs.files( + file('index.html'), + project.fileTree(dir: "src"), + project.fileTree(dir: "public"), + + yarnInstall.inputs.files, + yarnGenerate.outputs.files, + + file('.env'), + file('vite.config.ts'), + file('tsconfig.json'), + ) + outputs.dir('dist') } task cleanExtraDirs { @@ -82,9 +117,8 @@ task cleanExtraDirs { delete 'dist' delete 'tmp' delete 'just' - delete 'src/types.generated.ts' delete fileTree('../datahub-frontend/public') - delete fileTree(dir: 'src/graphql', include: '*.generated.ts') + delete fileTree(dir: 'src', include: '*.generated.ts') } clean.finalizedBy(cleanExtraDirs) @@ -93,24 +127,16 @@ configurations { } distZip { - dependsOn yarnQuickBuild + dependsOn yarnBuild archiveFileName = "datahub-web-react-${archiveVersion}.${archiveExtension}" from 'dist' } -task copyAssets(dependsOn: distZip) { - doLast { - copy { - from zipTree(distZip.outputs.files.first()) - into "../datahub-frontend/public" - } - } -} - jar { - dependsOn distZip, copyAssets + dependsOn distZip into('public') { from zipTree(distZip.outputs.files.first()) } archiveClassifier = 'assets' } +build.dependsOn jar diff --git a/datahub-web-react/craco.config.js b/datahub-web-react/craco.config.js deleted file mode 100644 index 6ede45902128f5..00000000000000 --- a/datahub-web-react/craco.config.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -require('dotenv').config(); -const { whenProd } = require('@craco/craco'); -const CracoAntDesignPlugin = require('craco-antd'); -const path = require('path'); -const CopyWebpackPlugin = require('copy-webpack-plugin'); - -// eslint-disable-next-line import/no-dynamic-require -const themeConfig = require(`./src/conf/theme/${process.env.REACT_APP_THEME_CONFIG}`); - -function addLessPrefixToKeys(styles) { - const output = {}; - Object.keys(styles).forEach((key) => { - output[`@${key}`] = styles[key]; - }); - return output; -} - -module.exports = { - webpack: { - configure: { - optimization: whenProd(() => ({ - splitChunks: { - cacheGroups: { - vendor: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - chunks: 'all', - }, - }, - }, - })), - // Webpack 5 no longer automatically pollyfill core Node.js modules - resolve: { fallback: { fs: false } }, - // Ignore Webpack 5's missing source map warnings from node_modules - ignoreWarnings: [{ module: /node_modules/, message: /source-map-loader/ }], - }, - plugins: { - add: [ - // Self host images by copying them to the build directory - new CopyWebpackPlugin({ - patterns: [{ from: 'src/images', to: 'platforms' }], - }), - // Copy monaco-editor files to the build directory - new CopyWebpackPlugin({ - patterns: [ - { from: 'node_modules/monaco-editor/min/vs/', to: 'monaco-editor/vs' }, - { from: 'node_modules/monaco-editor/min-maps/vs/', to: 'monaco-editor/min-maps/vs' }, - ], - }), - ], - }, - }, - plugins: [ - { - plugin: CracoAntDesignPlugin, - options: { - customizeThemeLessPath: path.join(__dirname, 'src/conf/theme/global-variables.less'), - customizeTheme: addLessPrefixToKeys(themeConfig.styles), - }, - }, - ], - jest: { - configure: { - // Use dist files instead of source files - moduleNameMapper: { - '^d3-interpolate-path': `d3-interpolate-path/build/d3-interpolate-path`, - '^d3-(.*)$': `d3-$1/dist/d3-$1`, - '^lib0/((?!dist).*)$': 'lib0/dist/$1.cjs', - '^y-protocols/(.*)$': 'y-protocols/dist/$1.cjs', - '\\.(css|less)$': '/src/__mocks__/styleMock.js', - }, - }, - }, -}; diff --git a/datahub-web-react/datahub-frontend.graphql b/datahub-web-react/datahub-frontend.graphql deleted file mode 100644 index 6df3c387e14fe7..00000000000000 --- a/datahub-web-react/datahub-frontend.graphql +++ /dev/null @@ -1,389 +0,0 @@ -scalar Long - -schema { - query: Query - mutation: Mutation -} - -type Query { - dataset(urn: String!): Dataset - user(urn: String!): CorpUser - search(input: SearchInput!): SearchResults - autoComplete(input: AutoCompleteInput!): AutoCompleteResults - browse(input: BrowseInput!): BrowseResults - browsePaths(input: BrowsePathsInput!): [[String!]!] -} - -type Mutation { - logIn(username: String!, password: String!): CorpUser - updateDataset(input: DatasetUpdateInput!): Dataset -} - -input DatasetUpdateInput { - urn: String! - ownership: OwnershipUpdate -} - -input OwnershipUpdate { - owners: [OwnerUpdate!] -} - -input OwnerUpdate { - # The owner URN, eg urn:li:corpuser:1 - owner: String! - - # The owner role type - type: OwnershipType! -} - -enum OwnershipSourceType { - AUDIT - DATABASE - FILE_SYSTEM - ISSUE_TRACKING_SYSTEM - MANUAL - SERVICE - SOURCE_CONTROL - OTHER -} - -type OwnershipSource { - """ - The type of the source - """ - type: OwnershipSourceType! - - """ - A reference URL for the source - """ - url: String -} - -enum OwnershipType { - """ - A person or group that is in charge of developing the code - """ - DEVELOPER - - """ - A person or group that is owning the data - """ - DATAOWNER - - """ - A person or a group that overseas the operation, e.g. a DBA or SRE. - """ - DELEGATE - - """ - A person, group, or service that produces/generates the data - """ - PRODUCER - - """ - A person, group, or service that consumes the data - """ - CONSUMER - - """ - A person or a group that has direct business interest - """ - STAKEHOLDER -} - -type Owner { - """ - Owner object - """ - owner: CorpUser! - - """ - The type of the ownership - """ - type: OwnershipType - - """ - Source information for the ownership - """ - source: OwnershipSource -} - -type Ownership { - owners: [Owner!] - - lastModified: Long! -} - -enum FabricType { - """ - Designates development fabrics - """ - DEV - - """ - Designates early-integration (staging) fabrics - """ - EI - - """ - Designates production fabrics - """ - PROD - - """ - Designates corporation fabrics - """ - CORP -} - -enum PlatformNativeType { - """ - Table - """ - TABLE - - """ - View - """ - VIEW - - """ - Directory in file system - """ - DIRECTORY - - """ - Stream - """ - STREAM - - """ - Bucket in key value store - """ - BUCKET -} - -type PropertyTuple { - key: String! - value: String -} - -type SubTypes { - typeNames: [String!] -} - -type Dataset { - urn: String! - - platform: String! - - name: String! - - origin: FabricType! - - description: String - - uri: String - - platformNativeType: PlatformNativeType - - tags: [String!]! - - properties: [PropertyTuple!] - - createdTime: Long! - - modifiedTime: Long! - - ownership: Ownership - - subTypes: SubTypes -} - -type CorpUserInfo { - active: Boolean! - - displayName: String - - email: String! - - title: String - - manager: CorpUser - - departmentId: Long - - departmentName: String - - firstName: String - - lastName: String - - fullName: String - - countryCode: String -} - -type CorpUserEditableInfo { - aboutMe: String - - teams: [String!] - - skills: [String!] - - pictureLink: String -} - -type CorpUser { - urn: String! - - username: String! - - info: CorpUserInfo - - editableInfo: CorpUserEditableInfo -} - -type CorpGroup implements Entity { - """ - The unique user URN - """ - urn: String! - - """ - GMS Entity Type - """ - type: EntityType! - - """ - group name e.g. wherehows-dev, ask_metadata - """ - name: String - - """ - Information of the corp group - """ - info: CorpGroupInfo -} - - -type CorpGroupInfo { - """ - email of this group - """ - email: String! - - """ - owners of this group - """ - admins: [String!]! - - """ - List of ldap urn in this group. - """ - members: [String!]! - - """ - List of groups in this group. - """ - groups: [String!]! -} - -enum EntityType { - DATASET - USER - DATA_FLOW - DATA_JOB - CORP_USER - CORP_GROUP -} - -# Search Input -input SearchInput { - type: EntityType! - query: String! - start: Int - count: Int - filters: [FacetFilterInput!] -} - -input FacetFilterInput { - field: String! # Facet Field Name - value: String! # Facet Value -} - -# Search Output -type SearchResults { - start: Int! - count: Int! - total: Int! - elements: [SearchResult!]! - facets: [FacetMetadata!] -} - -union SearchResult = Dataset | CorpUser - -type FacetMetadata { - field: String! - aggregations: [AggregationMetadata!]! -} - -type AggregationMetadata { - value: String! - count: Long! -} - -# Autocomplete Input -input AutoCompleteInput { - type: EntityType! - query: String! - field: String # Field name - limit: Int - filters: [FacetFilterInput!] -} - -# Autocomplete Output -type AutoCompleteResults { - query: String! - suggestions: [String!]! -} - -# Browse Inputs -input BrowseInput { - type: EntityType! - path: [String!] - start: Int - count: Int - filters: [FacetFilterInput!] -} - -# Browse Output -type BrowseResults { - entities: [BrowseResultEntity!]! - start: Int! - count: Int! - total: Int! - metadata: BrowseResultMetadata! -} - -type BrowseResultEntity { - name: String! - urn: String! -} - -type BrowseResultMetadata { - path: [String!] - groups: [BrowseResultGroup!]! - totalNumEntities: Long! -} - -type BrowseResultGroup { - name: String! - count: Long! -} - -# Browse Paths Input -input BrowsePathsInput { - type: EntityType! - urn: String! -} diff --git a/datahub-web-react/public/index.html b/datahub-web-react/index.html similarity index 66% rename from datahub-web-react/public/index.html rename to datahub-web-react/index.html index ead3a0aba82cb9..9490881246e122 100644 --- a/datahub-web-react/public/index.html +++ b/datahub-web-react/index.html @@ -2,7 +2,7 @@ - + @@ -10,21 +10,13 @@ manifest.json provides metadata used when your web app is installed on a user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> - - + DataHub
+ {YZsV6YUR2!Z;~3eE%z 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 ssQSHO8sP_m)j(y$Hx4^iyZ;{X5v literal 60888 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 *EW!nr}QXr_{)h9}Zd5 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 literal 148827 zcmY)0byO68{OEr=MCp`R1f?ZJIu-;(T12`*8U;Z*mIi4BL2{80k#106>5%S}hNWT2 zWoxdV@9%f-J@=0}?{nslIqyH_oSE}@J!d|?)K$NC=l&f$JiL1vFH~RQ;o+zLcaV|% zI}-+z9Qn84d%seDhF3MlwvUIWgr}jZ{MsM?sGXwe;qOmwrc*y#uTldG74l2_`(umM zzps)B>&2u!CA1Np+S)Vrxv&OKfeQc&hcE+^m(`GfEM6_6;?uj^;BH*`!gi>=C%4q% zh~c{$IeX(B>WxD>Z`0y!7`;JA9VmPr_zDVR*n-VW_sj#fR29#vWH18|S2n$$bz&$v zgIfjIPQPu36Z(1m_tTjtX9T_zQCC9&tGE={BOS>3CNSsVQC1P_bUkFP!uk#y7}Ku7vYFQNHckbU6( zi(?k%m6D(_gHWe)Qga|VMBY>_C6k45>I9m8(YLyu)UnPB#@A}-N4!~-GGHPouIw;a z%=POFc6QRY9;cu60C=%T))NmbOnr*Arq_B66rdgKWVAE)%ZEenM`QC=Qr|N8bWk^d z>m;a!7<^|n75|Svj2CiMeILE*p$mw4%fC4Ez}WB;tvAJ?xsh> zO6ZIitHB)irvwVB$HMa zv1PZQHvHEIZ9;nOD-ONByvPL;0GZF^MVb(d1`VtR>H@FUcF#Flq{a%(_LTpQO7IG>lTP>_3sK*5J!}sf1rDVZ@`=aI#$@D#rP-N0kPR*FF_;nlRz9W2bLH& zHIcic7#)CDgaBR7%(NMJg*f4_^J2H&6n*%jzq;cKd3j>?+f*#%PUVU5@)g%+V3j@; zF(v*0aR^&I^EW8dd)ptX?oVh18N2=X6y{H{wLg<2$t@%p^FANvkIrHm>q5mp0{%kvA@V7 z$pn3AiEaoop_RJhhYKy0oaaauqq1-n#JK(zrpnhCORcf_z~rZsVHJ;E zXc+%|2*;6hF~acVe3$dHtu_%IHKqenCHZ65c@ivG2Le>amap>%9K@}gA$+wz4XT`$ zAuac&uREJ@30lM>>=r-M3LIsXcaa_*`9^Emfo$iDqT>Crbro0x_0&FJs;XAp@gpZ2 ziAd}{(HrJG-M%C1!)gfqn7MoFSxO|UO2HOVWIhIpb1U1!SZO+uKX7O@;JJxCk@NNI z9n2G-LfMprk9>MoBOyz0D{ zjOWQzl{?CR%JMl6&S@OEn3U?oNl;}*xo8oppQYYP?))ALSK4%srPDB=ER|>&XG*Tn z`b8_sg{aJ9HNdlWH$CWyoAsrh(m)Am`xQ3!--VW9{c^EBKt;A~^tO_>@z+WV`Em6=Em&wagsCRgaHd|trD<-cW^nPn zP;2w$seOdDvPWl$V0By;Va(*cAt>%kYmpg}^CY|0Q_L!Q=Sd(k@8yq6;S;QB`JD_3 z%TJ#LQZIP#E3F=OT0~uRLR_Bz;@jL`6YL}7W<~G$s7#>BrnI?u_{~R~AtvZ3Fv}eW z%fMR$BVEEl66rd5%kq4q%tiOU(c$O*$DMV}N=O^FI*&1RIc#l(*WL65xWbcPPbx&# z&ga5s0)_Bhij6Q2r~ys)(J9Od;IEGNDgBZ_DF5YMD#e2IaZP;NsYv>v!>y06n|9W4 z#&m2J{bh#Xvk&1C=Ms3=YZ}sSbiTaQPLXHqg0*D#9Fo>mnR;H4jH*}TJ0*gc6`4aM z(Oj9aFQ#I9V|Y3vPJ(MbEg(yX5bEFS}tM6p9)H^C4(>UQ}9PKDH)o2mx(_mMd6p%-=YAPAAUO zbCQI-X6!v%56@uS)*BgpI6578fj1W2Ej6H)TQd>fp+hJu6T-=#8UfNvKS};;#+T;Wx5hM%xRH~SJ?V&eC>e@Gl^;M+~l^27xKQ!C? zwJLcX2_X_NyqVFvH~pgSE2Pr0e$N{s%=QQk{o#2H4t=k8i>$4BQRR?2&#xyMY3M4+ z`E3p72jm3ZeYD-Sj#=J(;#e*H^WxZ`f@s=ORyt@i0sU2Fc-zzHJ;`F4qIDcJyF50| zVV88pE}NW6EA26h^^ya~;zt5IsgQg!;JV7k@x79BUd82?aCS#I`pGz_O%w+aqFYA&fDKl_&azDW;n-%af>rB;=7033_}pC z3J25ydB|6do_!GN%74T}9G+Z>TLQBA2OF1Q5>Q;qo40z)LqToT;)ywN+En2Ru$5v=QDI z5dOxi;-v@E8h%Nrtjg2iUWN7*AoKKkFUIp)=oW4og(Nx7(!9iUSs(0@11P2t6I)0qh5}S6_9MbuBhVxVC}%9<*XdgWh{U*evT4GFcCikW2vH{tt+`dS18IkA!sVc zkbBFg3>w~7&fg4Wu0*NsZiKMiE~}WZF5EQN-TR$>r{A8U4pM9};D7L#Mxlwff&PLn zQBo3DC}?w@S%Y2C9#NbKMd=-tqaVXMq<}@(Vsr3_UkWMaCX@OusZ;pf+hOsuCCjB(5$t~8A(BfaQyz0u-@ zT5HzbpR%p79uxQ}KB6Qjvd{68i=?QuWG)ArP%qIbes^?gY5n6bCy|7SUV$rC?r!Kb znE19RHiNBW-YJ=B${00{OUnZ;-T#yVaj*Z-Lzy;FDsOQuMou-!rAqmXXWZ>BEaO_d zH#mxc^ZYItRu8=oJ^J;e^7MCOarM`P^fbcRTT zoCm)Ty17EsW}w+hLbBZ{!8;{upDk2!PU+^pXHK$KC(gdVaWW?pauap;j_5Ptl}IxD z5)0XlblviIIW}}Rp_O)kO^oDfA@8G~b!YljN(<$tO_$L@;UQu~Fh zRO>PPh6jaf2`}Yt-CI+Rn0T?L)3Hpg zufuY~2XqZ`>?>L&b_~eBrmc^UogH+-`r2fl|Ac2VRw_x@%UwOzwaArtBTHqsdsawo z1FtB0*C)tvTjGg&D!g#zaXk$zkIe9ntV$qYb$MCL9zeP|Q0A%>gz~^`$=F?;>VW3b ztK?r&VExT-uQgYhzOYH#H(oK|Dd48VA!4r&@)%=J<@h}?wxy>ER>$6^qe-JTyOA}e zhkcFN5Bp+2wRD{fVfhrHB*c3={)p-c5YA(?M?wg3(_SEXbzSxMhQd)kpk(I|YBKd9 zol}5?tL+~mz?|D)QePOKs z%5w!2Wgt0YXPD4++tqrkOk_7rTMM~dFrk^mSOAND(D zGbf13jF`)l=d&@F(AYIDeo|i-p2XUX8uP`%WaA>XdeT{h1*LmeYT_m>gw8ne3!f#N8~7V806H^LrT}Ob4-lrNmU>g#3Og=GmRA z#g!WI7WmFDiR$`h-rUq+HkGxrQ!R@#<|}b6&bdqppLMX+y&tGyHDD}YDrj?~1WXUz z-4vUUpocP;N{8v$l9Kylh8U#@zJ7(VGqEcDg!diuRE7*y4XS=OV#=V_Zv_6u;H_vtv965BtUN(=Cx&0Kg36wl zJ^1tU5(pZ!Ut|E)2)jm>Si^9J!SxOTZm zX~yjEGxC z-f98yRoY{)a1w>+@4Yp~pU9kuPnL~{M^FKP8l+y%9W8~+@snWU%(@AVTQQjFxS>6& z^d<__zd>vCFG{TArBL?etr@}hjyR%|IZPr~rG<{!LTxrD=Db-rvhv{110 z0K`o#^k-MAr@-XezShF6)Nixbfd8wyqlf9ue>1_rG1SG!_Zt*f@-=g}l#w$>gGPkO z;Lg~|tlO-PUdtRc&vaxFe70VM`qlNX;Oi*6srv&!V33!M<>3sk(jIe-RbjyAcu4C0 z+LJ5g8Ife~XYxL#uW&1%AnXzDY>Ojh;99Q)r&4!xFNl6;^4DBqXDNgRp991kHue=n zeWAQ^DD>P|$Ri9JL~4VDWAM#~aX^LE6hFt6TH_V;Wn(JX5Y%FqHg0Om-CcF^=$-i3 zZFTme{t?f5sd4&>@X4SY*NGt7aGou>4dF9HZO&QkexrvnO z7sn&pYp+^uZ&)V1!w@lc?fS-tOm5nd7c=wYop}XrUE2COVjp=a4iKv!!*p9wjpB@A#$Qs%xUAYC1sA7HCW!4$c1X8|QW*4eNb(aBG@3MJ2Z3oX>f^ zn)I5%`cTmx$WqudYk*UlXO2<_zs#u=1?5T3rD^mpvwt|3%bRBwW|@!sAN8 zO6MpJ8%XPef#m@dC0xNFL$L4GgAEjn5vs80BO?d;kQ19I6~Z;_+=t60hk z862Hw0LK*q0XrK=BVGYZ7~gOJBGNvEka*Q?jY_b!wXHZqA04I$ltumYb{evg)byE$ zbmq}%*4*loAOzf$1<~;4!8P++%O9~o8{|gYSW1l`KrJNl6OV<66~-rz)yLAripU=g zR>jXZqbvm^fQf{VRt#Jz?QW=T@QIHD0`e&(Hay?&A>qcZ}J;8ptT+0g)^qJ)U%(k+Ul*WV~_Y zn0c?2w8&J$;Gcoe{zEBPuzy({-7;h~a`4Lt>z*_i;=;>MB5@`Ols*@ABYunWv(0SA z_vk{pFAXDXpX%IsP8k?`=-M9ees?})p7P**n_8SK`u)$VM@sc%J3X(!aNG~u(cNe{ zY??Q1r{&hx+fE;?6qCcQ_mwjXviUEMQmvl)D^`B`K6eQ4eh5*G)C?~oYLA~eyWiV< zNAA^Fy-E9dNPb_9WDC=tLj0~&$%-pKfl8!|o0ss0^u~6-sls>)bbFG~iPP283G2dS zS2*KJei5ICr^viL;dhT1?%@40fOZ%?{bQ&!X|7z*i(^Eu!bhW|AHy2VQDq43AFavJ zWSUEr3?X!GoOV&5EK4v1WDJ0)-T zIO*KWKU|Zn!XN&$j6|j=y^5}YlxxP`%J~96HS^NJe&<%5YYXf1U{VI{r?Fs(Psu~%|`;us5P8N2$~6!PXNqC1T{2rWO&CZl3a?#1zje; zxG-x|A&`U35b_6}F38YgYG2fm+=_*Mj$Uno;<&@E*=yc~#Pg*Vz}sTGP8A`cm*-kP z-$O$P1E%1j#{)JF#4EHyZd8;JDhF*XI?|0su{o1L6L9Dv*egm9YO`%Xdd!}9$hhO!5NO;u-+Ze0l*TpY97cFB^1(M(F!FHu{z?d|?uQRU?^oHd zCnA3NzZ1=>`w9&TNWLJ~lr2TLQuDI(;8BAW>3szxQXV5x3?lj@zka@Os{{v~UXO`I zy82Wrm+Ff5Nv{mt{w1O#B+Ip0*xZRJ`1`5e9;6xe;s^_c_9$y_A;DGCFa#|H?5Gq{ zZ^_eo`1seHZ?ycF-@bE)#_b-Enx{yUEH&>`2?DHbb@HFzuFHurqWBg(1s?8y&0#0W zRn%qbA)4>j`;W?_DZsP;QTbP<2RuLdbsa3_VuAHtowJSaDI$JzT$lMO?95acS~Lr7 zHe)W^kD*YZRr!YQbc9!dQ?rr$ou@tP;4Umyst0h!%hr$Y$Y{YwYkz=bxq;rQ@+T++zr&sA`W#7a!a3h^3BFDXF0rkIax1{W)X^K*UpPs)!VOg9$& zu}>COAuki!vvZ+0%UI}s)OJE!yWchbn=S5pM!k}@x6lfOy zQ^n$E)JXv~fBK)33z+Jz0xN2(_`I)=^Z<}c&p9mISw{bb;B_& ztjkTAJ&PJ;leDWB54N@{#6`mTPgbeI1*shOOkM_-R4$3su|wxQkxkjf%)|b1$l=<8 z{*RBm(#`?yx`a`s4DLf@S#*R|8u4Fc^yOG>z*q`j^~OyAcH^N?Yw?`QdT8Lz&-k0O zg_5+fQfCc@f)m-U8Kk0!=o^IfeTh}^B1jC@jhvXV#WO3ehU!}C;41p6ocFOSMzB^w>%tq@%8J9|e$UEg3AyUZa^s zpSO&54DRqJz8Go_!6*JZLK0%S|pN-#q?hc~r*f|v`{~~HkPu1``fkO(o&JoF&XvBNhD5;mQ zXeP&bAV1gs-SJ}X$!-5CS}N~1gi=ghC5A?%c+#tE_5$;782K=_E~~vZOtNJ>h?VHu z9bIGORXfYk(=7?&IAzcmVljFDf)e6eI&X1}i-uMCFz;_?qwi^Wg+3H`=ts$$?PsJs z*eT4xQBbO+zt|yv7ZPMy^R~7roMEr&>y=(};#tC5p&MBCwrJ`S&-+)8udh!{z8MMa zEYm0>4X1(|7~Pw_&oU%gS8B2aeRluChST1N(NyY)ppeaZeE4~7Mumq?n9Yo4Mk?8p<=u6FM?3?!R_fa`3JVDn@u#_ zjC_iKhCrR2M+CSN#%SI84Cd1!Fp9*aLmdeX3tsdip7AMLBYR~M?1XyIo2vs&4H zF$Y@BIV8me#8!F>EY@$t4?x=P>YY?=4a19(w|blc#1)-v-TPYn=x1e-v@aH8DC&8C zz~i!wk>@VWEp{781d0n!4GgJkPN>J0=&o~j@l4aQ?%#J?V0EGK2_W5z{94#wo%}o&D_ zqjThwUvG5=AoA>7DNKClqdRu>wLh~8qPB9b4$xhk1kR)7B{y4R%zClqHM0gJ$;Kj zak}b2f~xSl+>~F&55(73-Kz}~7zCskPq`I40Qs907Uj&MT-j`RaU>0_TwDt>6LM23vZNls39ooAYvDFld5l zsqyFU{@$-0L-9e7;bp4US*K&`2Dyj!poom>-8ldg!lh6Ioa0VWGHZTE7)AKF!UHD_ zP>Uo)pOa|9-!lYY&-ZemAAY&=YjXJh&q%_n{!N%`u&Aw#EkjVKzBW7{$1XdrW9b)i=#Y*ybBE1uU%I=!J3b+=e>*u@66xq zg3(gw465Hjx>2-f1y9?loNDvA@&uQ{@Ix>M2CC$>jq<6fZ{E9>VS~F89MBw4P|QA; zShI*XSl(T{^Cp)niJ_<~VovCN&jH+~5m^#B3LEb9_?0J%-eEJyvvud8)CFW_0ypE9 zZybS5_auK!J@ZlyIpH5Yq*&su^L5w(m*#wwAqSu z6l2@WO=Gf1BG`8`YL$K_oxZ{RK6)d~Yul}myjyW)Fpy_wXfv(Tebs9M#tr4t`wT*r zYWn$jDg*ZL@!o}qs3>3#)*W!f36JD!2FP{no6C!;nuj|D(5?)?JGmR%w?=O-o4QDLR$YwAKaG0b_+G zHCR#2TT_@nzSkm^m!$AB116-wex|PV$HeZgWXQ?C%my@_t8?0gf$M(XLA8MKXR~0X zds8E0Tm3jE_qaLv#{1U4mYh9(uVHLg^6xlt-UPhyO;^)k-mT+>;@`f{KAaNUt&$EN z!|v9oL$7F-x^E95U-2KWb5@O83cT%Y5IjPerRdQ3I(e|2?mc-SxYiRb*c?Qy1eSjfWtC7P+^6?ilhL?%kS0HT6_=f2hQWxF;eX<(wIsKsX zcy$yECr~hgk_t-tdhQ&~-A~QquHX{tAV{_`@gVm$Ju0@RXQbszy1=_Ct^txihWF+w{Zjdor-9q z7hsd~;JJI={eM32{_>PMu33x+HK~1cb^dF3zRX5bdsf}z_Ez}t2D2&t>q`pnnRR`! z;Um;v`el-@FK(QiW~a&f0z@jWnsGz4>dDtfSusu5KdGXzOq%S^!n2;;dFr;=<2uRl z-tfpV+kemMz9YUC?N*}Qj9halS4yX4A-XTSn%Ydq;hLo4Iq`Z--d8H)UT0xO|J2k* zsq!Z&MkmPg6w1U5O6&3a?2h|UTdpVa#$lM=xE^bF5z?1I`y-(~&(wkOuRi%WD$>v( zNss@l)o1+sZSy;K?Nvgk>yyK9eO0%8Z`*OlWo$GlOejaa_V+OkzAFjC?-l4oc$cS@ z;%Ir^S-OhKZXF_xr4^IT>^rI;E?FGm&PEkX`zDW9tcuS*mi=_TXpm(b=jKUQ$_w6K zq&~v(TC**P{^jm4{W>0*{q24u#=#PbSMbJpLkFeq>{BT-Nq>KcZR_=wCJUQLw;r$f zOr-qbAkSFV?GHU$n@n$GgU?<>y1+|Iky3rXBzb~~xWe?`BPXhdebOagtwqeI75M8n zYV7R0<$iyzjn6PNsSR?%tagZz#ct@h?y4D-O((h?RyRBzGE{fQ6kL5ktea1VKiMw*x3#qVH zizqMnt^u;H$@gzQ$!U52NWn56r_o0@a%1re%=@Xmb`O;+p#5j~KeRU6zOQLjc?rp8 zQQ(V~8CVHBErTQ2hEo7qiN6=R?y`#-t&CkhWv`7w6$xPfsr7(Xt`ms_mkN~XPef=h zBqN0il&SgxQvx{t_z>Q!gVlrmqH~QKybzI(n0Bbo5;k_-M{8&=JkSs zQGtCpfrS;-xQbwP`)MnJFm*Es2QTS=&iZdAw;&=Y%jngVqfJ9UW#wh+$#Y_5X(2`# z1E%yE)O^hx=<4DS>MGO!YRj46I+dWqB-T+|ny}S>_@NPRav!9zOpu?sbdKNC{uWNQ zxI59^({ncgii`f~I$gECaB8YQFUB`(cZ(>dMU?T8-z(s^?jVf1GtrQL-S@4L99i}6 z8C5Y(`!?oj`$`sB9PS@n+fEtqHDe;Tf-mhot{wipMhfhw92&dkOTuJos(cQXU0gf; z3^HbTWb6=BoS{PYdOcBl@UfYj1S!RKx2`oD{{{zsKm$bJ7OMXNox)CmT&ZLU*8_6F z_E>wHgJM2nJH^g?++cK-$q5Z+NHC+(k>Z|8H29E~Afu4sxv82L&+BT&VS_7_=hYv0 zMS<$VY|h8Kxb2av4#dt$ZF?p0WiAh_o7=3soSm#{s@L5qgT(xfm-~H77@%8FGd8aot z+vK>(6zw>J3gW8%avL2|FUWVAwo7oMci8uQ(wHC9zg{#2uO{Fd$Pd7M2x`Ap5nSC1 zyj2HglmxBt=WPnF*5Hsk$=>}8T1|M4}^DHc0-2hPnuxLW^u>H^uk z@Q8(aH1GXFG@g`#CLCRD!|idyb9;FN+m>6L-|r^~lz;pw^56zql~VQ^-3C<6#ZKKq z9$xMfAC+Fys6Ji)n7i2YnMh^ynO5PW=AG}E;%hc)nh%}Ei56bsEm}WZ_R^mdm#*URVXxUn@SnXvPIXMA+amruVl>U zMHyAT&Wx0Xj$;i^wxc)P`#o6sSUv}ku=$P-E$1YJb-7~Y4{t>~3j#9-(|clyXa50q zdSp$Uo4(lDoFCynaCvD!{08H8HRp{%q%dSgY#2h%Eq@O4|1v`k*^Fi9DoEPbd2JQk zox0fK4QR+z4LfDF4?cdToo@DqK~h9t!GIW)xLy8jz0I9cK+i4!a$lkqRT^xWS9k<9 zIV?=Qe_=hU`%kbd(7?y!89^Qi94CrQJrd8vlo9VPD3}GpxQ-+`j^JEBOZ*yPnD=~e;!1I@1Wp_a|n zPGaF;xnEaZ4KV=-<+xA3XE)7P8#g_+uX^2of|>!nmw}7c*9Lw#=yp$2X|5rD5B^m4 zs;@{hZcAgh82}y`zSEl^>y9rvu_kZ5>suPO=}WT9@_oi`Z|*Pcwr$1YSc17x3JiOJ zd~4O5zeMHu#=JH{l>E7s+vC9_n}{b5s9^8S7~$Vl#-@Jc-htcI=F?b?b}Ovs>c0Vr zq$_DHZNIWR+Re@Hu@X><(dfH^Eh7xCw-cmCAG zsI-G6{zH!s!kCTI>4_8%2l)8&L-d*<7+y*r39#ca*PnU>t(815xZ*i|b&}W#dzeg6 zzs53CT-l2+I70nrCgKUuHE_}`Ligh2q0Cu6AHg>rjL$b)$>?5P1qAX4Vl zDHGk{85GA5g}RyS-s)Zl8C>hUAfdmzg?uN&Ixb zPdckG3++0C@a}nnjakTJ1@%TjS6RX?D-M7J&3cA}2)!P`eG_a% zso?XEpvrUF$UjPnhW^a>x1&mKBnBso`J=!L1Ah&E{)l}N01YZHyZb)q?0KzP7xQI{ z$a^yFfgOiZs(Wr*tX7;dl8+5PG>gylu7T*DV`k(}p=TVpFB868cq_Oseo;MSHk`C&!Zv&7kGL~6w3`tfywn1lMNz#vD}{jnRME*?bOI2z)kH|D*ptz z6$|cfN*<30zW3cIE#jT@-=tcaq>RmJJ!{Ga$bTgLF`RXv?z&_)nBOh`dIVHK92K;i{J2(Up>Eo19m$0*jh{r6%-gcc58Z_d(u1F^WSv!H_@;-wYV5<=(yk zX6v(DedV)W{{-XNoqyT^+cYua`yZKp(1L?M3?7~)aW|7nZj%1lXAo6`I7QcNUvPGZ+7H|!ca4Mg>iq>Y^U+B1(DDNeT3KkwRQ zhi4#dB!SI~KH4V6&dRx67cp}RTasz}zizIQ9*+qL0^#Dayn*+B%KUw@pY4|c zmmYd`WHXw_z<(gU=Uj4o{AiLk$j!s0Uggf~L(RN^8xMxhLOg|y`)WBlo!s?Uk%zh1 z6l*qmESUCu(^#MU;A?4%Q<}4poZ_}bNW;>@Ru-u-do>TrA&I`~@F(0y|JoVnS+#{uRv-YMB-u-xk z2{N8$9;laTo5viD8CmwDI*3VxTmLoKP?%nn+g8VQ{%OS+JOA;Pc$L$y(;|P>LK+KO zIBvmw_=la~-tPvIC$Jko2&COAB7q79rk|*(3vqz$pnf?VN#@ce<{vKPh~`9(H0N=p zKm@uWPYaIzF3FuflQa+xT#7Zjr?p(R@vqEilMwCAI%_Lrl!VJ?xN5x^_)+mG(k^k= zT2`X0>3)A9hL&L^^X&YhPrPaYNyQLcGx8(Nq2*PEe02SFR08`_cFZH%%E^oFmNu1u zZ^3~?Wjp-vPG5)bsWM9!Bf@UTE06ZfSw(sP6)Ueee0Ho+mcK;973%sav&$C0&-8Ku zg0dDT?i$uI87&34UoShq%?;d31>?5*@ z9wT3f_sXJh9T(Pnd=Vk@taq2ZLm}E)c1NC8$D^bdCZ!}qt#F(SVW}bYEGNz-SSr%` zPor}@bPX^D5u?M;4D_|q_BYYEOp(>L;_u`G!zX(B%l3{@Jpv>ag#NZYSI zJU4-H#b4$__de(Yx|u2IBmvXEw}MA`UR3H3_oa2|iC{sK2F(5JYnCGqaw#;L^4-jK zz{RIHqyXP>@6!g68{3{)O#inGl;LUdLyXYy3AW zO!sjJ8|BEqfHN(yC9i5yJD{pPr>_fh8Cqmvu1*>j}?F7RrO!rjUGV4`7Djc|u%CHNKsQ1G#Ia%VT!J>}gj&@xME9mC@E^FA@`C-rn z6q^$=JP>^2U(g#$W$_jF{zS4RR%ddgeK2hJ`Xiapic6H0=WunTP(w^(2xaM1UmK>j zi11=|PSQ1EF~O0!E9#I;E1s;u@gv&(khHQUvUY-eWByMmZYG3dROb5?!h|~QLALjo zcxgX0R0P;e6#E(Uy8m#-zDzc`MyRmv-{2GX7sk=Q-(>}!dGp0oFp69(09OyZfrAxD zQP-ePnpt*0++oVNU88KIk38{tLl{WL{4t!Na}MnnQQGgd?h+NcR_{;^LjKDW(8hlP zkT!+)1pXPc)0VA;+lbzifbSh@3^n22Av1QpT62^v`Ng*s973xnGA-eq$M=E|kB&K8m{jcj>1Ly*GgZzMA6S|rUG4ys}ltlwU{A)M9H{Xf< zRb`Q|8&KjH_-yRL8ed1JT;9a@mLnCJ`>D2-5IrEuNwI?ao6*w>3BQE&4^Imz#1AIy z-8H4UBOBrTUinf3hj;ftQE`x%+)^+nhp`nyy_)cW=&lN<$Z^R$-5+6U%dz;!KmAm} z=>E*PBb+vE>;L#Oe`b#dA^(RzMb+h=QXaS6791U?2EY4?^5bOyUUCYXE@hcP6&3fL zBSF#^d>rYIqHz9Bvv|`RTlpMRW<$H}&cLyv#1Lz`Su890c>b7+ON}Wd8K&*`gos=^Y5j&uD6T8WykmOWj{8@unfNJTFWjSprpt^oD1*i6 z{TMIK?GeLEZ6#HU(n2O>?cd$4K%^JHZ)x`Xwynh8Q|!xchLqoSlAJ1usGqwL?AS}5 z4yOI*&qlseVOw3YvpaW9tp4fq?rL|?4oxfWOIas>QR+qN?mvAdVo==2v@|J%g+pa< zjke!D0eYA3*yuk)gFjM^ty^kK2AVa#(Lp9Z_*px&vNRfLyZ3TEGhdA|-@hS@d|g&3 zNNne+`(ygbZ=8t9X05kg17No36Zqv8Sb(R<9tMgWFB&BHg=K?}_qxIG%Pykbkn z$CsVBxs~Wul$a*Kxvyl?oS~({ovbjEO;h_sGWdH^63hB(AW*a)MNMb{U-O70bcY*pVG$MUoaHc+f4(Qw2gs`;m^(YATS z8*NOBw-qWK`d@>pE9#zyVNlKby#CHV7;y~E*wJIu4JKIld!N1LgTS~qGSacE#(XB6(rh0hazH1{nu%xJ?Z9dHz{>Zz%5rYU5bQj@LaF;W6)7c1;M!fm^d4*W`2Q162e)uN_pa@XB#QfkbZ=r@ zdzK0LU_)fo@%?`|dJ(Y`xO-sL%jc80JmBjzMyp-Y&z+aC%nbW}voXnt??tU04RVK7XoY`BI?{bzyHdpE+;hc!)%+ZqzUXOE z%W^&9Ya?{WmcdASwH3>o>T2R&*!~xN{@_a7LcX@S_7z13?>ZcY)i&c7_l3tct0uo3jC?cMhX@dfvJl?8&Fv7Sg^PVAzp1Or`h9b;q-#+G{ZEV1J}m5m5Ma~{%?og zYi)Um@FV)){{m=xwgRgUqL&Rcp#lcx`({&>)OVIzK z>@B~djP`(Sx>H0t1f;urC`D;Rx5lS|pu0-Pa^J3tMbJ??CM0lORsZ{219l~_jX-Jh?pO79Y zHB$z9@z07@h!X-Z??>GoYWMu1Y(GQ&+G)(bV`8_MHbKocn!mLerka}vIU$;ny0+hK z_Ph-U8dIB5<+aFv^N!YTmuV**gZ*FB`rP_U2H!JFjujD(p?sb+TP8e)wJ4ODX-(YN zd8Y&MD(S{5UwSTEU`*Y5U#1wwwlB9wMm~muqAh{PscgFQ+V@qML)Z{S;Z+4&i0?=z z>m{J~2INuN+=I2eAUF{;cE5+_zSb1+Q}A?%O)hM7804ucNeJ39@R0^?e8lRrP`XZv zFm>4z@)(VM9M7B#ydIz>A%6^o6z^J5Wp0EiGL64G+bcPHqj>H%3v4*-<5k5PYjd0* zU;xMI@7E+ZkeoNn=Jz|5STRhnn=X#^)B?C1((;_g=M!gur^-TQD5bcbV!y5Dn5g`O9v!Z|zr4xD~ZgH2cBwC-Vvh9kqd*yVw4qD&Q>1KgT0vd??em967UFUD*JT4EuGYmDXYl$#N6t_TR)p+AdwzkC~(cCtQDJJ^IZU ze+h}Y$z}j@+~*=AttCT=*i9Hy!Ub>U=;(il556KiP3z2hO$=3_!p9oJ0ZB;yT4$6iMm7cB*1%jrdzMiAe>N9mMP;J^k7sU z=u`z7LLv}a2QwDEg0`hGIuWIPLwn1vX{Sr@%k58P{PL00D%ho1^}>U=rSA^P zZM)9XaKiSwNg_4#e{j`V;`Fs~v408~U72wr1}2aeN|&9_I(HSEY&c)iK(7uhrC*JQ z?Ip}f-}IdS*|zMpei7`FXC+wqa?kozeKX$1w+Pv%Uo(OlzeMS6&vX|JE$cNA=1dJi zR#uP8i*mtIX`FLtKr)Up(29{>*6d>Y1Z;=NO-SbJwf5qf#J8!}s1G+`h1S~hZT6^x zwQmamJVRknz}PpmDPw5P{5)uxKBujd?nPDGtl`$BB4s_R{^y~p>-FUL+d9~0_~oxw z^cIBng9DFDm3n=g9#+R>y)J|@EXJl1EK2)M(;V{p9C!&R=+plBd*x;ZgoQjgnAc`# zwz3E*9A6AYrdw8i2KQbM(AV5g*2AzGCy+nEZBP^&QvElmAyx>Nqch=W7{)*#aF@cDu<30%IVnrAE15n$$Ksc^QUZSH&)ko&| zdg|2+DtIMI^2a}Md8GGt9>XjTwKuglsoZ+le?b$o3R8@vaYI@XgEipV91Gpq&tfA( z&BeyP8D`O(B%_9snj>VrljioGE&~{=58EVPqnNmJ_ZcM2f$wVWL1cb4<*f}<$Ysb4 zg2KJ;61aYTPh|V_=g@X1CR}e^ycMwhA9eZ97Z9u#T|^hJ(oEC9H!|@l3|)6N`*iE- zPshcAQOLSl${8HuL}g|dq8ER11pZoB{)S;5IACrQw7jsh;fuZj&s4GNH|H7X2OYI* zd788*VY`i!h3Hd_Uv%SyH>Zd(zuY#?8{bli&Cv+n2(8VLZpYZ^|6H>X)!8HA-49RSYxp^9ly!eFt#sSQG8(h{0+m) zouBIRNEo)RgDg`Hr9hj!9$^*FmBR7id76*A>K5Ml$LdGlk~*yGx7908E`6L#BdjFU zQI^2(5Uji?Gh%yK<;x!OIni|(fC@VR!^8}A2$=l&>`&l-D<1-~Kkr633Jfe|Bud#P ze%h6jMv)=!wvy{Kw>R!-HL8U)Z~G73yzQSQKKqP#V@Wa_&LXtAJfxc@a;Qzvk#~?? zCj9^4;wNFmSnBb_o;v6ELg1b=w8wE{1uifom4z|-LY5CMQAe;p5bpd{eVkq}!7Utd zl{`x`_X#!s5RZ^7BLS}k-ZZ)7uM#fLW+g!MW-)F)WHly`ZU&>onNTG~Cl*|N%U{kF zWq+2(7Z_x0_R1gFKM~>zLHF7x$NNGZH_yeTi2ubxTTi^GqpCM`&k@xzV_N^D$7Rr5 zfX=P~b8H=BD~5rQ+L_zkSdoUaZYua`1ib3okqKiIdt<>pv14C8dC)+2lySW%;C7>% zbU;_`Z$eReZ`io~9v?STuL*bAhriz;_A5fthg&~w)GqYJVxz3XXDS|4zE4${@N7%{ zF2|i48^xLL2L;nGeDzO%;h{W4i|vm0us-TNS^l5qe*-n}$cpuo|3)B^u~ zGSi#3o89TpxPrN$i+h_mBf?Ds=lXnu854O*Dj8YZ--VABv)A^c#jN@nae=nxYeMwm zL485QM236n@w7{;58a5*owpG0FLa%@$CgU+Kb;S&|9_niH<*AVkuJ-Z|8zcjn+BJ0 z3<_6l<^gNMeF9BFx0O1T_#Y!qG`7E)oSbgdDODSdHnT>aSAM&9;9DI1|MHlrmQlRC z+Z2L$t{wQ;$-X39bP(q3k$B6OB{4C<9wx1d6)Z*gqYVBcJG*^T+k0tC5s?wLCm#$= zp%3(^07ZICepA$dXqBBpkk%E@JWnjlsb;+l^bl_QN(VYxj|y1|6&%E$5T707wR*<$ zYEpynct4ZVbK!lF=aO-@ZN1kfixqOdoAeNbBxSGK`6YG=FSGxN7Bv-EQF=2>+B!m; zl!R*?C}qL4y2Vlr?vDsKXdSbbz+4Iv@!fY7X>klZb$v*HNLG8=69s-q=82yAz_7lQ zHj{&}882o^c&2kwV$vVABwcX`(l^H->zE&VmRG!udR*J@PFisDE~agYt@%{rPxtPSEmkyGYto*?0~= zv}8<+Rrr?dWam)u<>$OJCB7VxSHbr8$lTE4Jih?*-B|@^>wY<}jR?yB0l9Lgvs;A; z0D$|K;cK{cL0_Luyrv*p`GOvb`zB;qq`hiu;a|O@SpI2qGd@K|&l~}WAs=2#aG|Fz z)ZHS^ppf@6`E{4kuY7v~B|=2;{S}DKxt*Y($A3%2Mn-%7>VD!);W6gwkN1~@#L>Wp z9sJ1vfxf-+BBsmncfr+fqb{0}{$jnm7>~qxI;6DTa@vtX%UrVNig$!l3U zLU-26K$(XUF(y>maUR+W-qY4ke}H#~BIOO7MW@|O^TX{<){NEONtwX z6vaEChU+_A_P?z&Wo9au<8rqj{i*{CwdQ9el(*NrgUoB?TM&Q5hwozw_bOYj|82si z4*m@*b|puL^jD7n_&uSbK+R2}L55h1`Cg{jXV#>_zR6|F#$WVm z5$#L^hMSZCXSxNqjSasgkB}*4!uch($0UanAMO8=9C@8P-IB*|#}?HW!`#@Pb&P1E zREu+RYnEZ_A@C_cVrK1E?mf#3eS%V+86i*$b!zp)!*+zS3u*4x?BLX`gQdJ)6Y#57 zWs$yQA0F8=@XE>}?6vHDPe@~9V$SEAuS+c}-^hqr_{qvmA)G&chcjqo68}TbVJTL= z|Cc@MIoY6>{g*wvZJrAi>(bQf3FzLQ*pYuB23295P9wb09;Tx@!#dMIW&rMwT3CrqEsUgL_ax-2eRNSCggNC)b!D zk?dxWQ<{HrVHHDH=(2x9++W@%l1q8LjjsgD;gS>nNtpxm!m@9mR1JL(&4vzL>F4CC zfUeiplC0_yg$njedP28KVCO=!0nVHC(%JJ;xyM+DT`fb#%fOh5bQ&6qg?CpfGW+rl_L*?w6QD2K~g3~J5uUSt6Y%M@K!u=w-cpHG94jadFNZzfIDIuLYzKZX_i zru-nlJp#5hkz#<+=$^~==AGfs>XBT*Le`WcaHDAV)u#HQKqPA{#x|cvNz+@ghg8)C zBo|or`8+A@%SfPf$m-0_=LnTt-^VrwMf3lh=h(bRSJqKzIBGvs{F6KBIgD_k?l2+* zcAHt!rVMKM06-E_gWKVzC~mU%a6nsPn)#cB=CZHvTwqC0B|BP=Ef=p*oH+o}vP@PF zc#Jb@Y~sL^N}#pew0HUM3q+dn*y|tipIr{h@%KaV){u#K^B7B(Yx`R~BOJ)3;)V{= z--^{wUCs2YcwxHh*VD>hqt=#8^Ze6O?~YcgHb@3Zi!NtaBFJAUD%_LZ+&f!L0aVFJ+okhtezdT(B zq-o;ep zXY62qrg)|aFft~u>-ICO5=8mWcU~!&7*wC+HqM(c-#)oyOusBcIW{Uh_|^-$)1ENfWHp)m>zKW9>F{L77L{ME z#zbJiZCIC|n7W|)-j=pri*O%Vsl&_0l*-uq8Dgsd!K>2n=YEm*EACv>^rF9sQ1?M!7|#*nN%)8htBLdJp!!!q-*(H) zvv0?GbAII7+P~xIU^F7MGx3cg-J4TO=8>J)ez7DF*tR6 z(%M%^yK&>6?5u5Ze(e$l2MWI$mD+AKN+P*LTErQ`pXqQ9=FB zve}|_$#hBQZEa1-nuU5s{uP#w#+U<%o*G+|chbnjAwUOF?Z-~!BRgNhdouCqnuVcq zD8zV+A`PSM={c{^`vfjB!cm#3-*FjHXb-zG+8F$8_7Tczq{E_$e~ge7s1g(lA4|mD z2tK`Lq+_g-{ReT&@`kL$&Ao;0a?au{~bN zyi~3CR5#Z)jwM9*lnx9>In}ad3n273$tg263!?Dar1DC>6`--%P0-JSQm|07Cq)SU-Wb@g<%)@+jhpX$t1 z5jY!!y)RH=z7=R!31zG1L?1l>Ug?qD+Y+tIQNav%Gd4yqdrcNXQkrEecQ$B_e9(XS zNFw&gU}*kb(WlS{nQyPDZ&y$}gUf+@;>tm7vWO@-*m;dL=T`8Sn17zLm4CpJTw1ba zyqU7Xa;X6fA}d}{5?TX*9|p<}dxbp4Im)sErI)T5W~0U3C#oJoV5qg?iyM?~i_HxM z4J<@w2rH{=pIbz`SFGp#^<;sTW$*D_{g1}K%>-)SlIjNpvWlu=6E1<4Wqo^47<{T7aZYFnP*Btnqme401-XRPGIu68Xyt3_0tKQA^2_hai3C>6_gJ4)eO_k#D_kC=&F6hal9n;umh$`*ua1>;!{R59Ii^}u+#;0K;5rUvmO6x3~v z-iWepcq&`ECGQ3LVe#{jhx#|D4Be2%)k<%PCgcx4r!9Q|R2%;`;Ndp!`)v1bbXiaj z?0>P1$&Hc8fT#j$3l=e^fzC-ApTQ8@A#jI{F$9UGM=T)rUFWDaMUQ`3ap;>MJsTu?sT;Udh+<^|B5)HmeL?$G!jrAJ89w+ zSG0`2o;cY#kKv=9PNJLkWkk0S+3?gok4pG9PhjpQ*M|SnRD#>_X%UtC76+QI{*x~0 z46*0QBT-?vNO0#N-;WQmiPU#BP+@hni+iTd$&R(1>KBc793!0*s?U&pd@9H)@IS6h zl+OaO^iVXBVhQ%OX9M-gwqF?rIknA`+5CA-JZc%lIncB)bPh1yZMAncxPMN(UTPEs&|Rf0%{ z{t=9e_dFB>Kj^wVyRCPLRUz8heBkKCi+nLcE$4Ln2_2q6fFgu;;_Bj=4OW#NVL&6L zQvkYfH&JSNy0_WH4Y^_0L%R07G**Je$#8eS?2WZp)jljPV~{)CEm}Jnc5Z=k!7j>g zdav;y5%&RVLm?d!^llTlS59}&wEEtUmRp;F!S9ZFgFXbDu}Dh39OsDfQw^fXhY`xR-q%TNhbJ}T^{yuCRd4)* z%P)HK;gzeu2%CfWuc_NUi=eRtrDZnYyj0TQ6Z{947jaHHJ9$VZk@>HaZZGcO_~y@f zQusQqPgz4IlP^p^a&ke8s|t4VDD$k0=vHw$RHq+G(@m=dH6us*q%zMZ(HUdvg9?}c z(<-Lt5#R0dFB%DjbR(L@Y8UZ0qZCO(7j!(361amXjy3}+Fumx|S?n^OPo3YElfmnk z;|l_`B2cF;0o%^Dyej*qaN&pY1`3EGS(QZ~de1q{DL?;xbFnubKiL-jY&yk``rgH3 zI%AUiYBtVZK&0vpMR`v1(t{-XaAR&hR{To!oU8Vfe+CVbc-U(Sxq3nL3rLT8Pm%;x zr!26&3)z}f*z+@}i(w7?2||xpdrq z@xM7xIb^xIFxiw0s*)4dubsQKo3!KFX(Kl~7?au}AVc`F(IGepT2}#JB!g-y|#$@8>GTumte?qUJ~U1Mi`L(LnFG z6NNW`JDRWtfx?yyO%Yd2njIgV!)RwODF(t7XYVZq@aa9aNU&dH)$bo#?>t{lxh@gk zkr{tvyQz7vlMJ2tB%3-~+lCyhUF~?D;%&;Xc zl06wNQgj)fQgca@6_K|c7g|`}@IErYHoL{&3jMp}TrD+BZ#&+4d%JpDab$1^TSFC$ zjK5|LI_pm-4IWwm_N|7YNJGbBKo_JSc+43h!X0_;x&aNwLnN7ZBY9Bg{iq8<)Iv=6 z)r=+lJRWr29Cq8y@wMA9t=W*ZYaN91w>$Y4Q1PMz8ae~% zVa#r){&;iVpUXka69SjW6 zlpNf*okg~*YzxCQL3hy;)GV8($KIosMCrbL+zyG?aIYQ?@a_S)Q$ z?7cs#HckSWSn5E}{0-40HhFpU`7KU=m#{=qIdjS4ESgX?8ec2if9)Wt&2!yUHTKsn zw%_7%o0Qxai3r~*T#SGMo_36-7&n$?T_G>u4JUYK|9;A-i9(qfp@9;6wmYvK zz<7VfLnK(^cwj8&Ng24lsgZ&-RSV!)>s1LW{nLTpa#-;vZ(W4DhGJf8dF~48AW}+b z$?Xt71rRQmK_(ZpM*oebP6~Re=VOU``HD>+fb&R@BRwxA9b4?S?@{ghk$S7Z7!{;xP zJlLK(kdzs(bhgw-&VoPENCp`?v}M)9LJGcd8l;Z-mZ;%EQ0a`oJPe{8Gdy0P@pA zO9w7#zZqvXO=YN0T){3$M2~w0<8ZH27-Lk0{upA^+KKqA4vMe%B4zfcAPN$ScarnT zle==KB#4cYho|JKXRpzWA~+-F`ew5ty*+t&tdYA^Vn^3)9A}@5BpQ!He>Hb(FjigXT z{RMqji?SY9(H(<5|C)ZPy>2!*nKRX0)L;A+_g9Jb#G}KE*(fdUBPYZ1x>L{M%;HIY zUv@v=sne(H{0)U1edeKlv$uFt@=_jksa->3j3Lp<%jMdP+Q_vB$Y%{Wbh+shP|5M3+bn!>2XwTAA{41E#AO zHET$MJrW(OWvPQ`-Vkwmb$Dqw+ES}g%G6xDTX^agR#x6tk})nAsTsu+Ro>{yUx&*- zfVR6iF0~KN0z;0DgF%~2x%a^R(2V>}NL7TFTf$s&;P}nGkpE5SpFzyyu!55@I2Z_N z2ixgzU-C>I`lEtbMDz=y_ET>GOf{jelBRgEFP|a`VJD-h$qAb=@liFh>e>&p{e%5` zxnom%$a@(uXdC*FAj(fWtKDdDdcxzg_PNYL$1)Tsd1BGJy_wbhL5vbRht1x^i2Mza zlnRukGOy!fE6rys*D~`j+?frRL#6lBq|5gTG3m<18MoDm+iX=8{({Oz+K+%s&l)9I zKD4;xE<(`txR3n3MVD}7cJ_OGpdS5A#_?QFK*{ezt9bFUZBgXZ~6p5;ck z@tGBXxn;Rqsumgl{3>WJS7;RUsLgH&g64wH9$YsN!EB&=s)ME@8j9n*=h>a$>ZD#B zF%e2ksi}r2`afP2Ni`BhJ}G^>c=iP!Ebu?X#|9)lgPSeQt;|bQ`yyf@MuLPJh59As zRb%ZYH9hI5In(a)QYtr4=c0VKUlhp%VgB!LDQH+I?g}?lp4#9<$qfbz; z7bo?x%f6OI1I4Exq^M~$dQB>oG2g(jPX-09qR+!$e#E>c?=-Tro5X2y`oyLGY6H4~ z88LpnG|sSXvF6Y0NYBW|j7g(hb7&kRDxpcMEl9LaN3t++`k5pOTRP=^5p$lNT>4&M zffpQ`{F*2>2x1ERx!kQ_d}y$6mmJ|l{QB^mM#^Q$KP=^oz^2!%Dg2YckrrxJ={NH9 zD5HNbuh{#FWSj;bd_lJ+PGJCC0s7$$ob?tZ_`m-Zfxmb#lx%6=>lg zVMcoJ9^@RzFXZvP^c#!~mYbxQ2EYb;aGc6_=a2E#7KZxg7szBP)Z%E^{ISda92zz) zbp{&JC;7m68u&oYH4W@QZJ95vUEM#PdtZ=p39fZ?^()|+yVM=Eb58Q}5Wnks+I{yE z3{z?-ZCmW8`{RI7J9?yvx)!maf_64~V#Drzd1gpg&8~msYp(vf>O^oD$RG7j-&Gl& z|3wkR_%~{^-#IfDZ-za#A`!Uw&aKWdb!Q{J%_MKW>Ky)2)m=dfFviQ2>77u(LJVmhokUSi>@ z93AMWywEo@wj7Itfb71Zq7uOSW_*{|k`27mDy~;~Q39 zp7~9Ksma4!QFxc?60R3Jh??1W6~~&%3OP6T$$3za->+g+3Xwjyiklp^T)fi+!MtZN z#E)Y2Lc1kSJQZ>EKxL6J>F#RxB+-Tfnf?Tf z&`&&vvJwPl>52x4k$>SWI!|_I^G^P9&q>VcWEVqE1u|Oy zm0@ysykual+d7`Ot)Ogicyh`urf_wdp08sfPf6x`okD`)>cJ?NU*mQVTMI2|Faa#e zDh-?D?zdl=)7}Z81-z_!OBtxmA@3+)nj9SD^2F8r&^Y%HbmG3M*7g!v0*b`WQg61X zYmp@e9W^4LC+o+SIJSnr?#cO@qzA!0l|1Am!>*E0Z8X+0K_Y2hmk9BYw>jACx zgc*yFeWi?S2w@LVh}mFB!T56%JMV?BDBSH%cXptvZ|%>+JlvT9VxBh^qHtci(^_2IxJzb{O<`qgL=c@zmJ&5z2b2MO-p@PObM+PWHMYDFPNR`{v9b2GDbI8y zo6(Hc(xqhlD}KLP1fBC+sr9}UREpA;tF#D@gw3uG1XovUQu@fCV~5?su{z&P1A+Ew})0%+Mg2+^#NZ1XmoN3hX1nAaZk7Mx5qGze) z6Vtleq;@M9dE9<%($lq(EK3Ckvz7a0w)D;b6~Z#2Zr|3#31P-$8IHz?Vun2Q}vXKHhRXry$LVQkh5o?s9*sP*s;sD_fS1z7*O!#_D2B{EIR}0 zsjv$BZrSV zL6zQ48f3<#v+Q5GT#iJ; z6JtQD?8&J@WZDP^GB-KjUj&F{YF2GPG?BqrxxYT&q~iTjb%GIZnyJq#zh{OmNj;i6 zpifjQne#76EPj#bJcqka8?aiUA5EHNQJvcx;!@Le92g!oPVd22=etEV?RQK@(Qjgu zy#<`%`qcTM0DUK#?PHx79uf*_NMqpU>##}R{U@8wh@g9V#y zQMp>etP@5Aj|u;_x`d(tSCrF_m{h!aj9V9HJhSUdWaYzTPS3UK!K926`p9KL=JP48 z1Bf^N%{i}PLd6d$1K>(H<^;Etce#vD-l=OLpK2@e=VJgln3-VZg6JD19R)@3;EWGo zHt;}ABNXeh?;99z;_)V2?tqa>XtWQ&C8LgjHUE-mkYg^wj#N{w)uOlVjSY!$WJcQ? zHxx-r&5U;@!Q+IkJwQ5|b~7&0k$n+J5}F~KB)go=C8FW`WI5NS;37(-U&3tz9Ec1f z<~X|r-jMPE0_YDu$1;erGrQ}6D{uQ$056oKVx}w~l;1pz(WLR4p+st@fbC>%+}_6w z?iZw5UDtc9+!oaxJ-b>}2y0hhbzeIuR(9qZAeTMtG87{TI|0mt7%wj(B(lsB<*%h@ zaW8J3y0Hvg(w}Z{d_4tB)lTIg`(U##N4Vm@j=tC*hBDRcpO7%(CJ2Pumyo@|K7&{W zfPbMZk-;p;=8K?rIpMiL6WD(0Nikr?hrRe(*Y#M~(@xf>DcKWpb#91eyb3Kr zB`yx(rCY(c_dkO(vrEk!MjBG-r=lF(1ukEo6QMP@^%NXWAHC!CbYpq#t9-7ZkI3z8 ze(2#@o@?g#(uC~1-_Vi#eg8nLEArN6| zt$S0q&xWnoQno%m)71AJR5;}0LwoBSDTY_3BVxwq!vmg8FGu+{F4p3pwxe z5e&3`gu8=VB{;OvHStp4^Gtym4c!iWHhO@^kF*pVOH?Q;4kTg40ERK^u9g z&>B)*|As3wI&y}w0mu;dbD2lB$$qkkF%I9jl6!wC1Qz~qgIESgS!ARp$C3<83x*Ah zr#(+}+ayJ?Z_m5?kqiyr;b;D`N8`>Ot5*iMtpHT5e~4rx#G&5wZFN-OyUINZ%U&fY ziUt8I%9f^6uy1RR7G2CBfzd5)|@NBdwd&JXL>0ypm|@?P_HjMTVerG=<2QWOaT@W zTVB*|+WEPT9QJX*Et03Z~ zojp%D+uab%3rf(wm+vC_Z6>42S>p_*l-4*6{((*r0On^u>foBx ztUKZ$#;us1@YYGN{FWCMq!#+wYSDG2{kh`4tEwvKQzoIAuTj>Hbt#?}U23M59hI3C1ebyMK3X=P?!grsRNKC8U zikeqN))=+8G<(29x5)M#;`PRdS7g~*n(T}jP1HI2WV(E-x5!hWK3RNAo#(18S8-9W zlui0QtQXHI{%fx;_T<_+r-Z291s5~HRjAyK5YSyMkOb;;V#obk>d9%Bv`I`%tlaIY z@g@-ISgxn(kvB;=LjQmyYu;DP{PRwztt}7&>rm{;Lsp|1GPVfE%=8F&jMa-e!r&NU zXUcyBq|AKW0#SRZQY{jX;~WH^!|+cg(i|#WJDKtMz{qo7P{no_#YV@vAY!8>Qy&<{ zvdJ`0u;-BL?{Ei4oBeTPe8*eWI&#X-cUc9P2js{QddHyC#W`d5+kgW))?Eh^#n3=2 zXylf9{L*`ThA17dTw=THu8Q8e?rr7iyVvuEp`|YNAAbI7NQwxul`B6eo|prZ%O}L; zgFhWLn@3ho9ca8-9Ga+FLAodrVzM<+B|TJX_|OqRPC~N^Lqg7XF12zw@(P4bdM)fAzLM?p8D%wufK z9W?`Bgg!iL>YPLU$x;}f0nk25RRK7Aw~!y%>?XtJU{pFa^e>#JKU6u zd|`AN0F#Zph5ohtaKZk#l@uw{u1M4!L8@os9<*7#Ma`$h0Q64V~oJkUb1~Qx0LlP$MnUL`+6!`_6L*_PZD+_xY zZD?Y97rdWzHC#=f7r@j}f6!bRC^V{}Y`=*FvaSJY78M&gItR0fOZl#HFD8}&(uRXD z{c#$ClNTa_+4uotm)X2L?imva#oja3Jp3U-KN)g1$4MTdqD|B0SMv)GQ zA3azrj{hhO z=+7yY+s?N8p_fn?gU>G)sH)LzhM`HyW(i~BdG%$?f=C}-%)REF@hb_RpHyVBxQ;jX zp4^EGlyf*IWQE6G4{i1pl5I5k7XUklRS{bZvzuDJ7uJG`B>P+RSMSXn`6<)u&xrEQ zuX0T+OQ#b*JZtXDNEBl8$~X65?okoJ`p1{Mj=h+b1ZUY_hgNKf305{KAn5RX)ZLuw zV_utzXIy&lDq|;1e)gAJ9%vx(S$IdhnlQV2a_xyWftyMQ-x`}#b*=@@F><6eN2nCt zT-=<^mYdX6u)NAY>~UNu%?la^1jb{V`00I@HiTAPWezoA^TO$a%CAW{)xZpzoKd-`>&Jd{(nL>WG@56dE-h zjW!D23xD>=mT9QHvuhcjezK=;8)DJQawuv(@Th(}x|1E0IsEGw3P38yhQV*bT4G*3 zIb{FlO|JnjGp(6T&W_u=A9nQEUMV&6y(-T}o;nuVCz!ukXBeb=0ZTiG9VhIyU`fIH zEV6qW<%tyUXB0mwWvrLrhf_C~I+r$bsm^GA1!DF?gqKtG-V=$y5=+NMR-k)e7Jm5U zXYa^1+#g7}v|eKokvf32%w&)!XuKzM%Ti04R5VX7i2V>(_QJudu*18U4M73aZoEC& zyxl6l&qnAKpRHt$F(mVd%!7XK08=oi(>N4<8_KzNpTchd&@WN%x-XKOujc&s{-TaO z4&%8z8c{ZF?3L{h-#c1=hSsBgyLDlz`=#UVTVOY;J`F`GoDc4_>lypG{-a93(x?&S zYCj}0clh!?6S=ac%;N%07z}?@lhy5oS9=y&nFb1{78^f6=+8c*) z>y~ibUIB|L7|{1X81)wlM#3E;w_3j!KuQI?3b;O)K^N`ow0ypZh&vNZJaykMe)yvB zk~|IwWB!m)dvfG@oI6iYaO^Wdf>J*9RhX$a53rxUY*iUd&b$J{xTshbT4YREeuCVd zuOZ8gP@~nQb+Q1o=T_RtEAkf*j~&S zS0~%Q$R`U|Ww<8eTCILhebkLwV#4be!W3Oxp8Bcn_Y;*F##*y!cAaTKH>W00Jr|UC z7G4VaJWb#Zd17U=o;&buY&umRAPqrSlD-gJ+X~r1iL9f}YfjwOXL%@4%kRdHWr#jF zC^*9fK6#t_C4S$g+HgkoG82DaypPcM)7X}%qi^62s|AzuoQ^r|e7c6pG*C3n1}#q7 zSZ7t)cKqftl24+e?a1T_xCMO#o)!ChW;RDv_=F*h7 zeCCKwG>BFHN3ApeQR{1podr@xr{BjDyGjdm1|#0!yx!&D!b#26*F;=lQ|I}YR)2%y zS1@F*+}sXgb%zOlx6JI~lQ>*|5alI$cl?z$aZL*kWp9CC-YJtB{$QGnY2zuiI_LfO z%FQ-%FM{JC)_P zfAN{1pXipq-t{+8U|5qbP?W5tv)3Ip(%R|)%pG-Pt0vkX$zpb@_J5-Eamn9KtM8k* z`!gqbOm8_T+o-Io&>l7)O_r4i=!oHR0&Z9(JWrN+_$NdqXXc2orkcryvKnM(%QXd9 z+_XR_?@%{Wn`%nll?o#`dIFCZ37?vYJ|tf}k=XK?d>yD*QRj$>%af4;{~PKzI(#p* z{p1d(y~ph{R7Vi6S+8YeFF$fv_S>~p1lK5YrP$uQcqabhH0#3ZAcH1a@#lA1%Tq$3 zXj^KjUiV&&w1R$K5sHGyM$OjtNH*EcFD>vgal^azTkZfunC4Ur^PyAHv-J}ml6jO- zcO9T~^fiFlr=U06d9zK;QEeYA>0f=KG%fPOJ~#~E&(lCVEadf zojI++96hM~5WrHC-2F5#&Vn`)jpDR#FXM(0mczG9YdE;W&IUVLB!0h98nKB@ z)kB9G6T=dzysix~v1Lsl;j5b~z#w=vIHZxVH~VeWY5j9{vl7HA?nRh^VM69fIn+BE z(q_g-`VU-xhEzNl@=0T^P^o=)?6unwNFYh9`j2b#kM9!9 z_}zycgfuzE0&F~k zJ;t8(Cf6GJ7*SWR1M&K#-b0xgFu#q#Px=~#gnDEOD$>}?B(V%wx~Iz{!h>f0?X(%F z9^@kk*@a4myr5-jsV@rI-%Y|(kTMZvAPFC;KzGp2fJ?uvz_Ls?Pb}q{+>3b@jCrK$ z)QhtHlTlI0*Nc%0F#X9BVcC~nJ_)YT{WY0_m}Bg3X_TJima<_)uXjf^r~a(bq=F1u ztz2$C{XayVRa{hY+pd)skU22eo-2?1#a1f)Zd6d1a@dniGqrE^dOq(M4|Msn!x z?#{{0`|j`C`)Hl6-#S?9S@-{Xu3PV%wCrjkH8wQ85UE{A73?|E@wJ8fWBgtmn#`b5 zro(RM(2d>pGDfZ+Q;4;*U4WTfZk^#~J$=M@g5l%SS^RR8ZicwZ zvbrY}EHP_=yJ+ha`x;8lXQonc7J&KCTzFc6_c zb%S)!4M=;IISFz#1b@Lq51rhKE@=Idd` z5c0xu;J(ivZ&W1&wp#yqj1YWx*br;q0F{0XI5bBZZn?(TW)lvVJ!gpE z<5Woc(y-rH%i>%iCF06c8L-v0g5rSPtyy6vz;`cTk*Aq`5Nsj#I0D8pj6Ao0+Rz#k zfKON?<*$u3PD}Ke_4ZHqKne~EaDX5iv0RGTUJ@Xmz^N;P623a}C<5K71Erb^;|OPW zr;)c-e82i&PgVZD)}Kt?i5vJ`W@h+cK21@Vr+)O)Xv#>O3U+f5mDB#Y%g`t%>q$UEJiRI)Tu!AWUr;AM~U7NPD$ z%{JL(QcqIdFAxfcSTkmJK#%nN;Xsx=;)bVZ{g!QPeYC@_LY?*JUpYdmmp@A!N7?yJ zcKo07IH&_wjkj~X-QEs;E3A}yB97^?<%)11D!@iI>@V|y;yiMIL5|spd=gCW|3Pp4 z65`|#lQR->Pw&m7n(xF$3{zC&p9?|26nIVtSd$AJ-FS5T*^5X20cUVBa~kIX=-nX#)v znC|Ba^4w<`l4>~Y`~HpXS50wUC4UimFFzWx%Ny3|yc>L}Ng1QW<(o5M#t>$Jo;XwX znyh0E7-;-XZqUW#S03lv1*3wABVV|pF?yYuYRLH{kHSHYF#RS*-nFRu zdPJeN8Im4kGx+wIzaiX`L55`Z(n%RYR^T7@nAj+0ZLjSxp`7cn$TzJ2bTnk#|8@Y1 zs)C7q?!WaCt*V`6(%%f_SI+tytuPsC69A|2eI3{D8MJnG#J1S3n?W|%n4>74(^`QS zYACAkW#b!-n^tR?93=8Wxd7FhgSL5e+``-6Nokc@Xd2Y8(m%y&5>OG<(>KAScBfqf z!M|4I1j(2jF;M5Kn1-HJ3zHS^P}$?MdHag!t6i650yn=)YFW}N>_1mqlmSzb8< zj-p~t!HB#2he{3kj`hIOyS##-SuW3c4HFj}sseB>2WkP&(KivcIpyclqnH5CHGzdk zT;YGu0oSeh-e%lDfZDpJ698*s7a*R!1NG|oBZ#5;NBGym&f)H<&k^W^j}2aXqMg3$ z1HyJJr8Ud`E!hznDWdfgP7^(G%2APQ({b9Dn1=PMaO%@5L;%#bs?T?=BY`8_XcFux z|CKcMvO?n6wpzzY8y@=R;iIkZ$0Nnn+JCEb4vR?O2&_EzjPtEY3pNpS4YINDK z6@~MZt;(=>C<}79f;t9Z_o26aXbrV>CB0iE8EiJjbrO3EMimEmk)^d+P0=o~9p9Ya zE6D0mY~5>g?N1t-KzHbvl028YWF2$~mz7`H_O3WeG@=@1xmu*Nkv)ZRDl^SZJD8H& z&{OM(X%?q~SWcxEX~V?cNbXzBIjSkCpX$Xxj8h+e2!Jyn5v}ER%<}^3Y57(o7HvA` zQHdg?15x_N`+9d(_jv~#Je>&0TWT=6h*`YN6YxD0a>B!RGP)Qex zMxF#e?IIaVO`-1Wu6EQ}AOc33mVcVXJKK&C*`k{GP7qj@e4M2c{Z_R64*cx!^9Hq- z)cmZbm^r}|6G7z#k7HSv1g3+OOx3!1I_Cg6N??|i+g5;G?aib(qXHcU)W&P!bW>Zo zrN@=@N}@G?oL}lgq)RPO)qJgKwKn2HFYc>5Tc!DhT)A>SSE6!VS%nE34Kaqmq5DH% z&?y|V3;7v4UPhLUz9_hOF52oNe3?#&a>=usSYYLQ(h+Dk?6b zlb$>LP<|;uKo@0X%LW+}xY>V1#@G@$~-zR zkwDtY!6$-)oOrA|MC=N@OfvbNnm_rXT2-Wr99oCb>?^Oj(%h=@yIiw1`qxtLjHZe7 zaWgo~!n8XLvR8^<5pt$V;%qmv4dfi$4lGM;Z9_ZH&)zTyTtxEhEk^pJieAqJOAY!e zyv_lbhrEUwJ>8i>+tOTw0Q{=B0BZ3 zhjX2L<$I2^B*9L(dA9&74!mTCA-i-cAU9IV{ytuTly?bG6c@>+u#*mb=H9UnI<5?} znqhW-Ck@9K@j?mm{Ekb)zPy}q{k1CFziTR&~tM#@A`(!Vf*_-93*jZOE@TSJ(af9OoX?>+mnd2Kl@{ zdclEya-qe7|I5iT|2%YH*%7C)QiXDrMdY*Cgf)r=@rq19TbgRF-+|4m|2`O(oqaY$7JfWKEqK@WTWjo7caROa z2`Ig*sjWQgmhJ~k%Mbl{AKC96AR=jgnd++!$9T?uqd}P3YB_Gy0IwxLfYYyR$ znf8u(@V|lhgCD5g#b})W2I5iM|Btq=G1pRTRC43sUUK?b5{vB~b6Qyj2)?gtH{AO@ zi(q;EeHBMFfTVi+&WepKcUOuc0R57aNepJE`g3Z5NFZP~x-Nv5chn)p{_GoF%%At$ zz{hsWxW!dx@0QBg-S^??!Nd18*iKIK2L_}I`p;Uq zid}t9i?V!s41M<>T~d8=wN z+^-~@Nz;x~v)}%qWM*>G@-fm(k$P>YuTQc2J{c z&3kyI#cd?l)%s_Y*FT1Q=a}W1>7ijxqSqWeCE7!%P=aG;x?;q58tct-ybnAgOeNc{ zgiACdtn0V$ow}A z9+7RPkSg!!*EX$Zu6`n0uRTGw<7ZRvI%t$D>CPhJ3X<#VTX?24+>zB3H05T@R!f=V zWDgS3S*n#Id95?-l?;RHZR6|54gajBL+lDEokMFyuE>U!SL^=h^?`+Jh!^!lMP`2u z+vu|1Tomg3O7Y?iAlFP{HuULd5XxAdbsBPjV*hOa2SZ6{&Fy2_DTADaT;VmScAw;)SQ3; zYjMuxr@SftgKOu6*DQu@3}?0g$u!a!q;6jQLx6GCSbv<+DjEJy^xcH%lI#Y{r_CSm zsKQg^26xl7BEJK<_dr*gTr%Y@J9=vL(>35IB9ojVL54Jks4*x&1jl=G% z)tKrNc&ZzE&P?U{gv$CB8~osV&BV##Idc)IC-ZvEa{Szs{*P`ty!g<^KaUUk>(}~I zGR|vonI?Eub%F2 zor4Yh)%aNgNcs>^7$~WD;-hDf@2&lBfZF*!_O_3oR$SVH&d1_s89F>vVBBV4G+k-1 zE2u*+zz%QGXUefFI;qigix|lU%e1+%{V6f;5uHECY?ur}dcn8X2%ihIa}Ei>q~(mepU)R!_uBW z(0vn)sm%!Ubuj(lXlmYK4)t&TQDJK^BOEfywW9di$iD(j{S4g0w0y`sYjTI#3GK{?B+$2yDuI>&*PgL0>`~BMdCZ{NAGj*1?x<6 z*PbQ5?@>dtFEyw5{5N1FA`(|+@2ma98>3WskTZ(1JVi0=_-C(1_w@+L-f(j!2(q9u zd|GR9%C;FO9mUDM^=xU=yY7@@l93+`0x&}?YS=l5KkNu(bb!4Z$-(di(S|CCac`G_ ztxs+%s)PaoU3ezE^@DB%+ABU}x1d%b!kmxs7hR05gM@zD4|;z;jCpot+?kyeKu7QR zt2gE&8c$r{rH%sWC%$ib8!vey-0k9yphP;A-l8;uJO=SyKrkF*Ry|A>TA7{j+PAv@ z(#fPx>w;5mcZkR#^q_9>Ooo>!_q|GolH%DGxG*;Eb^>c06FdoXH{)9wez^6)z4owO z_J%L^zWETBGAnL=24vFL&@+3FHzcjHxTMQGAPMU@8$L){lS^rk6AF9PP4K%zvT~2< zv=Z6Ez+lKrfQ-i##nk;gBb_*p+c928+$uomdss7gLr5P0_EfOXjeSoJ=1BPxPa}xb z_Er$n>PG#}gccjDG3@6boKpy}=uM++S0fNX!58nJT`tgFUwZG(>2+a)S#ZaxT*^hra-8ODl z0of^>_gfQ?)>hwvL*wt*?I(j0)s{fqm6A+k%;|aO! zjBY@wnH=?tC4B#cd^GMQ)3wQx>5-<}fQMYW^=9Kc^ zoY_J1jAfQUqqa(q%6qh;`HWt=Gi2-DZ+)D5XJdfX@_Vip;Wo@Uo$Am*o$^PVb#2YQ zPytVpI7#=yDHUB%5FFCR*E90}CLnV3j)-z7)wkVHh-QH&@i7QYPfob3OMpb-~ zotH$nhj#e#GMxJXW^3v1mD`u!fBtJQ#qhkYByHwj+oD+-B7L~rl&UUS?` zv5{!x*)sd|74epE1{+0h@RR`FWPu2;n%aFI73V4Q>vGs3Y?cv9xOYTTHk~Zd4xgU_ zQI0dc!Q0L=dkZ2-@Fbgh#BW={dtrcfGCK7_vd2579s|5zMh?*rr1XdO4ns+G?2Ln`OLbo628#*4e?ZX-VT~0Qg0EQge9~ZOkq}3+ z$Gz9NC?v-A3&Z>zvgEPZ?GvDop2vNN??3Xo65AHQIwh)Sa}nu;<(a z=sAX9FD{}(X?e=JjLtc5N`#ccPLy!C$e0RlWRTtQO2-ObqbGK1B@F+7QnU`QNSSsPk9!)rXh?^G`R2PyQ#t@Z{*3LwKn036l2v>2Z*I%leC-SN=778A9m>0f90pU}P_03WDkTS8A|! z#_ysCXio*O{gzj;uNf=2rPKrX+w$O=@1mjiuS9}xZ-jZu`KHi4o*h{BjCk<2B?p!_ zGe^^X$>FAGxW4H2P=OZi;Lc<1)gpQeZjxdE?? zN_GnjOx|9DUXqHUDTQ)oo5|MZ7TpwW47Gyalp`Gw0})QYPFtznzVKJ)K`|rssHD$pQcDX( z^`x;wn^cQQnUk#|6${?ys1L$T;<}cj8hlqrkQ(yJuN!-qdv6qalo)cm3<=!fh7N)P z6j)nLn+3*zX9=`wIMo*|rXwDB3=v!ePm9|l=U_pF5-qd0EeCr5Y&xw+iar+F$L|Aw z=0u)_nZaJpFlg;zuA#ci1QRU_AlBm=z62(L*mr>Ijg>%hdI*3){9w@cnnB07p6R8~ zSVoDsN2{B0SuFx@-RAwa62I5!89r;EGM|J-goN-?VUZYL%b7S_?W-5|)+O!$vTdH@ z`oS2nScBEYDiVA78wa^^X~+lv0jw`T8zadF-SA*OJ%49xf0?R#mnaeG$|MS2i~#f3 zOq+|(_Q}6XL-k&Z#ON;3H4N8YK!w#?j*B_|B*+^`vD;B28oPY^g`uG=Hnu-oRE*a|@wG-5He$^Ftv* z2ig`Ny7uQn8mfL9*+@m35spu#xO*Tx7m4$MU&;5x74U6p$b-Kh2}K36vGi9Qk~UiU zOFI_x#5OU6qc_PILDnB5+-*sp)?VJt@_{y(<0Ppq_U-3DP3f+9F&BQdRB8Zv%UMcy|s3}W#QolhhuwGbN72o&>-EB?ggVk zz$JRFX5d`;{kmJ9xwfM;tc~FKv@4O~mil4F)DaC6qRZyZiO!1`sUQ7nE6N7h&guOp z;*@XoB4^G3kuetY5Gv)|9aTOlA)-43`+^ZitU*Y3y}mP1G4_vdbjU|gW9O}(1YZQBKn zYtAoxq3b{+b{n5AYX$L9LH+DW{MqZ_J=kR`p7<;jw`H>L20>^iMv~cJ>Y`hJ4Z@2T zeg7|oG7Ir(CS4u&dq3E*dn8xyL!Vi zsodIE zCv(0);0>JndH?XuHQ;2leM(%yG|2k4AStwbEYqNDJ-#)vWi3~!2XpRQ`@S&(+b-zt zABKx-BiK8`v6%MT2wYFht}#-**TN9DV>*o$Yp;3eCH(ofL9P0r1p!*Y{iV3SR;2`2 zm`9KCyPGcgIsulC|G29=xtv6{nr4*U-@YadpIg{{nqhTN9@BB!$9uXPViS7!T*4WL zPsv2SOp21cGVZfV#;>Rg2Q0y1^fXL9MIyejE64H(;_S^ID7bptHbq%Q@QAwdNw0^6 zsy%c^fI?BEBuS1vYY#Lfh?ZTY-7<(MZFycwo9l`CAPGC(cZJw{WIudEcF;M4{n*xC zKoDMD{k^p%;tb25dDjjO@&EX)WcOJ$k$A*)00>$aoUv1J9xDBTG^H@Gfoj$&O`XTs zBU|{B5h1?Oo#@w(Rr3+x929CB4I)s!zD1RoqV3=n4=tVuwh?(f&b^s*YeLquyD?Tf z`Ff2V0mG7eHHS7GAHKQXt17I$hQ5#b=;iwb``MXMt%tn!I?3xdYaG!W(SrbksXkKj zHgQci@!D4gjz>}XN2i+RI2C)H3H==YVAgvSQfl19J^OhF_j#|Fc)moxldFlAob?~n z&hxDC$JKJPr2zg2GJ6+|lPKEfI7dNd=^&2N_y}LNZ3wBJ0>;;u%IWZZb5?^fO=C8Q zFd=omrfF^`q(Bl!u1f(cOmJ{lP2wt99%QvF-^C$W9V&%(AgHpfYiW`tmcoq7buhB>#-;XtP3S z#fp3-y1&=iTgtSXzewqLC6@EK<#z@w%;KxO*$BZJov58+<4h{Qckqray6%IfMr`)O zCl~K;{K9VQZhjwjWRPjg9&9qQ$a(3Ps5L#NK5RP{Q4DLoI>q4 zXJ3cX+mgVlTnF4Bbk(P3%H~{IKJ*U%tfE;(0?GYay<}o$vTCxAssJg6Jp-;BL0(Z@ zmO5e!16{7(^MlQ+1SwC~+gd>6_pQI+F!E^n*5Z~aWlPSX-C9W0Ea}#z&s63+tx>nD zJGDr&`=&cfV(N^Wu2ZC(;>zw8ns=&EW%*(1PK41F;Pmhi`@SHl*2aYErotLg6Rd>A zA$E%Xu&s-OgXo2FEo|Q1i7Rm4JiKKV=kb4JRZ5~@%a`*>ZEDh%Wt%>&$O9VX@Yu#* z#o?kn_(du&i?ZbD1xBw-y=<*hQ1tJl+)nswB@}VCe#lj_{(cp0$<7@IY8Uq3yxXbl zwnR|uX)5WsbvSi~CWt8I3G0bnRP|kf%*FU7BxciuV&~=0s}}VqxlyEh{M4~X30Hjs zZc4|eBV{1e=Xonb$HqrlVG-cK+|supx3QT}b0MUp(1M_NQFsnhh9&CObJM)nPQ1|% z>2yc&@OWb2Qokh6yS*+#?<+=golLqnLVfc25>{vc_I{9XBgb0r`Bp;=eA@NJXQ;7t zBKH);eLIaojxg#Wx_$|A%iea;b%N}0!f0R?jv+|uz=JOMy;$b|%5nHNMkWdP9ymNo z6g|;e)_WjUk8Fa1Zd-H&S&_C!NC*hF2i&)AD+8A3p+-(sVmmIo_!b^A|Br7`_z1vO=>>bG6RVZYKYE}^`qXJ^|gzd zg9Uf{ITVA}ErE_5xwMta7R zIJ6~-XG<(2_>ygsy1uE3W$baiT?q0wk4c^GA!EA9z<>oDx-7=4i+*le12*u|9gI;N zPGEh}iW;#};n_|kMozOw(y94RXCN1UM1cRVy=z9*SdkL7Sl@SdQ{J6&qsLAVryG#J-3H98^9G38 zfn9aMQF~zYDK&Oz#ynKj^&WH~Msjioa-DeZ@9+b-;c?@=^?(~E?=miM z4=_V>gi{;%k5ggZ1AgyO4KsBDfqtUe6&H6f(%dd5=eiGXt@yX`JsHl7GBqXDL2j-a z)o8Ul--qL~FHdGM4=9XRfWhmK7RlVfR*M6_98H`Y+MH=Kglo!2Puy=DOLyF5`{s~i zXzS&DmkVg}Y-N5e12g9N(ZsA#YDl1p>7l+R99tr3VEn@GukdY8+U;>f58J)ReVezV z){4i!D7IqLoqKeFVg04IGL3lPkl_4b#SDZyff%s;^gJ)b)(SKF1&no=g zp2kdsm-r#NT^d*o}{IQFBgIC!CxtC>y0t5zTm6YYrMYS@ioE*N%%M18$?#H(y00+Sj&nS zr;s3~x&=D`M3w_qZX()zy4+9wv5Br(UM7}H$@Vvf_wk_!9OeRQ*IM_{{oId-(rD0~@j(3iQI{CAUW~_L^9o{J?|7-YS zXy#7+eTmX1->XoK#{jwXZ3OBZTJusQ@x8iV?l!wXFiSKGHS=RlX4neWdZn z&oV#l=6S8qNIkY+I-x|X*oz$dI(;M4_01s=Cs?dxZihVN1_1^?H}$|5wsd5pcr5?d zpnM>uxqaIa9Qt-XEBxj7%_g~jp65g#JvL1o?N_%SnQRAzU4zaOSMGv=htT~G2NBqR zUM&eFf?u-al6R*D zK+i9}P_e=>%2z_sp{1s{40jq(>2X+W%zmAo@`|DR5^jU$NUd?6dMEiQd+p`QTmdAc zT(RupH5>v6dx!nyU!Lmr=QZ6Bx$AX({ufl!wv_4H+5Wz2z_y@A^mSGuv!mzb%yyzv zmo&J9p}7KkKJ9mIIvZ9pBtrx;!jB$)FuXIXiT0tG#>tlc8y)Ygx*qUU9InYVG#Sgc zNbWN&(pZ0+LoF@IH*^sdZ2LN~E}UDRWuOhQ+F8=jGg$R>BDFZb*5hEG(#cZtKstvB zV}si_0a{2;it$uy#flz4XE8SzNeu8GB2|KMk~+|(Wyb0j`(M4;?md$&fyv&m7uo4U z;pgapUjYpM!(~6912_h5xHSw}s{c-1ucK=_eC<)0>w~$^r~`n#s#<>l4n7-J-6=PX zRW4sgc4nuV?f?{syx*Z%9irq{&Ypb?yxTtv-=>Y4qD9%ZbQ0(4B)Y|#O%-*>*jMZ6}4E*JaRV9ho6pF-Ksu2>a;54d`S`@EmqDa(e_)yNLdg%g2o& ztb(qj5r@T1GJB?RB>vc#j@`eAh^67L#-#5wP9^A)KW}BJH^OmEVRv2xXQ!}s$I$_= z9U6Uu&2v>Lq|;}GoyJeAtW1{M0{KQma4y`_-r6oIG|tDDL2uLZT1AM&KKA_Jep3e7 z`Wpiqs9p~gpM+5iG1q8u*6rw3nCZQGb1=hmm&|I0lmY6%YJgG!WZ~ zLzh}0!+7k$_U2^v6~~eMimgPRqt0`{aMgW0OC(X;v@5?e)qPeZBx)APG(_Op`# z@$TsvOl6@Zy!VXH7Ymd>yd@O;)Rvc&95iCXse8<^MwpW!_Rm-a7jE>8;$;Pb_s0V% zPiE^RX`S_={tcIv zgW$W!jq)`7CeFxx4N}nXcYEnr>E{aiK2s?5(08Hf zR%bT`o7Y$y{xT3oZJpQmM@92o>y1u;QFNX1OnFU+_~RGQxb-JA<=ciGZ_mQ|zL8a+ zLB<3Zs=pU8wQ!EEUOZ2NNo_x3>kyZf^t8Uk-yc!WgVTGF-ggtC8HQeKuyB8zgFkdAODy&r<^TS1h>eSED@)O9{dPMNak! zc}X4h{QS;Y(4jGo1MK*xx4%^%DcQ>Q>vSmc!v~Tr5@g;RapsD7Q3`>+_5!r_8{$-X zc{D1(drHj>ng3+($B^ojBpbL{8?THEu+E<#AlwmiLzTV zUG)KqW+cA2^?m7jkbUz;8{lsGeBAfR#h{K|>COI1u2kpFo4a2*nP$ZB?-f^0HLRM) zw%7kARv$_W3cBqo_29^ z7ysvwyH_}PRgceqgmL|q&!Y(oA7U!C0gv0Y0dj-gnotGvgTK2kN+l3@(L`!uTRlOD zoV>3Upyn~^%6BCaSbu*HW$dy8k_NkhGOfb^-`INIfS! zy&cqstQ7{zid>{t*ema&_Hm~&fGIAmC+WN$KN^ublX(g~f~$^Bax;P6pqeTOPSeOl z1w`5%d3Xas0=`;xrC^*SYC4}S{acXKNGu4iXND?;H1_@ibOgQOnnR-Dr~_cunh{W_ z=^LiUxo%pX)~(e&daa5x@^rWq-(_yvV*QnPXQx9_URVZZr-th40S>pl&2ll1Iw;U59M-?~Yl9 zgFM*W#rugpEnW#sH&vzm+%qn}`$^?<+ZU>hq?5j~Rtc`64It=b{-NMEm>aDq~|6M_ct3>d%slNMFj_3qahxI2#wR)(PA6 z*vaz)K17`MZAmTWlY%og`e~NB-fIg{SfsXC!*pMynHkq2D{>|9_ylr$0J^E~RPexG zEJ!BG$a#5nQEK&USk!LlYqeTp<1@?=iN23}GzIG8i=nFu9@X>vCfvzf2G9+r5Oam~ z_+vJD0wALdz2u8teUb3k#@QwuVlt`NJb5Ej2%D zx`5jc-Vi)a`Z2{g>I<}}IyUpSM1;C+#b`ltEo9{>2_%2fM z9>l$Sm2$5evHUA>?_56JRi#(fPTT91ZEF43hh0q(qKD~Fy@lskJrDUT$kHm{+?~=? z8CO~cal%BLJ;yuAH_wG%B|KwcoLxyXSbSN>OSOXZ(>np(<80tz|#B=FdX|9+wt$HgpK0|ApoDpV$*fjC~qk|4-N~{H6|L zbL{f^j&A)wVYBPOQaics{c5iobA0Bc+NkV^O7>D~Go13}R*m4p9E@9RqV-msbQOC9 zHpwqnJwv(xcY^QQT(W_ zDGS+;QS70Bwxi*wI3+w>pR(X;7>YePZEk}Y_;G(46JdNnNNXVP!7k$E-^@f7(Kk2- zCCkP$iwjmAllmN2Bi?#lA@q~(`{o`97rKj}rIoM13`@%h)f|F5hA7OT;9`Tf>dQdY zCIkO{hhdpWi_bK|_roruV<1n|ln?5VbF|dux=Bpj=3J{~<0s$th2s(I1oo}-#^)=r zzwcs*z{!9UiK-bB9_fRzbZCG{B@z^V@BYbByZ!Gm&*Q{;8qmzr~9N?cr$j)hWCz}A_Ktu zNTU&v*-u4GK*Y&W_R7$I@n{s4UKk5=dG zA&qTGD2M0rWw7>>-Gt@VVV||poVIY=*Uc!)o*u%!O~BAmka?OqE~3z6FBG60J{LOs zq0&jTI1rC?E~hg~x8UMMkOzsM8pnV*ygNnnd5qmt|hCn65B_Oqc^*D_kI z*XRd_X^pXSf~~MKK&RqHKVGb^8$u}h(i84P^q4@PwG6`U?#IJ`eI=_=)nOU5JujSM z$%6Zl@cUUeqIgTb(c{(hI9Kk!`yyRc8C}XZu7=;U?;xWqGCHzGP1HOt7|Y#KHZyov zJ&C-QLi#$R%&$hZMy#)hfZXl$FCm@B+^W9=cvB&SSu^KnCmRvHft`9zXzJ{Qwle*` z)>i`}sU?i(BU)$h>p95fQym}Lb!98sAQp(4p59c3OHCFZa&j71T!d^x+4!Fqrd!(b zaM^%@aYF9}FtL&e%Ju*+VLIWPTts-pw|m_PFp3zgZ})-5a0fgs@8kaO(Cz(xQ}7x^ zBW(eJgHs{{^aGwua2q$)Y-Bl!*rCAz@?YIZpmhf+AOWFsO9Fna!|e+|VOA$HQd9hH zJCjV~sXGh_W^$HYuP;c<3*Gj%^ukNwo1WvCvLdOg7Tx)?!GPjJhwYRbFw-3~f`un) zh(eWXM?Z(KRwO{E#A?xoIk40lfy)Wm0 z4=+l+R1dE_Ztm0N4_fI7+B60CXz%4ppNyuaEt4Gg*?am8c3a0HR=A=ARKJKO{x4gTLmvKzjQrjYDyU5#5 zlyvVcLFH-=mX-v0u@QfNLw?BXNa!s&Jg}-Wi`}4ZdDpWT7@pI!-}<6T3+(%mzssDs3u#E%ffDZDGq&iJF&O1AbXL7=Da{R^WDjB@S=K9YN-m{TckL_3Df5-92;oWdj8B4hagAP^R zU*(Yx?S8Os(+M?fKRC>XSd0i5oPjc`oOzb>2i`T^CA{L_s_u7tNY=z}q~rcQ{DI;5 zWyh62#{BX~F9uIODx~0z$Gvr==5raYrZX-ze{)QGzO-<<$GsHpX?(ReQ`)}kr?L`m z6SdJBY+$e1KkgAW|B&05pVK_$=H=m)vvP45 zFyaIAD{0oRwrH(C_8XZ)&cOq(C3cXGQ)S2{HC{u5IB8k6r@y~F7k2n^IAZ0Hv9?+K z9RY<~;^YAia3sA_w&&y)iR2*DB~{wIum5;htR4xD z*b@p@35NuY!&fcbq%1ONl^Lw}y{-qWxY(yTN!Aj6%|1uq|1p=$TzW_x9$<9I8FnEV zm_KSzj9u#jfRpV@8GJQZS-!^bFGM^js%`HYSlYIojt2+6S&`r z5v$5>ab8f(`C(W`$$kYtNsLa123!^$)f-_So-t)XT=g_>JYR`mBZ;OFkFpTWYCBSd z3gC4ySIm|g`0Iky-jxwZ{Gg8;-*@TEE@xl{{zo9D=7|Mm{tG9N2J#b*Q?*$|~s4x=Cyif~dXYs)k^4pW))^X1*+t;_ZL$CHy z#y`72$#I~0Xn!{ir52qC7xFe(ZQI2hdKxxIQVj+t=kOv|`4AYvS+#af z9nVz<6pMJyFTwM|_@^i@r>>gob^!Bc;=7G+#!O$y509vDpJf}26uO+gtP(bG#_iY* zW&5afzHwapZ@{iIn@dmr!v{#Le-s;YQ4gQvnA*3UNAMg~Q9d4Mo~Vd-r(5oR&L;f4@Pb`T zLTnBmpen~Htd=8DW$4?}fAoVx%`tWV;{M`OqvC0A#UW2{@Z6 zxlp2p98e#Z=r@eRKJM2?HY&jWTex#N*Gy+_e;!VL&dT| zTj#fbjHm_E%0Xgh3&-Ar>YhGs&VAO7xX>DrO9W1zmT;D4U61`uvC4169|I-mkLPit zmn1Lj%!&RmBzxR6yy*C@SIhicfYvH|sG%5?6Qfb)uvyYFMlhOwiK^ZG(YTyYxfNXU zSe$0K{DENZ9r?P$7gSC*--Bc%q*y8qK%CN_Dri)FZN|{5rqchCqcPWm>*Ebm z43(B}W^Qf1u(1Z{VMG8CA<@4d5mg6jTrJOxqkVM0B#4vUfV09=X5|7{_Yq=T4O)fr zns$;SydFQnMVkjM?L?Db-yH90`I_4kPVQpz9&z=N|D+A%5>E5ra?^fy^U(-n;XHL! zSkF-bQ{Q+UH^dFo2Q!zCQxij6glex{v*u$=W$-Y3B;Indfc zviGfJ+y`7H^KJpC>g}tJ|8)Vlq-}kH!aOTv`e4mj`S18<_`_@{*eYE1Di--!%Q}C8 zHlS&oU+hiPU-vKqZW=52LrObzQq&uaok9e{|K06Ft+(?lp+?=ztD;7 zBxDf?2JXT^!rgZ3tV-p^R_GUST&}AHut|1cg{*4S9zv>vcSyfOVB&c$tOu_tVE1LL z`>S7SMzU{4pQl#!9Qj6`Y)cT4hBsM!2(eZ}Kaz{+R6~J4vymsqanzv) zdfmS{lr)Qj&0y4rjRn}j1Bokxa=<_iyNsvG%xt&4@-=Tw#bl;h>v?Ue(@hKZ(DTH> zs^0I?r()Zy_2lfR+zYzICmq|wEM#?(j)@H6-M^3Hde$-uz_)I))Ym+> zoU5#lGrpgygYKwmvd>=_Y%nF@q-nm0=i4cM(z*1LS4NORRCts>x@)Y{_eEp0D5u?r zv)b~f5^m{K1}3@x^3$meZKr!{G2{4^8%V!6XHu>oe^Y{sx&|U+N~)7)W>0&821HF5 zQ;(cYov{AMC)10jDGrlPagU#9HcW#PK9;RehI1m;2D3isQ=jSm&Rx`ejN|(l7Qbf7 z(w97wJ@1H_jH>%0ab+4~RhP-XL(&_8aCor`(x%3;kL#Q~?)eKpmUTkSr;**hikX(> zvY|zSo8o&UTns(X{YUT62eI&-R{pvxOna-Ey;oZVH(Q)jed|TlQ60?NzZq>9=VS$Z z{KhJDUlTMPxESlU$2X3?FAg75VO^{~ratgR6{fyQR`HZ3U#cP#4EFz;hYdun<0&te z!dyHjhY`{lX%bj6vOl$-5!TFc<(Db6cJXLb=*zUH#)2UO-?`SqoY49^F0@>H*7HcK zx;CHB@1w>;1W0C98dsrLz#Ei>qe*;w3lOV95-Nsp$B9e$g4oKiv$f%LDXR5zKZN}s zn$G*34L5B2N)^?jMfqBxR#mOqGt}PQw%DUKwMlHXTWYTwtu6K@_TGDMg4hHJNgmJp z9PbbJzi{2hb$-t4JY{`?C!@Dn+Bd#_5sCWo`?IE*!v6DRNXh9-LP25Cr%HaWg3Cg; zdY%RLST+rgE$eS0Mvm@2#}YNuL&bIGHebC+OPO%nAsw!CrWhCIl=*QPCShOpsv^|` zlT#b1I93WHq}avS67G4Ty@@l6o{K@@WeZQ_?cfDXcV(Ta|73pst)H+9SIMI)jj#S1 zdv|xc%SXX?GaOY<Zkb*SD&Esh33M{uQ5eS+!>+r_D?0#7Y2kJK{l>F;KNTA5 zxt+YwkTauyPW0!fAlUbb!W}Nm)n>D!baU&8L!85j_fQ_T#Rz25M2OK%fe&#GpUFt( zNoO#VRT$Et4=#Gr8U?b5>b5*@x>NE-A=#QsKj0aY32%Q^T|>uUJD*#S$#t=34L3YX zDN5T?Vxew{bspSx1^eVThl&G}<3DVUcTF_g&ZDxWLtOSan^q{&7&LaVIJMoTCNMu^ z^=vk_w(jMZ<#L>Huq`m?tu{2(VEfLYr7F5)fdA6!(k~!|iab}atal^5*Q@qeJ~%y! zV_`Qo;(?|kndWd#yD*=<#XB9-Oyb~?Cc+0N>wI%8$?0!5-)%@J!8hDr3T``7rG~{n z{*l;sNrn+96L045e*IEFz2Uz1w7eMLY&5#II1DgpA z^Zl*{T_K$VsJ+}g&)2?(b{OilF)ji_>m0p4-K&U_1;2`=b^T0kaLxBuMJADBjz+mcCwE=>({aFxI z@aEzgYsUo$s4$~s4shei0MD+RnQCQu1NCvsak2L`j7J@ozaD3z*U3pAJv_V%B`u|S z{GsgF8`)D;0w!JrDt}}Po?xhbB=yOD9qia{a2%F= zhX;0cdX*#!JcJYpiRg<>i5HaG>I2&h=A>{lcB8AucOKa!qJf{jOL1UWx`jFKY%uu% zIM77-uRl^Z=)Y`U#H<;k_Y1yySH-z$@}yJde{GK*li! zd>2u2)P=PA0`)gT<|bCPk<%5mus%(CVwQ_Tk^91#j0X`d-*Quorh`L+iG2zdzuzh zme4n>n4C2pCjqgzIy*?`TW;H<{%j5 z%?qblZ|wO=r&)D#;ER_E0KE1qN!9_bx-6;0gofU_F_PRqFYZfyQQJK)cz4bB+KcN~ z1zj75Vi$91-j@;c%ev#+9U_SMm=a-$f`j(7J0UjlcHzKk`LDW%Wvo1@$gb6!YqGyxJZbUvJyz!p z+Xvoq0FBulhqJL;Rj)#P8`*)f ztgp+18UF7Sv%(J;*hwri?7R5XcgtV`7I>O|y;>HwTKXOW0jk;z8;!wdPqTD0TKCRU zp^r_aVS2Bzc(Nx@md6*<`WKy)K&(e*E)nUXat24|*@j(6fjH}<6jHTMEvjFE6hxE) zk&_Kr_*`cz@%s#h+e>4EPdh3$lvHBB$Z82a8{>U9OS6RDet{tXfVWNBp+{5{JUDv~ znCnHDFFdvg*2xnhwO}gImqDM*$OMI~+CnzG7Pz4maLRp;t255}J|^%t-@|7yXg@%Zgwu81z2fWT3o3Dhr`Qx^=1elQpt`|oMAvJyKyY5HAUqG$X#Tj7P z3A^vBuIW*#H8wAkZS)_s=>uOq?v-)NCXO2YYhw&slI#e(GQW0!Auq^p8c+|f7G^t^ zr3VHbTcbwz#u`_$S4_^nk6c~9V<^Rlnd6fBRJ6ygQ6)@&zV0y0oPqC@0;&^}hDr9S zw@e%}89;M@Nh`*2US{uyxgt*qMXwE?eN7qoNsG@7HIMo;2Hlu{=yoeb{;JV|XZDaV zi4_?KM`6EkD21fhh_*a0qo0(qZ{^aGPa@P7^k*g>pSdw1KWj5?ls)(pSkNT>JzySzpwKg!r_9dKkz0)-pL!M90$2 z(#~TGM>*tDzs7FiH>U*J_Hi;d+gCqi0N{#U^w?^3oK&rJ4yS(hQ_(i^)T=mu?c3$6 z2p7-gkV*%FB}Ot1IQ#AOC=fh-Qjbks^Egtziy{U|fp>6&OBZN{qM(faMo-Mcz|FW> zn5@;+@J-!<4y12pDN5#g^T@gl#|tqi8M_g~trBl6(D6Q;Iw>BIJ{DYskUacYCoS^u z(#*fD%H7^Wq;;y+>9m7!(x!@O{FUji61Ru+T7Xx}MCh2FLqXj$d7%Cdx198to9+4Y zjpZ+}oQJe6SFihv1_Ehgq~|W?DEKcmH7BMdtca{&lXkPG<2j#%+N%$Avex+zidmw( zwL65&@Fg7ou^W=G@Hq;;Vtc38=$9_J8rAkRC*e<&sfNe#O|YK#za|a~4a#}NThi$? zLxY)}OFNsf2M9W3l=*I^{y=DXkj`*q*|wKj01@j(pQE*2e6=v|goSF|CW}dk9rg?4 zBJ9Yn4bxlX%@4lhAV-jPNY_i(t+?H8EfF0{HLrLU^fr(BU;~?()|eVjB95N<16%p7 zLCCSn*X4BA$b!{lRt9m%Oy|p5&9hZZ=pWw$nb=;wfY@Y`L)dMEZ=EORjCF|y(lxWh zlkKtFg@wGl%kC?4^8JikY;^Ly&c5_*R=$?9s}}V?xDnW_W?haar+b2j0XGdjMCY&J6I zQZN&3jnO2t1aYbQ?o1csPW;ZGWJ>=J!7B*OG3)4fNG znZ8qcImedg6fp2bbxGzjssr`rkL>J>>c*o4hIT*3iAUd>d>qv!A`2NGP1dqcXe%HO zRAOyNUXHekSRQ2>{+PgDOj}$PGss?SyFoyg+OWP^1W4t(O%QoK3^8#udM)#Bj@qUL zDS)}*1PP6zL1bT}MvGwuv#^stHNBBGCuIk&Wkzs0;a1$G?fPEmDgT$j(3#<#Xa6SM zZ%cVLI^XDj&}YLA{wPs#1A_ZYcdl68bR;o}^8p zd~o8U1Y&-*jxGzFUmps#DT4uy2a04OqFdf4;*V(Bw`!<9$VH|v#JlOAqT_ki%zzlvG#RUolNlf_)JDtA*I75;a_`8VEM4 zo}Kpvat$@_##^rtu$F@pCF@oR>gAn172Q1@&HcKj{AP5%Cl+0gJ>eKoRsH?cBbCFx;It5ZqIM`WmFCZcI4FFNx z*$S__zT59wa&UrJOx44Un*X}7=6r9|pfVk7SG!uYCK%7AkR<%4Z}vPiPO)cR3UR&K ztRqL1>U@%TniI)U=%Ab}ex~y|%Hp@f0nOw_2eJk82__dAGyBz|X>y|xKXdBgrj!aWP-%NC2;==sEj(&ekX$aO_o+~t>e&wjNzX^pR*tv9z#*u2%T)h8fl#1 znEl+IvIlS)aRKsgYztTxwwc9n!o`v51yC8>7ns8rpnOhi#Y4IPuq)?K3Ko+A&WtuKb=_ z_%DY|5OMjBm0^Z$RMY}=38jAq>@?UjpKWTplU1sSS676Mhuana$Tkj>%|~qiyHUp# zAgFf$!#$AqFl7Dl+A4a9+#Yh(e2u_ylYv4rG|JUJNz94=cRi#3o?rfnw7P?TlA0^> zO`93hv}vy2m8uS58Lm_Jh}9@sp2oq7K)`oQj~seAWctL)lP+DZPVCzr`R?hP7ZrJ# z){A05HM0jF$E8a|dIdf*?r}NHW@FZ*cd2IeC9*HJUnHDIUqIhE&7E{N$-~GI*I?9x z9Nu|e*zxvN?pT{Hjtl$?XP%K==TkSQ$(IHS3Cj|avi=(406^PTF2(odx+RUobdNKv z#&uncWmV}|(kyS-@3~giy8*?cqLPS-7fm;|GioL-SmQu}^3%_)du87LWz1Mmvz`NO z_+n-`ic?o@dnqXlH%Z)UBANB|1-$>!^^htD@czzEr2b^+@xn^TpO*SB8p%`oje*Ti z%tfW6oW8QWd41-^hlPe%yNqnMab>(lB_`K-3+=v%m|eGrgneSpUA#fP9<8IC?dv7S z9TVvmFZt*11kQN4B$n(tf0b+*;pMOXw&|EnYqva3?IJ_eb4VRWo$JY>E%x!=tI>58 zPX=em*B`Ezw>ZlDsP*eb7&&>{-2Mf$I`}S~$ov7g#4!BGNZ4C^a}t_V)`M&$=3@4o z*}zg-1E>{}5q!-86#Ii2O(aEbFD2b_8zrn?=*MI!Cf>JAwR`($9p*-PnX4r(qz^R~ z;{s$KSG;Q9D-r%=J~sF4{q%6EyEh3`SNH}IvFrbJ7M*>JQ6O`Zi+WLh<^93#wqH*t z8doVX!|L68;@mX8!62>bTxSY?4l&^pqD$_yTxY4e3v7`f)<%Tl$Fn zAhXWnohe4B%qP|h6)ajQ-h{oc)}h6Se({N|=}j+aV5vH`r{soL8j0qMi99X6AJKxR zlouFoK)@(Sg)g(J`WRFB!gTMN_l*jXZ z-I)x}n^1&H{Q(Zy9NaLua1epNPK@PVrngr#v9n@Z!9kK)wwuklmAIZD|Lu4O|5Ch& zs?*9|V0jize`7J&jTZ@WIT|=}YeQEZC4wM5)#y#Ra-!o@zBh-zhHpn*j2jkUSkn+%hq!Lqzb5XYN5?43N_yGf_AKde?k6afPeD% z8~vnVhrLb3YCoudv1 z2wNOgBKNU&XA$~`PX~X*SmpWJx@cH&PKr0aWuyQG{(BQ~0x^@HlY!?cZhyA#yT*P$ zz@)_F|6I3|lz;Zxa$Q?=5tUywEk+aN-?o|X!uiXl%1UcbyHvclnXKw*FduH47A614 z-2B#rGwK$EOpjV}txYmmyE^q);XzksSiAT~f@xl4Lovq&bq2V*jiWNyMF4OO$w_`d z=u?6{Or=&jsMIUf#+GMq8o=oD3MH-{REmJvEoH997~W0*lgkaR=TXB&^vTbO=2$h- zbTo*W2@u39y4I#LJf7&M)qQf?#?bdXz0cto4h@St+4k``rY70vyrD8HsFUGc%4Jj4 zgHz;e(*_fYKTSbyUbQd^;vjV*$h&BKqep`RG2U6<68W~`_|5@)-OVEGciXoS&c^$# zGUAl(&geUM>b6Z^HE;oDznpJEpX{W`@RaFRqv^1EEa<1PdKAlew2kE@DlW7_WjC7a zed}|hFe>&=2^I6QepKtCMLRrLWizqNi*+*Jmg1d+ZH)gPpYD^4=|1bSjm=8{_ zMc;dF!0x!Gwh^K2VDsp?MrG=P-^>HJogsqHi0$|WyUJ=yM5C%F**3^!U0+iBzu-XP zduh>M1m9PJNS{Qi22ef*V;Asy*4V6FMCvt)9DAG9jfl(mXDs&M%mMvYnHjW_6D>(zPH^J(UVHmkA2|9;iV#t@}SII!r`Omy2><%(E>B0O$) z>^)DY$&`n}M;_*IHy4?X>yuHo(;Sm^2#8TWw;9p-mHMRebU0@v* zP;w23bLYb6(Wkw?Z2Ml*<>Gn|L86Ne;L(OTYs}#Cx7q31;|h7gnq3u z0C2^ieWCUacH8irRIFa!18iRDUkin*^&q;CT1*lic%&Zc&=!(T`7DpJLdAK8wbE zw9+lpYru{s2E+o7QA0AnSOQUExO`VKv0OzURThbCIg8;HR@+f>vrfr8s(*k3v94mq zXEYxRjoCx$nL0UF%?1`H&6nVr#si11hAb$r=M zWn|xz=VrS8;?P5Ge=w9zwg_-&+kFbJKmOa zw2Q_FOD;o1-b1P?%`)z!J(DB*<uc94PmQMW&*YQTul>R^tKCz|Raf3yWoTzNAB#&t&pmZ4ZEQ()(^gry zO@;-Ui;aU+)vNxBKrRjPH|c&|$jW5buBLma%WFq)vK*aH%PrGQ=(qPA=pmE;tm1M2 zhN0x893sDbRYTV3Dm>LHS}Brj-`zD=Xhc|nj2;vHZY2L^*!zTuX5l}&_Y6-bt3(O3 z(pL_4k272_U$?!quZx1I<6bMsk zt%B9rP-aWIF;Cln+obZxDxUwIQLAlD2Kj&FW4K>W^E`iC76STOwP3htnipAOjoqSS z$|io78=Uo^yI;O9m(ia(Hc%(iGIN(70moeKFJ}~mA5Y~1RwMuWQN^C&f8zfpM7$tL zYIf5gWn>9Iw}{`Z+Whp9=v(w)4P9uaIHm4J8M!gOyJA9>*`!bQBq41w1#3&}2hX$Z zH$--rP=1}s{jV{g48|1>nI`;J9|$Sv`>rg4yhz zXp}-`SG@Zjk_C}GVqc9Q0||dwcjqC#0pXY$E$6U&aMk}ojr6vW%lRub)Vf~pn{FrActI#bthEJdyl}{$j!47 z;dyQJ0@?hmZ`Mf)yO{0BeA{JNy?dPXq7KgN<@96dRPHdE)HziMlca zq%6w;*=>bPYLl*w=xjzRiP4s>wQI(Z-cH-ETXPlC;7G1b|$*W$jj z^fq9IbDf*&s@7GlKXDqpGX!<3AN;R-K^yg3L)MRS zqWW7>Ql_8c+rHxxF<`+7H`KAcJmzU}fxGRb z)yeK9Xoo|3d|}Tv0o6>3gTa+4s`pAa))@a+k7+t3D8y+~n*8M=y!}iKE(iC)bkiw? zdwx#f6{97%|ICa}bz#erp*6-PjuY9lL)R*iHRd2`Oo|Kgeh*VQ>p=gFyo-GUu6zko zM|9kdeVgX{IGu5w<8{e`d8deZ+oODmO2O``U&F7XYcpa}m%{#I<2jcR;gu%P6>OxJ z#cOa~v=2U7|8`4SD5rD1#@wMz?5Pj)I*m4dHYzFo7A`Ht+j!XRakj&W@g~V@(>*<6 zU-d~ki|5t?8~VAZe1W&Ybk^$7nUIpTegmC3?E;(k(Es`?df8{Ky#CsXFH^&jG# zvTb*Nu9Lq&#%V$xG({Fpzx5^pnxY#Yu$m7Yj-4>KI}9Fe-*zz;=e;S%ZO&c(6yuP$ zCioq{a*(C$UzI;Em4kesM;?wTnl|*Vh|pc^GyLp)BAV-3|6+2c#jUe)H!^ppd`gyh zZX+i~_-qxAtufoZA$6$k;Goe>9|B*SJHe`CRJ*+_!LA}NyNh37UeyL9Sv`;$XVsB= zu~i7soO<#FSSpIRU4xv2%lL1p-cg>JK~P)*Z9k>3g)&I)W$A;trVGT%*a6NGtfEIEr7OhA1`%icJ^CowuScOQn4Fic9qR{hr5J$t$Ex^GI_>;?cooE;)MT})XU&BzJbdx8W4#U zEn7u?ssSDa&n2@$P2HJu;THZQ|Mg!I(KKcM5+009S`& zi$MBn`kq%6_m#C?`_noU<*$yw`| ziKAzNv>g<_9W#F$<}SmO7s2lsf_PAI`|jo9*$rRIoI5tN_TyG>vf+cJ+Ns}tDK5wx z!iA{|e{9+sVai52Vh@6{itvkNTucfowE5mgLFM~=6S9xBn7X{TRb_< zJo=7|l{{gPu!%NaUIt!4P{A7}a76Gi>|8R8A#sfBg=P#{eIi9_hAW+fTo+Bz2iiPY z>SMV;BOI|Pg;vEofmuJ{r!Q%pZJtPJR4zmh(KIGK>V13e>ZjGeiyZ(b9HNN_yK zZh=%00Q8A&fMt+t%Ik=b>XB^J1GiC|Q41iGcs3?9r|;{A4T^rNdN{abw8cf3Xvpn% zrk2x#j&DB87c@B~9O3DoJY&1z^n4vppLiPk8Nrb9U4&z~*`7h=i*}TBcnvBn2s=k5 zMUTy(@4-jx69P1}yZ}08bWO||b8?`dzbU0W3udE*XI{=E9@59P)bC`-Z?XH9+~uxg z#?iOY{q7@-pLZb7>-FiWoWQFjbY|`*L*4Y+0O_$a|4PZIxgm*3Kmxbluoc1BL9G!G zMR3w_Gk{27ys2b0-C%=^3|gSkEF8kLCUIa%K_lKE$;S$Z zJmz0HK6BE@nr2)gPs%;)gX#559T$}VaEYAzaC2AhQbwAm8-+7>EY7QgBCcN;DTO?Vzjywq_!7(H`>ClTLeRw7pdmhJ zpG}%<{B0%qQ9$tN@o|rsUJX9usNdFhQ+JIk{NFBH-0Q51y@==72z=Dc_?C8>Ix9H8g-}9({?jG2V_y|D(k?n)-%TNewlw;%OC;%8UWeL7f6A% zB38uyU0L7%s0pMoDIL?9dan+=$(nH%40n-Zen-DFH1SAiT}?|Sz)9#saTy%dR|AT^ zL@OKKe0bVB<@LLh2a)~CqR=e@p)ZltP3lKS0 znD`MsQNGl!R+lp{9l=wjkL*SJCp=)117C$Ztr%ZLa$inK!>=F7pFsLp>7kt7Szw*n zf|kR{qfbJ{9!OMA(xx^Upn%cqbd~dx8x5E!0FN|-BM-i;xgUAlD|jb5DW_A;PB%0@ zBSgugUt>XJ+hVva3nGzG5tHmSb0!?&k!cnr$n}@QUH3l?4Dq@7iyvb^>#UyWC~k2S zLf;j?V`^%fs~NO7B&)z<{L3m5Cn0poA*o#4b58#^1r zr3p(Up;;!W{9|W6DF=|;u!i43ef=g6Y@a}72vljrKLrJ<2L zzcX!FxDvdDK-mp(nKWT(CFMUAW$nMlXRN*tnBn~0_yId+nKjJXUUx1x-y}GQ&|;s# zW_j5(VOP^HKgAY9w(0a$sOrkIV@|vepz-gl?kb|-{5nH5j_k*12dH7*ecmZ;~98>HbqG$@_S zQ-qeI7dG{1F3;_~@-XF_saPNUo(Nj?KmW?}hOh1oq_a+? zEef|)Vcqv)IoLXq?7U7yEM5@5YpqH4D=$ZzQ00VXiS1=hgcpvwN;Kd6{h^XxMd`?L zw?bDL!Df+j?^1MGB*JT930lPGWHzSxVpxk5{M;)grblG~Q^FCi6!fx}l}@6ZqllGx zq{QW5FRfZ#bE?FoaKz!?U;?@_WnjO#5ucL(KwEnkw)%Vv>iLf40T}_rL4x}4JiYI zGf8%x0-`@0l#F_dloq$djq>^AnZ-T6?)cby#gg=pg(-O8(F=vo(Jnk}8@E9^OA;qy z@IC=TuV+S<#;*ENbgs18%~LP?$4|Z~UCe0`zr5bHHJB}e%y(xTeSS0{q9ZaD7REGT zseBEJ8m*5tL7UKm#C$gEK1beXjWm&ysS)gy(ORWvZ{zwP=lgIBGz^H1A-Bif-L7s( zXs$1rNdE&rP=l+sDs4t0wBR#S=(!=oFv#{$z zUof=X_{6m+dHS$)^91et-DZaT(Szee>uqL^Jj@r*KF-@{0nia3u+NsxJ4__^w|nxC z#_ZcHm_^leV2SFVXL{lSnRGF)cCkJB-koJu(XQ$2s{Pv)-+m~XOoe|T&gu<9a9GQv zay(kHSu{0Q80e7=boI5u3=f$;)dI!vIJBA)(BLD2ijXm2lWF%-Y{ScZw}_PGaSx0w zmjp4M&BVlGPg{Bqdkz)oSIv!AS%U=fbJ>RP4FVQa55c;>q(_e+sNYGFNYQD~old<2 z6dRk)0_jCqLR-vkhafwHN92&Bw&{b#-A5rWFlt}e1@X5O{jOI>*FsRjYr6Ss=1H*= zmo;GPxxdMXnFXD!MXxVcf6)*+;~4+S`?tSSW!rL7m9X?5Eb?~S;~DgDmXmM#*6ZdA z)9Z~48r(ZC4L;ZHnF}x>CfIFz=BlMY3R(mCp|f@o>5I{)Q96v1&H(Q$k4yV#01%yj zhWrca(=Pv`_b2Q2AeO)i!W3X*hf**v*)uz!$BDF*2z zS9UdJ?t%yJYm|Zt35L-PHPVXV%8&Tp3|qUtN9l^w75~c{G(iQAKzE>-u#Dj+Usl~%TT6(PfiRy z-1yF9ppTwT0x?o)KcQ#xFc?sYOzPoYb(`{w!_sVWIL>^HP0Vuzw+=x0UX}d^M?3mK_zFiW<(YNk) z@hEGbnD^+FUtV9^3+SZEK)*;77_Urc1`vLp+G2ub`vHF)(1a(*-!~nW3Qe@;Lx^bn zt-8+rem%8$4D~AKXKOQRDB77$@v6&cEz!xn9q7JfzF}p-|IR?)xOC|8CqO-Tf{yjO zUWqJ#txv-~ONDBNfn(5tYM=I@xLn5kcVq{`#pcgz8OHdqTJYvaYTCeIU}Y&u&s@_J zg9A~5hECaE>VkZeffMA;Ww7rL4*yqkX9$kUFSRr|sBraYmQeSOc?y;9qYEmB>E1iC zs9ClWe9X3MS=Md|_?XUxMk30#n;h-TLJT5Ya+Z ztQN1}tAB&F&*zoU!Wq9Do z`XW*9wyz_ZlV+?cM#JcQTrtehuKbJOt^H?wqp-^356ZTefR*1}*79C5%Qy*`SA|RB zn=Kd`*0$RM$t7#cp0krdFIjx~saGQD=JYdoH25MeXquYmg@yTSx2z3@D!;^itk3e> ze^Q}Djt19J$nzTqrUGZJgm94Eb?haz6EC7qPk-CZ=(6EL*V1t|8~^zf93U$0Uh7Ef zFj)p$52L4I`f0?S#37r%Lbvq4rhY!7qU!4VyOv7=(Z7yNi6a&I)nfC&07js3L~Nk8 zN=An8wtE@(R=nowABn7wH~e626AmE{rY9R!cFQ0A_B)FI?1~n88Bh*wa0Vvyi@a9p z$h4a`$nK5xY>-&ig$KhLG&Hj_EEcKpB94q;$%-bPBQccE_PQ;Oh3hI&T`yCps5Z78 zAbvL_BM$^ndjZHTPD=x7$44uxC}na-r%66TsWk*Ow9V@F_t^IB&IEYp1+S&Y+5-BIQPk-rvJrCR z+CPdwXU?s`T5q+Pim2biW_&M@1kvZViZW6gjZDp=?!Hv^+xkD9o%d3@t<;4`(togy zMIXNQZH}TR^AR15oA9@Dw$maCqjtO~A*g*|{Iy{vSWWlaSKtImV%zlh0>7&KUR>B9rGxI{ufeLCDb{uMDBezx`VO|2Dc zu)CPlRvJ;%&#eQ_{ZdK)y;H4(T9Vvqy!@BeB2_V=Q|CXx!5z;A5`&ctGE4fz7}r<^ zzLY$MV1Te0;Fm9_1&rh8Bao9u^p!RQR^Zx%@0HMzri4wbVxr<%@j|D!#Tp9~xxLfk zE>H3HOJkcahxLsPqV}Q|mT|h7Q~85$3E2vmk0G4yuS-a{L*_MRZbu(0^CnM{F5Er< z89(i)TbUqxjF_gf(5qFkY|9%9%q^*OvBTs`xEvH6!S1pUpXSEhj|VXK*GFKKGJ>#h z&^D91}x%FnLln(fn*pKF)3VT(-HsA=B-}c_AYeX4D@Dp#T;Jw zH7ai>(a!n$I0qII#g&HtEi)L}_=qS&fB5ue&(Dmk>8Ji}Vyy1J=*cwxrSh^g741Op z7?+TtqPhsafT_=FZd~9yQVR|Z^T`l}D~8S3zTKyCHl+V)bzg6e1DegLnjO2>|DzsJ z6?s>>ohwpKH<0-5KkjRdxDJ|z?e{TcPC=VBA4GO^Ylpp`{pu3tF4?Fg&q&%nh-wrx z1+-3f_FkrQ$;2@>(8JrjSa%S=>{-%TZFOB@e(ScVV7l3$vT3J?3CIBihy&dR8WtGV z`1jNs0^N0TpJc8)ZV-2$>wk$(Y}o95mFYk?aX&*t`{i1&0an6F1}}odLp|f%TQr;# zx$|gD+^HW_M#PS+d@>PRdg1l+p@F7S5KVbGY|Hn%!e6bHWA++>qge4`{mj0>V90j9 zd{zCCdfWcN5&FWfgINfQ+i1Q7;0C0;lF$b9niA#Ur3v;2z27dkJoY1Y!UBpgZzQd@ zD@5yU2C%oNki#C5T(}LL+jR$i*o*LShD!jYlkdmal&vh-^GF+`(`SVw9v6$VGA)Oy z`=WH2Y12E6X8*tyGxRYVZ$1Kwi>t+~coIZKEJKqYGHI&4)NR~}2Y8rt=?fs|`o!qT z8({kHj)~MH&ydTdUsME^3g`t~hD^8Uev1>Qb?!)cEA;4HQxy|tL;)~}8L8eKt7|42 zmPP;zf7{>qC{#a;qr^LHEU2sUa)2#!hj}cQCngo?D?0L(gtiH9;s^oU9+BXQ_c&Ge zq8-{(BvU+8CBOpj6V~k^msVh|Rg@73Stfc@%d}7VWY5sNV01RD;v@H;`yRee=I^F_ zb**qNy;1&`J*2wDO!ALQOnYgZbNDs;$W!-?TvPujCe}TqCDZBCgZ#29Om+Vf`Ic8J z?_)jEbNq)fl2Y{c9j09wos}x1i`VQgM=aN5^y!`<~@op~I@$DQh(7?P2T2&mvIyoQe@U(P5SDCV0=U7$!$!^35_96{6R#y=V< zhR2Y9QDpV%Z0MYX)ju4Tnhs3DPApfP=@`eXN_9^CpBfcZhPLlb)c;24!>!cV*{d#I zj1yq%JdK^6drL(8JW@a27Ic^-@L)2Fu2ot{DnUsA3OdjswxF)+f6BVwrvM*oj4s=h zc>jCz_dA}|x1bQo*+$EMK@=hLEi+M<`EG<^0i%qu-GKcs@o4~tw43c-bOCn5_wtkc z#mvA6c)8yf{M0a0Mr*I$-*&jZ(IXv>%^FoX!kh_;o!<740`}5q%!?$|e>@j-; zhPDP{!1d$cM1xY|Nb@oviVCl+Jj@JkpmWEzh%&tUyo`X{XuYO&ewnFmxThW?(o0sg ze)GdYnn}?_I$@H5Qu&6N-=miu9?8zA<7#1XTs>v!!Fy;kDdtLXn43E!Mf{rwUBEP@ z3$uDNkGmt`bZPol5jMN%?QcQs^po}AE^lypZ@TBHT6CJH(Flo!VUH-1J5TjF8QJR} z@y)K?zgrTAK1UiM;;7KYcn`F689EdZMUKa+mQ0Fu&+}NjyncDJ7Plt`H~I_ydP8QT zt2E(m-m)L%x!o6b+9t(EaHjs0Y9yR5KUPfh(%OW`q9~l%MEeuW+f!7a7<^rpaMWP8 zK|8{|QjxocNl;{)FTfU+oz-2w(|t$8^(i+T%%@I$cN0}Dwx{d_a1y^I1)j^u!rfa{ zpcc3w9U~(&^&~o6#?|tfYTA&{^0_|DsVeEX9Ci)qF_<@FuBq;p^435EEWac7Y5j<+ z5NmlZBsVjYa_*ywn`}?5AN*6@`B|83@`&00lE#t3ded!2HQl<@?q!fdz44LQw@EJ~P*M)Xo6QIf~$DFd8$z&IP%;aCY=ePkMmA4brv(bwX zLN8=kM%WZu`+!6gtTyTjq#MZzg{=H$>71d+ zD_QS-1&*j2d;j0UaBOzyTlEls_WPvc9rKZ$kcsX`8(UMREyR#|M8Aa^)=`-DLvq#dtf2SBQ&>Ca*^lZQlfGXtFIu zM?kROl>jGO-lsS&%C~k8OBT|>yQ4=&PU1I7p@;A$<2(SO^|BZ}^KDQr4a@fLBKCvc z%@63MuO)wd;48~3iMA;Az}*Uy*G+We0Ypy-E(8v1?tK)yTnuMqtjc{+&`+VywCpDY z;`<3y%whoFKfD0UGm%B3T8mt z-wl6wb$)=K4uASz#wvcPJn!wgt$lB|cdV0bz{TJMYf9Ta71#>!DO=;vdB506_&LAx zU~K|duo)-@5DG-|C-OcXdp!r2G3Lm+ND`(Egl^*c|$SF z(NMTqltaq8N?!B)u)kB`mGnSZdOca+=ihpdU(kRzk+$^alwWw2^PwanA_Ddb5HaWg z{zdEgiI^olil8`Pv1O7xu}k=rwaUhRu^y)pb6v!=~tW%HD@Z zc=%@$cQi?W_pTLUjWA(!l2`oy7PuQE8oLbS2xxEzAKMOvAKpdaUEhC4HpYfc9&r%! zVqEu&f4+yUA6oS6o~W-`1LBeNpiQ$=J)A`kW`ikNF(wrWTVR1)k;}S%S zPX68qk``^?nqZv`BKCR5BqQVlcpYejcSxS(<*e)$IO?td<7yaKJI{zxbk#}QxOy1v zcHI!7!}|zPzXY+PZy# zObeO?iEDt6&2wCD84E9iSMa}SH;KC{>AZejye;&Wg7yDr0kBj&Hvx#bO3L>h$6fgH zNlM-F1Kxyk&N687S^Xyg&h7t5!{+yb_xWb69)DHN#0R$aBL&Ro_9{0Wnc&xIURER* zD>pK0J;M}CO8)DR32RVG>)p^OP8i^X-oL#x@CDRLW#cxNciz1-nvW?#3s}SGKj>G~ z^HG+41gO-@dwg%E*>l3hL;a&mIK&zxJm;M5BFh*)B+y>Jz@YoHqgUrPzsD&3=F3^| zMgF;DrL#Yzqe9w7>QFlxb zf^8;{I$}uK5L~ej`;6*DRUg=eszAHTDQlutKxdci}aiNW~O!8BTkMB zHJv`PL#p+Dy;%whrAZy5k^2(@Y9ApPIcl}mPuTGaD%&#B?sDhdRsRPB$*9*ww?FEH zNs5M?sBa!uPXYN;*Gt|qK2nC;%hcpUy7xXoyhxXuvw95H!CR2)j2IAOr()Y7Fx6G_ z>Gt|&%XThRvRa0w#w8c)lgbg*OT;RzGg_#;{mHsU%e+1jYET0i8>hIEoaYj8^6B0Y z9Yig-$qhVnpkEAO5E(lV2yaruUzz6&o}ggZ?_~$M=>gA3Jp8uWgQxZAN2a zs~6b5d(hjk3OBdO8?{pR8htFCg2;i{W4dQjqWMFIBn`w1J2Kdp3$Mx^?$i$)N$#G( zHihHL2{4!7!8swo1jieQ5|;3lj5$Nwyd8P55cw+bu-R@Vr|osPAYdAV&p;xd`}%9J z-jT_#)yC1lqm=12Uy>X>hOXDizD~2Ltc#}rP%vQsho?MMa@YfzCqIbMsS2TGhQEIL z*0E6a20b&ner5od;#k&AiGE%unsaeQTee|!Oimi@kSt{Hn6bs>#FmYm+AdVb4BxtD zn9(w7jdo1D>W!Shn~j$u@0IFofKxwu^m;1BdFR93_(Ph*9l*d-)$cyS0t2+9y&jt^#YH$P)kAT-Aq|4Nn;er{25lTtNniTRTAG*gAS zZ)hHQ+k&r8QVO+arUqE1(vu}~Ong&+p_bM6aMdIYJ%pPTUYZd#vbjYqgO7<$PNAWy zdCyIG^1!3QGv|%4SB=oiXlA`ZIgDTQ#oEitcH1|>GR0U0Di@SwZR7ti_114qzi-?> zDJ7wFBM7K8NXKYUN+*p2UccgdZ1R2tBF-ppwPTaWXef5wcAU$Hzwwx)E zp{DL5GK}_j2KD+IP9evb*Ue@al0|E8vHOZ^X^Ow8+9^hVcc)vibJ`^tmlt1vK77*l zROJ>BsbHd}>@Xi5k0QoZ9~c~+2J?tKpRi6hz!Nbg z2>*pisf8~Rjmx-|u)?r?;u_n6Yj?%DWPBY-4%s8#LsK;ZFQVsN8d_6xE(n6zU--_t z=s&E_wRy}@92j-iP<-CCx@xiAC#b{%|V&@ccr*fU%2iDoQc41eNE!?mitUyjHszVwkQ1&s4-yyE3k zXEB?BZ>2~zH^WD{{O4ELDtDvwB5R}1NIql57$I^0GzpOX3y}_M92At0vyiSB8C7zT zt&ekMr&8IXt8tJD6_Yscxv_O1;42xE)85Nnd)6tS5kL=C<1tjZ?DU^W!*)xp$ylyB z(Q73v~A z2NdxrfXl(}!k2Hys@Izf>^_+KVUbUskJ*~+P5<8V!RS)lkG}YDjg_dBL=J`>+ z*j40ivygpNzFHNE5#8P~L+PKbsHY*sAn4tqOqEr_5Z3cg+*_I7INO1%r zuIJgG$?10dO0AyMn!5VgG@Lw06CJ*x_=`tp0dI~bucdzhl4&CT{f2sOfh*;nS;tLK zpkyn8DaEp7zMisva`&gzuBd$|sQi5~&y2Qh8Igz_LN-49I{ejw5l9TC{sRwL;Xj(R z1d=)aJ#;_P4Mi4u9q~SZx9lF?0rec+T zZ)J7Yda+%MXdzC`8zpZT8f2tl?mK%Hpvn^V^1F?d?(+lRxkY9FOu2|n#rI*3PYj(Z zjNLG(aH{F$u{iAsb@yz37;%!OQ0Xo?Qq_H|b68P9B~yGUnTf@X6> ze&mv;b4R5Xoq7eFXCFI3=UsrtJz7F%RE_#89q<*NWvdHnm$aJSv0MuQPC4hgiLi!h z!?mFa5KP~oZ}IOUfRpmh0%^7Kk^$dz1u&4#*E#yEr7C8kjA_8TIl%G-3+aen5{0)% zl0wb)LDdz#RjlnK%zmyJ=+n0P`)28XQ3~B1UcWZqP?2svI&)dRhQPI65&jhU9J*{T zoGSG~N1t*rw6o?(u&pV-?riI&)mD>b&jJ+3PQsrEaLT#)FSy@jisxK;)Du*8hwb)z zd&=XgcdYpnQj8IJ?F9@oG|zD)4g8r=CNhP;6TZsB082X$${>zbYCcsw$BK z8|me2aJg+6fA%q6(?neO6tVHW)(+prb+Y(K&D+tzUnM+zAc5I~PsK?A`y)sAa&eF!T0{7ZBB<9G?Iu^4NQwcX{`A$anLIyoDDn2nC&w zBvmMUSLe5tM*_N0!=HmHkFKcyc*3pxx8v4iLKjuqc+0U;5Vp)W*D7BQ@yJhlYGRz( zUk&nbm)jKT0cTh+o48C*T@ z#$?5G8{OZ7Ov8qAy-S3KUjsN@pX=0r10%avoue7G_I+hDnNgd9b@cs?iw7wHQ}Cha zNKvCimK!0wS#~5Gs=2h7F}IhA{g3=FIcbi#D*NZ6FyqIiF#qv9)Z}WybyWATnNR*2 z*8Ib-X$-A|wv(|nj9?QR<87wv&d*GlBeaLXs_?bGTftBk(q`I|Vg{#d_le+%vD+~W z>Pt`a1!PSnD}FIZkN2(=N~HQbo@sS!4APmPN2KFgCw(-iisZZB9In?wcd21Z#NN|G zj!vQ*MC-X>TD>82&K|QTwXah|O0NsiJEhBiX{%>|7bVoQ+M(vm=Niv2FM1|~Gn)Cw zfB|sjn^jX+-JHBPwYRcAX6TY4AC!bUBzeW-dp;egwC1!Gxp@bXvm_QnO9u_5Mt2_g zpLPeLsv9!gW?Ya;VsB^CxVmzk$l|ziex9HjDnd!hw}&)$pyGHv%D57wj8jr=PmKwU z*OeB(hI`E7xX;Gf$0x7Q&taG#{)ovS@VTICz21?;dEYGMUU+idRY54!(i?A;!8>?hx6q;Mwu7CP}{87IgipK zuK%P*r-;$ZJeu7EiMNb z5*MY9DR}cX?Z*fFNawkze>PnVQFY?d;ZS=cHej-iJMF z9m_@K#^()Cl@H~ROJQ=__CH#e z!29&m*50a7f>@#t&ABblKhvmg2==cUZ04=u3>u%W_t)leX-Q_9iXbt|S`fNcn~CI` zgSe+mFz>YL*aCvp#?4n)mgI~&In20WHBN)ne;iX8KS@yi4}d(RpEiDNc{tQ12ndrD zsJK^YaL}?vj_LapRnUK9^j|_eVAmz*la{e>9CYW7N~s~c`Ml?kZi{m|X>e~M!eZ$2 z0u`%EzDWy|3w)g;&hWt*0SfBaT1gbZ`d0-Cys#?=<+RJlf0f4ot$R6++{S^sRd%@D zd*Yr{DNe`TE&HN$$ex`qrn&$>)3!-Fu(&#Y*K=dHr^tFyTbjOxN>*0Q65jZdI2rz5 z9QNd29lxAZB(KkjeK3WH`Z>Da<86gy52>{lMch$2W759YbfvS-xM&i9PA_Oyw=}^S8UgR*g4qs~E zcl>L3)X%kZv86Wym6EdlnY!V-dc>*coy40)BKLpPP7~Ck#NHeyNFxMFamuuA`c7`v z`g-j@-j)H&_n=q5k1yMIhjid0-7ak??10F{`2MTq1ltC_iNTT4YH8cHdLcJ)2hVb9 zlL^mLU&b6x7eD!pgNfcRRr5L$Hps~*jvPnG?hB7SNJ?Q9A#1=6k>|OPn6&J$f}Tfn z{S0)#uyFUdbGR5!f(cGj4d4YwiMz|o+M`c;ot@lErC!Gj|qYRn>RsN3cr_6L0t*WMv* ziH@&T=A%Bp2p~(66{5jXMD^goEdS&&Vjgp@imD=$O-V@X&1-Gu<*ll#1pJkem2|Rj z#+s{nyoobk%Xb{Ajo@1zFS3xvYyJYxtVnJX&-KC2G5BIrT=V{&HN|E#vvI>qCd5uipmILwc-?%Wv<+ z$c7?tH*^A$Ilsd_#xpe+i^b49kr>~;TdHV(v~E(Y-n*lt<|fXhCVIi2ES1(Yd@>b0jkH~zZ~n!9q{34-m*tJ< z^PZ+Y9f&>tW<#?-|NHeUb?TIICI>>O61%#BSsg}m=f_Fq1bU-9d)n?<&FIC$@XIHk zUhk>}QaypK##xSij^?5u+`NDIY7*Hg>d`5wpR|Ir%m061M`-Eg+`o`pqyF63tMZ0U z%~%voSlCq6G+)H1qy~p8a{H)N-1g}Bg_w{0+k_BnH%~ALpTXypfN~2dopE0h6;zNd zes?_ebNL^UzwgX|eqY(8Zr>9<|Hb5+qDmp4CI zh}_0)+GSOw77>Y<1krnP-Q^vODT?gGxwr~*{!Lt^ad1>{ldu`wqhJJdNBU8%XKsqr zx?9T)Nu~ZS#kDJSB_+HHSLu2Bcd&ObBvT9CrI2sOXxX79)15}1plxHa#Ee3K#}PcP zF5V|;B%-#@IV(>8gBvE7Us1&q=j){cjszdW{Qn3K?6^NzAgisaiv)fJz8awI`p~Uu zjHO_n9YpTZ8dY+lS@&h=PMd{V?&0t4pVJ3A0RzXS`s3z3vqoeR)QvdivKe(H%KQ&? zt3mp4gV271@_03V?V1n4)yB@zCb0EjoT0i+k5EBwcDlI+!uU;2DESj+W_9mzM7%(l z!5yxd4{6LFJk0jqNK5yxX)TU4F_is@w#XC@dNx4)Ps#;n%jEWOy6rF45q&w?)02r$ zPK2D01z&?}?-=X}BdA8tZg=?*pU#g^sT^nhD=(2fM`&L_yT=>JG|CbD#Qba;mge`3ee2GVgOyqWvD{lxfPb5 zAcNjd)J6<5?%>jhK?E;azVhbH#vr-o4M$ZB^HrpFW)>h``yDc@A%F+rf08G;`VGec zv3T01?V{}$P;uw|(M9_ngzjhD;A<-TZZg+dgeC2Q$$hoZ_8YYrx^r$E5_9T9K0`h` z_2-Hoj7RJ>U-pjtsrI&za`tzSdF^CBp@UOm$gioj^PMAve(9Y+t^cl?)TM`N`ansn z;~JT42snGce!7AZ;{Pe8ocVGeA}4r->Ob`kEL$_tb*w!O%5s@0R>qx{j!s?X>F7+1 z&vZaPKv=rG6>KGvMYNmqJYlAtvQMmBgVHi~qdXmQ&m}?d%>BbLz*?!LFA7u=^;8+U4(P1JtWP%q*_rp_GX# zbXGY3?!7~KFp9<;k4GV}!?Mw6R*I+fVo)EvsjSoq)wvN*Ahd1PS{qN%Z zj(r^zG`gZoM1DW%wl9}5ZM47>6}5c!eMtlh=H8lWr<~7BulCvrdzHB4`6&NIipbVP z*uahvX`~O4&?cin!<5wPAFUu+Y5s#3<#VFutOl1|+5RK$qYpxO0+QG6y%~OR+^2M9 zpb$0O#e&v!veHG)2$$^J2-kb=6D5y;9kXow>Yozwp{%2o2bo# zf)ev-rTo2@xGUgCgLV>XGTsP_O0V}b8p#nW(E%zVk*56e3a+}-$()N)(KBU$( zbiY`=3n6`^CJt@(s=&aQg55^CaBjnPG<#GMK3 zHOq!#HH_RgQW5ge089xg3Fo_KcJ*cpI9=z7 zKfY1M`?%X{(p4%MTGR)=*b~@zcj}r;QOiUocyzHu#d4C_B2j+m6l1TpdRr}}%km$gw}>DK2p zVQHB?M7aIU!_|`Z?7SggLziDdh?a-7oPcO)gfM5B-0QFaFHVU^AJmUCk67_%$%1yx z?SZ8TIVFhqcUX2>uIEQ2d#6G*SxRP9Fcm9MJTe!PI_lc*(>bdH zr#i%p)Gjw@2lNf`Qg2Gun(@rVO%LFb)MY7q2Av^kOWpT_;lT~lJ6NE6?-Nv5Snf|W zi0tntOd41?58_xIJk4ZJd!D~72)%)hTy1C|?dt_72wom)`<)D#(lvrETQ7WvE;px3=a}QWg zv<+XqVj4X~zf$A(ZH_76{m`t!>-Nh{n7mTi!I7EexY-i3lY7nlvr5^-$P~Q!9+SA* zXEy>`>O(JHnI9(Ep|ORdmFk-t>>8G9cwHW#Imc|S5I8fcV+mRu+o1%K{B9IN9=aBw ziPk(W&bCNsR+1fNQh{m4bk;!TQ!2R@zvFz%utl zGpRx}Uw+QNvI-dIx*G3k&6(C}de8#iM>gaj1&|59~{9S=v6-_Au@p>GR=!45_|1ZC`jN9vmeoOS9bJa*Wbn+fTiaM>J-x zvxknTjko%w5&t&&9eR)CUW1~W`o`{0-A|Bi5$zf~IjDEtpZ^~Ew4dhKK1V;hK8hr8 z6hZY<=++H$Ng1JVdqj!i@Ew+yu^j!4)wcF9Q|LpQ963g1hxcE2)yEL7xJrnYn|OBy zyBXz=m1n*xPi-b*QmwpJ0N14yP?kK4?M-}xG6@$3OO&g%RW<}TjfvCDUyrO8&n{e> zye&RnzFNyxpQLAVJng;aeiALt{#b{60$DM{4^#KK!s+X}a5d$`iz&9Vc>+I1uhf8X zPWA#OVJ>`NX6djzL(Ln#>V_t-;raR~4$cV+^xsV1&x#&5S{U%zA=+SaS42boP49=( z)bi|3!><4$Z+`u6lP`-bkldy&IeDI6^ho*C!t2bq3a-`GR3Lj>Lns4efyo}?fjKJ zjk7Wnd(;=9_f9d(cl=MtAmv%uhNP!7TEQES`FHO)J6vEA+f1S+qJnt!J5nxgQR3(+faq`t?)&N+y?ififfG;`s{+J;p}?8Kx} z4^-h_@F$XW1O(6!2Ohyw`I!=W5i^ zBT?56ekl)AtbEwLSbq=mknoWw);uTTz^mVU0ML^LFw8Iv4Z~`vxZ^!oPq_umH`3Z86g-I)_Qp zVIY}?>f}a8%8FSaMJJP(VQQR?MNt>C+~;uxp9dmtH%AurUESe$U8_|(vO;-|j(K@a z%J@H)jOWA5g_~O`3R}p7^mTy9i3_WSI5ib+l3`6j_+-sRVegD8be*3I3=XY!ectr&W zxY|GL?b|8di{n;_aQ>RPMFaO(k+gl7p<`>$TsA!(+)DQKPX|{M{)n=`o_!8~R$hZi z;;&AKH?wCC+A*N$clxrH283(#wINaCh>i)F4MgzT_4uQ5l=R_ZYN_8EZwvOGVnn%F zOU@U4fZhat$ayNK3||<1eTu-{l6w3foKcMayqAKqjQU7YVw)MYeE#7F`Ns#p;B$o7 zkp>pzt&2aPclW-J(2ib?Kkj>6ZXMot3LwJ16rOsrsR};Vlv-kZ+dV_?g0=HZ8b3nS z>W^@EE%XPRqs7wM+NGY^`1wsy+J^h*o?xIKQyw+iiVZ5b$5Kk0>?wWU01v+@v*1ex zJap?YO4&t+6V1`E?+3e`^F2%&Czp{5^nX%+E#Jk9gl96ra)Jw2>x>>ty2hy(!#>%z zS)D%*5&Jpbw0XQBg9YzJ;4hC)13(w7qc=d8JSRLVCXKQs0HTVD^r*i|I+WCfz)!EY z+{M%T!$0xHFb#h9h_oS5jyf*-4SA zw|Ta+OckqQr2OYc35LwQ`R)U#?*^++TKk4WjtAGx3B)V(y$Oiu&r?fkx>$ML$`;GIQ;`5~xpk;JX_&%lfBL=SMV%>Z)v z95*pT5KTq(_9Zq=Fq`_=?`0)3I-x4}QIa>Y&)$BkaC8o!!v^oXSk*sNk{#)3%4|7P zXN%-wSXOV3f<8|jn6on9NRIA zR23t`;V$P%@)NUTFF5etRjh3wB)^bjV0<{vXHgHmtvG?)6%OXgDEv9khumy&Sv&iT z#$pcSvWmTlmDzR2FjEAV-{^mn6OYNWZpz1GiL!t4wH@CWJ@EN2ods9U#d(O3cUTHa zq2wRk#`xDwjh2r-)PKG46UuFBQPJawU$zesi=u0*=OSQ5r#fp4#+Q$5R{B>aI-c9L zBcr&)y8+^T3|!76GHx?qw(F;7S>40)7=!Ky&J{Ht11u^G>B`i$Pgl_bA7&00DA=r! z0^2p({*p4trZ4Yxk5;!UkXz6;O7MoxbG4{=FDHa=EBdo9Ld(-XsnBGuA4#};m*YJ> zQUalMKrfk&wT6^XQzFJ?zu3^m3j)G01S@z)8Q$VmKIIKEXB1_zd6U{lMf1dkj&!f~ z6Hk!PQNB%_kv!HnHW45-I>X%OE6M^}IZRZ^8`dtP=4IgaD${5RD?p-V0@dEWmmry( zzC2)VN-|D&**B5BcvOm3c-|HnOz@p z77mQc3Am3;(O7hDz*qp9N)Jc$gmpS-Q1zokSGf5eeA*j~Am?jMz zXQVR~Y?w^}qeD$Khxs1*%5xFui^A`EOcm~=A{q^)98Z*)W9G!me4iMN`7s^yXqj*P z?(B}fes0%4CC4)}e(<>>9DhGEZ`>0B6zk+gI@q#r!b)r1T%pefXTykryq&aL5@Y2U z{&j*vf{M_?arCy(e<8&`7nMf-1*XezogXS*cGy~bwc_uf^TStVt|u~-3#CD3PFT15 zU_7j8<&n;!+}18fKOugdVGio_>uom6UG6`WC3F@GTaN{II%IFKeSvJpK|Yn;b8DZi%7!xfbSPgpato?FS;cD#%Fg_tH5>=MarjXAA7xNA}Sn1 zdTM8(_! zIt_qqZs&aA%N099nPOfp-J~sn$?}6NKYVJMevv(6p3pFM1)Z9x z#T<&c^x5Z<3LF?5XuadiL9)35P^4fC%OZda`owB6GG~TC#DbqR#W&A+nL~>f{Dmnj zO$(eb*i|>yOMkn2Cu&`p&XhSVXOTX~mm%I_m)21bu*V5Kym8C9Y7Y+BI5-X1;ad*a zh(4{Z2(7Q@9mbBlazdwg7uQ1$=g4!01qF;Bd6goPj(A zN6r%2ll*ZM^Uqk4*5mPjU8xu=LmBPXzHsY;3u~_3DlwNxIre=nf@o^LC(^h&e;g3* zAa&<3gO0RNxL!)KynKF1CyeX56TxpfpTQII?&)Qy^r!5NcDS-8AM-hZyCj#^=AR$bTJxIh?tetpbu3Qo)0M0nlGffIx^Z6eA2Wz4S7ttw zG#zQ%n)9$(qyAMsEboBv*J*5Vohnc;Q`p=h?Yw7Bz(Obi#D4o1uCk80l_c`*Two5o zCqy-9T$Sd(feEJM(#c?qy=GtU_>jFZmOOt;y4Hm;iP>Czz4+|rjvU)0J`ZBWYOOAA zY3~2})P4L>44_n4J+qI)(3T-Xa@*#db61#y@{(D7Acy+iKl&+?^ZRSUV(vcBvmL<* z6s38Vy@%VfeKEHpYo;m-o7_?PEi?e_=-Q`u%3&XB72}c zI2qmWtt^ylr|9ZPrUS40`UjivVCPA*4~lwkH{yxNAp{PTkTN0~Azb{1G@q8@Hp#Bx zohv^gVDX%s@I2Uza93UhY}q2D;N4 zvce4PxWAT>c76mY8m9TZVkRrS&Bd(SOtmiMfJLTCx-b4*T|)P~rDQsK&T0jv|4WJb zbQ)e2ifw>6I?t zShRcdoH$Cy&}21QC-CWk-3+>A?K*pD4F&oyUm_Z+eX-~7)>f#k>qD=Yx7^w{ZV3ei z$|x7>w$?OWNrWCl5u5==S3RLq#W)QDth_Pr-! z@BQt+jhh@>hh~9%-oj+-EdXO}Ip_A?7em63mR1~UCrx*cY||) zuP9E6Co|EBHn2Cc&i0)xUcwH?q4Z~6jFwylLm<;w_Lut1aPjR)3A}rz#GR(7*c}H# z2AQE26}-{IlPxG-j-r}kXEfU)>d3)dn9>^knw;qLV)o2K(dlnicba=I4`%R9Me^In z=d!~ZLv*0IAR4dY)BvkL`aC?Z22yu69T{ZW2VuQ(wKCZl)mRu~EEO$YH#6y_b$wbM zFD4w0DioLl9Bvzb*FDrYsS^1)V({!7sWEx86z|aZM875-dqP*3iXa+z_s~-;nTdr- z@Wopv7b?X`LfMS~p?S<*ib1g~VfbMJvM2okw}t>Lc<|-3Mps97&~Mjz8|FL*Pk*8b z>T+La2hH$5Q8gi6F_ zRDV@Zy?(F~={$y9?f3X&AB3bNH&HwT8#~H|&>XL!h*^vDV*o@cEy4NvrVfnAQ|7rs z>{r+9ljKn0m)%92JQpAZ%t?dD2Dw|O02gd#D!s=u?w9|nE|aFB%=#Zg;zVn zf5G3nEOnH8+Zy3upNZe$mg{E@7h;1^Y;nJAU(yfds#|cfFS&XIeLa%GwR0%18v$K1 z=)9)mSDfGOdpoXPZnYNm_fM)o98+aY>;d*p<=ZjT(sWyLpfOXoj9&&71X>FF#pO=JI)IzN-1!tkkRi;mQzpSO7U7dhZA{^tW3*ayf(m~hhJq>X_i#-;M&uq zw{dD$yr}*}QYM!Zh}FEbI)0Eipf;tGk}Jw*$6>6~lvMxLQ)^;Yp9#8=^>_U15D2Z> z^p*b&+|}iA0y{EzwRjIe$y1KhmOu0zida3OsHab zqbwP4ipF3zh>%RWXuB6+K=J5NMy^j8UTH*|w?oXi*34R(pOA4*^L8;&7&VPq)L8GM zO76qV8vDUXN_dG(Pir5pkqTOK;o(XdarUWvAaIN&U{gLZ>uep?G6h7iZXPP8H+MWnIt58vOZvlj&PxgWyg0vdqq<8B+c6m)D(oZxo&FF0s`{WkTc;s2 z{gv5+Nw;UkCxIO%DReL&*JFVgiT+dh1YH4+g6L84O_z&sGY~v`-JA?Ag`|fd^yvU5 zlK%BwVyja2W9oTEay09WeCAihB%iM<9_Map1$li)c z3h?0-X@~`SrtshOAdc=bMg|=gPR)$R!J77u6w#AuOChbXA45^A=xm-zl^mc@=rHFr z*4QAi_CP~>dZc3cjWFf|k?yg#M5iG+bg%lRJ_qHgkt5cfX z1cD>N?2gx;c~fAP%jTWq0~)`i#Mj8yz1WNhH!+VsXr@`s`u~x-;H7@A)J8CS+{%Ri za;5S+<3KC1`;tl(6-zp!6R*R%)m~CFD$?bx_wYd{75%K!hb)k1Bcdv&}IW)sM-I z2H6}m!CmHeuFO3eK*{HmD0iF|$S0LGnI~LlmPMqaGJBq3y&rR=Om0cFk`_2_vX|)F z&+5jnp4F}0=HE#&yH2bPu+iygh@-cg5I#x_XBX}Wt#KdCY7qwB_j=J^9dEzJ{Z~hQ z9Q-U|;rk4Q0`bl{faac4ffg<(RAl$sRg_z(N=k?$e`4~YVh=()aOvQZyoPMB!H@HT z9u4jos-vk(iQKCoj5?@X z{$I7GB)Vh$n`-$X=ULQ1>aMzi8gP?Dz!Fg#;znbP!Y*OLoL9w)S9KBpJ~cyDt*;D} z;mbaf*90#SlJ;u79zC5>S#PgeqRs>$w$|V`?{d!N@Pgl;AS!0Z1o)Bm)v?o1T_wQL z+K0mndSC?ve&xhf~K{Orm(v~qv|6leIJ9pTa&HgL0vlZ-o2OkZa?{xn~2?`1E3n2 z>%!r8&aXV8&X43&6o7=QGYTKb1??RqEg0nMkK(51Xk>uD2#kJHW7@B}1VdrA{bxZX zdVB5y84eFV5)Zd_nrPRi$2uy=vaI`23f?iuT7?o-2vPeqehEh3-Qk9#pWq_t0nSrA`QFV9uB3J5)1!*`Bhkk zr-}a|Vb8&NgOHb)*NDlGu;*aRB+UPWtjtm9I)9ij6P%J!qki zCn<27IWoKtYWVF)J`%uUMPQexzqEfa@++`YdN$TL%VRr`h#W}Y<4@%h7eE&Mn#+tu zX?nFhUChmliAuAi1pRYvNk(`x(&A9TdoE8Ft%P`Zk#~Cjk@Ru8Ph-}x2#r)uygt5Z zKRg#gbr+(uVsj9jr>)3t7vGY)jWQ;)WDJsDbwL_Q%u)*7d!uM!OsziKv9v;(Z+Mv@jJS|ULN7YwM#6Ba98WFKb)1w_GUbl?6nvWN%X;o;|kN8_m9Q*gNX^2#JX2K3_SV#*1aTM`h-hNnK zeXsuSV#kt6t6Tz!tpUz~kw?DIoYd6ojW-qr$-34-?)%xAM0V(S=jF1(tBmZO(Yrlp zQ#1(9$VWwTPcLz^p^2WUjWKy*X*5b3U)Yx`|5hJeg>v9YUB>VWQd5-C#is>5v=N19 zPouFXtv}dyS5S{lM+p_Oq#E`krUcqrXm^r(9N#a!)eDkdjwz&L?g+;Ij-F{~-W>-c zvka|xu2U50Rb}fw)%1(ZRkMAy+@BPet?r#BC zsQJyk1@QQMyAm_o0~hQ?dAP-xxb`?HEvMuqcB1!t`wW?{n1gM2YCbk>n08{DpX%Bs zamUPRGF1aFKDK9K?mtVL()|m&yc1CO|Nd9xd9oy5)A%LPbHs4)hZO6t7`-`dMT94+->y_p4@rUe?RSrGI|#| z?9rBLGIDLhsJdhBeq4i0jAp-%S33J)L%PPR<+z46&7y3}UOsDp*!08^6a?`#w<$hm z&L_yM*uof~|8&Jl`(*{vWWAuXA}u9g0nC(0bae(`DYyy)O2%k^T}*(DFhWn=?2b5n zxkEuAJ5-jQYtZpt)-5gl)uY^if6&B_OAx80P{L&N`HnYXW=-~v6Nb=D zS0?+D7`X}B))xZ}!B0F_;Em&U7qjjp;-aF<$y)zE2jDt|AhO^EuZlJ#Mfo%PqLq4p0(n8&(+{u!!KH7^t{PQVW%8lB&MN7=h)ejl?}fZm8sDxSuU$8*It+dj|9U+AlAI49n#i|RffH7kbo-L5od0_9(pY@yyNXp< zL-s$t928Qg(g%x+^sAJYrSs~}9(_+xi_KlbKujdY^T-Btlc|lXZ(~#*=MA>yv2@jc z7$EEzC3@=jAW-rU*`~$lk8@Ip9Gri`+v#1|;T?b9k4P!1633u)jim*#LouGz)h(0k z1Su+Wu|!e4PT}kFL3#1=<6rcx?rgoQL*GEoUOb*LDOljCTM49E%PAts&HBqjO$zoH z5N2H>e((8sQFWN=yK^A{?T_#%_pu!k4Upl=q)a3HmipP-`nh+K0H7ygk0X+ADs|C^O50E>`+K zM1Y`gpu+cL<$_tYTv!?@ItvPXWCHW$>=8T=3X-_jXwr~hVc?b@6jYLl8Yt^B@9HKU zxmMB_AY2}P^BU~-D03%^ZBR01K~>HABN2$x8AyFS{36Tyfmw{FN!#D_A=oyKuT3QO zn)E2@-bQm08yy+>W`AQQNWW|`8!P=3c7EV?^`m~6a+qNNgHkPeeAbdGV%4q=5_O9r zR{A{jxkgtrDu1C6xW)Q*82hP+o-+V}gnHZuLNBSTvfb}~j^9lz!2r8ECDcT^cG!(6 zr#BEX#KRkje-!y5NQ)o(Era6npf%{}<(KbDl|9tSy>@fD$hjey3}UANa2EF8XBF~b z>e7M)0?uOakjB%0E}+DX8MkX@XA^rAdpC+vb>&T`(s+0pX;OaKvw7psKS;ajGb3HJ z;*CwZ);skdTF6-O`lq)kFDmEx{{iGIr_o!eE%EEEwTz}z&_kqS8!0OrD&M+dE}hJG z@{f#JJ^I{d258Nx*(QE^7|8>LPs6@aXdJ>?Krki1uHBAUwE2Es znIAU!jN6zC3tO36l>GJaBM?z4A4xDfhg?CuqZ-W+t~BIJbuK)6b*!I{eFJUgY|$Ze z*urK}mvYycuVfxl?0S1xIuR#NkEWns>(yJ~jp;*VHK${GZO|vk7h(U`1%P8)Iw;m+ zxeB^R@F5SD_|OR`+z!sC5S){o*~k1T#%Zp$dFY5<4Jk!ojA%Po!s;P!beiK7)fFGM zf1n8~4~D z`FL|*Z!`b(%Cscj3k4tT>A%;<{&oF(+!PN_y6s=nI|(s@3{Yl)USDL}=Q@{t0&W{& zdD)7-$Mv)sn%#0%O#vAwTT)10r zjht1#F$HiihFoS3+P@080p9+3I*34Asws^z_E-BSH1#Q5ZbrK0E~ku`FYk`hTDE** z6eX=Tx2a*_72!y+x4EqaF={Bm5JY$ZM+6UKid??4qnHF132(b+vn9CZ=`DK9k0Jga zyYF`1N%>>W3=n&~#Prudn_0eJi^?3N;IJl*?(R*0{>9MiE%ot!+Sf{vKXtH;4Omfl zp`T3!`_G!irbApwGrNxEUJG8;`yX_18`tsH5p zt5k9zmi1BG+sNx@jr$irAc#4M?He1O@IS+)DdGG#`x%|$b=+g4R_4Wr!l*5bCdsj2M3>XWSWp^}+Nzs7}fP`cq9G9$gE8($o_tenYyg!#*DoC_J2 zK5F*DhR^#z%7Qp2o|TFNc-Tu+ZVZ{kI^g#CDI(V-zNcqgF*B_ERI4)^uK=XIL!IxiXfG>Wea4y%lu}4R?y7fY&9bj-GtEk1piirhu)u<=kVT2Y z2Dp*6vK?(W66PitT2?_$zT_~J$)}o$uuQ?%t8u^U$OL;xuf)8_8NA)sFl-NLx_T*d zdJMro6OicS)fS{M3BTEQ{nt8Hbw!ZA)I2uO`kx+hAF5dtC- z0#XtRQqnIvM~8F`327KGnlWHwyUt$k_vd@QKb&*^g7?rdJ%zlHQealdEeT=2Zia%thZN1TO~BD{KGp1;bZ@linIZ}J z+=VR+PYI&d@FWu0^{r2B(_ajWRyjNUi@~Y=)tB@RbQ>8#2%4tjC9B}<2)BvE=%t{zI@Mag9a%B z%6P0t<^u~;87~Ql`Oq^SJ99mtm(g#9sfx9_r{@=zd2D@WO)V-t6Is{Zc5|?6408F+ zB2{xl@-l|(zZpV&Io)Yr%aGBjpu3mw4D(!C?75kDMWr*yZR-j z{DwgstcIg&Iel#PzL2avRjI1nwXi7~h^lr?a4Pfrw?_Bv+L&YAXi&q(@a3#;KKl1D zsg8o*PrN`-z^Lz45sXzDSkNcT?I{z@iB^Te{)^l7K-(FDP!8^2%2MYPfWWGd&zK(K zHN^GV-aQcA6-Lujr~W%a-25Q9wl7#`afdUUba+eLht;TuFe+{`gPpfxYsI*}r+Tr6R;^rzbxAhIe9iF1pjfoi57Hl?L} zaa|H|zLmPg*`;)q#)m9>&z`GDeh1mUy704))Km~NY1@3tcUp?#t-+1%SzOhbaa7)D zgx|7M9^-p~e{W5Ybr*mH0BfW0t6>QGF~;Y}UYSXTdAfByRvC=i7!4*tDHUP9!DXmy z#&JjA-L+%*kXEYt!JwZH)~8sm+B0&qmhY!P-E!HTUIbNw#=R)2g41GckD}5QC|B0% zH&RuW`4dk0N&yqt9F`WwY9P$&GLgD@a#e|{{lZ2cvhj&$i(yqr{N~|3_#)3zj8pv3 z^T5!YBNOJ57Q2iiM&Cs`(+r_W$m>c;PQ9k#LG|HRodU2OEcM=y*)T)@)n%bGn&6XU)XCMxH z=PO3R)|SAZ%CFn+%(L%`jctiyg?QKgW0=!wfQ&uNHZ<37z zF$@{^HD+EJFz0Qaq~HEsMD5CVmhT{-UzcqtU(l;`xxM6aX3lD~9gc#lB!6u*-!LT0 zYx)Vc=b@KVUG_X)cX;rA2}pNz*FE*T+=f`LH@rJ{i|}sopEwCI7NMaq=NkH<$$5_c z6X-*%s6;Z>ijoNg_^(Rq`c2a_-kI$8kj(F%3Aac<`DQv#4Hw^`@sCxZ->Hy3vj5z` z0Ma>?FszoU_kAZ9Fr!Kxqg=8zL9OgPx?x(Vx2=8S_DAcMlj9 zFI_!=pYTg~An6SoWJEN5|0x^5;fcJYvdckGgyO1!Xpp9^vv19ZCHn`>6~Wi1Hiw3m z6BS*`OsB1R2|})67&%tN#`~64Y;?|1=w`t04Sd@MyDwYNBQaDmT>(y^GdN|7Uwu)T zdxjV#@Thkm)aD4#Q%8HfQRP|BhVsgcb*z-p9!ef8-7G&oHmc6P{+4El%*c`;pGq0l zVGuD`4YyCJZ>ca<^>cMs#kLf5k8SLU-t=CAIF*ML$BVa30Qt$ovuT4Me_)BVXGV$9 zv8;8k5N@@mYu08eLp{cpY~0T?y{h6D%aC*csv6`7u_I+W<`24g468jmedx!+;~Pyp zsPKm-I&$l|UX0+B02Zg)@)`Ec2zu zBW(?pKk%6=H%c}IH@>`&Ht59OHLiz_NGy~Rd@SS(uh4UjYuEJz{{%I`xh_92Wl)k=w5%ozBp^tr3TX{eS-mP-~r~0Va>;k+V zcB7x_4u89**Aa`!v3R=7Nu10|;0WE`1lh@4_gogG+gIwY?-NaOd0lr72KXHefsNpsv&i@SQIj_oTOq765O*YO!ySC(DgvM`$&J+fFP z>)agKrdNPJ~-F4pHRPUzmkA3p?NOz%uzu_D-!n6OMY{ALLw();s z1OG>~F!z6r7D(7YaP2x(#$L|!mfU8T+4Z%{Yw6#};=PU5cwO%dg{N2}j^iK**dF{8 z1K<2WI&TQV1%OwgFfG1XWq|TO*@B!TL*3Qk?WOiy5Z1G3;sy1Y0IYL4YQg&C%Rx9U zsaO6oKWCcrP=8MvnKhKc!s9>UmIW7!mG(7>|6-c~U8L-|$6fdzn_Trl-3~hWTC)>%{pC#m4|PD*C`vaV z;Ujdqnrr1H$&PmdKWu(?(Wft&b#Q>i%}TZWThhmLrK8|-I#HksRtw?^m#0AhC}b$62|K` zo2uC~=*G^Ygk{0!e?40X{otT)XZnqYq&p-xK3tX}=#atGZdUu_CZ7{qJo{RQlkaKH z#2&TIll>BrLThh#2mzP6#a>Tf2gSh4FSHP6sU84&DR5pB@cYqEe6{PyZ<`G=sgaAp z*J81Xgfkv>@Eq9j3VXHW&{zG~2hlPC){B&>6UAA6$t}_BDVwl36kPa&5YdPQxIcp& zMW;Oj8s!le{sL&G)9(sLzMHf@ralt*-Sf=GgOlga_C;I?Y{-Qmuud0aMcbvM_gU-j z*nU|BLpDjIlY**c0c+>$Fm0w`C}ns&hwtJ)c-tlsZ$-b}e~QW)A9(^86Tx?(%&7`| zyOd99xOtl;_8?c_l zn8IZJ4V^>Nu8YA}*9XYD6jYh11o)6U&z5s)eH0DZ?$V^Ws)~m38~zS>Pk>KD{78|_ zG#F|~;b)IwSF8~`p!odknkuxy%6nOdgr@gB(CT_Faja;BT%Uj&=HPoO5JyKX`kX-v z2Q!4nFuOHx{pH_jdVJ^cISWP!{&B0Z1OXd+uhF?cdQ6SsMqI(b39%Vrz~H*ySQ2ac zhDp}(=N{Ixwvnup&e|wZ_S+E{XY~DcSJLzU#vBo zdrWJquw}eM!@T@V0C<7Csf|ZGXm^4XDww4QhgC%3_S)272C7lvw}^w@)7d7TqeN1( zq6xNIcoNU@gogS2XxExG4MeUMuZ4rKxexRTXPkf*X&r?kw8s~LMSZ+FsjH69*cMLp zJky#Y8PwL4g_=iPFD-5YNF-Hd@0_D{x5(Mx^M=Fw9$t!UJ;ovKOIgsAYX)@Hzp6v# z?GN&*Pum?8*4|voS^tX7jC`Q0lPyjZE2RGy9Owb96hkEUSfCx>D_FD`BXc-=#hLFu z_(P~?xFq<0%zgnw;!*6h0u&h=1GA?Hw;Z8F#MAy_Xs%kZYm&R02m38JG%{K8o_#9l zw@WRxju~Yy-K3%t)J!<8WqcaM%Rpvj_X?}d!c=z)t^6bA%|6Z8m-H`jp7-`~@(~;U zU0W;TsiUFYvx2oj#e6y~ifO(Zj@HZq?$aNTyo!ja=1?Ssx`n+zDN|rG^(Av9khzlI ziHz~bvlF+(2DiDT6ULYdNKPb)ZA5ku+nVEVs$uT&|6&EmxBI8>@Yxl3MbhVuvKR5^ zQ6CW#wY_G0n)92FU@w>z2dC}TT=n=oY3O;H$YoV1Z~7TNnJ#sD>N>7iXd2ue;|a%r zga3O70QxF`2*bc$iUq86_Kb{OHzjpl0(XgLLlHsJ{f4vjw6VCG=h)O%Ze}Pm0vwVYr&2b^~%tF{UecGk*%3gj*6w> z#@9RqwG(bpYW~x>u*iSwbLF3Sz>8&p7!tqv^IJ811}*T#q|okQPUvX(D-k9*789y2 z#}J5JeLTax@fAeLF;}$5gU9hjxy^_Bi-lDOv|$mex~?9o`6voQ*Rof{!qDEwWVF*n z0qg&=1$qBr3$87xyL|Tg81G47zo1!={!@7F^MGY~ot2&YwUp;iO>PP`5St>Ige7<6 zIxm^z{W&<2y@iPVm<+H@9@T-)g(_(ivt?N7&jdzc(cuA)GT6g~ZlSVNXEy&~L)EIBOC_TYG{yxuImyPHpEANf zwxcP$rd~ZfR~(CzR}Y$A%jWyWAiYpp2vi|fH?5D~6Z**RJ1CFlN+WY(N1u#Mc3~Yq z9VRock0ap0k6c0}EITEDaHrO#)_9CW=&eHF21@=7>xZ$Qa?;$>V>+#rviTuVQ@+U| zzMttR9FW~RMf@N)pH{N5A@M@LvgG<4lTaW;EJ~ZeL=jgZX9hT31tG|(sPd=x9OHU8Mo)IddcD7jEQ$IP$3NNMF?_&EhrBnm<#R9sxl>z8POfx#rUlQB^s z30?ixIRr0sVIEs(F6w-LYs$W}H{&hMUB!Fe9uk-()YOLM$Hs!QtgLEVbN@PjVm*T2 zpn%Yf%k}pP4Ugp4`E}i(<{ugM_i8am8AEoyidCp%c#C7cbB>%O`%ZBRHG+Mxnn5&w z9hPTE-$o3g4>5Ecxwe3S8$H9X)NTqABzRu)9?m&ZWC$V8JW&zf7dCUBwZiFRsN_53 zR9$7&*+;n^+S4lC?d3Xh<0@=_R}-1|B)ZWP6A8{NHa|UV`W<6aT~n7Lf^ca!rBNyy z7XXQoSOav#4aBnzM}Nao*BPUN6|S8XD`Ju+vs<5RPJ?|ih#+A}AK%EVh0gp0b5*Qh z<#+Y;C+EX@Ry5AQY^K%Y$TOLr_hTuS%snT%yS*?7uP`ed2y*_1!+jS{VhwuWm(~>> zFf{yRmvjvlC29>+8dEXxSJ2}rY)PG$r03l-aUVTXaY`BR#lfi#>Dc zxEd$lPMnt~*(sLpSjXwHbSne~^{}ccX7p=RrXF53Dn+wq%(snpC{&KUDUh3vzQ7p! zElwU&ZSNe(KA>jLps8!CR_1cPX3gX+r7Of^>WlJ!EBZn=)9J5S4txSDErZ`NL!8RU zJ7nhpFEwG76o1jjzsAgl{ha=06i>@{@x9X5mC_rDNb@&d&k?$}=uh>ju-C$9afp1} z7-sl$oU4z8)Csr{E2X$i#sgC_%FNSKVc7TVE>gJ6_|7V~(>awMAf~zefYLuV(|tf% zpA;TAN7lmpvZ?c?DO6xRNmuX&|FoVL)*yb(114gDzj{Ob`#!$$j4^!bkKvs7gjN|Dq9MNtUs`hu^zK3a44%>$1S_z2{1vVa7Cep}!Wk6Lefc z2AW430g+hqQ(+^b6#~ay??|9o&*;>`V##bK{s^*I~cAW1IGawL4V zUe|dhRGD^j$j$dQ5<0Lr*W~A+PB}LZ+~Et`%V~m%tFX7H2AQ_^FF#&NC3*1fqyYWu z@`Aqp6~oZ7vAjIO{pgVZcw{FjJIFCE&6>%Td90PXwp8X8yi;fU-0Y7Sjw;kEo_#ZpMV_o^xzC4BPt9c!V} z^w@^JO>riuhPW7&8`bEmKR38`zg5dwlYoO?RoRsQ{;YKm8{c-aO}Y)J!KR?llK9wI zhG8_#XW72)WFEBUqwzU*MTjk5^c=W&^wO=UY1{_ z=))ZFaP+Ch);rZ^tI!4x``q03!cr50i55`>&jmY6y^lUnzx|jvc8HX5l*`Ps4e=^} z@v4OuHFvGSl!968%WAwtJPAMePKBS9yNhAKj|vE*ZBMYe5BhZU(_-tfhI4|IKuy-yM(WMoJah?v3wR5`_-t(=sd8~1KKvr*RBd+Ca!gl4 z%@O=rjOP)E&NB*Hk=oy5)U?z%_~TVD&MLtbSk9>cs^01B7!vRc_us8#>&8Mh6x&rC z@AhY*2}X9?$s4W!IwzoxbhsS_Pk=zp2f#GhSyBBcm*u8f=?S7)BJCFld<9bhFJC_) z?na?=#Aegq5Wn;TiatXkmDlOhx8${ZQ~XrNEuwtWUtvQVEysdG~=LabWnM;Y4)U& z|0<$3juI^m8$4>-fp)R9#Ij zQzG1S{6oG0PyYkA%*ZRW7n729)aAlHwo%EX7lf9Y3Y5ILuQJY?02P11Dc^c}e!svK z737iB!3~+tq`T`H3##Cqdb4|Wh5PDBf`#Qq_Y;eWPCjl9y&0O(aI>cOZ$`H(sU zGh?XDO9jsYoVG!u@Y(HBgbMeSzv>-rTUab)og^i2$(2xneINxlNu(5Q5d1;&6Vf|` zR%D7ebD~d+lFB#I^V;#O*AMP0@dSyN6~=iIk8>JZ31x_2e_~g?eo=teToO+Hs?~n% z>W6pWCs(z{6brh>%{7~q4XTVQ-^{^WOC$jfE3 zUZ`XYeW|Xq!;e@^98(%0M3iTI9BfZlP8{F(U}GlVq4v>fXi8JR=+sCtO>UNZf7c!- zuzfKLIt{2T3>14XxK~?c-YCx3U|t`|NJ(+8)&B>5hW1j2&3!w*o%6fXZ)u;zP?C1a ztp1Tcxpn(|>!Qp4u$mO<7XrmIf7K3UC8FWlpU-V=_ympT)WQ~p-YvlmGx5|xTiPBu z*o%r+f7s10Nuecv4^FIu)QkkUA#)e?d_m(%K-cn)bKLlZ8pLFt=yb&A3LhU(&9C2^ ztM8Z>EUH#IfLm=|Xla`z$?*GpnW;NnPn7TVwTV9i(;~{VFF${oy_zbNY1l(z5%!p~ z2(G;TlE)v!x9qGuxvaPt3J6JM5fKKUy}&z+>tXd6PZ23~gBy00DyNZLuKKfb4FRxH zfWGFDtkw<28V6qDUxa&&N065sFFPPGDRJw0!7q+3VU2tFO%Lhz+qncOPn;qs7`zM8 zoslo7CeNdvrOUR{PTo~c&AE@<$cV_J&s#5cf6E-veeHv?xo$j*{d3dz8#5!o z%>g_jWHrielUlO;PFkS!8=m7NYKOBPRufG@f zD$*Q+fUSOrIm@(Vi*# zAV}SjyR0g7yjXr?)wT5IeCH9R!-|vcX?6qh#3tP^+geC<$asZ+KKbLT7(6jNQ~IeN zXZ1BfXL~ZAtQl%jJd6jW=mguOK695i@B=$X6Ry((pf`c-aP0h%_*9Q**J5!MHR|NCli&%p%Cy%;`Txr2(j z(t3`h&m=JyUrrMw4tK%(N!GqH2S2Tya3hE#DDHd_!s>>QaGEU3btPvD_>l**3%m|A zu~FzrtX&3_Ggs{D%vXD!2`dt2uGEhZj4pO?D*Jol>gI1W@X0^lkSY&;tTx>M@IRG@KP zftYwLXq>@QxSqo@Cp_P9n;W?ussH17x43I3kYOX>G62An3{P z$VBfy*FDIUN5kJJbLfW4D$!ORx*Shz4D|5~pvkDiT$!dG^FMw2ppdN+<~qs~@XJcS z+FJRF2BoERklMA>E=5Z7EJ68DpW~-5yafJV^wPc;fiNx@#k>_(@+nU_v%!MWoIz1!uPhOVqiiu^4p z{P)Ui1^O8w$PwaX=3jNqMfEkVB8NW(*X4iSCTlBkw)%7<@9r7cck}l7hogd=^RM&o z3A)6GuU%eL?>-7L*&L_U0n!EzK+ z82^HrmGCeYPC|WeO;RD`YNJZb zHW0>myTK>61Id%H-0jGcu?n_`Dg<0`?AOi%8nU314;gvwuojcTTx4$*9XmU`?_>;| z$tqJ=sy)e@eeE(vQGJzCDB9;mmj8$O!?0gN!@s}6(!D&MUaT0ldL}>C@OW@1#&Z?P z8O{!LZGVGe;6K7~7_iD#jnK?Y&gq87x5}j*!?jvJWrZs43tR-;ljc*Du__9Z{S(8sM2{9q4}joE)yHY>$}JFTd{# z5;vw*UN#y#%FH;uV-dni=vi3lMex7N|Lj1JII!p(1vurmH3UYg;dm1zV$Z)x0Akxn znP4BT1g@4jNYWesPZ&@#J=?!RGS;BB5A8kImJlJkU0-P3b(p>lceitDc17N!l!30& zHNJC50yzk2ap>QVcNi{Dk{QHOB&4E+e3|eQ?F;k*4h0MmNB}c$%-!ar`Usz=8b5Mgxx0)7us#K^&lo#psFJoY{^;-DL z7--DSc@ff~{w8b%nKHkw)$u<3C7r1vhc-vPn?nRC0{BXBvSy~8<#@eu zok6WmNG!IxPn1Hfe3^4Fw{A0$6%WQih=o_YI`rBJsop!o?t+s`HqjOerN|p$jlmQj zLEjw#)Z7DAQTLtg%#L%vv)c=P-G-t8b!~RoM}Yn9Ge?$?lE_>v%3Q9`X&2L?*AXRY z4h4n>%o}-x>97B@o%W`+k#nQ%Xb{7EUCn#p3|z0@lqY}nmp9hapQZa}xjx_0F)O&b z(%^Mvh^*u9Ik}tCPfljlWQ~{0taq1a-^_j9gb{CZP7Y)NDd(4(_qo_)x4&}+y=ZNW z4VC+oo6hS^@pCjFy9ASR4>nYdjMPnlhHM#nP*~=gjIOMsQt7%U{X1GYK+Km>9?*PZ z2LU9#+7ZcIW*6GcDfdqkX362|BP+bT5MvS)B&bm5h;rWMEJ@f5kQT~*g7AgKMYC&* zJHEVW%r6`N_b<2l5d13nHCsqkxs?3KZ6*d=hO0M3{VtSaf*=YV&*ka{;R?-Rdj{KZ;-W676)VYh!+NDUy0%JCgjVyy7%YK=V&+ z+qp@tKy`VqFc_+u0G^>=qFT5q)v|aVy?NOeG+KEMC0T>M$!p*}7i*ctVF@zvU$=#9 zWhmxej1Sh3eTz6!zvk^n zI`q9eHAqA+-ssgYv)x&OtT=BFc$S>qvu1Gok)bZmS#?-{(SN*Wtt03?ceYobHr{77 zF9*GbG5L_j{SLMG7WTjJ8UeK=Hh-imqI;CSQQsQkCHqDM-o3}ocx1e`Z-jjvdvI~8T5483_u?Yt_C zy8JYc`fvp#s~8ckh_>M6HqJ&d=gm^u_m&g~*&8;8CD7MkK0FX*Fn0a9*_P>-4Z1XU zPB`4Qo;`1K?72zliie!_8aBh7I=uQ!TUD>m3kK{)xHhbp#}`_C{jS;s|8S0sXl-k; zmN_ku4=e&PNb4+GM}6OsJ^ERP-Ckihxp_ z!8hKaxses^J_=n%sMB4Y)@gH15;+h{oxi?rTLs4kv2zT=Dx;bI9tZEiO}_p6^myn!g|bws!4!w#&M$@pfp+#jZ= zADK05m-(GmaypwQdA|#tg{-dC*9_?^^11M-%>lCL8N!Y#?kI3{rDrxKE8ykbY{RYf z;@n3^hcpfL&p~1>vrryU!2U`x$@;^7>f$_ue`Gh8qep&>&v}zXH4<#(d&l$lEPLH< zj(8OFuiE*eXMyIY4TXJo$B~lqG28Gah_}f0jf>(6)iA0w`drTNDx#BJ&c?Pa-gRZ9 zxjqjLToz#yXRjQdf^(kI{6d?^0S)+cVT94-wZHCWUn19F$+UG>fS+6nX=Ks+Iu541Dkms%IuC4GGEPzT_)YkqZBH2k}+#X;nBi>r@Se zG8j}%oj3aZ{Es^57`U)wGss}Cc6<;q_o-By@iUXMLluy{zVH<0&Uq!S*4~54U zGt_c=WuR|v=g2Yz<+%R6>$CYE9su`B@`ZxSoxeH{HCDweRGvfjBS;|vX?FpCrH)E^ z>(50Sk*r24aWh}tMZ~Nkx@fR6WF{&qCBO_-yF$+EN6RQw*O{BAV{x?>J; zyJ+nz`GB!7Yc4)jw`nans-lS&8n1!fNTTgiaXk$Q34TWq;1$G3KFIEPC#vft^mGW8 zXIeJmCw{}aqLoHYd&ytSGMDn@r{h{NZW;2JWNZBcL^F`L9 zfBNx3tK~l~x8O%Mdmfk-r>x1U|DXV`NvfdsE)rJx5FILz@{qyS?~F3X_4}Wn(a{}; z-Ryev+Q6KK{zb3Tn$13V@GAxWL+HD!I0rMClxS%eFzSYzk7DR%crejbQS-hoY;Ar= zGufHn+s1IU#}EYE{kSz14jD9w*JF`1^t3uTA^J|SqF#B3`-KZ5Pw6()9)8lg7c`!W z_S5&ZQ&VLX7n@F&xGd*`b4DBEWK9f|-tKIgxySPuVN)1ro zd*gterhVXYCUwF?s8Bie-eW>GwW!&sF<4;rjia`3x!`8qcWHe9DgvZu(_x|I3|kVBN2 z3Whb?pda6_vITd}86AXvY%$;{SL{8fy{L_ajhsFK*5oKRUpzXIEESxCCF&sS%408x z%JYLf?d3TROmqUi_1ix0#&4u`Pi5wp3wqs{%31BP-baLMO%xr5yIzz(X(ON7NPjEz zNpog*LJC$%CVm$2@Y7rGteM@8H*)P8w?V+g%XQm7N@&6}LP&}PRx7CPnBTTXS!i(w z$Go)4?vz8%YLt^-(c6iJ&IL)y1?nE?uFZs}e)UxnJyyS+^6j-D>ue1_|GK%ogrSCE zqjSL&Rhg56t&U~m;MQ*Bs@UbDD;GOHCSHl70Yhl}{3{8WK?H*eS=sRn*+-jvdb+~! zeWFJD(N-+&#U1(Op5_s{q%Rm^O6@n~zAYee8(y>F;5>t3|2>U}xEo0w)H}J4u->DkA$c!y;AJoz znQ=?)!$^0qU zdYz|;u2;m$M~|d)uP}SSllQv8L^d#%fK95ealD6q330oUl&4l`<;j0*6MqGqhMa82 z#D7eU6@DC>HwhrMo(W=1c~sGZ0b}2$DCfb?Ka6F((9as>F>{SBb!;Mvh*I`8CFT!? zEpS?Fd#e@oz|D^o zaHT`4l8zuN5TTu4hZ(*-!%l>muR+X6HGR9T4)H&Co<}bGdIkN`s&1kZg^|C1AhNu+ z^m9A7{ex*18_t@@{(8oo{ST%Y6Hkn7wCr~JAH&ZbP$TPf@V2ENXsIEH)rj8XsntKD zXzeQVE0L4P;4mjo6xzwWI)6zT7-F>#uS1Kzx?4Bu+Z^lMfkPd3S)mQARh zg>_SGL6?C)|85buQ&5r1bfKbuO`R`PZ=jfcEl{!c5eGoo=xIs?Hf8+kxaY_pW-MQI z0325sZzz`C$!jRl*MD9I^o;-?a-X((XTIR(xZd$_Chx$v`ZuSb-Mc=leU2OYK1($L$fK^^vN@^0tTmrGfe%ayWPrTf!?~voA>O=#xKXkM?1em{p%7l(`;;F9W$sl zBqReiwJvB1uR!u6EFlKi%Ne)nt*~mqg{1KjQ3&VBmQ#-C<@s*&9^g;gx|X%r$wywq zIZ((vl+`98Ydm6=4QKBw5zDB+Wt4TaRbiIy7C+l>7cI40>&td&kjdv-Fggxm{m-;K zk7YN2^gj7*d2o!aNbmkjHY06_r79)wn@|iLwi2(D%TJHMdaDtOL8Y~EM10I6M-Gni zWMcFZSBLtAUCum*oi4w%^go@DD5*)WN7Q=`ir+C0lUu6U$(@Hf%mSw-zK`9daXh{U zp*4>;lrnfC0=zCEr03Y0w_dQ9AI85d;szAVgpV@@Q{^2rNee}W^=XU|)5{!n z0`2r@XVzcwYOIt%qJ}V@wK#cjDt-saoxn!2z5c7{S@~{%VY(aypJLH_jQVo+C8bt> zb?lh)Uc2dKl8)F+6al4bUB)sg?Sp+ICNAUPEG9|knJJe_D(yK^Q`@?i0A?h)Fiy?@ z`u^1GVpM&?_d~;Kj~U^~ci4seqa;v{qL6#(l{Pzb@mBs-zpNZsRmTR`yFqb^1ONEH z{o!#BFN2d_$#{=-==}9lU@nsX$y(&A3+3_QIhd~V$1uzH+;-fHiah+#+mo@A$46!k z4rz;O3wZzv(38!v6<9K(tHwNt{e(D+9m2@IK2ad9pqpmt0r}>I4 z-KhKFGZ3@=NIod!h-jV>6b~DvSoC;U`f1hJU0m-hhfNG-e0too>ej#|X zFL(nRSKo@qzH41PGHE4VGDV_~PM<>MJ5 zM1^1Odoi(ZwYNs0TQkRIs}|WAqy=2X{`2jDc*gkM$6_r_1&?@D}x=4YK%t-^9kkiVD9jg`pX}7cFQOBP(PHgx!6zVP_(YR z`}waP&OfQEZR39pYIjQ5JDeYa(ZF_O_DfxQM=DBiFP?F}6LG)L1kD*8;}t6TZr((f^c_tI@KDT@tj5~ZK^H?-(M8)Q~kvjhWG)fsi-5BTcI<7w)Zg#L->gueq<9ct#~0Vp*L}$pMeKbjnOmb)Va04LPTUrFDhA6FMab%2SH$PGw~eJ$UCMv=zW{p`Z7`3Q(U}iP z+kd{GJi7ZXk3+iid?bxAkXl$zrqsl@QH9xL!0zTN>a`;0x13=vy^|^mdWMbE3eWd! z*`1qn^JuGV*DUb9GL~84Nmu{=6g<4{1at>h`63z$6eAahRO5*Jz&r8@VRb({5i6v!`-; zBLT-h&(c|wZr$krru{|i9O;iymDuJEAZBl#4__?gyGYm$K=shR<n-*XXz2^FwS8GPUP5n-Qm z&B4rV>#}(@@YG|B8P|;|1`|sFfjE|ipFTz{3_dyVlHdnamTkZyEvUc=fx4~BG>5R zgvNC;2bsz`BJv%58EE>brQSweZ+M&nRx+Ep27yVlxS{Ix-9X})jA43H`C?h3XsiTB@R|&Y zU{#f{j*vE(FCp0woi8O+cTDjLUW4X-aRB}#Rj0SI`7|?ws{0v^s|0P#L961bKYTP- z-Aq3Q2*#a@2@D$#yOl}~Zfi6KwaNX(=>sd?O^4~D&!TD}%1_vNKtxa5bea(%sxT@R zyVQ;>s~r!gynssS zp&9AR;3no21xZyyxie}oaO?P&p+Fc z0~T?ybk#Bn$S$&Ol{{#RFIr*y1oAkVq2Q+s=3Ao{=gWOi#>&KkXr0&9xB_cUv zEi4Ps&t7wiB$Tzc&C@a0Il0nZwBfa4S_Un|&h)`dhJ)maQD>*%yPYZ5Hu4nPc)t~p zXQMDjq*9eQq-^U?k|CZLPj@7LfZ(MJVebvZAEYzVh+#~VB1tX#5501OOox2EF8Phw zrNY7J)Je6ECr82(Pu{E-aJc02XPz2eGktbjU+{BdgZ6iaR0*hqCWgADgxmUUAzH$0c~J7m6(YaaO4wF zn{Q23Y)4%3g0cJh1Y$fuBD#@($tHV&4odXCsnl<89`nZ=JlxXeBFC*TAiI%w7tI0a78Aj+FktI)35Sz~dH z(D3cw!?*R+4ot;cTvtDbZpPfBe)!>5m+BUOqW7!&Ri##&HjG(8lnJ}+TfX-LR&DsW z$w!YD>Fv$hnk8&1|L{ab({LYTa26mC02yVKn#?-qjm_1MucRX^2ajlS9LiPbTHjWe z!7mV%El=Z{$`I8ztuMX}hH)|Le<<#_HscQpKt8k7m-nlDt%GPe9Wft#e)=YSrTG@Q z{j%vLuEPJ!lZc1l_jB;Fkkfsm88~zH*vM?+zDW#FTft)rnxXLZ#VMsnYmIbISX)NvLpXn_`5a9bIj}JGpWYk_ zjP$uuDkJVsm67^S55$Fyp%#6!%52sR$*qA)#wHm;uHFW#=<HxiS|cz%LWx^vYYt;@1C*Di$JTFCPDGL2b>viC{he9NoC>?Fcf#DP1N#}@?~&kq z78|sdR-Imbdy&}6CiOBg+QcPGLZx#GZks`Z7r*s7wq3ijtsd@22rcVt;eL1n3L4^~ z@PLI8p<8S)Eb}PQewKV%8i4|`i8G`{CN)oVcqEqyS#W(2>7VaPEFNvarvhIghx;?c z62$UfFyM;7{Vl|#0&RcR@W9*>{48q;_kM|R;fYs1AdWB-1g)bPUSVc&-8a`iCl7!c zTQ8FW8dIS}f%8(|0+ZU#li<6H2*Jk}i*Jw%jT+CKs!^lcRfWONHVZQduAISU?-FZR z%HFF1#V<>%=Um0LPLNuDl*hip2fw%BEu63I%U_lJD9phVJQwx0V!61wU)8JiObYtC zdWBr0R2whv&dy(gv^X!FE$uOspz#EGU&`ef{l(XxR-yFk9ydmsvMEDd<2yY8;ZJk} zes_AGI-M48<37zGY*WD}Ixl0Fh-hN+zHrop(s=NGQF7P#?TeTeA#wnoyka}D${0O zQe5_UnDdBP3^g~4+DU!f{@_flDX}ngE#|~ELS`xZqX7OZ183cH*Jla$TG_yb_TZ?5a3jY^do0bejjdV0h;GD3C6 z`qZepP4m5<^AKl}M9}Fh^bgSu*JdoMGw#9s{5Ezh&v{%6=w_DpGf^{wUC(UWFNhnI ziI1m(rh0nfz$2JeT(87DQ;%2F61f}QALuI0aPx|ip^0>fsCZ)TV9ngN&DmU(Yzv`T+JfxnEQ#k}n8=`Z#a~BHbwV^;F72UX zAN2?6SCC_wDURgVj4$FK-kiU{u(5Ss@}Qn(@JkOYdVLk(2)tj-z5~jE<5M8w6b;)2zRIB2o0-uWz}j;B{TZmU zRK~9^Yq|5Xwh7U<{%=Y7EZOzq3!v;f_tfWl?A(=y*2vvP5K6=QXJLa_?$ zp~exe_hTilm9Q|FX$@>uVJzoX#BQCkUb`-(pI=4$=ab`izF&S^eRk46#}isy+A;>i zV6=%*^^E%(M0W}M5Z;FMt6g1K7(T|&y0$%la`6yRiYR1nL>#BYG>ydxW20*%^Z9S zuza?qKWlD@Ny2||kQ}E_2fM#y95#O}+4_T|pA&R&l1XXf?K;|_;8&zHr-l8!=3Ljb z+)z$zlYRe;!1f=wj;Xm}1=MqEkekkI1~u$UHBazcBKFTGTkx~0_@}EtfIZ@D#K*j; z`pyPgspXqzhFqQ15`QgLp=fhul;L^0>h!(>37Y~FAHEJCzdvQ(ZfEix|D7?i5V4Cg zFVa)%Noyx2m%uW(Ia8ie43}mfP<$rOIBcX7Z+}<$ou73(f~SSHSJ~k1)sCH3k7F3! zj3RdAjzd|tfqgCY)tD#&&+9AF6J{JbSVZ{*vD+J@r0CiVw-@h{w82XN{SWQWyEl>^ zY9QlKl8x{-fko#lr+KYNkNVX_(>G2pZsyNVSH5Iqboi+l|CCate5g2N8=nI83|2g<@{@b!Aj+ua4e)Hfex*GE-%xOf z+F5!x!jyzE>La7r@gy>w;r2bLFg+d!ao6lzh63hOtnylAd&;LL5Co4KTFwFCEhJ>+ zxARj$SW9E*qp;Yui0|$b7vshrEVr+ehr6figR>2TC@A2@&JPFJ8>R++_Y4fZi(S_gA%@EW;Rd^FKB43SDg>U+SxO1zIcG@t@P%;v;Jz`7Hbv z!}>bmvt66q9s9SfkPa=<-x`LCh9CG}Z(#HxVX8ncnJ(E79%OmD^tm!L?~o;KI7Z2KE|@IU>LUX|2` z7UzWC4@#(f&CxFcuTPjaeF?IiK*c3|Kdm5!G1_P9d$C}#|5f>XbrGxBUwy%&wHRep zytwdIJU}+@Nr9db^Wp_vi#$-Ck*YN#_5BjN$%6fzc#xp zq+`OoyBX>83=Y8uKPe+2$b=ssMs7?UgPx1NXqoCVO~jw@>v*jL+8jwG7W%7893ZUh zSH}}t6sUkyYMjq&8Das6+;4tqljXg%HITvx{SumKO*J2~m7JC#9Pt>4qDRnzMd zI~#I#O?rAgmOPlg2=?1J?0r7r!t{9W7|f@eOX=AgCV%~6U~D`#lSzCom=3#~7B3qIc*BbJUy3(sX%Pu~vavN77q z%91IQi{(FDn3AP?F0MG5H=~;q@LCajWcu@i$LC_8?XZLi?~VpDKuGB1S4;TM@`}5w zRlzXdUWC}Qx9gPhU7)=Erl+F4NePGB5jto-Be(P;;tfaAKFi&S%WBXqPX=~C*=N`g zCIT@Wjh|f}JuZEHACDni-&VDaqYJ!ETe&(&2E%#Pr49TePo=>gkCd2Wr&UbYeTUzk zf0YdPu(xf-i^r_A&{!xXJp|?ZogA96ax(WzX)8OA2Dy|UKEcz!V%vKrc|eB~qgEiT zC|;Er2kUF&F#jE%Eitj>)Vb}uRguF`6T~R6i?VrE+LQL`;#I|$yZ;jQEfe#dgNZG8 z97y)+hVJ0?nwU1`MWBBk3gdwIM}oVx2gEKOU)1KKA*qVDj^rQF1f|6ni9DKf%0TY1 zJxj&!<80{FE`Tq9;EqgHLQDTK)MZGE@?(^(h*c+|603Au?MK8%q15G=oOZQDP_*l|{s^0jy$F6Xgo|_)4^((71A8oRE z@hLdw8c=R`d~7+BOP(sNzC)|YQ4fvJdpHvIMGzw5DgeLDxWCYdEXsb`tQuEyp@Xth zsFfcB2YoJwdcrl|;X(MuHvbJImP*U7+1^o$lLp7(0Klg z>6W=aoMpj=7cd#7NFVm*W}-FN>X)}lV`)c%&{*?RjR(KWRq5?^fvk{7dAV5)3a0S9N;K)6I;H*BK$5$Z2=c!Tg9PUiUjoJ9g~Vek?PClR)e; zx+|poS8(`KpEyCdv3%;oyRv;BFtARtQ8fx6FBrCU%&M-uRs)J}y7R^woo4p#*m;hU z$3lGiV9bwPZ_9PZyPIlH%#*by>HK1B5Dx<$jHQjHnDFgi-lNV)Qm1b_Eltg1rw>tk z?5c`Smq(3T_klCLvK^rgOqbe!IlQ*c@*7c!GJ1t^sE8Bxq~FaJuzMG7&bf&EXEkxj z+UljdRQ)6I5t+2V1WW_9YyFaJ@4BxXo&QkJD&8L&#PlE9w5yjp0h^d*17_=@s8Eh_ z6e;%`5q~5I#aq5oL<=k3aVPQC#Sus1+q#^rqEj-AYmdP8Yy3CVXM>fG`3SS0=9ni> zllk9vU46>gL}eDewP*A`ngxPakm$cYwMFyjbK4Pa2-?Vbcc6M!^asNyM@_*=W1}xE zI|^>vBf=JyxL4yAZ=%|1C|p;K9OzW3$MzrPAUF%f^;0@e1HsgmTZTo572EW(J3=3RS$EN7|QR%K%QHy7d zEk5K@kUf?AS>w@OD5OlK+teB+a|4pPzO?vr-qi=#a&XO0Xw|ROQVGT25=;s+j`&@C zTK|DrZ^K{9l|F1fp6|4pFE961% zoGn$`qpHP;b!_cU(tJfD)+JpJ=KH{JP|BkM-?_Y8roiIK8ourqg_Ssem39fLhR&GIX;U9LSYzd-2_LZaMlzmY z5W|~6+NbR5yDFGOu?s4&Yp?-2f0ELu(?EYfjtT;Yn_T< z(;clS<>jgwWi@)TW5Czcd%uWA6l+~S)$$N5+O2tB>I^tMHzl8=9a9h)7!w1p8P22ycQ!v=@MOOFxe`Z<^ggK6EBWx) zUc-e(jAZ2y(h`kp2p3z(aK!#N9(az>5#V}?c%q0oAxp9-#VLisM*BWuJ zEk}!Pr8c}M+}ma*o^AqxAJaWg!xyi0&|0dzxy|-@0jUi&0{Zrzj?7j(t*kAK)ETk~ z3+K9)Za5A>vH*|^jinoQy4Iq{}Z#!I{?wKzWq{ZdICU2(XlkEI{9`veWbxL8lx>8ug$JumH`EpFsP zFxOdcjI>wTZ!BDuN)&#Yi`AxN^{)^X{Hw^%I{N!4fbaQt&VrMW2zoC622@Tj>{D)4 z=J`*<4qR85HshFTL6CMX)wqvlc{-7V?17IzPM(y7E3L_+Kjw*KY^TfZvd zp~e^34JxZEU`0`S?XZ*==q`p(w0l`49pYevs*W@S&J7YZ2jP#D$Y;#}4*AAMeARSP z&1cmOcc1p4wQjv@-85S8cf*K2;*B+9uY1a=eiQ5CbOX~}YO^)N62Ydjh?KG}I_FqB zr#qL1OWNlL+JcMkl*cM7NpR!BJumNm9{(-3tlb1O;1^k1KQ8$QTZAqi-8yrpX(K*N?umq~bj2??lIM zZKyUTvvR*_5B`5>zvIQOVf81Xa6$2G%dLymPSG)zTOv*0|G@r7;1KN5jw^|YU=cy= z#EUlUJ6Cph?8`l$8v?eZM&y=ItB3Sc!gqdF*F_^oOVx+HvazWV#akPMs`vgDVY6tX zse!`@ME!1+K!gk8Mm5MJy1D}&{j737^#zmkLas;$^cWbI4|ltP7(RU~T|?y<+YIK^ z&!NdFj>?rVVR#vj>BZFNW$<^0#qjs=^<&0xRPOU1s4G<9K zSXRR4nv$cn>(a!Rt5cR#y~W^5@mUUMx05ADDeWQM(XYq;ain3DJ$Ar!^{Io1E%T=Q zT_MmP7VqY<@ZgHsBU?56euwTO{UJ70DiM>JpHi5H*_fPeJW>KsHN`uBYDWV2e_j*q zTj8*S>oVDCvVKA-?Z31}MT9RsVy|7qxZhfM=Y^5@Hps)Scb)AWe0wN*9s~7tqeB^x zGP|oQ!uKotb?4$GECwlYF!2n3A9$D^$@?_wz5oAk{qea29kOyJ&Q#;Oz-Jp~jrZSb z*kT+HD*}``u=|$?GO6K~^o6M5`rg)+P7{4gI-14}_6OC?CET{@?56GT!k3!%2=%oW zpJavoB8_@K=*_=2%3R6p16`~|fCD~lw0yq)llKr&;na$sL06#9_1*k@*FaXe0(~wr zQM|*s45yiG+vRJb$aC`2>9_P5Yjy7>oqe^hvCV$-`uK10r3}D7Za9gTN8Y;l4kit9 znIUeV{Gb!NfyOS7$NL{$1iO zyfN%kWHGGx-J$qnHx)N3yZd(2w!mR%xZV8qr_5!amK97WuANtTF0xteBoOh z5xekWkPcsd|0*GMO!u7wr%F-_KvU*b));j@x?+1Z*)gwXj*5R=*$TL8itH+z3Pi(X zznmKLX3ajRun(p|8~a2tvQFb2-Nz5#vsl+7t7v+v`@Qq#wsT3GLBb>7mO=oy0^dGn z=6=}?zR!RP?pDB=gtLVW^G9BeHNISx{(230ugS8@YZ3yR?y5ff{sqvK{2_>)2WbWU zO)IcL)4x|>{f_T~x_@D8)@J2A7*72Xk3yQ?q3T0O=An&e$kB(c8Se2LuArRRFMiWr zy0`2Q^n3#e`Tbn`?W7+nL-22Pa!A91{@N8(_?Nzhsy$8m_7+9#(_nJ5`QiyYhS@IE z;X{G=6p}ryd~g3r>{T_e0?J%k^{Fcx0&Y7Qf5x=ZAGvs;5W>Qtu_=Av!S0CfiqVq@ z+N~CLn;Tu1jjivz08Y40c5teB5@leT&PI~0mwmDsY+9)&;9@5fv{390ua>AYuiZ97 zu}h{JYBL?#u@Ghl6#W|;oB63qrgVjy3-f*PK^&aCKX z`{CiBStgw!rVn6gabDbBqnyiI-?1L1EvvlVxu*jE^fFr{`ljyOdD6H5nxBxj_hy%| z(;DiNmKK>Of(xMHQPVX+Ox-DF{y43gIXVw&$)|de7!I|izO+dfzkeOS)C!IK^|g1N zL!yV@E8HVYl3cIb@%O2${WV|m7*#x1$oB}1cj$_XIzlD3qEa?0v(vaZe1hl!t8QYj>sq#^Z{t0)B$aaozK;zS*joU-;{9{L^8WESCGJ)$Hq_o78d&TuGH) zb5LVrzp(o*r9yEdb1}1vZcg>i*Qo>$y=kZx356HwC&MC?j#F-%%#M8+9dh9D3=9j z)n=@x9}G*nx+PLCdDO1)ZadidcVrLs+u7ir{uXqU3rXj+U{U!*MXC51y!tM&lzpz^ z?o}#w;DNM@wKQ|!`k=eW_3@x@8NvMLSgg!ilos&qLgkHVGa3ID>KX^IirapBlKCE1 zr@90O6|7-&rHCIh=7>TD20hVL+KsSZ{gzMl^6(lFuj)o z3LQx^()))p&+^*{9ZIbPm9jb?!@<~=ZB>(6Kp9ukbSdT8U-p6M)BgKuN+NhXQ(FhM-UnwEDWrI@^$YhYs2Y$TbYPvCY~P(Cw5N+9&g^3ipInmS=9W~+|i!XT|lx6 zWOLm+4&OLCF}q_#fV_0fMc@f&%HjF^%Dc@zGI)(1Et7|d0rR62Jw})QTLc7t z+P=>WN1RAEu)YYZ&7NJj8g6CK;<3_Bveat$t(LPJ{yN>gFw^WgVM1k4VNRIm$@Qsh z++4`}4>1s1`|}0xi3!GiM$iqfQpjBwroP;>>(awozD9Rq8ms3G7n{#lCcn5%Gxl26 zx3BL)u~V%Yklvta*}~eTX2!h)cM0?v(B0|&Xwa6sIsG*`gn~J+u%Iu(8Z)uFbiN)e zxhbTA^u3q&m(PPIW)Fh?Df`RvZ3ap2K<|7=fo3l2hhZ1#Bl$KA- zQw3j{b)JWDx?27E=^T8D?CHvtnVHTx@dVzt5n|TZnNVig%8yv%)tNV$g2G1i7x9d6(KPw*+!s++T(=vR z>sxQlx%Ot7BU=}ywK<6`#h?D@Q}K8fm&Z^n*PylO|9Wrni4ra19Xu+#>`=O#nJT={ z4}&mFM<<$W3cSGhD=4|Pe~^0v&dJaC|GYCo69T%~i4#E$b$NYizJ_L*{BJ+4-wb|_ z`r>D0_?G1Zozx8!{YzjrKA`NC1iG2H5(PcIH#=JNKLP$qDovP<87>uyJ;>`!+*J%& z_q^Y9q!-b|{KJ4(E_a>{Yw`Aq*i?pTpUsDza<3jUyx1h=`V#2#`l)ki==bxCsG9&s z>B`>9 zj<2-c2E%MPGes~r(X(nV??i#?bCHK(ys?z$FTWEuC3SC;AKu(H8&TB$t%~-@(;<~C zbk4xc-Fu=+xgZ6^dVFLMbQ0sqDJZiJw1Rydz~8aE0jSC zjva?qeD+?xW8*7Ts(sv*V-`%Jn?T>@($-c!7IX7~bK{Ss+%R*2gsau>a?0c%95eps zr+o22m9OPx<&gI1K?{s1%rSTu+CdN;Se~6zFUIx$- z!6_ajT2t)k)?Hq$Lo*jm0=)x62+(lHRb>D6(yxk9HR7^UsenMSUgKvG z%J?JRSI@l@bLIAHz&J&AZ4)^HekT*jOd$BVUmjtOC_6}O78e>VFEbOd93Izk%rSVb zfE%xdp*r^ZYFb5WAM|{Nu8HQcUC=eNNaS6UE}(ja3ivl9%JSGQ&7D4r+Ze7JgxdV% z+V$~h0($YP6qVq>y@y0ELa|cj*;ZMkVW3W*{uToGYMZE?xy$zA_x?+N7L{vX)0gHy zug&rf4gEi6u|Wo}tDGo?p~_D*&n=9`^uG@{mOl>o)B7P2fCR(th&2cNoqwJ64T?wY z2xXM2C?`vO>b)K28G|bXxiF(wHR7T zHd}rcGTry89is)XG#mYSn>iB_qUhmiqUg^^QhEPb{&SCT*^R=eTQ=_aItRnt@v4JSUsDDe72Xc0^_#J(B2_v=p9Lj;v~@Qi9d^r3Y~ zm@*`kJsC-O)zq52&X9ZQn8Pt&nj`N~7+2gAzZ)!43V5XL^EuU`78b za;>AxEvzd=ouW-wh%Syq7fVoP3nI)Xa})37=se{x9Iw~hew57%5s+54;*@1Ylze?m zT4#Fj%yGMTJ-y3KLoVur7jDtlaMDL|7We9N58>%8-%3Y=gV2ta;U9EM>F3?iSWW74 zwEgn6IcGFrvz3{Zu>PNd0AxwDIDi;=6#VR;$lQUO$ z^znW*<{ACf_wc;n>frzekI%-8OtR9tQxj!}BIcYeM4Ug+%4>f^Ak&|$4Zf?X5SDef z<6?`(QURE>K;&8_Bw63m10gp;XFT*Wj;amoQ$_I^KOfRqeA!Wm7BgE4ecf` zrq#ESSRn@9Bxva(d&BK)wFD0}37p9S$0=bue)LHl?D0&x#8{lqesKo6rUsu+8b2db zXo@2Xy8#KleaQDJDuE(IL*Bldiki+I?j2_4c9Q|DI%SMRTM5)F+)0dhy=t?HzZHxgYZJ9_GMrY$l0QC;GiAC zYHg*K^ll9in8EgSd8+twN7v)J2N5RIAGID>WZ%Ou0qXxyI}?_AirLRdf%_ z4K!$F(6xRDaCW0t@$p3oUjXih+a1dGzyxtC7p%BK(yMstzhB+wFL|C1BggFRt z=iEs+04mQlgn4lFX-fLQL9rN{-#*AM@pAt1M)tV}HC|F?ZqCF6jgp8TtE$gNcA>aU z*W8~_K|lLW=d!>o)~1D?n&IRd)|Hq9vTBsklXG|4;?PxXvbmMsXk;*mOahY*D1Bqx zle(qPUW%xj zr>mcG)^A#`QHvfJWr!8@VvH=LST3%eoZV}~Ol+0#B3hxQ48ni2MT8jQdG~-q z`{G%*__8=!P3`DV%jexwhlwhw*Sep1RH@wGo%^Dn5SwoJqjcSQFn<{hEvDcHG-sks zeD70R!Inp1dt2vUfM+;ju{~zS>XwN3W{!+Ytye)uYB7Dx%K!MITy3>Capr2k5a5cG zRn(<}Ds;m21|q0uJs7+XNj)H*_uHYT<}K#6UlsJ-TbMgM=LBz#`QK!K7iZg$O=jo6 zPTLN4QKg)h`KHCuPAM^!DY@h>&awxx`WQZc^I`l5~J{`Ur1*okzFiaNG`S`OWY5vb#c`0eWcm{ z0PS#f;q<;~L3M|fX)Noswv9oRi#7E^GEQm7X&ypbhDZ5R%9eVR(+iog<6Edc+ggVa zjTrB1rQAa1;*Ff!6rc*Tj(1C(ed?a&#ub#iXwKI6^`Z& zG`FKc51m?pJIl~p`nFzXV1)S=?F}3=-^)=SC+t4#)#NoY-DB%d2s4pvv;O(Y$8Wri zIUcVsyCc*WF!S+B-=C5y{g|xP7c#baK-fL@fgNK7jz0V05>eSSQhyU!b`b_yEdF2H zmpwO#pR!#hi;A7QOoFh=bEmHj%?8VW1h|mHNv|VuPTzda<$(jiCJ|@Mv{esM%!b^( zj(NT~!HHdyJg9(-Y5V|{sPb{TvgJG>PPtm6q8<^SoI3@AutDCiKQ9wwd3GPQrSLD@ z+&=vAKbhY&j34i|jFJqTzER=E&^N*Quh_^OU+bWB26Y*5a)0d2LN_#pB(b(rglo0= zAuT!A6`$w-FUDTFXk_&Zs650EFl%!;tL=|{>UXAfoLnQ*+y^QBR*@jS$sMHHw^@=#|IaP102d^086UT%#AnsL7W^;Q=94$O zsh_H1*U5gB%AjqY4R-;M2@N9cj`r$Odp31j7m-%Brfio8{r;osQ9xgmc^U7*fU@eH zc+TJC1Rw7;^RtoPZ?Dh&SW6=nulHB6tXPc4DLtheL-x&d@bvM&NAsR4N3s#T(UR}l}xFSw=dY^kF~ zw>vN}3%|}RwKk%vm7z&7ihA)0MG)9<8jFVJTS1FJH{IB9;>1ULFJEvSMs#N5D$x5( zW5myXz|es7`IG0S3nrK7dqwpBl4=n;EiNVCl zZKWMd4cGp@NT`H&L!+=+gc_Ow9}Vjd8tm6{=*@Yzzfof-s}>Ikwql%`t--NZ;6~Jd zPPE_C+Dn&r?z)0y_QHJxVnTw8n8S@v^l6xQr*l-IAq5-E6f$XoEllgzlOCN5>(~u8 zWh(@E)^`KZWsvLRd5^-ll&yq1@Kd_&22%&mm&~FQH|bdGjW1a_Kyxe1gu%mtH9Mu_ zG2ky!0<*+Vz&{6-UF1B!1id*2;ia#p^MD^oT}DZUgGc=lC-+0p^^(HD6rVv%m;OUS zd^lWcnzyXp?3(kEldQ!hC1&(oas08*&Rh4%bej&;XTj5^lQFIfLmE+mLYs}d7Myn@ zxEZr!i=T9z6E-;!^?LX9e3fou;A0yfanE!XJTuVtKywyjBhDzrGCM3FY&`wFwR5`; zi{Mfv<>h|V1CPccrTDk}DLlZg7Pyei;Lq=xb&q*E<P2~2xG#?Nc`3HYBc$kr z5NK2cYlUqb9roHfkbGQubNch~MK%OQ?SpJ;g0v}Qo_DMGx6FZi@S2%=gFX8q-Tlba z{9@|H^Kt(`Y{l8DS5ebiwIweCC#XS)dhn$11`BEt2;|%RGsoLhT!LXUDfz7Bw8zrT zx=|#nbhr<|s!7d}zd?qWo<%pF0D3;9CM*fJ&sf6LRgUNve57KGEDFwOXu<;3G$Cg^ z>nZ;0(r$I};=x_ak|{U|w{HQtts@<^HFv&4J73#nEO?>M&=sn#F7)GM(Umjsf;;1a z8-o|33)6Yv+(r+lt5`lu#0qcyQg?;@UD6)&WqoQ3sxM>p(t87NB+?jgA5XYhlXxrTGb?^ z+;W59eQ*RccLe*UZs&BVLP`Uj~D611INLYRuo_7gn}2YMfF4O`^Ozit&>yJ z>1=~ey-~MoZ5~srw`!nu%W0b$FwbAJh72S_)sz7?*_|P`ANmv4ZLO*a<`s>Ko zy(RO~ayhs1URKSH8On5&wbRjeRWV~};2*3whr4sBVB|nx&WLT512?%W$F+W>Ki0Hy zh;IP_@#7Q=3ntd8&JMTH^{R9{+12Tl&`ZWZ+bE<+}UDpgh41XZVha z;2Y=uUkf8gDVhYA33&=53b@48Kax7~@5hD|GEazK{l(fU2yo*T%*##1nf7{T%K2WT z-`0DCVW84vR>ibsfW?J0qC)@1`)w7XsKRLD!z7Z9n}2)t3bf8|`u`7Ue`b1Y@gJ=o z1o=_G9-?t3PlGh;#3`EwN-u({6cKe}sCK~{#g%id8vww{4D_k;9pnA z5Tpe%k(+c?PxSk3+}(py9110xjCVJA4C!NV(%YDCxxRn)!8ZlUb|kZYl>A4g?m^mD$HI_IZ&K|f=qpO{u!yr>#a4MDX;(*`Q{F>eS_@oIBUy&NAL{`=3Hl#$R}N$ z%%Zi@g6#A0&UjInT+hHrx4%r?N>WetFL~wn2~h$ZYDLTetPMOIf~|AH;`Ku7J%--D zvOJv-yrY%r>1c7AAjj4Tx3cp@;eRRMkHy}I_Qmao6mMujRr)-|z!=BFZcEEL7J3#K z&;<|!o2!90g+TOJ#R9c^hiE)-a05m!n<34>0jLHh?(xGJXuLiWdJRS0HiO_u;D&cz zG{G4q54t_!4eVQu=9Yq^AaKUPMJyrjJ_fNAerey1@UR5G?3-B&=>-6WU1AwH6RzoU zt83!_2K8>TrSE7e4ol1*r=5k*SFbGPeMfqP?J%yTGkQnAEt5Y=(g5jD`yy#TCY15PAA{DW&=U=)YN`-A zE5ba>faT2RM5DQwttir7s*BsJin|_Q<@8$ME${9lUR}zBA3+N=e@d+)!d`EG+Mz|f z>Gu+>ZTfIvN&0Re2wy0UuiZ{)>AYPj-D#S@C5AmQ@SdF#)zNaLH7&g&@k3=AS{xJu zl$vx0BsOP>r%Wt}Cw6>xGvPh`rW*&%Alh%Up{|~ zF2RWI7Z_?r@&lD=u1M#aQ)OY87K%8MG^n(Y$iDnunw~Sr0X*-Fss&H3`pDMvMRb@$ z@k5Eiq8y}$xHq~ZUghQ7J`H%KEZCXQ*kcf1|31C~Lu0b*&*8SThsA=4UP-N`M&#E2_w4tU znSmXs%DHRZF~f!D!0o3w=$N>F_W78-%-z(d^o*BA+rHqhYwLlKt7(R&KphKLuPm;s z0yoY2uUp^c#!IQ@ufuQ%KkXr(KKFnVJ7@PGN=;p1Qgk9 zcV^tQ=IVHit;jBUVW4N+NJ}}vlR6{dIjs&`h;Z04RI(oY)Unfqvs-vL-9)I66SWDk zTk2SU4xV|HO~+^@OJUtwtQ)$MHx|u3$|rpuIbOP>5)|^iG*Z{huZ&IG#!r7U7c!tju_0jXOxD?btGx{jL1=$E$ z`^b7fxXsyy5Ik)=1S7N^dZ^T-eAC4a?bZ&U*(b0CwcUVR`qtdKjWwBb(Rk;(-`?Pj zn?RY}LZq|C;PgrI^EGdN@=APniK1|uS8OhoL}VT;bX<&Bl7A^-CjZX`(0)*3eN!UJ zi*vpe2fSU|zwj(G>NRK&7)F{Y2{{3wUrX21-x(Zk2BJ&IrN$;G#j>@4t?>>uo4Rww zQSAtyO0y;8z2p$}R?Kup?4LfUM|V7+i#<@?mF(pX^!}S&GsxVHno_37E&bA3842k~ z3f1$p*+D^^Sw@wn2gvorIJv#ddp@x0^Cq(UD^<$id=uiioByV5O4DO6%Bw}%Z@726 zY3W;xk6Ui|J25jSt+bIrqcqjmBgmXm#!+^G7R0cJFjx1wruM?gsm*UKdbcV?>h+c< z*t)l+;BP+7+GyXAeiqn|4~rpq3To&3w!l{|vNdKgvC8?`PnY#e!KWc4&voK*_EqYi@)xjw)$bBax}hB19+b^8ruseMeD_!3mML*w3N*!HzLnUV z>EraJ!Vy}g7`pH?(H{wF9{&iKekav+zo5F$gYLq~8u0!TJG$gN3Uiixbf zcMtHfHenbl0fIUNKpx1AkF1cb8wCVs0`%7Ak~1Om&7&)Y0(GhK3L2iBTW2|`> zW3Zkh$3u$*RT-U${E`HH__~okWIFo~?=f#U_^)!e06AMu!rMU8D^AhUuOSv`2kkWu zBb?bc-V8~3zMu5getU9!)cfhK>He~$k|fxtr7b#Eu-X6R4P)+_Orj;ma(Pl@Tqe`V}>l=if> zOq)f~xGrGj#_yC`D`@}k>1EY#Eu74FQEXd3_^zPn);9`AqcKHpX~rPn z@AKOybJlqN2|ERP zf0_ItEx}DXx?m_}JbUZL*VB`j=vUh0xpEtEwE#X&HnFfOP9yp-`iP^u(_iQ$f;#_e zM^t6k5k*?FDb7L5xA$z^F~ounzXLgn-OrYteJ|CZpePp$j`CmdWmpa>nkUT^ zNL;GKYSbz!{w4FT)s;ohymG6!=S=cXo20Y+*7sS&J{W5UV!T>)VLwuhb7i)<-S@0o ziYBYl~YwbC^#_$>-&@13W-9y(l^`#mN~YyIz?uI||9mokG9 zCtJD|pOIRZoKmF-(GOW(%nYys)vztxQh1~|qq_Lb{h&9a^jf`MzH|0$Xzm(})AsF5 zeYrb4>22*PdY`4m@(}VSa<3|6;MZvi<+pE}cCX5_lxOB<%dc&-WSpd9g1C9b_D1Ov^2KdQHw`Zr0R&OCUP*mdBsqg)xHs6MA zG09zR>_Wg@sHMVa&vEn)RGZ?BQm5``Tl&#?;{Y&OkKbb{lVqyZJF+K zg61zz{zbMA1?_d>JOO;cQBsX;{JH_Vqc<(R5AKz^BvvWE`roBS%*Gy6-skqE-Qw6Y zL>2C%6qrS?koP*4##(Qhr}rJVmeXb{P|61s4DIK@^Rd3eLset%m; z@1iAYL=cgvQG*c@5ha^W@I_tER4ORjk5+ZC#^S-R4`mm6|#)F$E$JMYSurK&@Ju`-6zDM^`!lsDoP zbz8@McSUaD`~au**s8Jt_UhZ=rTeh;)_e4$JkFVAG^(H`Soca)m*{xxp4MN&0Oqf> zT_vA3Ik$LxkrT6yBg&K?X@%#8`nyQq?I&1DhcSJHyIe-&y$u1cRJ`}}_9DqDYO;`q zQwZ4I?erCxwm(<(hK}1qNl3k{K+rvpgZ<7vbNkAkAd!aHYKmM`I3xevYnR75&u|uk;w2vLMHALoPp`_W5=_3o+fNZDcP=sa;E&KhJ7H ze)>anmv{8dlheItpCz`<7Ux^Z8m+BZNxEdZtNlb-!R1)SkOXDW;w+_i9ytq&^c0qn z26*XytT=wu?>Oq8jQmSX<$TinL0ty^Xfb42{fV&l1gA3!-umJ!HR{V+#ykKYj!E3mlY!<^;e_%3i@Iay?bhmK(37n83%+{1}|% zDy8r}hm%w`{5e3)Ze5B=iC-U^!IT;kQ5{z{i+H|t`BT{^g^10yjkLE465?v{H7D0F z;trE@xlZQthr&O39WJ@nZj8yYldG(g)ejbTmn`pYst7JjVXYgtevhgm)wXtq-- z+V<9RDeE>B-utTw#Cpcjn*rN74$4K*E1VH;zQ#{ugvX*z+m}yy^@Tb~YME32QCr{4 zc%Jl1cpZnufm%c&R!*l)Y5O09Gi@G6zuY0do`Q=A<4!e~7;tLc(C#!i{|PqbFU+@e zDrp0qZt2;UgW~`L;+TyW;|dBpHK}PXBm94yE&YD;a;k4ZeRKMG{&wwt`zVQPLXO)%()#doZ z+XeHbDJOAGZK*|;+UJS*^YV(b9I+qY1lD?crCAx*bAvbw^9-_(11g(}p7$u93CEGN zn&u}|6``{1Px6e*$vxR&Om{yeqbTpem-zs!(uAGTt5Q6T5Xam1Yx3j2#fj`Ioc4zN zL`Nmh(88lXnmY!8qI$eAv~*@h)_($M{QY2b@YSZ}k*p7O!Fp?p`$6}EGErFczYW!a&2#cjORWJ-EF3SVmL!ydUm| zU22A0*#&pW9jaa*1|mHHipvt0^%ABUk_)^qz`&ax+pna}%AMSm-CxP>%eK#s!C`JJ z&@-!JHtquNs!m(M2C@Sm#caV?O0Sj=pO5`8v>T7HA|m{J3C@PSwlf7PT?o%blD&7f zJ^dIvO>GLM%^Bj2F(R`#efy~xz6@km0M(<0>(e`EnGQ7S;@2s|=^n`bE$Q){9zFg; zQ0*F>1CIWVw0HJ;{4u@`737`#YeVL;!1}4)t|PNX)EtI=_vM52LG&5jjosC^X9k6=?5>4LL?S`{%e}oA z`^sL^KB~S`-4?59r^_#sQQi!0^Me+))+HgwxtnK)Ct-0k+ClHDAD@91nh*R;SXN$L z$4kxTXkV(AtG_#PXWFf0xQ$$u`Ciq-@I5X2Sp15o4bQfFT3)>ZmiR@rV+o7g4K{@8 zUr23Bt;0u;_fh{KoU3r^KeQLGvx^PS5Ai`N4_jUXy^ip+JI|BY@(}y-IdH|WTWo9w zwBDg*V!_3l-cS$k1YbI8)*A5uGq<3CKkq{dbK43_J0rTmnCp%(-a z=2N1j-^tB$;KUr>;_L^vhhHa*lLmIdp3D&&hmp0P%Q2Q_e%#=_+5&mOPJe8(?t||5 zkKl~pI@hYVAt8m(NCKI8h#KZy3tSCOx)M=prDFcvw{_yr%Sv)*n6@Rc-UMN=oaVYB zhqxqce$-ozZmbSGg$Y^pUvx|&i{)q52}vWcVfZ*gW3|I0jF@_`_RW07BrG!cok=lN)jS7-59_@2Qlvq2!-JzY)sK>w~z8q|?BKuNx8536s`90gB*7dUD%Mm_Dq7_z>eW`tvBDZv*865ce@~R z$kUjHyR*k$*W9!6#grMf+xL6hbK^2~s?mnEQye&3wucdKm_}99bD_>ZyhRJ>V7r9IAMX0sL$pwJuiqmblc%6Td@W}e{>D!6~^$2It@->F( zFJ{um)xcH+Yowo^*xZty9!43{B|Npt^S+6`|B9$AJ>ql0ObC=O4UnV@?q$d%wk{of zZpP3C!}!c;n&8SYlYKHX`JsgYH$HRT-swy{znlu?3H93i6OEi3){?K)2lp)evlI@Q zcE%-7zT1k+DWd@K&t;J>yf^e){Di>44smPHZX;-9KF@-7evRZH*17oRj3c4zqEJM> z1gyF#8Jc4j`{8OZAFAe3w8F8iGNVZIH3ESnzwfg2zdW4h+5eDj z{CxKd;pFo#_ox4-DEXxKpV%B`%EJJ-Dm8M~0aKvb2yf!tI<-z!e*1B?6w?1;+vA0b z#B0tEA{LxSgh?H2PJYIVL#}-VZq|h^WtkCSEiHOgKJr+;zQ1?#M8x3o%bPU31lQ-= zufxa?>Th6~J?2DlO!1WL9>+kei3eyY6 zt;JuSl>b!dM;UKQts!zo)L8Z}GS9#y_yS;d#}mGJ4ck1bQ)d~x0_Qlt1b=;K|LUT^ zga7%}D;-xE`OA9+>eyykyfwHWUVjH^AYMj>r(abP7YWF$|Tl1>|f%P5r`$vdRwtgEn%RH ziL|zPQDt)??(lN6%nnXm=cyW~>Iv*3GcJ9f#~d~rg6@NG#aDX9Hv23bd4dtLy{$$T`ZpdM#PlXm87| zqp-LMohJj6dFM zDv%*s+!QZ{qdqN%_JLnwl}K+6*(~_*B0WM_;VZNg!gl26E?IC;gY-YcalhFM-|%3l z8R30##)-}+@ZQ-T$yu@XprPGktx*ggWuq@N0e?cr>vSn%w5mSR^9q8^&!*2mM02fN zV`Pbcago_QtEzqQeT~BWBa&OAw;EX8oOk@cNZac0u)$ngh=(1-t(5+))M-0XyWCHt z9$5sfKoZ`*JTMQFC(TypB-ogdk&x_|Sc(xVs>7Q4`^7B3H?bX!Olh=OY zBelI`oU9`Q%_fT}HddMEXfa8u2MXCRor@2K*JdeRy!|MG-8q9qR!M zs^zy^508lvK0Hy-yecLcbXZHRM){Y9k?h_p;rz~KuiBrQ=u`USrO?yVB(Y+VZ_R;X z?Sc$%-!b#wDm48;;x_kkTfDGAGQP{2me+eF_Aw+rGrE2-ww2Q zJ!pTcT6?Uleh^zF_&oMr;(Y$)9{cSMTut{Q2C&JDEd8~rYt<|Z3+>Dd3E`%D-(ZQMeU6YefIX079AUX5Ozz|+G@mlbA%m}tOFlX3;nXIHQIAwC zF&lCl`8<)i^DL5!B+16nd15|ZH{Mhw9_jmrsLYF=wf`LKUbu9`P_ofc7uL=21n#wS z6!74S@EKI?JPuU={-9B+jTMZ)+ISIk@yF97o+1Br{O;O((QdQ$ImnQ&*N!#4hoIA16h$lVx(#n}d z%yeA^!AL-rt^~SwdL8y3E07O{XpkKb!(H1$*=4sm7*aN_PbnuJka4{pm_v;#9@S4N zdeRBUUAT1^p-uyc{F!A&4VWMphQgB@`C>6tELwv*N8sBRp7`;FJQrSc zpkZf2=#>1j;Y*sKrycoXy3%k2pHPDgmVX@2Ggk=aecVRHw!pWnQ%Eu58hO7^)8FcCoiXQkUb7XOj(?GKPc_c zG>&7&VFtI8)GD=U(a%zj^Z1TL;K$)e_Q^Ou)%*5OLVckqz0dxi69_49rrC1$u8l8RnL^x5aNF%H%GM(u_~Ag`_dI3lN=*tlks}Go7XKvskuxl zy*FZCCMO>1dPQtO2J9aJ3vhWi>z2tiWe>-%AH`5p3h0J_ODUV$Q(Sv%s`zDi?t#nL zKa!U%A#x5ngePmK3u99t9l+5d&c~d)80uwxT?Ih zAe^BeYiYRHg!X5yH+);qbIm9g#8XEN=9ILx}rLtTU4`;(rv8xM=>f4ps#|9-PX!uAMv72qFN zZ@75yZA&WQA+>&rM8wt+7Tm$aLZxlW%`_5y{cG3UQ`8}2j(tHR?LQ>D@6`tV?VuU5 zSwn3eHq!mi*bq7GI7?$sA<%Tt%u}5{fr$kR5YtMmZmrThe%*1GVDx6-bRg;``G5|R zN(C94n`2(ubMAsaWtp7J-jn*fN{V+_BOv|dhzP9tYo>YnM}kjo=JatP^#prbOHQ4l429V1eUqtu>1^xqKg#EefkA}OTT`iKRaghCB zyHTt6$9a=l-{q}1dC*<0bDNq&i-g)#_ppuJZzL504?OLb^xfqql|^^C zghN?3t32(xpOx4WcvS0PD>==by-61QeP?7##+)>&<7q8CPc+pAFaM|xkAJ0~`D*oKx*^Etp^$**&&$p+JRF7j#mgDU#`ukbTLl|#1$pxY25Y^Y7Oq?AGDVBK8*I< zixU=U`Y9{l{J0=8;Cuh?y+$j6_^9?-_#5sU8#Vp2>FT01Djn#9;) z*X}xbjU|36!Am1s=y46Z5smMTfqj2hp`ELv8IH{<=?XV^)HrYq=JYCgE?m+*aOnT+ zD1UJrC1K5OxZ8EOhwXZsH$iEX4V<7(0d1lI=I?Za)?W z_ckHR2g;#U_7uDHrJ96V=%EN=Eegra$R&;IF=_<_?%ZoIU+axOv7!4s| zDA2hWQWHHq{=K^t7*)p5`r@0j!5<$wz_GDU`+7ph8c;Z5zjMv_*msjQp*UO7oW5A^ zP^ZCau$p)9d=!Bas5c1`keAJIA3#<>Rb=PVFfLb8wFSHA~*}=tG{%dXV6x{~}jonNep5rYf z>_H(V_CmWTo{HR$_ZqJLdHnv3V&^2>HQEdJ2_}RiK#yA#9&HaAFK7`a($NvFPWlqV z@B$mVt!?}UrdFPhAYC%UUB+#jsdK_)Ep9ngr(bb?v4;~`ev|N^WfY$m<*__hQ&U!g z+p@uYr8|4fb9%>CM2@4WijdC(k6OV)IG9DR_lQGkA$qq8K08tEd zU;VOYh91TF3Ac4GHLp4M7T?V>OQ>%2GoB!op#B^ck(Q;n7HZ?RX5nY(@s;#tqtJqr z$6{hcLbBHCB;LV+%iM9uLZ06h_{xk`ASo84VYutbmR3zI_qbs1*x@Ebd?f-LW$4s5 zw6txZ?u3qJOty9iH(10&o!ULv_Y&2|Uq)q>@skne;c@6WgK%3_;BA`ZYfHRJ}lZzAZ!7W=iN<8BTon7Td(B9qf zUWS`Z?dC8NHC9;!MaloNQF(j&u^KS-_5^|x_eF4#@Ap-DSL15N*W!BA&101P5MXOF zaZ9NYjMC>AFA5cYr#l|=4|7vI&BrD}csrx>E|DEZ6~_s$-Hs_xPqp_-!L{k7Mij@5jV@$7Wq&cm5bNU-s8fo1{9QD1L}zA>V3|#A1RQXb{3JrVYGt=frFVv znbrFYzK*>U&JM3BWqZ1p2XJlCCfuns*HRuyH{DdXW2Y0^F=qU7?C^Q_?{kn+o&cCw zQ>oyVKqZUlP?Vdz`5Y=?7dhcaC!kS_b8NQjotD^PV*He2 zAnf7V)i(&>z~b#Ra&;taJ72`oah&6|qwNlE8&|wDf5p9b1t%fPlUYb^SaF@%?!#9v zjPv^91nhq*`*;KgmmxJxBj=bd-e+aEnWuAe(Lfa~0hWzF+eh#b3=|)L9gG-tUkCYN zeb9cGcZS%%2m4ENKy*28#aS^|+Ac?}>P!F9-ictEJx*$yxN<0$5d?&Iv7Ra%)rwHO z{IcaQKLlRU3ftma+z0=>;E_b$|D3`+#IHt1;(A%Z(s+Z#4cv1k{Fp&s+nfXn`9nmf zxgWr&_58lMfUXcY{1lHex1yT>1j_lpFAE&bfodB&;jKm~F0H32VRs(N4?AVg)O@-s zmkSKQ7h3mF#AeaFc6-W+eq@K&_Gou>N1tOYjOo7W*Qn(uct4QR_6B?GNMjZw|A@U) z9oHHO6Aj)=j=1%eN{rh}dqAQ~0?>%kcy^H+H34^qDCiH=S5}riIL>Z=+c6rs#$GPf z`PJCrlO@3S`_n|I>%_4>Lo)OZ@%P9(7A(+9j zb#|x(0+qkK(93ux#gXc^9Pr;MR)QU)JEbsJ8O%fXpl1dNlHu~tBC2!*$sm))NAn8bcL&&fRxX-Dznl#dT zYAV2eXlQ!%>PVoN8Xoa}B~0WXVjdRg`=<*}OZ$_6qOnSD!%m$$i6wb??Pp~Tg$a(L zz?}cY(%j5Lyw_Da?)h8JPHtbc6s$#o^TF}ru9wI+e`ha*hjE{9=((4X+pEsqAdIwW zOe;>kvfO(h38&2Feu*8K1q)7t%C60Q4b9C~2dQ43hXK2(@@eu{?aiGjYJQbF>?S_JA& zPnljIlZ=BsJ$4>^R%bf4E{caTSpCXc3}5UpG_ofz7W@UN>>D&xwdtht5e%n2%V9-2 z$EN+N!Vst!k^j~-uz|rSkWvlD#M?VgDI3eCKrxCHC|Y-A;pO}_asZ7!czHL6rAt53 zF*MlWYsRErjrG?!2qgsFl!i)>{6{F|Xikaa&q0gM=eGALU;MC+dN&aHxShsmUBo<< zdR=7^e@n;{2gDH!w?^$I8OtZgw~=k$zP%#n!X<5)TkEKg+RZUu5fI5xl+tee^i~Zx zoGy}#cT`#uC!%%xz8V`6%&eN$y51Um&2@Bs?gebK2!h!S^OCQ)407`oNQTUKRCZLp z;4WIR`fQc7vp_vG3M+*H`yB&%Cz#LPa#5x;Ds4f-e!7|LlTDU@>_0dTptzUC#-rAR zm|PV+kU)1>bZWLg^>&zx8vH`ccMSRyIm(O{+ zu~AJt6Omh@w5w`yFGF5buQo7XXFs9gqIfDjAvh6Je=TK)EB#B=_&I~fXmoFbCt#g= z?CAz2!C%Q^$8}pC6$xP&y2*Dc_}8NLKI#BaP}jq{i~DS??w6YqS4@xhg#_J+jpvB zwV263!{yY?>PB4bo4suD1}9T|kkCxC$}Eh0X?bz8+yyIm)w|2{1DWJjI41K&xN|I{ z+=i)}lHXNzBUvxr-eR13BFasdBirwUV2HZJwCwL<7T+s)!m`pID-5CpgC|hw77X9! zqA1hN5cr01I!W#86Db+=E1#x?sgI&H>=uU}x14UJ zC*VN1K7f`l;RoI5Vs9-l$5chW$OnPMyOApdj@zv3E{py)&R27g`lC=#ygaZexuo)n ztAGU_Kv&LnNQ{mg7h!2iq_wfQ6al3z#x$tryDNfFB0*HHsqP*TqD zy-%XSqMz>4CKZLXIANyAJ=aI6BxV=tf@hk=D~P5NQzZZbQ-@E`Q0LQBi!w=P{ z&|Q4S610W`^YycC7|w#m0!n%Ro)WcLDsnD- znuFpA8qGP5$Z9CIR$Subgx_k#_5T)C$|$^2>5%Z4IYoP8|3;D{dw|KC>YUx3-;1t9+?ffftqxHvh0RdT|>zFaF@cYuuewZIyx8-q&aTpk8laP-+}UEv z+IXAR_O_Lm!d1?gtDzfq5*P;^j%zo@Fy4Ach?WY+e{fA`gPxu#O<${t;E|eB`9>D+ zV1Gcn+MCwQ=hw65AODF~r0zj+g_v=8I_XEhn>FXX6ZM<`$W*(r4u$XEAJ907>Up&L%(P{{>D{R%1gkkBpT<&rb`ejTB6#COKJnHhfezah$ z-HKDr%Q?>h6BggUXU{KILby!JM{*De+e!yoUi|ZiSY%+Z|M<3*{!QRJfaH7$rLZV%e^#LeNX$t9ojS|0FiH}cHf_?pO@8{f=RgbD)(|bwoTR9j* ztt!pL!TI#z#PUz@3pgye*8ECbQDr?n8Va8BJ{prfg%kP406KdkL=78UkJEs$&Yc|m z=k&s}7h~{6X!}tYefHMut$Is(sf!0wiAW|pPpS}217AHfB4BK}24j3k zPK|ixq~HGh%Daq3^4*3$(=Da*(-o>Z&H7v#J`fQhXY{9n9unpIU0?4tcy@I*T$cV# zmcBF+xG2~C$mFoi%U2n?ec&8^EwhlwD5#V&%*vHAw ztLfy#owA4ND8;4WPfW!tFBcch`A@BMg*XJ94(*Agj)__>81B1h$jc1|4&7FKv;t~> zY5M-%vBnxm2bkJAwFi|=ewvY3+-}3so|bK#jL&G~lzdsdfcx3(bjUsn<7xr4Fbs?O-FpQ-nR+l_&JT8^H z#OZx_eQ2LA&%3cAo;Qr;%~(^&{K3*J*`ZRzC_rv9IF~8$`QJ`yb$X0NhE?|ZDdk!V zX!BIPCS&vy_d^h%__&D3Py^Ng9`IHlO%q=zwmi5`lzMC1N_W9c%axnvX*~G|g-8~@ zfY8cuTG3Ldk2)`6T&O8sH=>SqGX6Hx%Oq)ieNE8fpbVtQc+|PT8ytU1`vbS!kvX++ z@_8T5>bl|R@N9~^Ew7TEr$qd_7osf1vVk`(JHBb3mn6p-$0NQ6173X!vXo>a7_O!p zP@pE)dS50_WP6ZUhsGx>r@+%1p_ur^SF`X5H+%mDNvK`*U8dcTOCclz8a82Of4_9~ z;rm4!E&1r^N%5M-EC);a71AX9iy`nB`e>(n;-s*7nWJM2|n|t`6ssAUv|jl22CrVcr>v^23Z1!O}a@cm1Ue zv~l|&69#E%BRfY&?5=Clu?Lp1%f=f~#91@c$AHfVF13d-!XH8zi=8U#fu65Y&(u>a zK%Nc7_qO)9ay$I+XsA;XTN>WTc#h;4=AvB^Os>wEEN=a8LD^xsC)De$d&?ikx*&v) z8;n_pH4KDY*xu_5%#FTY_oZg=yl2Asi1$)kAmH{;7pE7A467kBy)g35_gEd=zVbmn zxmOo-E$pnLL}AxXJN&6O0U=F0J5`5+)_Fq29Kl#?rJKOEHzaSRy?n)7^wYea-r>S>BeV?%>|ce|yGnMX zR@r|2^df13%V?G=K4$cVXOr2g!DMIk1U^xEfr~?1A7Ksw4VTRULTT9q4__horM@)SV>f zQDr=rI}!5Fl(J^{Bv+Jr>Z!7l(;rLn@AtD5TX{Yj&v@Q5rXA?CEp0JhL-}=tap_$d zwm2tGIBC@Nyi(>~FS-zt+ArOHJo&ZHF@Q_7CP~1 zhW+8x`&7Z~26x`q!|OJioBY(^C&C=pNI=vhVBl0+#s>jpR3Ns0vCzjLMK24H=RvTC zMKb8=5oP3ZJ!jj8aI;~((73>0kM4uK#J_$H69FHME9wRKl7Y4t2 z6S}>nEenzUI)gvnaN8foaUpo+vGR66_!7h&u z6=-(ueF@$m&T6!)mZt}q$9>A2GDLcgOSE|IL*+AUXjP_f{&r7ddb93q6iZ%D8oN-_ zNhwedb?x@OcUS>CKV$LkBo#O2mua{o$eiuenz5R^ywu@vah{{g>;I|WwoJdy(w!WB zPUHr9>a^hl`^b4yK>d@sRy}Q_%o?&)lpx=!3a%@wN1y>Gt;2Cp^(Z3R5#(2}RglP` zAwQcrXbu%|x01rl`RZkH7r3pS#fY%PG&h0O?hu?Y-`z(E{6vHajyTk0u?c2OQfx2R^{A@MZHBJb zEVEwMM|o_0hsY8lg{(GrdV+ov^-A}%>uNvzT!`Hw3Bn4F7??OWG~u3K9mHt@8ju@N z_c6$tVJ+3sNKi7bP`*)ad}!-Zm{t}Wa@?aF%BKR6`FcGOgKlK+Pe$ZA@`$k0Xzc4(X zQ4V)qLg&rBr8aaacYz##8jpbhV`4APoUB;=f(MG{sraJaOcbuSj+m2fDtl#bQOB4U zCuW3QMkWP^kNV?KF+-a6pT6*(GvDOlIkJDhmfJji`}AmRwplZE&|t=c#kKn5=&Q`s ze-ygz=P3WhVpDH-SYz?=%b#gOpWeyc!_Hq0G77X)F)^O|*A)p>Qu_hn7CaG5GrOLK z!Hwey&JNuJT>GZEe;(~PCZ6X2u-6fZEXQ)R@lLeuQHWAKH2a%N`fZgU*zlCL2~E#c zD`8gXS`!!CFZ)&>-?x;#EB>TSNN#1@PrFU!@AEqsl%n{QL8+QewO3zH>-|+1(ms|^ zcU--i^f_EfmV(JxZ1`~f+;lZ&bLc;Ej;(GL0L9x~$_U>C>3J5Qud0~lb(UKsRa0u$ zk%^fH#E<#;{S*7Cg^l@%JF3wJ&FSK;Nb}J;# zxn3r@emSfA`o|?KTfD7yy@&c6q%>)h8I@j=!;+3u`K1FYLULA=d5(OG^2y|CIQj(( zbfS4BsLxkA@nQH0cP@#Y4?X#=^sJ{>+2ZG8WTWQ{m6Y<+-mimJ`WZZRp#7O--|e*V%u`_}!$45qKjvqjRx&dDwM5 z5j`aQhy*T_r-NJtTPv67X~g_EqaB+N%!6FK8D%w@DFed2FZc@2_;kp=;OXd=O^7&A zI@8EAGwu&HVsj&(n(9@Zu5{(6eLY34uMVBecg4kz<&krsoxt&!^0L#HR3D(_Kep+- zu3+Fw#vg#2B~WnxZ_56T@}B505F(nZhsohbJg`OEXl@kv4>|+WzON9c?tU-od%o#D zpjj73));y?yBg}d5FUKX2xpJa9XCz`knq>$#&&}t5c&)EsjNC<&FXt#?kQ)W^@f+rOxMO1)X64QVr%hMjw1)xQ zibwcbd<$9VjT-p7wUeP$a^l$Lp8CQ@zEi|%$d9J!OR+ockSJrm1a{0dm@rqjjiSaT zWVSU5f@=m(nlfbXB9VS{5Bt&gFw8lj4a zwS@0gfsGkgphg$&-+-Q_cJA^MVR5VC=k=D$h#$v4Yh8mS4MPhGO`f)-vlli!So>*q z9s+%R+s}$dC!PoiYC$g@q^t=(oHo(jnlq_4`ZV!6UxUMs%GvrthqsK#yFWC_zO&x^ zm~M?FsiTeFd!Kv6ZIq|x-0)>|_38R~qiBqroVY6Y&gsNB(eE%tmf~txU;a{ZHP64f zxck$X2Wck57gOgsKL|$j(uG%w3@LzzWDc4+v%44tbz&;23BSp;b$>FAmy+hiKORr7 zeJm6UI(~A>`4gZgatsIov5+|Ju7cVRv5&C5T_*pAGpx!H6A7|mcL>-kgFngRg*zNk zS0j+^(nIq_upRU=XOcNWk96g7<_4rj`D3sA{a?-5#@JF9DE8>CUaSb?AcUC)Q{_Vq za#VrnjCJvcEA(P3v_Nf+9W`3KBh_qnXXgx_=y3jwmgXNfd}$VXgEIK`2NpppnVtO= zV(m-SPI(yNaf{;SR?(4^{`3JOE=f7>yF%3NlsKAw^!>sr#1xP!C^c2!;Z9NPD9HP2 zc+Sht(6#%oC@9IpW9sdrq|NX6YiKW9R&57m3hj&SsiGhKF5Kdtxl{ilnjQ8<1ZJDL;hbpuA%p%OBRo&r5Pz%iU~&s zV5oXqAED9ZCo4_Ql_nk;uAJANd2xFV6@NUnZ^}Kc#3;vr0t43lew|(R88sSrdO4Ro zFD;#*BG0*43>P%JDA@1MVLf7~0x*P*)PEYPIDwa2;O~AUwer|}Z|ZZ5C7y3HdIuGF z*Ie1}TtV6>2oiX|$RxYE5@Iystom2Tp3&+8jlz?!D{EO6GTQvz98VhOnWHG6ir^1+ z@@h#M;PekP|3X9aOSo>WB^0;XgnReRxj0(e_HIQX1YGyWVna#aeB?dhhn{zLhmYS_ z1l_v3c}v~(YVzgb{n9V&$JR34aPrrr32~K+ckJ}Cd86KzAD*ql*fk&W)j%Cy$Z7!{ zO_pX283Q}FzpE?mGZOguKQ>22f8I>juN{C`qPIZ+$7>0Z)zoQUSzc`$lk)kK<^C}~ z^YMRqmOO7_HBgS!Hcgu_3ERzl`g|H)(l#SD8P&<>W@L*)&hw?uIraZ4JUf%&bG(fh z#vDmMi!$s|-q@N0jvmT6@OXv2M~=qDruHvznjf1k zR1Gcs9Nf#}6}z(`7h7zhisk(d>M^qc3oUIdCFw7r*2B!CV~Lj6@|OoMVacn!uQ|6w z2puy|y1($Kh}^rd**LBVZp#jff{^;XaUrPOQYlaf4FV4>KeqeuJN#_;nhSS1&M_}n z6v{f6K#Jt|SRrMjqbO8>#vAS`n=>#+0||yQMj~DM~0s|{epZL^^4z&=zalt9$ihJhK!3BlGX|-9V)62oIuvZVxv%Uq~ zltul4EsdeHhZX4wJ%%6goPyVz5r(*mzKJ6HLzfnE^l`Ffkrt>At~4>sqtI(2+f?{B zaV#!E=GF{FhVX`IxJ-=Gxe}en`2$QrfNk&YMED z9N6KNJJToty0%WTg+6`*;V8r2{yo24b^;sJpE|SEi1_DSAuB2^Gkaf(Nvoe_9bf94 z>&819n}|t~r_r#w&5}1AID~xT6jpd~;-_MC_Z{>M<+e|ojF;h6%9%-T7O@AK0M^b< zE*v85nZgY*UX4SQk7$R$VY;7MS=!r{rhiR)EV;)c zxLpf_r$c{id-H6gg(_e;dzr5Kqe4dk7da9dQ1+64gHF6w;|ZN zPy_f^PDH#Lgj~8Sd=OYMMHW8ziR`I+{lJYto&NstzI#*B=d@F``5Bf4aj**eS2Q|nOzvKP?T>!eDFR<+L zFLLjB1dy`u3RrktFE3Q2dRw7VFUV2QZYvZ3DKYn10xk z??$$Z`5n#2y8>Bl&o?~j{JSXZ z0lh5zWvJK-y-nt{%j#zgr25KHbGTMDE~&SUu&lkWA=?yu@yYby6)XNkcBE|U;%YGW z09G5PA796vi( z>VhUW0=&Bqg|%J6N5B~Y!49Wi-!fV3@8!!`h-Ka-$i^5luO4@d*`#y@H62dxu3K#YbBUMFSfAd zz96qW%B4b+2ijx<4mBcFeFVnRjb&I~#qsmCxPcGiviKA)9Hm$?L11>E`;<4uZ5K2{ zjXGO4{@u|T<3_GH$9{QGnofMU?H>j$|IB|USvk2!<2lNP7<-9N{Y9#4_5SB;*uziB za>kD|dNKF_ijRUmxi~t4EnO2E1-DLR=~KAsxTR9RwDT7OD$1_dX~qM1VaV4n^;oxM z{&lXn--6HJ`eI+QYs+7Kjx2ln^uqRW<4ZXW-$&ttkEHcfAG+(3mqDk*zO__WPE0GU;ElGl;BV1Q-SyWqYbOaT!W2?wg))JS-Mf=-BT*qyI zP;shfZ~aZy0bdKWf&9VfT>+<1?KQ4!CeMr4*jvX-Cof01m;L3 zFTR|0TxNpWzc%IQ=w3H0e(u4^xv)q;3|+1MZ~!VCzoilmpE^W8m+U{%Q{EzI6MTM6fR@mT;!T< ze`aGxcz!J4+NvPsp+(s~U#_h4ZTq#nX{ZZ07^l=?PSy}G3(blL#oObm%hSa(3ftv)x)Z-F~IfjbN zBsgaZg}uvI?V@ZTBWw3@moy~&>|x-!xm~DGn|?2=u`E(g!cSd8!DQhBf&IJI%u8g@ zcck{VNeRs{LuNd`#zhXkgQ^~{{>{z>9bAUO)4DE}?H`<^4+1>aTOLNXvnkeXykZQ& z(rZksf?e{uWl-&BFbuZJz4h&g14~Whs1m$linxKg->J&^D)3(4t?yHpNK2Mc&qfh0 zhZQt;>FWl|RyMOPiR-cU#$4ZrM&l8x*WL0Mo_-jy75cdfH(mmzc}H`H845U~L`Cq#S9-heeh%m7wT#QA zSUI<-M7F|Vc;{RScU!@uT=y0fJ{_DO+7XQOxAvCFhZb1wtxbUn`gF6;L-*(D2s=V*e|ZapX3Euhi7cwFwl}3d zXPj7tU%xNf+{{x|?lunb8t&*n$mCbQ6k!i_NMEGty6MPE(>{IK<{Qv?57IFWU-`I~ zcTDv}p$@ZbWAO7GjmGQy7yslQF`m~SFTSJ*^tofBXK4L^1|I%w?S4(`U9y#?h5!Y& zJEDaVzh+v^O}KvF6XX04(Ab}vIau#VB9BgF;xMzLh`tY_xb={}2JhAYQ12P0d|<*I!wk8&Xq{~9hL+wU6Jl7DUH@Vznv zj?1ro^(!-iw%AulI@BNRz|SFNEPv!1AgV2ATd5pE9PQndi&u@v6s`G&I|VnteT?6m zE|x*{KF;9{6sE@<Rf3wo|b@R!hv~M_G^#1h}immKCX~G^p zy_yr^)+Zid(^S{1sHHR8cauo};3_Nn)vv`iKc4LU`$gru?@`Db8v6I?L(k-qQs|lX zTZ{^F=lP%6sYh)@9ZNs2THujSUhP1i39*<0f=}eld3)F2bi`?T@Dg8){!}KJ#n4kB zW-e{86^xWmGZxXQw@m_y5}jlI`}Cb?p^{{8w^Ywp@9p_XzRKQ2ZmzJbZC=^p1JD+l z_?Fxw^4E7=Ik&He?mT_1zF0bSaaBmBQ8wWWE;~KI4#Ta_YDY%N{$E{Z{nm8A|7%LR zBqSt61O%izha!R^As|SX(g@N$N%@1PsenaZwiY9vr$$UaeK!{07 zHK|Qszme&wHX3Io!EU49b|+tX`j9Y-w_h zWKxb9e!g(0kznbCP^C;oIUlV(9M01Zj>dBqOQhR+4D+~sNfRT1MIx{7It1?qC8NuF709_5QQt}dr*+z3kR z&2rlaoz-!4yiWD(F2h%6D9rtVd!ATFjqG&qt6jhw8lTl5!;_Ta>n5xN*)k4I&BR>=x(QWBV>AsfqvZc@(pbqi9#yHotfeKzj8q$9kNM1(D2LP{$ywI{{*G-wb zE-Muce?!7S(a05lWu=m_4XzmMr=W(VvWO)J)=ytV%Jaa7E?F&xHl&?=?=d(iMt|>J z4K>}`KzYr*dA7tASasH}_2)Iukpe!AX*fm?cKZrAd#yc|ZiH^5#Y*Ry#=wy7Gdq!y z6xEyYaa+2}EE+F9kORIs`7h71e0>QUaRyu|LEtA!^QUAlHm7i9(&b)7i zlJxB9GdOVcAuRe^F8w^$5pGO(&R(xlIOgELLCN6QH0X;GAhWFKtC-{7ZRVP|KPH-l zd58GdXgdJShrUjD@BO;n#e7d(>XA|?4e-Y zF`LGA|HO;b@oji~SXIvEs!oXYK6_j`X%fs0EBbBxWhS{>&indfYqKe?y>lh74cCog z*BpSK)Z;_j4K26C>toW2?8_-|hw&L;L6+JJW!`-1w{= z8oO}6hfEWb`rm^EI4cgjlcshUu`Z{F`ALX6C*g@hFfjDj9_8D0K^HQIy4dG`7&8EK zt&d*_cX~^yC5gSeimRhcj6Y>~*{)zo65qr|*tMRudph)M??zK{r(8vlxMbz)^(+|m zwh%c2?i`OOu}rz1+Re~x36AbK&LQ!?UIIs3RVjNSZZ^ol%8wC{g*VV|AAzxrWs5b< z9IsK)&5u1N;BwKoS+SsiyA{|k#TbSwq$X4=O%)#prjN?Gu5T>OLfbwRbD|&!yMow3 zGh^Q@U#NL?W}>P-Jf{aY&;5xkkJTF{vIQo3p)bX51c|I`aSfS#GwRdh_S_#D%RS%g z{9_K@gq_JIR;vkkrG(|QESJNGeLm6XYTHF%^<(o!`fb{vrdS_$W){y5_ z4?B{uI4m8Bn}G9+uS$?6m50IVPrLgU%y>zKmplHs+K+Z#rYjrl3koeJA^XRfHrlcG zT-7e_rUeZjlG)#zDCJ!?L0ZH#$nv|nF2~!{JI~r@JNYevYj<&Z|mJ0W6AWFZ~Z337++1D^EvGr`4lq2ERAQ%-y$EA5B;v#1_0P zb0;ep)@pvOOs>tJ7SR!y5E zF$4Zfq%kt7O-A+Rypge+a*J`6ik1@Z)pM$RJEFHY`q7 zqHwuj(L@^J5m?!Tl}Y21JxY7}4!G)ak5J8_wn*WfqM&cpc3tr}T!Fwv+;fv;q$mR} zIb@tCY*h14*B(_i69_@!U^I`Yqhc-km=lB{-0md3ipWf3*#!_*JIfevQCI3Hz5-mb ztZdMU{@Af~Lbm1Ke{aH}bHGM#6qQ8s$)+lKJksy+(H&7BJr$QhHKo&=1DftS@Y(j| z8FTgAej3BUYln<{#fmK^e6wq?VDrO`OXc8qO|?zHdTpvUZX&lh2l_QDPp#A!Z1A!b zPNEGOT zo5KOO^rfJ!m$k$RcJ)l)ky^62B8S>XR7HciKmztzPfbZ+R{9!XKq$nR4|yVdbZ92fARWp<#gW zsYKkwU$z?PZ$8eyfcZUvQZ0R9Mdecco1a^*2LCtwxGvAzXyE@Hw)h%j1;zS(P%Tqj#+*u zqEYi#w?%iq`8%ixM1(33j8KZ=HNiu|CD(q~&uba9xtUi(+GhV_-Y;YgwY6ja12oB+Fg;1UQo5X2Pt%5s`RqZ#LJqCs*H z>|rch>gB`gq;SMFuK7s$~xTJ^{YAKOKyq#HR{oKR35cEnQ=gtIho4$Y7t7h z3F!M6i=?0*b9^)84H|N&_-J#a%g6lEPxq?v(xgadHq~9oH`}|CfkSt{Ke-48pijHF zaxAcuHGHPcIh|mz%V>Q5hbi?DF@AS;Y(s+dt#Z+5+BD_+vvv7q`A<(UBgZWb+}02v zfup(Eb-a5_>d4ENZoD~k90MdIyVKB}dF1J@dfx-M4cjulMWwDlw`k6dLxg^PtYQEw zjp)WQy@0M`J%w|N2N+YHdOAVX`2Qkkg!Io#AC{y%FZK#;O6_>*KU$yl&(&IhhpgAi zSWYP>#oFw(?>2BT%Si~k4r~Yht!kp(Uz&shM!ib;{{ZMEC0J(+y_`032$VOC9;8(gYOu`5h${61{i$OX#A*RrN@L-Q03dSoKk zmx!2QO;zG3=x?-x1H1$S2$How%;Xf-6q(1(`r)B1_OZ{O|3?>XO(|MJFDzF<4nMk!7>EL8jE$D6eB^&RQ}Vt1 z(E9~x95c&!4dE-SqFZOYMN7$sZC6IeXDFW^Cr2f&itzXZ7Z? zV{t(uhv8YfbSAN9Rjj=^Y?2M&1X+31L*M2BV>f;f_L_^h*qK8~vqJg13VU5rmicQm ziI+z$t1nLskbN}&8PH#f;Dse(8tR4*dFCxpZ^0bNs7m#SDN2DvwZ{?Bd|nZwj0SA) zJ~{XMQiCfdSAqCut3wpNb?PDZ=ohU-YyxnZ6!7LWME7SISqp`?p!b}~<}VO!8gx6I zs}zOVa?<;Co5~*rV$%NsT@;xSWGHZarn1$d7206!N6o3%g0OxylV9vB<7$roj;6nE zs8=_>{S&1fMvT_IOee2jqtHVAz}Z?XOISKbi8;5Ngq{WF^=fAcfOKEM#J(PB!DEOp zPbJf9M)K97Dw}`@mvxw{&DJ_yJ=Kg31k#ukL1C!)s45MiwS$?MCACc}G zb;mRb8CUH!t&nod;Qdh-vRPoySy+T8|NG#|NiuO9b>7-yaOTz-1h8RVVRKS~lZ!R& z=;=hK#6uEY6WLlZi|!V|$Hi{I>BB*>S^L@DVuXF@yI)Zj2po3i-+POuDYj8x6OCzp zXv1-E-T317Rp-s)K@!YoTIJwhr`7Q2KUUXMT=J9NiKZfc)XOOAg8SLzf(bGp>e)>D zPzT(-4^Ds?P>W0PCfLum%D5!@QdgzV{Eo|cVs-DHMt!&@ujhSFoAh|ZXL@sNMas~F zMg1F{%kwM_0?1?g_UpChIsmaY8`YcK2eJ|RJv2U9s4Gd)>CF!Je?DGX4DlrdsPhPx zU|$P(a87XM7MCUHc7YB($g-qFKGyBlDK3j>9^XGfES|$&Zby^~G;6tEwHH&%cm;XY zSu()!SQtXE(Vu>i+z$-tE|GD*K-O^@rS)B209N-;B%1u*j@t-dy2Xv3@cJKbr?^$_ zT0usoDFkF*&stn)SdVm!Q>0gea@VfU0B(u}XV9=T(C}@1v*T7*Xo(C46{@ctSGTWb zH~+Z#@i;#(0|)h)&g~NLg24#MjHAyHj%Bt3PI648)8oCjsb*gWSJT0Y>4*N9{4ZoP znfFy!TogzT_uJ5x*zM;$Tf&fTF~EIfMgLdhPcfd=O%oLEL3b#x06i;@LdAjCrv=y0 z{=B|V&d4b9Ek#-U>wzc6elk|Q+i=C@)L9W=0>i?@v0z-f;;);3 zj%7H_%YF7dJWG=~z`k>X{O}qx-cJYr%hN>T%x-3VZprLqI=PHD>({e`s&-L&Rfih} z-W)&DK<~Yq@Us@QU;gd`pY$56H5A&{wxZY0k$ye6b2ZLU_d{^KR1Ge$#dxxFN;s(T z0ECJf_}+dczJ@U5rg|n6I$csdw*l!;Xra{N+ZOAM)VnQi5}6UE>Ricc$mc zc<`93V-89mm;GeTYzruvu=Qf+mYw9Ine>_d=Z7;p@UZk`Q^@|w^7ll%>m1&@2SoRa zp>9FfTcJvq>){BAn!7?sp}g2O zEQPzB%Q17h@;7i0o~Wdok0=;xi3h5U?qp`ZdDP1m_(e3goI(wF3H*Y$i+4E)c|R*; zR=9eQI+a6PXrV?!R;o2tu14fSrQ5veTS3&k>3Xqyp2sPCg{$h8tLBr9B$#i9mugP)LNTVircJaDBe_gntmr|`r%9Lc6*PK?hyfjHtsUMuberh3E(mQv( z1auNMI-Op_0KBx=v4ek(`61ve6Iq2>+KeeJk-u~}LJjYoxjfFvZLr53DOrm2y0JsJ zf-V7=F`Mec1Tj&t@j3C>IP}_R-$u9ukdK&*#!L95Zp4Rp<+1`_yeTQxVjhNFdg6_J zYu>!s0@a+_V~%3?7oD(5PhB~sY_Y`DXm>3sEkpJ!f+m){lG^vPv3^uuM^Cf)(zl8+ zlK{2-`{v~^X1;xM6C~lU0cH*5X6uDe-DQp~Gygn#ow(k?mN%#Tqd7MQ%CcVNL&H*v z_Ud11L&T0i#*G5<2tA&;`ZL_2_(Rymg0JY&I6@g%kY0Cg21Q=Bh}tvV7&`zr{iziR zJkxIdpK7J(HNV~U-S1>qzx&?9op3vPL~X$XBEgrVu}(b!MP-AO4`qZ@*{V#&6c zoeMdQABsX3CHhaM+*%Euvn{+1k zUGlx^OL78P^0&ia2@Qb2By)pq2e;O4bOtX)Fb=|H zc)hw4l&6e@MmA$%5{Pm+MNje7-Wdz%SbSyg1@vo~8BV8}6+HQCsuOzUYmzS3Os8Xd zC^`ss`uqidWELQ1zAKK``jG*9^tB)s!$%v*FQQ9E{_I}jl6$DM0VS>zJXkqEI$QOO z5${M-*7l0etfqvDC1cMt_vezSn_1Ox#52KZ<1jNUR~VyP!ptDR``J49A>3>EF6Y4Q z+KLiydmWd){B zICUCRVkDp~zBEn$a)Y1d^KiH_h7nrka=XWtrO(znWCPi>4}ddpCt%%TDI|`a`d?hs z^Idgf2Z8<*wI}U0bmbCr&TfG76pYDhwyclO!-H>Spda9%_;8T1F4)=|ux7eZJ7HeK z`cIv9pZwBkRxP!2xw_MYO-K>+$doy68yB9QTzvF?w9ZDFpfxAFIHV)X5x0$)()vH< zbRspGs?9%j`ud%Tzpekr`L9J+@}8fgNTc_5zbovlIC6hXQ$ef+ zQ*$-L`G#h+A5@W=9RhH030b>)b5?CqgPjlULO=7BHm8}>C6$8?mW<{tiTOyf+`X0^ zHn(T}bn#MgX(^kzLMN_D%KV{&#{z3-!0d1I8H|BuZMZIgqP;x+(7$#A`L81~>2RGM zZP(|nR5m(-1eq2;aS#o8-r4~h&zaOIUE`l)bn6H+kqm*$%jm>@3%pU;d*IzW4tVy! zOCfToG6YP!W!Vy>?^R{6EekC7WnfYHk2cLn$SjrD>-P;UQHuGl5{iFiH^2s;P4*a5qzOIU{^ruzbYaoGW$&bs;)P#DTBVQcY0_ zLKe*DcCvykYnT-ct*Rz>73SbCZ1mKli0*WJF=!7osliD|xqF@-X}0Z8EGy;v=W7PA zCNM;rk!HEu=so6X^aBVW+G>A|PDn!JQs8AYjC= zppuKNyRjXh#~ia0Kbpb-wY; zjp~zVbJj-NPHe}ej}_-p$+C;q472z46P+Vz|A;&6N?}xflw^7^$xG()bCKM%CME09)(Gen?S_cyyFoknHs)kBLDhRX`xtpchUO3kM5r8Bue z;cYDgvk@}7ChWr~Fyl}HZP4(3WO>I3UIXwD+dESxWT$(4lfRnvD4-uiJ!dvts_zHr zG6G(!K^Lx5f^UoCGcGx?{aCt+kTa*=%>bMol;>AHlcck4V(7N_tCn! zCkn`)<#R`^r;Kf_bZFJy26Ove{w~b7b7N;p0l9XCefn+$lK|bjAuWyFv3xYuL(OFG z`2p|C>9_2m>|1;T9z2~dW(ga_JwG4roU6&guw!?KpN7En63+v|7YJU8Mob(8Rh>fT zoA06D@~UokBfS`JMWw&Br#-9ZeJIA3LQTzSH6eHFB2(X2yL};AweTRkaM!F!0uEvF z*PE5}>oKl|^gjO_75N5;I%1~nLHw<`&xN?AslY(RvWnh12qw2gQD8YZ<-{kKBOdBu z@w|k9)UGZS<$tEuUJ6RKWuE2}(?)yP*LQko5vx;p@I6b9Pzqpj2`*ED0p1gIi zO4hX1DSiBu_w~&ZcoPPfQIV;SDd>Mx=}$SImuF`g)c;KNv>1yAh8OJJr?G#Dt|4kk zJp0S4$)30Q1O&yNB``4IIQ3~0CamKVxK8M%7rT*99w6JWF*xSMlM8oK(hgSY$UpYz z|4poyYg2j_Xe}1$RqGds8jJyf@b`ST)JP6M%%*jjSNsP^8f@*ubq#U-sg$Fcizn*N zm2}G1aHec;`#xDc%#vw@3nxWBH&L^40K+gs{!;%rkCucv| zIqGOq$#14DKhSaiLTnw$3zd1P zQ$y<-(l`wo?v053@nhEsaDSb94MuMXnXhA|Pdd>2^F;gZ(MkHtKR4(~-oN)1j&&s1 zx@6VsHMNa9F&>cQr0x2Edy)y#S-`qyoNe8(UV%>Y>|9QZ71D>y&5w5PXq$@+sZ#BD8P#EiqNm#YtWX!6rZ(A5Bq)$J5cyrcGpmpsekZjG^bo2&KJR z)Z@7y^|e+>BScw7$k{GKc1Q}{`g0PWl}q3yH62Oh7}!Ft4;x8AdhX}L-gNK;pZoIW zUYm}+cOT~Qw~xL8SR1%yegd1cIkbP>J;rbTj%-#@==Q4QfIhZjFPzZTy|W; z&4xw~4`epP4iY!BW?QHF$?-AQobPhaJ!_h??`vdPB)hGIe!FxZ53+^&>i&ih(v-R0 zaRi*{fJi21xmwXs$=7H=TqkxWFl7a}!9DTdJ7Dy`mfB2(!DkbX1TyAPzFQGFlE=$xsV^oy$`ady|hBj}!rq#ET2Pr#i3uJ1i zha)0%7SF%aFp{A)CW>O6LG1^v(chfI;m}lq5a=^Cs;odqfJnT@-uFxIpwCvVy4r%6 zk`}S8hXo}PDN>$F)j0@{nWTeGASd`Yobd;OM)zCl`no_-Py4rUMv7nXsSH8lT)7Bf{}1yVDDlY8Y+!&DPFF;8i2Bpd*oVXGLinwv>l z!JkU+VMSX*rpeyM0|KH_DVSio(m*PPx;d~vo1RbfI~g%2UVpR#EwC{Z$MxrMBb}M| z)iwfA>fkKnBL)FZZ@5L;*ewS4E+*Y3&o!zrGg15pZZ8u-(@qAPBISY zZl&Ep8F7d_t(T^_baiPaY&UtypEvRH>x0F023H$2Fi47JU?db_K=+wX0Ts~Bt9U`T zj}>hVD*kG|t%L2cV(>tRmbXL2|9`Ohv03c7MPVzyPy!+Sd>8&3c}}=@q5)v1FF0qC zabTVkEn|ivECg!DPT2eG3Xd*;Pry-|z{{~QrV!xG)dFUpcW%9#;|)&(b{N@y1@z>E z*kb^ahxa(KO;qS?DTtO#qch0$6H1(6HA5^>0AjF%jcP&6uEp;@)o!y=g~3u z*cqa0TDfog;m&#~3B5;&MGyaG4tr)D;id#v;OlLbn1zHgq`>Xr+Qv4{6}E1uGJbx_ zo7my9OW_>MXzQXntgrewl_e&oa3AiM6@;3cy)C$Bz|C0~(CRGAASQ6tFv~M5q>+Gi>H9H|Q zxAw-QW%X_V|S2Y@^plQJxli=uvW~`=8In;UWdzsQiy;0 zKb~|D=A0FfN?f7qaK7b>jH13vx!piKo|9NXo^<}%@Sy&gmVu0IxwTM_{MOxXpG=bK z;PqUTA5dhE_V(Mr;-P_S*i)P>O^r*0yrRv>6Nrss-+mHjC%1YI{LB*yx8TnWH+oo3 zt!#hi{ymF6E=6W)NP}hBX9aqg!=6Ks^u~6?g6Li^Q%B_ahSCm^H`QIv;d65!F9PB4q1=h+Xr=>!G5{TdKZ$>)yW(HNeyX2 z(%`EOo;M%Q)iIN))OxqcI9qxY4b}T|V}kp^?lS>{+8NHhZhJRG`wHXYRf7!^)j_qT-O$boipjvY*xu6lNf{rhyJ9rnK}jYz zr&Y?s;}pGHwjgD_>p5hvt>~@$bG}w>k3%&?SI3~fq?aNw`y;CZ1F7RO1rN}q%BPUvyUO>eZy#?5ehibe3G18FPm9(ftehp_i z!RgAf@2k6>3!SFr&EG@i%4T=-Z;&|S1+2s| zoWvgWT;#de&Kh~Q>dm05B-B4`Hm>P15cxWrwmf|8TR zvH3MpT)+ZLay6@hxsi!s4I5XJ(YHY)e{($xWAy!W!>53DHt6=ZZToxjurg4}5LVC) zR0irlmN9MAr9%0$hKF+=(ppXi(Q=!;ZY5d4QRq-5$iiJEiCk;n?MslrqXzm3-Gb&b zJE+G%XMG)rRr~=O_`zi1b0P|pT(AtYIRW8bt-eA0UtgN3f)FM08RtuTEZ(n$vhs1k zhv%G*WBd9Jkb3oJ!W>84zkquGd}%-LA2GabW^mn4A_xFpIcaY?YWG<*F5>ZJvpZuh zC@teEj1AME4e~OzF=EDajK$F%TPU@^53zGEEiFxpZ;UR=P|(2{>k<;l9_BQ~2d7+O zfT4`8r_6@6TK^gzxC&GrvDs}M86;>KOUEw6=Pe_lso_t%q-vGq@_0h+qewKTY{bh0 z&o%dsSzmtP@$N z6|D{!XiavS16{4X{f{j@BjF*mBc)Jr-lhmFl$f#!!sUssP#1Xn+K${Ew-s-QeOSlGTw;Xa z;`&)ub?1~Oy+~b##20Kxd#1-o^%!Ookn5ZOZJtz1G%|2TC z#t%h#f=Ny8hNnKS!MW0wf8}+LyMq-rSDrT=x$oogLKy0*H=?W0ny}Mm!agAue^2|* z|JJJ4A)7*K6|u>lP`2^D5@U9$)vvyXljR>l7GzVvowrl+zj1ELKUKOq#Mw;w4$@p? z?^Mx=C$kBQROb+7y0h}Tmd57Sh3)brx(@qHYb(F}g!@wP>P9X%3|QslG&KH{rKBi6 zwW{n(<>41E zn2md25^bH~zSLTwI~qUsgEW=iZ#;xKRXQJE%Y3^QpN{-mwtqJr>I-I;C6^1dbi}^>gX(9L*0R+zfWajRZ`qn>F2(@NYmVAi|x~xul<7xPFIGr6&S=%SXGX zfiKz6VnEHBf_^YvZosHL5%*4OPCDpqPvAX1j<@IC0^u>?ghXDJY5;gARfg)Y+OT%z~y5B^@GzBqe1%q*~4?h{q zqUidTzWH~cw-U%vxw*1+4mzvn+so0DWMzLFyU8?)c3!OAf3kCz)Fr2_Ksck*o6L>- z6lY4)UMqAS^9On39i%wBxk=CZ&YH9AvMHb@TdzhJKz8BQymLvV%fNtJele{gc1|gF zrKUvRKr{sD7h~#+Z)jsTYiRjR;IbW^bv^VU>*D}3>Y79^e;P~UKdjg7EL5?curFE@3pmcEs9J5VjSPE$vh+MCCwYl(^rg*~qgybqE|MI- zBfRbyWnXAiKqZ57E?p>=g=&3}Ux-a!GLuB2-Ms z++X?_MYAL;6KK(NHCeakDNzwnNC`Ca5*;tXh?{KKgIBLn^l=@)e#}(^;Lo|D283}| z{HUwRQagRS;WUV7Qz_1kAh)g20<$k1cylH?xJ(+#lK>)U#iax*p(ZthZWMt2`tI?( zRv~-7m4rTD%T@^M%ir#NRP`crW1gBP?&JPaSg709%3%C2>?QAmbJ=%=I^VFHX}Xbj zpJvuY&yMA6&~V9|F4qXIkvHn(?1X4X|8+F`-J9zjrJV$@c9DEl7s*>ha>E9tfaE(Tz;m^OaA!+Db9l;7@E=9 zSgMTi=KSmVXv)s@U4$5;1ld=liUd|1?HWS83WfPg_s7-MHDrnUR8Cg%P|)SP3I>6P z@>isdHJ(;YG0n|~i&X_!-;(_5^cb|-PaRpbNY z1#>en6I;-*@X$UYurrEud=khR51{(V*4;Fw*gRP+C4fv`^cd%J#>d zUIum02NH=`A8cv>v!@Sjch2iwQup6AeGiyId^=g7a`VT%j)s0I>#ITe!SF$7JM$Lk zEZ$x6tf1b0V|HyZm{V842g*wac=^+QIkmKng(3FK{u9MhpMZ=WZ1bC?pA}6#UlzxL z;eebXXl4C@m38^c5LSkZ(JbClua&Z?yEx z1~}wbim=DvfuSVFrt0XRBzSAexMX}~{EwJ*7iN_RhA~%>)cz!p2M@l?~En|p#$DZbX4LGge7LBI_uP44>kR^UjUdWe=8oKgLrCG z*!CSjZi(BrlnG@4TZW?$?*GKf@IB7k$O^v4|X<+$2f!gfrv z=kq{gKmR)!oH`6A#-w5)YVeTHKj$yS$jTD9<1P}MyqO8KelHi#j?;zhgxdhyFW}<| za~n1CFWmEcH@Bm6ojb=jozE*L&Gl9%}kY;Txr#S&blMLmmC4 zTz}gWb`oQu-7~Jdtr*8m^GC-ad5ezIi{uls6}9R|G3D(?uuS;+#y9 z?(;z__?^ci@Ew9DAUylh_=QX32-aH^_PP4R)ec?h)tvivl36$uF|2i4=*wtJSG z*D8v98cn;eZL>{jEU zf@8+qG$baJTIzqS{m_lLTuh(Zw)#^9lm_4;-GT|vh>>Ub_oCpLq;hjBFS-K5m7!vz zLuyaK?FHp!*0v``a;m|d1XYb)&kuW;ARY9sk_!rWa6Fm%fjacm z2D3g5;rt?%QZ+f<@L~;B#qEW@uloCRsxYe3(&=(P>(i2I)4?t;&<{>EP4@6hBUdSl zdYp}XmLnfIdI$65an^=Bp6IbSs8ku^P$VkZ3&-Zlwe!#46#r*la)3c2wFj4aK% zftsuSZ7{)bRGzb|kl*8ay=M9R5I<9HTfq$dqB!+(6(~h7ew1CB^T%ELO@kM*)b{$ceb4|+BqUH{BfRb=wW7u><;o3SMi-BhqvB?Yj+1J0_LdM?E9K!!jHz*w1_-RQS*EajZFGA=O0Eum&#VNy6g-V?BhR!_jHY${{bs(jMjM^e{H3sYgI9rgCwickey$Eh6ain$aICh0%4-%Y}*gbBCI282d@_Fazw4pT-7Kr{!oO9;Ga33C*s>z*I`us zhNf-J$!VpUZs4FLMB&fx7Oq+l?n?3>R?x{@c!Dk6P_e6ciumUI9wlrD4IggcFz{oR#<_+c!O^Fi}+c| zOd+q%yXPYJU+j>A0U|D9ArB_XwPaGgN9RHkcP2HbA4x`7?1wnOI03~A#kJ4Qj&6k5 zxfmSQ?ft@+J>5t0{gg=d*kx?Nn^ix=hKu;rNo0{@ZWQfxiaPv#GHF*Rrg>`BQ5u=U d3lzx};HU8)sjeUc4LscErJDBhs%KW={|iOizw7`2 diff --git a/datahub-web-react/src/images/redshiftlogo.png b/datahub-web-react/src/images/redshiftlogo.png index 5d7b37930be997e4ff21126cd511413e038d156e..30f7ed4a88c9a5b7fbe13a1cf75c640b4c057b07 100644 GIT binary patch literal 13749 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 literal 152085 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 literal 154300 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�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

wcd5`Thsz{BXU_b>7!!Y>ulhG|V5ch}nQi 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 literal 154300 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�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

wcd5`Thsz{BXU_b>7!!Y>ulhG|V5ch}nQi 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 literal 313530 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%0AV(f$gG! zm{hWCbk(>~46C?|ni*)&0Y{r@z7$(YwI-k``We)qgAHTZ%y%K0*(pxWQ|pd_0B z>CoOJz!+{3ZcJElf1hcso1+@8vA)oxZzD-2hKYF`Wbc?}TcRmft#l2gQfTlD1(lE% z8?NaQDTsZ<#8A)5-i}~nJN&Nl;>xQ{D~N*X7m+Ppx`L`WzZGhw`CDZ{Y0*{eW<8XN ztItn{I5#yAP>Niw(R-TtBAaj(?Gz2_IJxRNF&3Q_bHQ&a8E_Q8Q5u%!%4-^9)kSut znaB?zaqn<|H}=wHPwQW~|8!jVewg2H;@FGB3xrKR7J~mhh3?sWM1b^O#or&a{~@T8 zi1}#*#nm4jK->1((wc8ufKFVIFV9S^WqX?#)nHn6T(1kMV_lidJIJV}260ASzT;`G zE+uUbrRqhyVq0~Vv{Su^2UCx`O5b;_V)&^j=A8wUljmu@!^|7Cq)=Avw$c$*qd^oq zo!gHV#G2`J<+QU2jkkrueG}(8pji8iuBg?TG$`HND@?~rR#j(u;t!c`c=Y#^ajS;V zy(CjsjmZKk0?pujvecm*0w7W!9pa2pX0mpmaziG875J7ima7fbGirEFgsph-Yw*-$ zEcP~&s*Kv~g%9VDkVmsBuXAbC7Dw#irekil{QIZQphq99(9$9QM6G&}3M7A{$-m+l za;WqEI;_q2_Hn&5d`im@(01A+Y*`uK{|duJqS|Fd#TJ+XamZ-o3w7X$mB$~fAo1pl zM~U^#L&s=m*Gf=FX^AX%G}<1-SvyFK_`?RS_?6+HCa3G6JD-v4`=Smcdbt*Gh~D>7 zMk}lnucL6#<8eoHjk-Y+_MyG!_3!sNwBkAqDNm^f*dm^Z<_IWCaOJC9E*&@R#?|&{ z6ifrZ6yd33p!P7qWo4Z0hk z3k?RAt7M{*CK#7KzT9=A-NHKU0~>HzHOcBzDZp39jBcC~d@^h%Zr^D}S7XKhltxzM{ zSEd|FHN)?eh8~#m7Mi+jXN>PR&I3tn<92s9JQ}!z|0E>KlJBeXIM9Wp7|&zKk6U31 z$A@1BYNT+VvO(jVVIvb0{$}`)#r$U}!K$ECAk$-3dNMu(!pj#>7T#AIg6nR>bQf(r z#JJthS=_Ft3RUb!F$@Q1LYUtbL_F6Nh>s@*yOE}bXO6q;L0BU=vB(xIaCm22B&(F% z*O#g7PMSUzh@Ob#wCUMCQq0i^_aE*GI&o8PNXuh7P&t8>L-741EsmRrOWv~`Ej#o) zyZH?FGyB{A@0R*^vv)FVMC+f${LDlM&k&dZL{>ini5EUNA0COwqnoJUR3#cK4W63} zKsjtLEkiy>7@|Z0UL9(Wq=UyL^e~{hH|#1yovaEriRfjFsPts^d2X*yNJqHPRe=<5 z$+&vkJAccG`oy6vsTGhWRy-XTG{S0ddCV|UGbu+{M@CIyLUHmL%v_91w!pxf5#`rW z2xc@yN%$S>kJ@eTAUODx!#&A?{ov7}z}8dEFV3IG!5|bNiI>aZV>P?}cq4pvI@Q#f>r5>-kc$Q#$ph70pW-R!s(>_5C{5;{v<2tgdaXc2> zWW;OJPiT#tiYWSDpSk(3HXL|qFRac_@-(b9Z13!I7kb)rtoKok_a%ez+fs}NNW}(K zVu?{UESUP;RuUfgxRMn1;fL#HopdXFMRRNMKyMqrU5BY%^Pw?SU<)sMqiD zX3=0A8?3N--w~8JeYiePL&n(AZ*7Az!QT$l=tmk<-?r*rvKrbEoUJx^KzyOYY6-NH zeLv6#4UW28qzj-uYviXYU%k-iP`f4l=*Sr{M^#ZxP1jJGx;%7uU3n>MU;CE*|M^o~aHEKXiYecC?x4 z#UyV#1`#^Ura-rTLN-fh^g&of&fnJpL*IB|(2sx(D@I|uQ42I5vhn6VsA2Y%L6gpC zUVXOaFvdsNW41 zt$;Jq#n;K?zJVMu2eA-iTFttveEjI0I0`2TnO+iF*LCwGc0+gfwLuz)=Rj4!)M)GH z)MQa-2E#aY&0~DC_iVUqXqO&S-iKGq{86I9 z{D3kBCeE1Js>00g08$)0-0SFxXI4(5puu6L@^~Wyrh++g9lYR06r$pcp`BNcwk0cO z?4y}cfl@z1V1@d#2IWE7gv}`sOk(k*_-Ky3X4k?;ESp$6rB_pyu=feMpGS!MiCHh6 z@_OS&yu@$O@BshhQTdb!9C7#B#eDz!OVw3u&xUQY!@Y+7waR6rV32Bo+(?zwcgWR8 z5&?n4%)_+pxbT%>(WXI)Ou|B2nb10-#$31mz}$bkh|lkpRSFPkky}CMh>yF7?Pu-Z z`;LdGcJnNgRjq&7o?f-1&!?QY2?0Jk1`oo4ez?+!6@asvNo=WJ9TGGL+)S_wU`JCy z*Nixen1!yej9Jm1Ni$78+TPpmtX-1cHeXvdPIpKXeJ@#rv)?!4rxCe-ojh5)hUq|4 zoX^z+@BEn;H!a0krikKJs;E&odjA%rp-%}8jU-9THxwtdw)&FplZ(EEQ%UCYX=ANPmP4Ue;cg#04{>=4ax}lT`00|5pJE;{( zbo*Gc?WsD5`7<$RJkKsnmY~3endZ$Zwd`LM%uG0-5oN#e*v;3RG3dqB z8(9!hG-eiW|1B^V6ffy$Q-1R+dw6Hrw(1++hTbVw33Cq>~i|_k(uKnUebKna3}Or_MlaEvJ-rkL2txp!eEtoBSpf+!Pi6 zCw&V0sPF&@yn9GU=&qlE6J=^~BBYsbnYrUSWX#@xbQlP*#j8T(5*S2Vc(Uo#QKI%f z4`E%Ovn0|s6Tio`LC+~YL&Kp?iCt5{%wkuCmW#U)Qej8_FoYaTuJtfPe=_|xJ53oN z071M&(ekyN_8Gv78%6ESt)7#c7h8QnpzHr* zu8VOo=np0j@ujF^&tv-3Hv?_| zC*=9B!kJ6{NqUf0avize$Kq7wBZLoqrzK8Ixxf?S2|`80W`$`u#*>Vq@ooA7aSb;c zm(&)z!K^*_Ef^;X4O^tLR0qp=Q0OpJ)Li{gifSWo7QNR#!?uzM6V*6=m@Z>wX)M_% z<*KZQ78{$G4*Szij|120Q>9`WES^utX}sSp=ggjlO_m^UkOlQvFaO9tDA7y! z#6HNRm%X`Vt^z-ol_IS)n0b9giSDq0yXC#6ElI~ATA!9vZxGdFr9WELjztrUZ~LHt z_fUxnDf*`yAZ7KJ5h8EXv)y@6JSkt3?=6ydXhC9;P( zG#xb-`FA|+!U`xbWuX@y%gMUFo)9@TDIHRX{LvyuqyrSWS8_b-&!#GU&$ zfR?0Oj}(x~qlvsrdy=7sM8ip8xe;jv8C0Z}#bpEXvmf##X~%!PjgQDu!$33gWw+&Z zH!~$hI9?&^F7=0A9eFDg;fR*DP1_8H5t#|{G`hn_cs6uK^8COBoi#ul>9==A^z>-V zUN{@z?Q+1vCrH|2dLGk_7#%f-8boqP4yjtTRsBfGylr_D&EEq(ahWDZ+arVTwPMg& zS!8tl`#FbdOkS@BvCU7D3qnLO<9XS|wwi3$g0lGY=ACEld7`#^*}ZnwzPyd#L5dd~ z?WFjPgN(;!_~z(orSqtY;R7DV1A}sknzfrw?>O=jM~JXNKi@^+6HlupR9Zd$nYcf4 zcHo);G|bEM&wSsGGPrsi>)iQY4)pi=fQ!fuhWjL<3E9cX#uG1*7*!l}1ZR4^#2YFA zRbIkh*u2biu{mU1IrDxMlrf(OErI{E!Wo7Us!))hUdV=nS&sX&MyP1HQV= zfyV1Idi`+EHS|&=Z++)J+C}?>OH*z$*l zQda`VmW}>@Uc|aQu%lJ!LAX^h*ZR@vb-q7N1IM)T?r=&RHnl^14cO4LM;7bZwTl6m z!L<}Lv*X9uvnOYR`yQeTThmc|gWppjA5!=nT_47kvl zchyw+_&Zv32vpCU<#u4)wf?) zl#a~*ha@3M@jrvuc#y`fLdu2g)&oW||9DZkIT9_KOfcS`Py5no1Ih9gEUME=>Tn(T zGZ~i=dWF?Zs@QbkT6xV%12n#*64ZEQb@UbxE8V~j^Q$|@@pCpIwGB@N_5nTHm%IZaYBmTjI=u0A@Bc$a!*2E(Tq#^Kq2#z6tcoZcz*givHvgb8g3@HadXa&t$ zkCO#mPuE+L7J}59r=&!c;%p@?90Ebopz=JwIFBoGLQSx*b!ca&TdRY4>ngl@8aYb( z(HGB%?L-<66iyHY))Y~PgWq6^449UG&JJB2=ayDQbWaG(GCW>=U~rqjZWQrou2J_b z#82rZNGh=AJ5x3{FbNNs0DR*~Q0p?7!Hyai5<=t!mSX#)WAl)@wIKB+AK2l4`gmcf z>^lEBpQ>cavY!PAal0q07u9&M!6nX~KO)Kt8nw0=vwutovOGRw#P2{hAPPc6Xe`ln zpI_2@RSs<;XzsMWYWAoF~A35x81QeVtB?^?Lf~lwAE?gsg~r-`xCI z*QqE-wQL~G&Z{?(hppPp5+moD_>Ma~O|(cx%TV#sKmX_rf+_UH{UH zcKw@0(R%*Vi!DIpHvUXPbiB$8rBmGJlU){=I(x0|#GDe|NWz=zG8Ly$>YzoEdA&iq zcM?iQX5`e);|@t@_9GNC(Y4d$E0=j!2qvroiXjE^Y^UD4!$yUam)(ZN%=MFhbFmR~ zr(#U>wp31Sx3}&LgEem_R53nm8xN>5bCIE@a>mfD;b9M-Bz+wzxV8XrH`c{+D-4v^ z2B6R33>l-o$jgbhmx|CjF2F&fk$Bm%>)f*-%d9exhl(dgf!lDaLN8YQ99Q-c6Ph7T z3tAd&QBH5NN@WNkQqxwl75r#e*R6$X)qLBYL?y-R(qylV_aOCFflGW$@+&sZ>BGnW zkKE#aOa!?YVPy}If{`K%rk|%9+K@jJqiuPS4GF}#9(y6;2Zo4UZh{`xz7lnho z3Ya~?q$SxC4&$KF5jNnCcgJNiBz`nr;clvli4_9>ojf`y0fn&<;#=LAn_ELD+#0e* zWcy47!(k*`YZ1)1l%kOm__3&o8QY9Cz7M)q zvdo7&YY4AighjX?n1fiPTJ!-v`)@t8dJ%hQ2h;@ zH<$Iu1j87V6zXeuzBv69b$O}jn2DaZe7$|1|3>bx6!l#2bhG16)0MNW`S&e*Ws)w; z6uD&)|J;5ZcKU04Ncr8RA^kQPh*@n&^JOS{V987WF9S6u_p7!UG%EVn3c%e+7&(Q- z@IZK4WyQXxb92kvwj{4xlkUk6F!vO?lYyaSZ;19O6d0kwiDNHODjn>2>z`Y>!6aOV zN;paXq)~4t=KFkWRfJJ zeDgdnq+kH-sDS@AY78GEuO|V1q>~f9w5ju8&d)Xu&@WOJ(#Mai#E1MU zw}F8|$e#UJ17a`KI%ra`wi1+nU;AR3g7%@s5KGCjsfzDe;VdHW-M_xEQKl_YR=}0qJGTGCjiGi$ixIYCTWZ9A>Xl$wa z{%Nk1H3Jyqb~0NFYML{NgY&6@*oq*IemgIb(&Zm9<@+XczpANSKofC2WF?Wr4PVn< zEBS-i=*$5)jTp=p=merb*N(AF%+eR(;k9uIUr`*o?biXf)rZ-vZ+|!h8moMlYB=Zh zI`F{Ss>@Etv6gKs!|yXDbeqFp>iHNl?XuIqKlkH=npE4+ ziWB7n{+MRxBjsLi3<%)u5Gqq{bf9-JWRqr} zRq7>vt_v`F-clO&;So0%L~a!4Qo^qA+hbb^Da8x}6N9~7Cw zWbSE^do+fN9w|Dyt%#8MCpo#z5nEl5ZWYz`ocZg@R6n-%w0lu z%i>dA&Ln~S5r-=3Dx9$QOlpU&h(yE?%5CKNCFaEjBiD_&1yX`02txI7Fb6J9`I6N}5l z=J*mS4GJAh@QrZ&6T9+0?I2DCcjc0Ad6pP&ZH(CW(Ob9FEu@Z_5Z7X_e?DSM)+#nB zgGE7b89g=NW11K4zywaBl#%!nfe8(iIh^>*q;W{b+m${P%`Nf{$zMDn7HB!+qZ=)Ue#BK(7E86ij$hkB0doSP9$TaGg@vfo5vM@xZPIu<) zqKC2Pl)6$jvK*yZHy9clqxKJSJQ9NK^xe^p9Nxmnc^{^`nLB845YNNL_^(V_?*s#X zkV=(2`}F;F`iU0J+9-TSzC^UM>00{xU96ul;`a%c6B3of)~>>k=9YFp>s73bl`@mv zB2OP4dM4Q|PkX;pGVih{t@8^xa^P2boeC%7zxjjIG$lJhXalZ@ZnMvMLNG}(bkNc6 z=+XQbmw8@LOUexM48u@SjQ)tp559#}kZdiRllX$T<9HkRu}aT!B1(Qe$EBV;Z73vG zMG>#c;SlGeb>bx%LDNa*C0;HpcKMdhqL?Y0WJo7}63ZQUVc!SCd8hSZ2ryY*C!JR$ zE6)=3jWCKgD8B`c>#0vGbk@>$5`)dR3<{3%If^qT&Tg<*rykpxZzf2g3pKABrs>yy zwTqKm`^n|$fw+6z{D)^-0i2;o@Iipi5Z_xR(k*Z5zJ4F1xR2?s2(S%WF9SAo)?N=u ze?**2?O8pP1d$dKcK4Ebwj2JHdn@)l5wZGGu1Bl$wkT5A#Vc^}_93!`eY{$)NB(V9 zIs*Pg`dl8m-a#E8L9G|FI_vjNr`Sf+dI+rEoS zHi)t%Y=+@ydF(ilN}T7ia$9~p}nJQ&KWWDPRM zfGx^Md49rP_buchAO1?FP9`;s)qb7Hsb@883_AJ|%M}CNH%)&RFzm`M;*OJPfybL& z{o`OUh$iEG51sSNw0K*M^u5K=EFGNe2lv{>;3voTYE?A|31p6{Y!m^P|8W#9QID3GTc1-GAb)8W(8bpNEx+H;jk|!(B(i ze%w9O4G^Js$O*sh`zvWFR6qp!@Hd$kBREvsPPTy>hu=wdK20`n*MUVF_iYX#WUcu7 zni~Wt*y*{7Ni_#DrTr9hf}%D0(IV`_nk4<#q6YN45QsK`#jvH0j{6!NWI2yzVl^{V zO2KLQXQ18N@;t_A`mCE>o+gp!#O$AZDyVnpVOd@WYOhd5hNOpPa0ocbq^%mxM;yH6RQrS={o7%BFqb71#1VS9f&P_B_S5KNQxI#!ix2og zizy3&{tQr@a@L;yMoD0eK9!vZtaRWvfUe`iwjl2X-3x~+G7@0Xqm{#wN|Zi3pQ$R_ z3+@U!DbwEg1d{$v_ERsN3Ij}1ji1}XOcD$T#@r|uS>kGsdh1r5!uAwOut(&5+FL7I z-6#~cAR(L6enrB`V$UtmhB5cED($7Xydu;Pi?}pxnt~o8Jk#`C@4BUA`Yy}Q6nO)6 z4Us=4bXyXaIUGK>8jfWq}5n4AxbWym>-9pB9uNp8qV7nT3dmC@|!c0x}tfQ zRYHjEAm($n?^8*nQ<`@LdbDW#N%v;#t&qTi$UnH=T=OSN9M4$C=c$WRZn2Oh=w_u2?^k`(YF1J_10z~c~MTU=N-C-V69MF(WxjD z%79w@);Ka-={&AN16}Av0x3Fk$newMIp3rOiF|e`C57VAA24}G3Yscbm>gqFg0U*> zQQjN<-GtZbjEO5WoigR(?L|-+h;WvN%s(Z*WJMpD;8G1w+rZIb!B^!u#V7 zc-0!xO!P|j!n*H~r(6Qxe-6*1&48MjJ{it1#@VI8oMDkEPG;5>JNd(e#p&?|9STl< zeIhnn!={vWT^&D}9iYiG6s*Ff@h*|1zBWye1|6KMGDeq`=^sO&e=6fIfs?Xh-v?^z zb&MQp`&D^)yVIz3qHLLhQ@$(fX~4D6t!<+ICU-qoc$o~{gWgnteJ1~8+coBeSux!Y zdfRP&y;pP5cJV^O^(5@T4=-HvbcKbkA`8>CGMR2|N$U@EW_v!iYtL4Y(b?=o4M+I+ zrK@s?!cUDWWx&f3;tfnRet2rKG!L1P9lZOF)oP@^OKteB9=VN6C?^%;0e!1-5v|gH zeuPl(rMH|iM*IrcKWhwsIFw1m8bb2(WEhdcdD!YAYB)1ZT#hO;-THuBIcjh|8caV_?54a2v`yTL;y5efO)ps4p3@Ret8v(=vPb4%|4*<-mhr$N$Mm4l{4*8x;~7StmJMnKvA@E}0JhM#bU=eM1> zHT&dcd{s?ho>mtcPlDSDcnm!0tbfD=i2JUwii)BT;n(?k6I8FsJ>Xh1Oom0Kr9pMI z2eE6!<{h9`&~WO2Jg?Dv_Y#lXx6o<}0%TtAjH9ckdJyXKbM8J|xjDLD+7dZ847x4p zEV1-k|Ekh`lJsCXu49Otu-)?^4}_T}4$_6LGJ4i;UiId&?}q(@mj~A=vA9nq1uR^6 zcZSoD7qds180a#nN83W86pCO)ohF?8GwBA5pSqmrw*ap$*f`OSvbjtRvX|YP!pFx>kkS0tvnABEE9J}CSm{6U4+&Pt&2R#OqT~!e^h5VI#8Y8JM$dn} z=fQ_;z8a#)H0frBgUUp>c*%2;+%wwIyZabqIRuBB-`k+d>iw~0!m{lG&V;}KPqC}R zJC489s2Sd!ml?-hqUzdq8Ws@N3zd9^^l5(J#Lo&in=AtwtEaNhvQZ`3FMyKiE(Jd; zdw%-PEKAsijT#HFj;6Ssm7qOw6cjcnd>N21)1RcnI`yY7EWb^D5^+9-*nR%;wj&?8 zOY4_2SNG@6-N^0-cc}WQkopMmy#3TM@XvWy`u!3&oa7_TS0sGU1}uF&I10Oocyj;G zbLQK>ArWOi6>-WKPDx#>Pz_RKNz<;y-TKT+j;}qSXo-Vop4I1UYQ)0YD@!^uX^-uT zp#zgtRWhC}0}V6aWa?!DH`J-e;~Nt`U=uIl=AH|)OPRQjM|CVgpRw94WKsW)qxw_g zZ?vaQbwRCKh#|i9jDcsLMMwdgXS>*_*|SMzv#1fBO7?BmmoJA467S#V0E@5G%+%(b zG2k$dacM=x=Xu`BZ#I~IG_iO{Vj3P|)nyF&)sf0&D&ak=tbhpb;;{Sc5P$XoB8HfH z!6=uLSxKOr)x{_8uC?N+#KPcVr~KWp?w?yrCHY9$__`H$zL`_-5Ta(c5V~Zy%2fy3 zJgJqRb|Ws6w(`7QU;eFFkqCR=Um%(C^$y~S5HMbZxstrj!-ATK!h~K&*@Bvt>B*%0 z)-qZik|ZWG=wDLBl*>e?U*lP40;u%;KCwt&%cf*m3k8`K9`5n?9vQ%9jIiEgw-f0P zyXMFr@?zr1G?hHfKnb?37GM1u%QjU`s!)!LkG9gbJ)FbRxACPchN1BlrjyU(NM@Rs z##XOs$QfRX4!U>Z}N&HQuF5DzV=$ihKy@k zi?y4D>JIcbj5TJx>J45KN_I>ytci~q-|O}hc@7Wz!l^~QZW(HtdTaHJYCSmg>eXI< z2|V$^YwF1nh((HE`jDl;=cP{HE-<`b<` ze0&Tq#$-e;{TShw-;Bb-k##4GZ;T!#6|P&(VPlx3J{%>e$By3=r%r3ItzmM9IpDr` zHD(f5HIU^^ocloDqEYk2r97vcl*{v|o=&CS5RlZYlZM+~jjP4zo18SnF<3D73>Eb( zX4%ZSW}1xmtwhA;DO3aB`~^)&{14+hN;`V;ORtjb#tDVpb!vx#?|~MVy%=JrNqt*g{u73xT)aN34svm}8Y|WxI<(Ib@KeO%uw*Fp zAFC92Np=hT43QZN=_w%k_2MDlwz3UzfOr3SU68}wv-5NF!Dh0#aSoAmAE~=r`!_eN zI}f|Y?y+3*X7GFl{o<(r6H4issEoqlv$U5@-q99G=q1A@hpeVX+Z#Bo#xXFcT1ISq zH&2S-#>pJu)r(UPbSk$0!|u)jiMnI%?cbr;azY5m%`x*mGL<6nRtR+)c2wmBln1@f zW7@(uyD*-u;B3zuC;-SrZKV%aUzqRbM$K%8*ng{2lM`uRE6^#XYuQB0@ZePXKmzQ< z$es)C(!w8}g_c8f&sSMKN-?{=&z#v`r5SEO)rHRT>LuM3|-kFWyuCYqH_l~;n`A91D zhz^^;HXl-Aqevo78Z&8XG#Ot!@92K_kQ8~TbSVEjy!j+v2lF4NXMmQR-Zv0 zeex;Lo`_xa3Wt8(=Y6l*G7eiFy!<^vp{YJlNu)~!i4@-Y(|%+!NV$)gFFt`H0rVZ> z0XKfzq5PUj#dsLL5931+b%L#60hU+1tkSP=!gsmNvAZvwW1epLzIZ{Gb)o-vW}=DXv^TTv@{?x2emDWpkEc0*5)u;Tx~ zMHU8|hQeBZa{NNv-K}!o-HY(;X+r$Ou2a6Ig3mUjTo)zw8l~hFluhB( zK`dNpBn1hf?1$eUEN>F8!{^@4owRoNMk| z?ACGsdy+*>$Z{9-gp&x~^-#Q&u7z{w-2Rc4X|Y0-VBLRJ25?MAt$zY&*0aH~Cn%8% zWU*zq7T8k#XO#OK`Q(x$v{|*Ul2fT<1u_VflP0d6`1VTQ$zbD&OGnXv*Sv^d#`u%B z`iTxR!dX9)Qi!}tuad_PgRb)<>1l~tNg5Zme5TBkzTA5;UWtom&qsYGEZO~zOc+)E~2rL_BkdjI`L$B(v0w_|Zr#=&qpwnR#%Gt|H)K+wov@c-ppQZ~^ zvo03;M;iYQz!73xNx6m!AZI1|5xeU5mI0?fzngIdWB81>baq_F_#sjxu12MiRMhTX z`e#ekjNi9=0`FVB>3t8xxcm5}bMX4tuykY{h53QSBYDX~e4#A(S&Z1O2TWM2IzcwF zB8ZDgKjCU7M3$cOYb&Lde}RD{u-_|>4Qrx9hTv*P>dpCjICQR7MQI9V%H?b#3AWZT zrJOmBKWFBqn9)H9wVk@&Jt5v$%bU!+nz~jN?x@NisdMOyGOoA0Ib{tpsa3kk$Sm)J+*M zE-d5I;1{tUPD=(>L5Z4- zShxp#2ya4%&T*OMVfPztRFb67zN2$ZNJ05{1IXe=;XVaOD~+sjq1lL@McFIx z;6|W0aKg{xGbDiDuG^CN@i7GU4XDO)OrdEIc}066coa4GMwgnz&HhmBbaGo>?yE7& z(g$T?TYL#%uWj-bj`S&Zig=reXmL$H(KDsocvvVne&n< z2}I63PkrYVfdx1y*F9KuBjkC-Iy{r;!8d+AM38wQ^6KlbqLZ*fK-^QMc=Gp-zJ27W zYDB~f)A2J29M=4XT63zua%5RIWD9`!6DW(oC{CEVMbP)eVZZlsS=%99`SwXDnwmZq z??zc0iA8PKH8c|f_NPPY4)MdI5)2#0VoJ4G*GRq@a2Rd(()vGZ0n%676ly0(ukoLW z16J{0%N;zb0#-KIxo6^;52Z7sRy{_g(sN^?18=Jtqsfxx3l#|7;~i7qHi$mM)?U^u z5Z#C;3UIaf^I5aW8Of)f->(PGZ|8KItXbJ!@O-_RYg!L-f;WfnNf)u+OC!sx$-jCJ z`j!`5dULcmgE!X@KFATAO9X3Z>f}F9Aj(19JO=3TWRku71cFLF5IqK5IIpGJaArywpZIO6=ok zw@2m`TxejPv4{9GjtmYb!Z#L^wVz*FJqH4!1-=^G82g zu%xd&0{ee`id;MET}K+g_4=)}MZsOSnA+zDR50ob3!R;YZxoYU0d6J-jS^qhe5D4S zosK*4gV_Zz-su5{(_oBrkjV=hjEt1!239~`HICh>zoMZT^JJl0QPgKAq8`l!dF3v= z{KOeGur9Xo5`UwZl7ROHw$h9eA#r3tm4~9hTYZd+vJv(^CuxBAD=`+=0{>=u;#qUb zA}?`^{z*K+Qa2{^!BynqETNU8owYdb>GKZW_v9Z|s`=`7bAvBG#6R8r8c2Njsg__7 z#LNSetG%XpdT)iyMQ%uo2kMXg1nUFMG*RF?Wx$g?WUQD({Js<0Ml4Bgs~>a}-@?5R zOWZU^LdY=D8mdYI-?91|DFortKW~xMuo4>N3PW!A#*RM4TDctu+;Wd1vv}mD z6Q2PDl1aFK(SomuA26@oiGM~iE9rCDPlyh)yBlPwy($%P!x+vVTgFFLqVoc+MMi;K zSa>SKUmvUn6p*X!+6Z3W$03=<=EWtSwo8t(;bJcJJKn(o(M0@yANwax3**Hgks1rX zp;b+|VUMRd>nWy(H`Oc+nFh$XfJ!Fb7e_SC`vl0EngjAx?$l%XAnm^6LXr;4dFvl= z{0Wtfb0Mr)4sNyXY{NZeAj9QBHFmn$y%zhm*M4Cqfk$BWzUGx z=D6Hr(YSPW%1#X?NNsP{}iL^DWxu75pfmHHj}&~bQhl7+i&z^vQh5;A@$KX{22 zqI9FLSb{K=r4iSE)>ttGN;|0X1A7sMtNc9`HEpXjKcWk_P0^sHYEda!8>h^?zGTf#ed)Um zp}8jzVe&|1tIE z@lb#7`}i1S&mJPX6iF!AGL)2E){w@Qec$)7hU{d^S}H~MY+(l3_a(dRjD25bn3?DI z+3WNEJs!V*{4*X8bB_DI&vjk*b?#$fo2jTXG-ZmV@#dQ4ASJ0?b-gA`%d}1!D0Vm* zB>pbWAWbq(%o&neI)IST2g&M^=>Ql6yE7OEqP{LDsH@u%^5y^SmkkG{<-%NKrS9lF z4y5mlM6`E{Y<`>)zy{1bFft#EFYg2!4()icHQ*!UVE+b#L($iQp{rlTR7qy+U$?qh z{gz30C%#oG#2+|1bMG!m1m!mhVL`El7s)&%mJ}_;ZO>;F+UmhC?6$~<_xjtBmRp$l zBBU7QEfQg9|I{aeb}H<8rClsKNa z$4JRya~{3=JW7^;@p1eCrNmP=wh6AubNY1ZucZx<*)4F3XO4)IW;)?0{d5n@kcQBQ zGS8IvS2=Vzi3=NMsE0lPMM;1U4DE-j`YoG{7eGuRMIu8GhZ7GIL=`}#5TStuPE2Sc zD^YljLdfv0=L69XVDD;w^S|KuPeRXsOi0{^+*A4yy%`(E9S@~Odv@LYZq@0rj(9Nru_rru_bw-%Yq`b-h!HC_52uMQ{zjJ9 z3_eTUTr2FDF9xe25Ap34!>Ei+md@5LZ;ORz=vu-bE-r)fV+IdS4*^ ztV9U@ku`d-o_o{tibZaS*W;=gQXZaEwr`r0=(UZZm2ce%f@$B8v&M@S26Cp_4-SQg zsnnlK-xV%*4bUh03nhEO!{0zl#0q`=GuwO2>@R*tNyR41k>9;>eSkJw>jU$j$IEfw_3yqBZ85kz zg=S#Q9+ICW&T7Mnxid_V=4j|S65tYqh0`5$g3I|nc(Ufdg$LGxqwm1!_(gw@l>FB^ z(2219$=wUbaN&J__GY}+{C>v&k7!Ir1GrE5*w$voAw^b|cJ9?v5d}9z{!rTY5hcxh z>-MDs8Uk9gmKKBDTlZPc`Ycr_Rh37(&|=UXheo>(8Qe)F_Iz)N0u{u~)qrKS$CH|`0R3AZD~4J%50{3&hP zYrKvmQv2S+k}rh7NHSJhjsvXFUMn&4n@>XS50(RJa&50oTBK^)J7l-T#M2NO;1Lef zfoWkT>}`5Ak@0m{gcAmFcpI)H3KW-bieeiR*n;UK>{{t zP}#5kD+=ZR7YdbV7-|ECzl;;VIi2_@q|)PH7)S0Y;9%Mai7FnBCbPW;X}Z!m>z;h) z)3*pFqKbxOGxFCyT|qX*`sv-x7he+nMxpyIA3~t_I7PSjUP7+Dq~X`PW&oA2dN0-% zBLc&El~r((h&u1Rlp9znyvdnb7)BplcAx%Ix0FPAuJ@+yu{3cWc_pcS4F|MVm-IN| zDfRp+`;5Y^$?oUe1?T7=^)he0RebyDk8+2V<2VgY7oWd6IS&$$QoP>~a;rdpuY&jL zxaUVc=I7-p0F|M2TIzwL)>KY@dK?^H!KbwN3}GUeCKp z*e0R+6R|C_M2jz}rff?zc_nA#f*W0*8-$14vTWLza=jZ#sv{P&cjSTcB-RmZP5m79 ziRSYml2ZwR>~ARi@%CeZb&fVSBNyYakR933G*w_$hG#r4_+Sz*pV_j_E@K(fxH+$} z3jHDxDbM83ij|wKIxIw$uWri^r;`h`_@uv}hXD1VN%xyV>P586SDCeEF+doDbaSJHyvEUE*WmgEBKLIL;Vbh2QC9N3Lz z188%S_Jj8x9@OQe?zg@~a_=wT1`(;3TsX_%nbLewAFwTjX$mBZ4pvzd9gTvS!x^S7 z!KRew*?&d29MB>K;3s3SdeN@|;XDRy`wpH1@R;Cl*d1yX;lV5;DeWb-5cW#b z_S&7>BJOlp)}nQ&VcvsWLHVxgHs$3?z0Zd1PgcXlxYq9*iWPj|`;7ODyM43QJaY37 z4)F&IBX~|PVAmz^MSb#qzJjbvk*kqy!3Z}0}oO`iDuw@ z8xei)s1y#s83`cp=T3(FvA7jhLc(>e+e#z)b?CHV-4MEh%%h|@Vv?Kffn6F0Nv*&w zFA~n==K*ghHJN`)eRv{Jc~4Zb#VBOTf{u|XY#ft;%#^CgkA9cU9!y5DzM?5<%EN49 z@U`&eZ~C2Vhd+TNUF!E=yw{cs>3K?bi%%TFFWwPUd|x%*`{eexy3w@0J5diFJ5xDa6WI5_ezSdJz|v9f*t z`O^ryvcdX@%}1gCA`m_NHwu0-_=Lv{yS9!_haYiXcpk%EmE#b5x_PZs51kQPEF*rl z;ez`({UG#Z1RfzxQxT}NA;+YYo+<&ul`Rx3T%^)!?*q+1{+EBsf$lW$9n=r(7Eu5* zg13vWI{VO=aoiqp*b)i5-t+3!-A{Za??$ec+;VN^PagdWH9M$l zXYG-tpyyB%TM#hPRs3|mw%C~AU(e59qOw!+f_%jhE`1SpOy?9sd{Fv2f9Lm0l3_7# z6OTiZ!S5f)g4?_Xwg;b52>&d>4L+82p{(uH>k*i%D1f!6l27IZ73TO1>c8zBfQBu} zu}VPY9?wZ6-i=byDH+U+?R^n;8mbkM*jhFa++k=>(Au>>1eU>;)nQTJ;6bCGTO5{; z>z5;F#&N#8f0kF2NP4FTs_X#z{rW^}qu}mCxN|dK-8|faI}i@+zu5=08rK4Y?ko;) z`JQ8{L%HOc=mUU#`~vmHBp?a@*C0mUPmBXr_#o>K$0n3Ex~U8NTG>K2tZoQ0su*mS z78}bu7xSh|i!XgQG2y)@=&~Z!BbU$CuQ@R;_F1J&au=m!(z!#X6Ei7x9pL{6Wx5r| z?)H7N|-l>4kL_a^e@(s_K^r*OXuh-1+8sQ!Az_-Sq zjTbJNaDa+C_WRgjQ~B0a8)uXmGC>*ep8cKnDKTkx@LmY6{!qVCW_jP>foC6JVL@(n zI(a;TgI}JJgPws#{1!TBEDK@i;pY?J*Gh_UbS^Md%G2Jo$^_TxFRR{yA&G!o5J$T z0ypS#XRF_Vh||6@E)iQlV& zbi~;vJLUA(saI8QFI`Di^-3$3qaE`RLxkohh8Jy{0l< zq}w!wWmqdC{Z^15L}NT#fAOZ+hmzY@yGtvx+Xc#-1e6B``B8uPZkbcj=aPK3q@dJ! z6IN7sz00z~_*0x($j}Ow!Hs6P9^+2_nq!uTPFjpA+Nq3gOpTrEFh=gBNnT>1uAshd zk(UianbJU|c!Dh3V#{D*n^}W5>Tyhi-sQUrm%%qdwIy@xxZe#GZBmZ(j7mc6gA?l? z?XPxiCpi8A=dFX(A>)H#{^1G%BY-f>2J_}r)(jgEV zZNGxwuLpS-C;SC6SHO|dT`3f7&$S-v*j>`;F{X(7WDv=v2$#3izr40E*2U*HZ7*Sc@1}RYjbe6})}d+}S5Jph@WJ7# zhDGs=(^Bxw6J~u;o7O`b?|I*kpne$#)Xkk>{#Bm~wC4htMPrFg+YhhqcjG$>dx15U z9K(464bBUGBH%eOt3!@qL7jE3NetPf1AQ6t$fJe@*WiRpQ8*5?X7m+tUYr#E0(VOD zWUu^U#^V@tBsA=6Yhzc=b*4raI@RZS3|i{lYtp~{`N8XIsFr@tU#o*= zEaD9gK41Dep)7w5?%bJ?(R_>=!E<3P{iSx7ju2Q<8|l3!CXzw_fIi}VHk8!Gi^J*` zz~&K_3ezYm56p3#qxM^90b$k=&NV#f&J9L3;a&x)EQSj12N&Iu10nnWfKxWmLFiit zbRbi@oV2$XGgKq8q#w&m$FIgJy;ln1q|x8Ka4!R~WCw ztigA5!y3`E~96b+kxpHpvwNjmS zc4HgtBO?xOfDNDtwRRmMTAH*u{A3>oxoHhYYd8wXkITnalL|Nu&u$Cxk91bvAWb9o zm|b5U`uXD4y>A8t$zz+e#-D=1f1_#Qyr%D%Y0`p2Cd>~J*e~mQ9tTF&xT7kE4j_Yz z@{Uh__|PL!)F0gHezWfUNvIEe5(g*jd_mB5`q}n%Sr7)6Sb#d9hiuh&7a01rOSN%{ zBJAhKS{N<$jQWEI8q9LK91xUsi~q;*&$~_19PrB~ z@OJZ2NxqgAjI3)k<12!6<;F?OP~e5$4j}r_NrslI&Ue2{-hZ)2v?m-ic+7+L|H&?} zv6T6rVPODPLc{x9W!_PFFqcw$#7-NuSwH_S*cu&L_Ecm!XUgjIENU`8_twZ8B(sMy4sZ z>XE2x2)f*!gwcc!(nDR6NJzkowbj$>6Ai1^+sU6+n3>Jk@(z0E>R4G^4V!v<she7oek4L0=7)Z5Wc zX=TgnRf^R9wgJ^z@&zJRIh^r%U^nf<9( zHpbZCrB_&++Wh9{)R#A^ORxw#f+)P|v>dsfJ%j^K`^GF6OtVk7AlF6zcQoLBeX&e} zP;@P$Ht=K_O;|LIg1gR3=jBThL~ApU+;tM@r_;#+-}UU2pW7SDmX@?Z3k<9y-;P=Cb&+X)VXnvy;8)X(LHH zUNt&#mFoD*g?<8^AIbS!QfsE;)DDD~)z@_jr*|4xN$4GK0B<4M^l5RWU9gg%`p%eT zj!za;yh}E(zZ#TN$fI{YuUm}b7S@5Iz5&xS0fbG1h-e((E`mV7) zByPC@b2a`8_IuXhn-3sineqpUg7GeXE8veOgS5aYALIa=vn*%O_ycw!ZkQ_>%wQ1v zm|v==saSsGJuBI&G8ah-TUYfBXfu&`rbSvSA6Jb3H_DMNvDKL;|CL4C( zjCUZ)HzpXj)GPsGE`Gc~2|5?gC^nz&a*p^L7PG(qwE*qq27CWl` z6)}X=B?^7}HwYKNSesr(5tt#(pH>qK-kL-1kO=6Rw|BoFv0U-sfE0g-cG|sn(_O)i zXz3%=#z97hN4Dvvz5%6!t0Y7|=7tlAY{`3%NXiuB(wVXg-`ab%^jeT7xkLUUVo8sR zh8~R28?LuYL+^S}TcxTxmq>o(e)_cU-b4LflE33ix^L)&9QVf5d-ZD=I&OHZ6V&w^ z#rko15!Sq<(S1#9N>7cCTf03O4dObADV<-?&?i%f@GpV(d!8cVO50mYcAjR& z*u8tOe$UYj4mQeLKor`IIBjxSt|s&iU{X*%$VjjQ*fm5Az;PY$)^aJqXL*R_1FFG9 z)a8xD+y5@k4FpbByA(AdaSiBYwe|Ew_#M=9!W9${@ZsMRgyEaR*YUfdVSe%SVO<^BXqju zwQA1jG+;UlY4~zov@NsWO_)R!!_Ch#n8u^Q8&H-&-{5FK682or=?6AKtgXIsxcJvB zIG+N!T!QrG$avI;n!5u+hyws~`n%B*g}4b`LV*ANi#T=|8h}$_Vh%OY0i0f7=jgr7 za0zPNWMdOuP+#@)Fo+cxav2kpKOfGT1@Z|If=7SJN==Y_i*i37;`Us|*yr(@|3hG) zD+3${cbDUmJcM+XPmcom^g>D=nwE91-%Fu3@rLw=z*1@MFnyKpQ`9y=S3k7dt3o*H zV&q)oK4S^B-BUqa5snM07h8fF`cHE*LLj$npWin+mQcJm;*$}UP`SVq!DRS&(I`)t z=;6T&_w2XK7OirMx0tRas(oKCixik``W{lxV_?&!o;}pbTF>I0*8TQePl+y~4L?Z* z*=|B_M`mhwl8M!{H4Hbok-pYm1*dKuS$Yoy%9rC{qNq8H=!1ORj?F*Yh07PfN3?1C zRn8UP%cyyuTU)p*K^Re3@HiHx*`52ZlreI7S!7Enq|v&R7tkm>HhcAoVzp!Lu&U(>a$Zfmt!K1Zs0C z$15Ws9j@Z=Vw!u%-Z>MV_{?{8tCJ<39$I$xPLl zD`slr-660c3iA1F%MZs6JiQ#6hl&vAi`eW@Wr7WZGiBWni*!q|wL7KL9qG{dj@sK% zOWRGMBQiw_dp&}x)KkB2I5~HlH73H3#RI>#)fKXz7UwkCLl9&Qj412^Ul5k0#{bt542L+3wY@}0+!?SCQz8`33hKx5YeI1#lAPkSiqatXLw ztT%zCm2CNgJvaMK>CsErRM6lVFwkI5_`iJ-2l{*<1>xKFDt9w`!j!bm3*SAhGJ10e zv40ssMSjPPzvP|y^}+3}e)V2s`Xe)4fwgPcqU|We8=I>(|DLbw@026mFqwQ2N2?a6 zIzZ0mLREl2ak-o8G|yi%KjxM2t_x!Wz44-_$6#fJdC`wg>RPPjF{i0Vwd`^PhX>uP zc~33(AX4UiH~Hr87ZsnduWuiI;a@5#hpkOLoJ@n~tjS3)qr14*>D{Byb%AUX8QK0A4^cd#de} z?kG#dbjNU}4y`2imk70yTrHzeV?I#>ny;?Qyn*=W_3`rDcFG>lX)umfG);C*r=u%j zoSm}~@h!0*eYa||nqRC@GB*{NoK5qwNiWuyaZFiGbx(oXVEc~u{)R8JU&*_S<9Hi) z6-Ajn68fa8legQYmtmAmT?>ab3?A}tt5Ype`ZXp?4pa~QyZ11GD-@+9HZ?JMj0DkV zS@1)BxW{_p{X|i0K$l5pUB^R!SsPT?HbBCbkKBtzMu2P%MAxbd=e6*kuqbaoFvSEd znnXCTF$dg0!AM8NBAXf?1@}mY zf(if}xg3`teRxgK61=4{51_Z}<~@(4Zg@?yO{%)As6lCcZiLHBJyMLe$Eq8N;o4ogJc}bV z(edG_f7|XnrIw7~E^&L1V43@7zEo}oe?y}0l~MN247Z?Dr(YYven({h8u*x0Thg+E znH}-UZ5ym@ToywmgFPW5e-H+-n^Z_!9gpNbI97?!Rqsi(LFFw_~Um(wk|RJp`qv^R-aIVU}aFizbgT^@+hE zsbVf;pkuR&)F)mHP<>uLQfoS%#X55-bXcz(EpDeg`hYi*eho^TUSY|{8Js~s_ip#s zm7Q7V-j3h9gxf4-xT;DIuF?)M&ER%#^E&VS8nG#Tm0L>K!q{vtq`!P@&{`{Tn zI*?Hlj|JAy2w{k8kxDzSm1AcR^GK9q>2(G~AQ1EXS?|Z#)GK`1JqG?t%p!{NCG)uj zr`|pG7NW7+lZiadkF5RG3|>6-EOf7blHVXVEANBPDX>Q`ucqYFEWGJ(m_^>8S%Ll$ zqoQmheS zSS-@`bZ;oQH~91k;`6TR_zxNaERdjOC_}qev_BXSa5*wQ2geU{^@Rg_!hC>>9c>ga z6tJwj$~A|(L3WzdvMc7j3K+paCo*UkAs|9)wQw#dXiJ~GWJHI2a{N|TXQlqfWQ9Re z8iwJLV6pVl#t#(5>>kk1yw$sZ@i%fi1tAH4beO4XWR0v>oU6N3y4#owqy$So3sR7< zTwi%ilz-Z!&2>kx*2Xb6zYyQ76DAVKKqdm&yt*E^h+9luujK$b@Ye zqcfX^GaIe`-t3FH$8VBix)8-x#dqMvDz}2mzxf$^z>d}kbIJsiBMyO;K$;F+QVQP>e!kcZE_G-n z0SoNphye@I>vxSZmQle&*}>D=ewpD;dm++h>jhFVpSpzHA3rNhsWTZw<`34hK-QGC z6`Aj3*zeq2@7Va+=WSY0*gNW0Ylr3_()o26`T72m09T_>RpY9lpILw=d4(b}|L+q^ zBn)1?x$}`Nsi8;SWK$F+A5CGQr;L^i_D(!!GZUrdR0h~@s1;(LEDw`s zf=dDn{C%G3SeS+~?NagXefnI^lP+2;J8mKAyy_6Rjlgg3z=B#Aq@$w>a-$>U|B4*}w-10M$~Ww^6mgPAK*y%r?XE;D$`NL>ib(hA2ef(-C{l>2J$kY9@xZIv1lor4@DbDl+dMAbR*{Y5TP3 zs#ybY;)(*6@h-3r5%R?`EOO1g>L%fTMZTi9<~6gQbfCXolQlPokKOW@r=Oo}T(C5;<)>Clcbe zefn0FPeWXRb9yO+N)L+e%oIkMt4l7|no03d=X<;wo`bYswQ)+DOw^eDnp;PgBJ9-# zrI*jX$P=-g=4QZ3f5|vnkTe<{u2yZKqUgSQMX^B>Vg6*-)UoB`!bDj6j=*zu|xX z@cr?e>(k>}Sfk|u!klnfP8t{>d?l_H=5)b(25$mr*AnUltXF|I7{sn+S!D)pAp7dF zp}-Ljh&l3m|AjjYBl@pqCjqY@u7!FvcFz4fw%4T0DiS!V3hiq|AYz6I60fM#ymfod4Fnt!EbfoeOj1( zLWks|&WE$lcW14tQyx;yXdo@+_o3Jva*x@;wcXtt%$*JSHu8JWf-|*`wWM=D-S{({ zaOT*>*+FMn$C|#q2yFcI#980KYZxG_j0dImlL#A#jqNh4o*+Xp;)~25*Ch+aoMuhE7*Et$w zPABA+naoJO9t9-RXnJ11W7as-eyWCw$L=a~d_FI*B7_HeO7R|)%KfD8Vu%k?_a zJAb~e5l~01F#A_*SOg54I1Y(wNqneDOM&aYv|0tgU%Vs(7Y|S$z`=!MzdR7`N#N}} zPxzuJvl12T0bLI$R_-U=ihGh4>&N{)IOvT!**nX)`BNUfwH(rx!TOJv98DhJ1W6ym z5C-GC$~5YCZw$9<+dii{$e$^5?+e%8;R~$T-Aq&eIk-U5cGUC7jW{pBg1zOd(@S27 zR7(@ffE`M5$EZOclO2@Et)W?E?FDPmX>|+7B3AW>uD)~iv#w%eK_>V9wpbYKK5?ex zG|d-l&vD^UILiXY(g<9#7oSd3Jdi(`4DT--p?zjD2!9Q)07DRzf{ivJH$2`MdaYRg8H+JrOPV0!|q&jg%C-ANAwC|3~b&@zn?v9duYmjRs|9#ds z`Tf^h%LFyOsu&f=?5fC^Zg4(d_aPMV^avBc?Qsf?fAEgMg~PMqh6z zCa(_rDNJpcB#?E#-I2XmIefTEZ6xVAx!Y&?%c@;Lko`V8{QeTtRok7}$fwS;fi)6~ z{S?v*BAyK8B8Cgyyu=gAHu;Qz`i9G& z1=sm6{81LTavMlLq=JgW@qL2?8r`o_p&w#ux&O`Ii~qBHEzu{5uk4k44)LX(u|7wy zk_Q$g$|Ibb+@77> zj%#PLXvV;##&R9!dw6#goUTjV%W zDX~0H^2=i?1E-=065#mmXMJkO!<*6(D$6dwbnaPaB2tSB)0;sC46y--oouA(YeN5V z;*#+6krC@N(vW0eZSDXel%P@aD-QuFc{B3_3Sdy@%j4ZZ1J}Qm_y4PR(gYu2NbU7t zwWsEW+8eU@dzM)uVmvF3q%&?;o!f=@c4d@neOw(+i>NVF^Xm1pnKx8fMBhjzN)`RD{PcrtD5(G9d+F1f)Y&Vauob6$PSVK|XTL;n($I<&}HLIMJhOr(WvCwlSEMkli`5I&< zC}0l$sCoMsF;aF>Cn_n98M1uC)pZhNfPpPx)`ps%eq9?X%x$V!BOtt^n${S7exCCI z`C0_jMvqp0-mW_x>Z@4k72lPUBnA8bLj2WPGVrD!s05fW3wcB><4LB<4wl2-lB|q= z4quj8hnklw9IV(8k|lWe3$+{W*Y#tQ@gk3 zcJ~(TgIJ-(T7?I+&x0ybZsm-NnWPe*RXbK)&KEe(O3FD>ka)B?AJ3MxAWL0#T-;Mk zH96v3sq-Zkkfj5ciRbJWABA~d_ug^c{l>jV?M-!v_y8gN@jwFH>6cns?+7jcZm<;~ zUIt1LD|Wk+!ktb;EY2_Yz|Pykj4}ach|8A}D^1VJJ9VF*jo5@s3*nHL)%n-42t$yV z{I^uCSip%i#8C_)`)yVbS}yx-BeUT}Lt^O_nM`;sesi2{O`2r>e~aRo#@X z$aKa!OWb`eGwQ8W61d)Q?e2D9z3}sXCpvLQK3rw1Ppr%A=FY+mB1hrD6{)W6)rsk; z>X3?Y!J>Wuh43V`^$)w?ky}kfO;Hoz1)$0Z;0r}%V7R(L*aVm6${K+W=xaSh2$A5H z&ylNUoD5RTVf&YLv>bti@^J9<;=hRduVSYHvQ&_&(p~5msvt+MMvgG+TUPapU8!mD z?w*)mS7+|yeuRyCF}m-&dU6F?=d<57emV$S+UYrvOf|n6S1RtM|H0c8T0neu7P3)w z!$!1E$oWRLIL|!SSp7Z~$*fbztkn5qOYJ^x?^o_0xB}uhP4CV6O^L0-HtUZU@A1i! zeM(KJYkd&+q*t8L_Hv$Nwc zQz@@mbVYTI>M6O3hesVWe~3d@$gHMOD*_;&(&oD+SF;%9Sl<&8BSI} zP*a^Y^ijkt^dPCFw5}=VHx7{^iGhu|{j1X%qb953M?hmDCB&Ova2<&(<`?|F<49~u z_I18O+|V`S6Y^2q-L~7OUX1x&5gBP7`d_Gq1314`U_eYO7tBrwTRgLM%MR38rZ0Y{yhOiF zk8j`Uhc@D!L1J7;%RIY~BUy49}m{P{7M zwUO&(^(>?>FUiweFRE_lSZ9`L@4Ndl9Y=Zo0|}pf`3>rI@IO}+RPCm8 z1*oe7DfG-;3E)%~*oMDyA^v+s5%Tlf*NvPXiWjA5m!BHwqm<9s26p|-hSs`Y_Nh#P z2u(p^Hs>eKyeeU!x7z8Rgxub=9s0*48zdlzN~K!N$F^GIeAs-?_oRhcRem9I?vIfHeQfkblyk$gcbzLrmCV@^Y#)IMg6rIUPII2i zTvClJ!x|rwJyWNdhINHT0cnBtJO1MY9*M@3m9ozs(imXsnOyPTA?Zh}`%QH|h%;ww z8ZF^Pb~_D$mH^`*u$p9Jf z5d1@0-N2Iu5wqn1m*A5i@5>_j;G4&YQ;BsTd(e+@SC5<(`Ns$W~=WbnOq1e@_r=vhSM-NV3+fU6B!9t6M?7#Eck zkx|evG0f1bECCWVLY07PFYVf>Cv!sq#F8oj%|$vEyit zIYF;aYOqqhc6RtPaYd?;rw3QAVbILF3(VTf&AQ8*kPh+wSbL0t1Y%Jsiqr;33h^Pa zKRXY!ghnqD@w4fn)Bbg|(5T0fL%r9rWWLG@)U(bs_intliLrc5vAfr-NY0buOk&r$ zc~c_b9{#znB8}z`)hO_&FMrOq z$dpB@^w+I?)EhSB#OHD8QE*!M_$`%wRNV&BOJ!y!&`<=5zG}42+>9)20Q-a6P`~Nn z(cbZx(Id9^eZ`<%32oL%B!u@<8a43B?*4FYki0msfuaszA8|Cc3L@Ay<}{qF>X*$F%zM(CM@kP3XPmUQnRnvY{p<;zVs62&e|AnRh{dv4BU{ zXY(&)v}YNPl}^`K3$!U~X#T7QvdVwbH+Ul%XQn5lJ54QQx0q#K107W+wXq(?%2SWi zPyL``+6?i7#Dds0u;RvfnnLv_kz%hoLza_K&d;K>JVA@YPBP@!+~VJHvDibqXPg>! zh7TIs6)1{{tDft#gx2_tYkn@Dc+qkb>eK3_i43eDTS>Qyz)!p4o=O)e%*~aPQe*1vSX-#sB8BXRvo0bJ>FG$kKef==JWmk~nSR?XWFIFgLwuD( znSZ~QY_$*d9)mXFZQEwt4pN9w~unAlK0M^$`s!tDU_mp_=2Wh4`LaxK+gwCLO4Nmjc1 z>stX?mGH~S730LU|HlSz$d63`HQe1OaS3B2JsFDj2a?;>|g8@ z?Q94&Xf{a=l@Q&;e>Dl?#BF7)?V&CvV^@K78c*;D1A{j92;_}ioB%L`0)E#KP(5Ni zHR=9D+ImW^yy34Mt*KAaRx|Q!nQz?})+J|kyDR-r$Qas{O!T?HD19I$Y9g5a+KVy% zd%mKNh@xrcrj=ylJp@lDNz43B@1Nw?ZYS*KzBc~})nR%65b~8=K~6rr_g2WQ?RXC% z+hl5-WI+Q%?(3BKQ)8#pO7PZiafK|w2jqbc_)&q{ z*+>jXJM+GwPMSsS7?Yk6YD!k=W^>OM__zqMLq}g*fmT3w1sKuy_B*$c7s$UHWtsgg znX$g)`9PfOgr&42<==6*=81(py1%~*^mUyI1DB}O?6PLXL5FNgjv>Tyya+9zb&ePI zJNO0>cPjU#ce(39aOsM}f}!6HP|D`7VS(b@OvL8`+I4hCkJJ6)>$NWzPk&NhRrGz+ zl0A7ZE~xkOJw&X1qDhjF=@|B=4*z5AZzxR_&dJBtid+QyyTx8XS1`RibCe3ZLGR(8 z&p#Q!%broUO0LL%sg=0Z|H5A9i&{5lvvo*r^iHP)1-rl35Vz1d?{cHdK|g|)jDE%AL^ldAQd)s87#Lot z&n0*je^iJ%zi3|UN^=~u4|#}h5uQC(EVCkz4Rzw+R{-(TRar(LH`Tn*8%l^tnUW zT@BF*X?Az!g80?vzgo>xNI%G2i`eQWPx{q(G@k&;_iU}GW}kk+W7)$os0gvD8LL&5 z-wt~Mk?l?o)mo3YdKaQ2Gch$>UG6OqNNLMqtNmg?p)<5fpy0hMMR*8n;rYO0mQ$fJ zGR?lqkS`XicU~klqg&a3MJY79=mdq+#cMGK3b7vha;Q&BWH)_YEUKbE(X$D4&RgV; za6Cfz{{pguH}uKe5Nj&4i!gS}gM2UAP|ZX@t?pkCo+klglv!MeZCDz-MN}te>~77bn-dhAVUDSJ8&8Gc{I zBu+Ef240lvwSp8AO8M;dbQPk%K^{lBeszEOf-HGdUfl7F@3?oS|0?!+s8YB2rnHDHA<~4xE&kZ zcGU6U1}>`qa~(CIj{yg7iU-RestX(>tIL%NVBm8TdAV+Rvp>c6(XS7PkH!`Vc*0n8 zMkWw6`)|L7Ja1ruxqiTKCh=>HTnHqi%Ax2U)7I7O^wMLF zlGTb`g{wV*?M#PH*WAwzL+!1js1gZ{(DzbTsr~N8#Xj&v?cD+yOAWO;n}U$EH}iSc zoDtEu4433^o`Vcpd8WnD#4$PE(lTeWk8m=(f;tHjeN-=q%LjQZ$XkoBqd0i&|F=)) zt)ji6P5Zk?D=wEs=>NykRmD}=JzcuHyFt37ySqE2y95EHIdpf2lyrBBq)4|k%0Wsx zqz?6bhyVNio(pcy)v))>nzh!<4(1wr_&@CkT8VQf0ak42)oWQpt1b$YVfvu0piJH; zg9ic?Q_NOX9LhBIa03*^?eqHBp;_W39ju`|o&z>cWkhmKC6RQP1s{n-m}L*jM+{h9 z9Cl_Bd%@9rogMT1=?kVjQ#R()ztuk&T=j2^Sh-{WF~iwkrR~n(h#xpjkdID7rdw`@G=+;zpX_b@TEk7f%%q9|GhEO zTBzGmGXG4m&0|02Bj!!9$q>Mo7`&Tq8#xzc{8G;TBgoZxkL_Pc&9dR?rS z#mjKupG^ZFa95uAv{fK@Z((-A2wjGmdN+~$^4opYub%__qhCUt;X?nE6n3p+7rH(2 zk&4{r#o{XN74mBK6of+n=V8-)>IX)`wU5`zOA)+EEAnt#^KJz+V&=s^)tTf6H14Y` z+oq2>^kwxo-UX++TS_Bz;tVUX)f669q&H2$2eC-m@GnOd&UdEGbo^NTMKa!**b&rP zzS&2^@K|$C$Ltq6M>1xYt6w%HZ}^}!bs90ID zx&C^a4qW@ZP~-W=_Z}i!FwO!G?tiaYY;>s3EBb;1HS=F!#oBiEn$MCp#9S6NZ?c*8_madD{1&Z?a6Z^$F+7*AI zdv=~pVVuD)tO?tS6ipo_EvP;%j}X~wy^SwlmO^ti_Jmv%q#F!5YMKou<=E|Zf;*AT za>=Wln)apYPh-=Pda7ca)}lv45ou9UgweSOVh}D`PrhW8m&g2SHg+mF)xEbn^Bcm1 zD@Pg>NYZ4Ex0UzQ2*OX}$HzK77dgqorq!x@+Gi(3!zc-x-i>Now(8wc7^k2}&o8== zd%htC6kqUVO3n1hpQxXR6IvO7yW-c(1cJIB>fv$RDgNaP@7iz49$aA#|%ZE6!OLK|V7d zHc^2shcBEV`2LO1xVeT$uV)XFqD?K`v8A-4qaj+{wTCEMKQ%CUkE0bl_#DYjh(TvW z>!HzD`|bz+i*dAi^1H$B^y1$62N=)cBw59r1&Mo{E%FFR-~>nK))Y(fcUfz=!dXz6 zw7G=4-l{O6t*5+ENRrL}n_iYnnd|VV^n~z%z{b4QirKRer2IU{DPYVZz~sV_c+C~c z+<-v${obz}9gy%32yGOzb-zzP>^uMVR62N*+R3IW*n0J^=;UitN~(u3vz zsZ>fnvW--Dra2}oB|nC!o5@F9hF?wvxPzZo=mrq2Har>hOd}HVGP&3)%k%P{H9~5N z2^U5uO*(w>ccZjD%M)f!mL7Qv$gNN<|KaeVvo@GRO!?uV%a&$bE~jSRLtU>54q2WT zsV|>ky7bRYsPS6a5>))rDeV~BDl@&(H<5bNa<7jgoBjJb)11ud< zLlulb;oA2Z5gW>-D9>hdpTZ&?^RJpKv@@rbG*+F#a%(Va?=bS(dZwe<@4_%C!~C!@ zrPV+Omjr}auzoK|+tGoJiM%L+M!;}&=kEEq#mmq5oXnzYCZ9m&Vm50GNOr~K<$e7P}MJF^NGB3^aU zChTKP)n^Muxu+$ybi&j$44j^Qmx-R-MWIM;$cFvw)cW-1M(^_^dn|03=sS4MWj8}1 zrXN)in{8V3RS?+vza+hU1!w1=MeIJ6%E#wX*%!`c{U-|OrL@QZT8Q0tp*if|2mn1` zpzB%p^l)D#F!Yz^FLkCl&~y$(T-&AB=dc(3i-f)rY7QPR)Dm{Roo@|W*7CmoSwXAM zVA@d!s*K#nqv&8?W@chvDPt|Jt)*7tN$pMDMBT2ZQcrlD!u;dUgQV`>#|J%z74 zx3)x!Gp}J3R~*`oJEtE<{_}!FKM0f$$RySJ;v-J{Q5a(n>W>?+C$tS+%{Wt9jKz_^ z71J!`s(qVyJh zk&9oY9ydbVg%33`tbkOXydF+Lkv*s81Wn9MFGjf^BgPVyk$XqQ+NbY^gfP9Wehed> zU`~9F?U8YBxvf#`7B_j}@F3)eC%`s`1O@8E`~4@%uNRYALXzvL|4!ASZKd!0RDcj} z29Sj-+JW*u+X_%28G0iB86l5>I*yGCfV&w6U&IbWE4ln|L`(0asBch{b4lhvwHQJ| z+Kis0;h1B+W`!9-?xB0Z%nC)~D0ijK_y!zCE{=^$rH?YQ%Y2Tp&9D$t!LT$tU3xgt z=-ZFV8sl-=2;cE|e-wjjbT(9UomA`cu-W0Wd@9{6h%w}q7RsUw7-#n`FLA(nM%ejF zNbPmHOfg_=Q4_pHlYxpjBVu^%cDk=aj`biC?nO;R*)hz4U7qfK3q1Nv8NLspL0)u2 zZ+5rDBzZ#190`lVs&a7%5G~oWy&jp9X;HBbq>ThT-fi<6I}YF#9d%SneID<06Fs_P z-12ShJ&ZPAI)jj(@(M)kzm~^HNI$mQE{OC=5{le+yzbgI;IRJChH~9Qy^K4wp^F{+ zVgJ*~EMJP2AfPc6Jk=!JApYxt`>u-I}JzVm0!NpN*=f4;uuFKfZjS{@@BJM%r*lwQ5EL zn;-<}@~~e{yd;KOL3mIb%-TvMwJ+{Y1T#wKFA9`Y9fXx(-|LO*b1g4H`@V!PoTrpxgX8)V-I(*bWR3q1tE8G?qJ=x#&J00ac2``Zp~2+tBW*>ChyGT^^UIWewvE?| zc^3)Xs&Ci2R~E?KbY;7jToxKx%pcXpaKZ0p{lqk7S!oV-2PFIQ7a9BfJ*fKFt14{B0E4 zav2Oh6KSgqC_Swswal1~P9c0)A=rblzEwN819Cd%V-kc<)~NAzDDfa%l+wql4j~-r zp|p|YZ8F@{(NSC&anvswD1RvlsZ{Br3tCm2LI;}?AMG)}wsZ+6!LJNv!SaN2Js38X10mQC3!C_6 zV@)oMJFMAQ%GS)HeaaY@aUD&zBg9&@{X!?D$`D5rP|VkclA!ijS#%9?ki2H~8yA!? zoeB8*&#NfaF8{SdI!6TVzCgR3a3FdF=nOitxk?A!+xtxx1u4yVeT8z94byDqF9`=@ zuNe;eB|Gqu4`_sw1=Fmi1giNhQk-__8+<7v%8^5UFNZT@4UuK8v~m>Cz-x+z>mXjW zq_BA#l{_X%6LeES5inFq))1!F9Vf#}6B~M-H}jUPoiCY&{+pT=G7d5kUW1Cvd0Ro* z32q5m~? z)%WjNj7yMr=wod0KdBH)3flJMBh77EIi+P z!4AHmg=&j^vcXptoAxapuSj`Xj5=s z2c7772G?Fg1UH3VrR8v-g&IQLg*4}>cadhkanGt?_qIDuiYzQ@O|Nyml>0r7Mtbap z*cg*BL}e#(+swp=fYr#TVmq{qRpiLp0a`(G6fV&iK5D5B&BZe}sU}4q6%)mjU4LNS zhj`b)jKR>=@V0%6wH*&9GYwnCWgQa~<{X0M*eHhCZt}<`)ut1ZTWYPlr)`R0H|NH{ zA=slQap^+hgB9}hpj8~LK2+V4TF@xBRCI8*;9;L6yifeDgK|1p->V19!~-9A1FMjd z!J9%)E7wTs%Tg7g?rW^0FKv@=PqlWD>7=bG7(~hGRi`phv2ba#%y&6|Ljb#&)OR9j zVt&axBa^5dXQh0H6@NS2iQgOYz6LaU24MULS`+BY9wiPoBy^`W zq>QIDEb(E?U{>ZtfQim=!mU)A(#JdJF37OdJRFd@>pj+*FI4sd4^7GTOayr~GW`2v!EByvU^X<)jCv?I(ym_UoCrVx+Z<0f*wUlw-?b0-g%C#ACT$E`j_D@*kSDKIc#&ya-enZfgzDv#D8}B1YpeqGzFC^Qb{YBdq zh_F2j?(hv)+06*?maQK1S2XukR;PDxv`aRtA6ZCQeURLAx##f;VAPSSctWHX(GbKO zleQI#=@J>S%hDI%U=+#_lpR7Dp%1s>z%@MjR(Ko{8PU)U3*bhG2*b(A%Y?geFs{~w zr*n#Q#C^o$2}gBp$MpQ5Wk=4yET1-@pNf!B{)743rLlutOc&ynnh)>nQO z8K``F9v_X+cl_3;(_&i~s=EX~WD#rB;_Nv9fA6mQK#v1pkVDBJ)EhIIwaMx7f-Q*@ zj8L4YiP;r_$b$7C#KufnXe(;RUmA)HlUN`T)7?#z-@H!})%$s9nx?@V2-V+2{ivBZ zL=#rdsV-8FW}~;1G)Oo}$B4W2cPX{HPT{ZSNc$~b&WZy0Mhv)v{t*frSh@t^HeNZZ2S!7Z35=|>G4TzZN^_5nhH&H;BH6d;5Q8sl!+u9(nt=i zu&WeLA}v~s0<-e{1jnjc$kui;o{T+n^H*(7=L50iWMqo$9ktkDuaiwu5d6)YAIS{4(L-?`zn>z~MgdGe_;z zciK#!ef#|GZ0E2Wd3U$rs+G0?-r{5Cz^Wl<_a4Ey&3n6Ubxmokf1y_xc?o`%RlC4 zIUCUW7*IX;U(^IC2%ACIJ{DZ(Pg7=|g&Ta5ev8_)V2Eb#hYQKw>9>NS(EAPnT6RGm z<)SN_rOwS{@Q+HPPNPRkDVgYY`s66vTH=m$??X&B$6(v>;KD~-@G@1y_jdVs7w5*b z+%j@K$EzB8D$2h4+Hrmz+v$qadw$a`^j=u&WB9wYB{m{d_BKweL7tv@WTfBPNzfgNbG3e3mtEE5?EFp@L&Bc4++6-mN^3(dQK|1`(Dt1+|_#i(vR>N}CnNtjs9 zhoYJ%(t0*fp7xGbWnCZzTeb`B(y!c`>WO|zr7#F}gIs2}gf!?wN~ROT8pTmIN8*pq zzw1k9VOqjdW3~_OW!0s4I~4C}{zMUyFCecY#v`SdE1biEAxiEw4>C1$MX+5$1K@gD+`oU_`15!iQ^MeAlo06G+^ z0OgyI?pcxxZ@L$*Ro*fTDAK#LuhZW7HM|W9Zs_cutpXk#wzCdDzA^`TZWo{*KH;W+faI?vTh6Y(QMlNamC8Oy zpyQg@G>}C-GE%4IpwRP6wXMrz(uJ1SU4q+M^n}H@g>L$pG)xuGjoZrT=GEMLHeGJgF;7*Nx7qMeqoqY|Cesh3zM z+k$5I4YayXrx%zpQ^*ry_dJD1>uMHyy2kll^)SYbVC+L(Dx0oTqQ3B< z0S50zi}N5DO>-31m;WV7AogDJ*ZM6RwQuy(*!fG(=i?<9!$(;-3*b-xn-$6Z53h$c zNL1@((ryNL`L;>0Gxqeg*E>R&TMiDtc3gTOstdcW>Kgcb+5H*6q#xi)WWp;(SlsiWP3feFH^fttY&g%30W-e1%jTyKM6`|? z?$e7lOH&jt;nzo+)k(U67#1s89Xi*L=SkbXH?VjkvrXE^UhST%3R;*ta8B#LHAjm( zSxA~<6gnr0grMwc8TUjPv<+H12k^U3 zH6?wkpDY?)BWc0(VrqUeH4Ydi#GBoGT8v3p#|&fA5!X&|h#M zGXO$ALBZAxq0iJWwVQn z&(u3z*tX`w8Pfw9?+{rIyv#bTAQbCbAVRve89uk3E3nl%5j+lqL40%c_J_^_o8XUm zGTI7P6I_DXCZopT6OrSGzre@TUU9nNF`^m*aGf>9ar&S{vU*spUD%lI<(JC9^yk+8 z$DM~XuU-Zmx4gekyORCW75$G?Pk+jRIrH4=1-(!{3i7Lg|`M2xD-mq}n9qet4 z{V!1lZ;o6797SEFd&3(p)lc27i?VQ)7U;f%n3}1t`If?F7o-|QZSXRiiA^N5Cmt#| zuyVQP`=^$6mz8^BPy=SZt$q#$Ex9C|-o+X+$l&3*5{YoHzQD6D%d?O7M8cDa^0a3Z zrd0A5U;pi$0|9*#2HlZl3e=Bysn_zwK{)D%9~t6jd)>q>oOTpl$Llgp>u&rA%I&Dc zJxLY9Arh%}WJ!GOaS@o5@0wP(`{=OCRarghnLlXW8a$^V#jB)Yg9mON(u!hcKLk_c z4671P@lNGl&>Ke7`g*kce`I$vkhpqpvZurL)M?T9E`ICVo(RnDN6~oi!Qpd7IDr(V z<%rs@Xp}%K@)@|)+tkdHi9Q?6@}+taVUdINQ85E^ugQAk%hq7suc!DvW8}cD_!ZcY zN=LsN<5eqyTJHdE$hj*0+Yflie44Cj7ma);tkO88*u+Dy~w`Qyy!C__sUoJo;8*nNMXTmCXm16WAE^jL9= z!q>dz{)i~Gcb@7?jKc)N3=MHY&B7oo7z^&rq?~e42v$Rk!xBThZHQdWBW!0K8X!P{ z%ki))M&WajV@I_APL!=CDlD}g7`3~AdQp%8e1JF;7`EqZ zR9dE7eZQRBwua`pPZllI`5Oi%?T$KQgoIf}rYg;P$k@V@HtcKv^`#wpnu0P7j1B}W z9m^I)byt)09 z#_Y6!^w;}i&3|!u6^AyRdt=-8SE0v!4Jzh{-#w$}Q}({5<%$!9wx zt(hJ-Q#DSO*bF?yJS`E5zSsl#k8K@GTwwX#KyUup!{j5EC}1}(aBsnj`BIbv;GNg; z4fyOn@jchmrsKX5vT0`p`34&^*}l?}&`@!q1E8JZb7%+NJ4~Gvz`4;z2IlYFWNie^ zZ^aX0{|Bdt3jj(BE75UKaK)REMw-m9 zvb?}4FTf7XFIM*T(W395XSbzyx8+9)rTszlIeXMOts>TL&ZMVl+ZO(6=K)E|sy zo4aJI!+UNxyiJ09o(n@&l_s^8I{1EK!GE54z@Ab_Wuog%jc9|VT|pTsE(S@AR(eGY z{c1FA?WZ8aIbGiO@zSxh$~#PBINWas94$WZ4dYwNcDFQ#q-KUaVr__FqeSviv9%UG$-eJnbX3A|<}Oc&1rOVt$#sv#vA zONK^8VLNwvkfTyEu_IoA4Mz-oEwkYyPs^@YM zHbiVI<(e>X_(A1}>PsC}Bqtn3#54menP){eKbmw+wPJ(6^2UK1eH+IIcD^VAqs3s( zA}NH74_+AXLms`R@RrUb7)AMetQ3^d?wb#h`pJ|GpC|P1|ZutQ}4AQPd)&yMZda)PxGX5Pk&Ds&z<$r z@B9Kr zDuKB$8Z%KKiu%WdfY|yy?FGx{&ryQoa3v)m77wqRITapV-lx}6H^Lb{ajR;H-0v1KiBD+K5B8VO31uB`ociZUn5--Sb>Z2iNBy<8`Il( z6)S7(&^(8#IEgib^iczhuP6D;53c@Dl%k_ms2wV4Y>3FW-k3IzZ#qmJb1-8yPUBn# zxeiuOMfH_2!;~5iam}^WiIeL8{S@)$C?E89^rOn=!Sf{0wh1JHt|S8XfD6+_+dc35 ze%n{(vL6Nzx5#@y#2jX41EAXiNoL9CP2!Hb|E~X=X&}qu2qIR37pM9I@ojr;99bGxK@CxA z2)*?1PiGjdTxA485-1)o7;rvi8y; zZ%AbHrN(l5m@^FZ@HyC=WSA;;yzqO=2Kq8A^m@Wneaw~Wwbe0lg^F!~63sQ@?^58> z0pm*4L4pqMd%k*ALQ$Iyz2bDg5w1_(zGG~n7X_?`f2{rG8;Y(`OIBu4gE&IxBBL;F zCG3wI)x}Y%e-qa9mrPA14_Kygh4JhC8+ENP_v?+Q=bQ4pp?}f5#qPfmy;=5pSAQU2 zR*=*1@EQ^vEzMY9lK*YP_Uqjy2EKaHJH=??x9whbeza5MY5e>FIS+;`cbYM`gBicq zN`w1K(*KpH>wkLc2UIP7QbP(X(w*eD*^(M^idN9k7geGU{gfBE5I2MG6p?|iP~dPS ziBTWxevY0~5mZ*kWTvxt%X}QcuGFy~7ZoB!SMbOnY!hKUu0k1dcJ?>9gjMaW?Hs#3 zPIZ?fs;CVtLzJ6LG|YQ8Ar_^-VC6rN!$TA@p){#gfxTImWny-@IhS&!B0_L zqcjF*Foqs)6>g*WXf57QDhq`brmGptWKbm%u0hWOx8_G$6@k>#w`D)(H?4Fm={Ur{ zxU8WN{Mxj$Q6z>7fU5h4@jxwbG5zU!5BLdzx*(v@DT2N)39ef3&7s=H+#MQD&$Gbn z##ok_xf!48=sgk03Qp_PdaX*(TzP#Dyb=SA56@f9v+Kf*ub!?vQ*-7<&}Fg}ztqZW ziPw#W-vMOIjSXrTEZjf;mL#*fJ0ujk_ldb1n)#{NX@zT2x4{$4_4KnT@U80Nk$?i%vLbE z<^QYId81KLAxb}%hO(WLK#?Rz#zjZHtJa@Gpk1vgXssV!gTvSwvyxH;n}^}q=kUJr z;d7t2rU49|-pd6@U%M5KiNJR*A0b8i5Z)!8+eF~|v-mE#tW?#2dE!&ZAj6xQ1* zfv0w;SqIuVcs^=3?+Cu^e)F_2cO`-Tm#85YLl)D^E-MA?=^1Q+gtz~!=dUqBHV4gQ zQ~SQ8CFxGfsTx~Y2L2|f69)}#NO&w;feC$$U7)FYFnmjAfrYlSzgcub1f&wko;I&NDj z7>)Mx#XB;JHweBq{v=~u^VK3WLH-IY*MDNBE_PsPlU4$;A6a*hxMyN15oM-NOY#Vv ze&S^RK}++nQYp4^kX7aD_|1`RZ@fBJ=gqbLhK`19!_^-YB(@FU^gV6eK>AaPo`wY{ z8v`#W6)j$zMA&Oy+Fch^%Do}dwGb^z5>27qr752A>UJ7)s#Y7*H+<$I&^|LWZ#+CFnl26#h@>f%z<87uz z6y?o3*!%vn>eCSqA>^re5te*59U#_X#Qx{SuJF-s;qn;I#a@Sg-Y+Z*><3Igjm{HB2Z4b3pmY9{bxK3~ z1e;oh328as{O{$3Cl=NCwfDH}2{C1e6&iweb#%N+h(bbZ^U8xQYI4nU_JcoNzMv)a zK1kl5d40Xcax<|O68{Axb`3SXd9rBBedRt>&9*~670&UCPN7<@X@#>b+$6k|)RFrA z@?5A==pJ(|4c~)?koUE-JbKd1iUlyv*+9^RytDlWpM+=oi7xUEey_*DgaeFA;C>1* zpj_nU&sSP5coZ2z(iJ!Pqy$!t{Xf1O;f`wpQ>;?#H3&qdIr1~JmzyqC!6>Ah5j+CA zx49w76Y?#b31M;Z&5QO?^>j)Fv!AbK-#6IvHWi=Iw|HymqYpY8p$oLuOm#iVXb~fL zx@&CxDcv7t;1)g#Ba-G-EoLguC;Y{IY2rZ2iiARGtP=TW8-FKE-0nQhhC?>NhKc1%L6^%`l9y!8edN=-#?@*AP^ z9RG%2Dp0@AzRZIh8ehT%4{WU)p_pfJ5G3sJ60k6sCL05X3%9dIdsdE7nv&?vB+%eJ zu+OLcuU2$S?$^rpE9vnXJ19VUMmMYRy1u;qD#;}2m(e-<54x*iyOYW+u=@eZ$uUg5 z4jaxM?BS)6dc)2@oj$8{YB-?Q1{L}21X?3!$MP_3-t%u1Sw5y^my0wk!4r#E|9H(a z=F|9CwT1Bd1mP5xe~yYQP29(AMti1|749s@++{>=HbARjW$-Yb0vT0x~xdAC0yR2 z!B)MBGuMKKVFr_Ftt4WR&aNQem%{y@bsGmbs1+YXfBgXlJL0 zM$qh0PT1Ye1iAo1*ca*nuv(OTG1)MD;aYdS074*K7qK4?S{>@k4c_76saEWE%ji;h z{9sqO3qXx0Ppcp-DfIL^gwAb4?bNTi&E2kOSH|C71k%voYhix7(Z5`{{SV42rTzO^ zfy}i9rJP@beF|L&$OIq`MN@}p_L_F5FBud|!|~mk ze{e;`)+NeV*6kCD6P-seWoavzX`x%2p@7S1ID(C#MBX9#uO?3tBa1Q9rl_r5g9+7A zI#;+0Qn|pV`Q}qI^7n5Sss!c}wFV4EQt42H`3H&*n~W;#nx{s^P#h<4g6NH$5U4^_ zP+4#jcwtfnmxzViu`I54D`(p!7u}$o#e82e2cX$OIUxhY@pwd2$M?vQE zxV~-8-!@Rh2=Y98kM29t`u!sL5S4A#*}+Rbv!?zV6E08bd1i?po@FVRnVl?EwNWVzlb$whH{xGPhKfx0*m z0*+CB#(bDz7$jn?#S6tpcL_yF$lXl9nKL%zE0iz?lh2?*`{-$baG>l?`qIKJ_)_|@ zGu0x*u9~rM7cV`JWchoWWX%ymvzW1Xa-%_awjZ1a>nf@+DRR8`*u9 zjX`&(o`ul$9`$ftXD(OLQ?GFg2So3Sf(bGxTuqFltuo7WkFU`NgqvZuDvUm56uW*z z8x|*AV&MQ_SoB3&BKG2Vsws>s<`-jGq36=T8i8sW)LX<})wLN%mxe91#O)b&1A{uq zvxceze#4(&J2ilhJ;vw)bT@JVc*7V-;U2-A9CgR0O<~p#u4@a2CS|m01n~YK6u{3(OuTd0ZY{pUW()->7q>^)?eYti1|4phCWTP|cIz;!u6@98x1%pRHPv z$475}CGfRth#)vSXvQ~SF#SEn6ZSk*ia-W@E+qq?-umR<@Jv}{&3ZJkjCEWc_%Q5X zBw1gxDqMUSr!<f~hGYRWZUt=5O1^-(_Jt9$3zP zDc#?e$}HgB!uW#XX~UlE#s7w#K+3c)5E5_|WCTMW_!i#Q^%?P-;A4>d2Sj6hJ zQ}NHnZp^Z0nFl<{Qc;)|EB;-yg52*f0@h~{e|^{z_x}Xxw{3-P$Jqm^Bd}l9leg!G z)AAfpQ-Xc9^9+c(`7pE#2F`QfQ1+N>zqfh(KhJm|AkOjfn2oD0VVqQmg^cqr2f6Pm z<=gh~d1m(WOk)9L2}UsZbOi$4Sn~SeUv6oBeh*6{t%u_1WP69WSp=snL95%+Q+AQx z<24o`<(9_Erejc|H!I%_wc-bXSf{@sqfTJ+9)H(Q83LLata@0K>~~9;)W&eKWR_-PaK1^=6)S`l1`)it zA)(3IriiU~qnYv=s8lya(po+)zdFu7F9|@mSSSV~Vg$?NQSI=3OqU`cZ zJpcCDbpyEd$>2vo^4WSImjglU=5j>?YWjQqJU={HD8q@ReYz)=ZI`}Kry&ee%L-0B z(iZYE%PgqMgZXTX3tjo7V3(hh_J<|KS&KKRAx^!Zv2SftEYMD-<_~ezaMBDoQB0wN z>8mY>RsMV(i+n&EmrhUh*Fe$?yu2PDLmmIea3f9VS~>k@HTEct5L^8VSsCGBgttn) zKZ~bKMU|Yh=5>?1g?=4Ps@#Bul6 zDwZIeteHmMWUi)DqM#Xt{KA*V9spu7Q44^lJAOky?KRnMN9x2sgp?x zm)#pCGb=_>ZwTP#1F928xQG7Hn=$$NfSXMAzt#;z@?iY^tuf*bx{bwFQRMq~^g;A5`xR(>-;(l4 zQtBl=J@SqC9?+DJhc@JGXRthD&04{ui^39~eo>#^_`}U)x8pylunGF$+To?7SY_80 zs@kyTi#)t1{ycPA45~EBn4)rv6WqJjBau8sU%wu{(<=NcP-e*Qdu#mL4w|G$fTwI~ zTn}vX+n9n_|1+fFWKuiiE^q&u+y!hzQv4uwGj^;}LBb}V31`H@xvY=uDY?TA_-$|%|)_@91H0L5^ZXE z*bzu&$3si+P{|#&*tm#LEzt3jX1MwL$2kV)q&fU~oRhQzTI5gQqK>V!Dcrwcj%0pT zN+iBhstRvoR+0v`t^;CAhD@Cvyn(__mQYQ?XV6n9$O$`eZ!#68!oCE7?s{zxu$~UT z2ZO#*;Xo-$W6F*tyvDC##e?*_)na7Js0V_BUJLvFE|O5c;7q!B@i5f9Lj81Cf(&S8 z0yDxa`w@1_H+xGy1!4e{jCi!`;Q*hAG5ildZ`Th3@nAr|Kyk;m!C6XQ{o1iQ;VA5@ z>SU$_?gic2RAA`2Jr25#d^^1v)g$6>Wl~FThF}3Eg&DL|L#<_XpYluoaM<0!C-_Xx zV@itm7`F0=%B2exv`DrZGFP^h$fXg-Ic=!a@TlQj-hor8YAvjKznKw#ak+Zf&{D)y z*s!tkZ+gDC0&^SXt!kDJ9~ugMY&j6?cm+J}aeOw1Gs zWxX<~Y7{Zf)b^XH72uQE6F6z50-7hT&12>%bglahM`R*Ba|Q=cgOAmSJ_=_dd?tFS zWKBlxrL=TW5S*KjsFS)*sG-{yjycod;m{FCG16a^xy#U(m0XcqQ+5eiHai<~F<2=s z*{CkMwJkmh>j~GCE$kJ6mu2&(ElLR%XqCA!!9F%>k0EF-*O`W^8w$BABkVr!n$K$FAQ90nWG*szHT-R+Z)~{3goglyU zy4}f!)3%iat*yvokJzs)vRq4`?ZWnDQMRdqL)d2-9PF$%8+{0cccGs0aTziVHif%z zI3_|DkQmbJ`X4h0!hPxN_|aR|=WEvR1Ue75Xe$EUoxa*ne?1+9-F3dw5jO7CJog|9 zpmAYWmM|19!dzbKuQP<<`yquMgO;(QB^u0tuND3lxU@?CWA%k=|wm$z_#2@WewyGdx54hpD5CoL`_p6nUKUqQR zte!D?c$5x^SOb*{NyD6L|Q}`_30oJ^rZ_KzhDTA-^!FQ%aHok9uxBnZ8gc377PsDlI9_D?U5TM>C zNNx*qD47jCo_<#gmExwiwR?BB|3Mt+J+IlU-R46twSSg^!HTC!XhM}@v|ZMQE^U&J zBt%JFF;E!1LOlN}iGz;>`vHyuF2Nc#ItBhH1O@rSJDj0;LXrFpT@OaO4*3ygaIuiU z64pgWbGnCg+eNHrh~tJ@DXUFYNV}KeJytoAA3TgPrL+H=d`7m|OWN=_GBs435fT=) zn4zsN)`3O6kxoXJLK0DeJ^nVK6&QrtYp%c)#UdhWeM5DlM}aX!X4_#pC!=p=NcH}Q ziqF=lzWO`;qg}bS(yr{fR8nL8#B+=F>`xnSBpA3JPc71Ye*aVUDY!vRvR?ZRi#gmQ zZ0PKe3B*Gen1%03uBMUMNralc6G$Rt)YZR5=gW$fVBee{;c=g1{>T}0%gFSfLe-A~ z=<95-qG7wiuf`(TlC!0WpvoHpq zq7{CKEcP;cPHdM%TT8RfLqh9g(g?zajH|GQ$VR%UHWJomOqU|+j#{i~&VOUnqz#8r zOnVqaw;jS@j;I+L7xU%#dG-J$|B7xO=YooE!3-CtZG!CeRpzqzY?Hox6>1*K_xQ^Q<*Lz3?m|0RA%b`5c2VH~{!1ONX8>FP^K zm@qCwhH1h{;EpN?0y@;j1mYd?w_1(&Aq)tJ4*DNfoeh3n zh8@~(G+;<_PWsYf7|Lw%{IfkXli6ceh^HBqAoCd$pAu_%kll{%d$oZy)AIqP|1b2- z^x&(p9VbQNWD(=Ng%-px+zq%^KFgnN-Z&DXrI;@Ap^dm}@lLq7HqUs)n_LB<&&#~e z9eYIVQ4}d=A{NdF%r;xQS3HDzhgratB*$G5)y%(rsBDiHHW^v&f)rBCe956wWf!ic zHp2>JzlUDq2~YwR-QXvlke?197qcteT0PggN?R;^sQ!K^8}t*wKrP!LZ0wcmY8n9p zEdL~TP3VCB)3fCDg1cX(jraR|)Ob~N|3wVC(tc_4qJ{#arU${}@vO>((f5~ko~--QFTTVvC%m*N{fckm}}`> z6r|wK3@bLlQIy9PqtGK=uKfDF#>O5yWcgq;?A(#GF4L(YFD z9oWNBG*p346t>ZygCBE!o*FixsF3c5?i{B71Kzu>5e>WM#UJg5ulP{l9+dNBc-V_7 zU(QK9;$UT=I%VbvM^MVfg0s{^hi()KJio5h9O(sVOct679mDk`Ys{^6CI*av zmG)QSi(ytHmabCe2893P=`6#l`o6ABcf+B(yBnmtyW!9&(iR}y-QC?O(%ndRC`h9q zDJ8Jq?eG6w@2B&9U1zT~=a^&OLw{Aqi(MaM?-A?AtW|9G*8(-3nkLfoG0cL%1vRW`NwEhDxcq6 zE(}yml7c>rHpVnyKZu@eTueJH#Z$jZG`xlNqNB}BaY(=?`zh3A-TFMtgwcU6*Mt+c zIoq)zGXu}mKV$!hZwe^e*a2v+3rP10+79mYQDY4$n>vxPBNX*N32s&bACd=}v%cz^ zR-gP&H0?Okd%!j)oB^;u9k4Z}qY(_xb`Qaz{A zVG~3P5u143ef&g_u>+6ePCVTL%8b#Tb{eofO=u4(B zDy-TW#VcfqN6l|ans*wMCjvw{C%9t`WetT8hGF7ly`H2S3`B#(Aovh1dTF2sQ=PbQ z!j9owHMqz&!S^*QwS8ez?c?AI63GXoYWcx+BbGPqxQ1_Za;ViXRb%{vmFybk^e4mZ zTx>dX=BwvpqtfSTGb>&#*2bJV!CM;ss?V|`JyEqX#xX)A-QN&i?&PM`(R~B*8Sncn zU92%)sF(qLWx|89B#+AUMjEzy!OHZ6JpA+r`N>fg{+dhG7S|^zNMC|B|Fazx2m(a2 z059EQm7vhh|H|5t%YczU3}i@P@Hfj3ScQCJGTJOSVu38t7fm0c2?YeRy$qbI6j((t z3spkrmt@5DE}xl=%GU_4%2=%acvs?9E?C;ueoQdx-^201HQGx}OO9{aS4!cDRKvUK z$k~A>S5#G0^F)ne5KbHXJRBnI*X85cpr>?mK>`PK7yOPJ@ukH-lu zOf7u}m5LOPX0`6fE#rp7R{ZUJ?}U+rcpq!W^SL9wh1HUj&(K?;<;KnY5z5CyhzYpjj^}dhmA%HAQvu+(cnt%tVi@puS`@Z0T8n0h7hcK{Vc=SKh z8b$rmsBf#a+gTYHlzd0&O+lQ77HSk5;N9*>m@SckuV$}lTG z64ck&HTC;VMuJ^ydXsGEWHJ+cUAJe){H2Wq$iPyP!2uHoKnn#_BMtbQ3Zgpx)$!mT zDHqt6Jj3KnYM&DRU}o@&JSsaA#+?Fw-wc^^JR@f)2=l4l-Ca-a&|^fa8|MVx==gM3 zsN`Tj@UbP)tNdg)4^b7GAZxd8cF)Ohk*{Flr1aeMzc@48NBlSl2&DBVqbTloTG zbM}R>vpSLQQhDC;_jIiqSIEW-(6$kc!g@UzwjAf%bI&gyY1KE_{j}_aNo9Mg^9>ixkgnjRu3v9aFZXUUE0XY$8x!)Z9++ST3?ssxz@&e^$Ot|qgiI_ z(@7fFA+zBpoEKfbD|f;^u11shG1ZzvU6X@JjJ;j1QQnx?r7S$|3nDBk)k;%O&TUB( zcR1@0@*H7)|KqXgWFTuF7z79L+z(V8OpKDDA8uj2W1i0>M#U=@KQl@v71+PvVX(BY zX|YtWfABO*6}m(Pa@IcQcx%+@d~ujhB^#sc_>$(kuv?zV(Tg%i znU#~>ter+J!-j}-4mMZ3A_-C_M>KO6`w#z$nU#@ba7N7hLWflPm-x#wg1pyN`OI2} z>+!hX*<<801=sU(s7(Lhe+Uhbba3Ct^Z>oB{P*RwFoE3VYoHpTzyMg(&@Z*3jSS~; zSGz7q+k9;w5@#i^2>X?dBfy<)4S-1utlI6XGL+li8Tsp~VLW{NQ%0v_h^h&lhA4g=`0RFp8O?|Ss` z{5Kgjl-vbzbdrbJ_k%mglJ{v5`vVOU^#9Z{&+Uxf_cxD;TYubsOK#jAv`hd!69A*C zAZP`9fWGxoq}V^=0-&@&!(^!|@4M))urmt%11foL1YEfcl^4wCC^a)9IV9Zu#Fn({ ztrn0OS0v7?9=;XWL*psDT{?9o|_O^kPCb=aO79cPtoLC7L z=SK*d5`ftl```FUj@=ybRo{c zv}w71^{mf$SR*8I;R{3dX|=qec9u%X-umjjhRm@ldvB4-Y}m&o0Z#=>cXLA(diDx1 zWdT)jcS#rZHFdxUctnnb!O}g>Il4Y z`cp}HJds=O@ujxl#_9!6Fqt}J`hga4 z2r;M2X&=}C&y(R^(tJJul>V${`+g|+|MX4+k`gxLJ7eA~P6nNvBWiXw`R5y;JzZ8v zvYdAxaei2RRj_6Ha<2XY6~_S!2azMt-H=fL7#3X<;KdWl!A#jnoT@=54R-|Ct+e$j zvctN?ss75B;NdY(Q7`ICS8u?{Fd4~u7HBC6FKI(0<}rgc#-Jt&(s; z`ST32r`$RVSrG6BWkk3`>=);0#Ifo9_nft*ifQW;IR-~xnh=SE#ayi-Z=|ihbiv?F zaM9LVRbl&`(5GOQlF_lA!Vp=v-x@FW9T&;!Ev+iZ35WgeQWsu0H0qyK? zgy0UBT z^KYx5gw^5M(RV|&TE2|H)q6>D=EjQDeO*a>(bgr&`p)k+Lx#~;7!0rthgct;P z`~!z&u#EyW?BY3C3_tkawIitIcQjcd$qUp~>(=ol5Y9Ik8}Q;u!c4?={}>S0xcUkE zh_lYEMCmMXl$b0O2SPHk?8!=Nyu^vGh_GQYw{^9uoC$(9rKrvhY(LCz?>ojCLMX;d zt5mZP{ol4JYngZ>g#E0Jb^MtoY8-})MvAvgk0U-8bH}M|K*$jiiS{G~OOF$g&IO3D4Q9UTA*&aTrikU1rikd7X zEs(S`3rXZHF4z*xCg?1QUX&{K2)#)uM6>Ro39N-MSMm-H+k~hxmDn!VNAc%Gex)Eu z~9k+ot)3&~#&UnIh9}I8)1#D|K=qe4Y2MGKt=H zrNS_X<#oA4Aon`=XVv$hCgchgdE&QJpxuAjwSE`a`fH;J>z8>(+B za%HCu0=jsEf)ggpX0cCV+0BI|Jw;xJp!ikZ(sW#sLuwp-`zmx z1?yz)vyOU{{?oa(!MXLm>pOqF^nKtvn1WH+V@CEQxd}c&l7G5)pP&Pdxn?;fL+fYN z6`2Q?zsD(ObS&d0r1%J7QsXgfc3W+|gFK-CpK>O!nlNvw7|87W|WQwWp6E1G4?d?sSjhULZ4+p$TlHVu5c& zra3e`w(QvxD-hYNjM6(z6m@)l?)kZzHwVBVhsV6cKNdA`0kW4r3yqt8x(8?)UfRyT zO>qHR<@%^#cDoOziOV`lQ??2@4hz=MePsy2rvYQrp+8 z(4^z$iWlTRD&0v8R8YU=yn`JN{Y#+toto%1CxQ`8PH6XunP`wTRoT%1Ty0E|bveCd8fn~~T2=l+ZODEKela zUyxUR2(f*4Lcs$x`wG-jBw~-IzSrgG?X!*JrgTxFXN9TN-uGco?_B*GN*(Nisb9;h zwrJ&9F0ieDDzzxl` zzb(Ou$G5mOw6-3m$fe5&h7Ts5^zTyL(Y2JH-B3T$IM7{ISjmKI5Mid=$nGCouzKYE z%q+wnPjD^MbWkE*d7zM(l{yv!GeurySJ+_d`#jmi#lG+wd8?*YyQ#hvn9%!zxOqy4 zEpV-5MQ%~Nii{Tm@JdVjx43(S9}i#1)Dd1fdNm0RC%Auq%=~1zsW!s*IUq=4YUjat)QM3a}kgxHJPI1QJ?9k zl-pGkB%($jL=0=%lSxQ86Xf$ZSU# z7<)Px_gJkC=XHyaA|j}#6mNl}EU9cy;1o1Le0ow+>72dH@*aZ5Xu{9+aG3%7%3Ei; zHa!UQ^CBtTEF?ufXHzSB*hYy%hzE=Dq}4zxb&FH`HV73jYc2Dy^A6!td6o;*6T;@DF?L8T|cL z-*buCGaBuPcUUb%+fF+iLtKb$qNXvlNJ{mHF;LfitRDaSQc+spgEArV5TGwf!X|0` zt(Z`dTZoqG-CJ~vMz_SBEXuZr?}YYJtGU(xbjE^6U=`{g*Zt%CDYN&UWt;w}spqQv z=(7Wf8C9Q!yZpmHDJkSb1$cC`7~tt`Q@u1$E0Wfph3>dhvOdgrVCyNQ^5>QChW}1} zwr$Va{eh&4mnIY5Y>oow>5{@lfGKB(*g(QL0N;>kRZMl{e>{Sj#!8+N)c+T~&KAB9 zRgN*iHmyvif=)54Q=O`A)BQk#k}sa05E-J76H_cNGtrNvWygLok^Uedf5WS`L~4b1 zV)u(42`@M;kH)3N^Y8WqVzBh!M=I&eVCG+JuT>D_v;!KMl61o0aFsG`QNo%`_+_Qp z6VSS5Y*=3B2F1b83}X77H)%*alqA<$mj4+;EBG`DYLGc4mY`?#zK;Y>#{R%~K%sm5 zO=$hr-NT5rHD=a8^(pp;hCRR{v~=a4X<_WI`BUFRC(~yJKow}+PJ5H`sloS0LYuXs zi(xDvZ2#k_&zl%5QTR^z{v8_EizZ@V6=O?CFU@D;deL#$;)BdOv?%wHNO)D&*9YAQ zHDNpq?~L+}92ps6YgjwzhmgUhF+Dk%3~BmVAqeZLtglYplKyMHXBauEV3}p%R)osr zS?Pq*5BzLF>1?_=cN%(0^gD++s;ioiIqvSJ1F)p?kS^Xj%0z4aDOhT%F{(u&%camS zh$h3S5&*@m_P$umt>uQInSRy9JqePN9(ER4B|naGzsAve&(E##D;m!|-COt3wB5;| zh34Jn7NscOd@y_x43)_kTI~U`XwDO~u?p*jiWLyU7~=FYb&3laP-nGj>6OtgOGgzw zYld3Jo|ZYsM9}gld;RmHnm+|x#?3lHqi3g*0rbyRDBKV+i;GgS26Etz0W5`AEWvH> zBmkT9U+ZgV<-))trf~d8&rd})`AQM#wpeKzJGOR> z)2U0TUuoCoJu1=caB7-pBk#rOwTX}nwt%6TFplJ}+zgCTj3)>v71;(CFCNg+gNLxT zEOq{Qb!uH4X;la1#7vwcOVwYmSp>>)lkq}c`r}YME3rz7*Y~J@SPh#ii;*LWEyTqX zp-j{TEX(kn#=}6sa-cE4Joxu(x7anv2W70Awy64jLlE@3O_SH**wuqs`@Lu=~#vwo>dNOS)R z_U=X6O*`3ht4tn)SdjWX7{E%Br9a)4zn6``4B<<{qVm&L_+;{p7*bv--WECoMz!j5hzIm;BEoFNmS%lK6H%d)}{ao3W=6NTZ zeKd61R>+MIT*@km*^;;eGh=vHNbEY zAV5{a&ITu6N{7VYP8BdY&*Lmnk&i&~M|DmI#O{w69z-KCVBh zR!nJCh8YHKlGwV2q&n5q9(ri;BS@rdNC?fc2&qFWhaucH<1U&oQ9Jf+xcx$Sfw?R% zT;GuZ>}238I}|GtJ6Qt9XtPsU&Q0oF^$NtxE`Gs9P2$zI>s5g2cFcbel^8(N{|SHJ zA^*;rq;BJ;B_hR~lM1+F0k{^ky}{0NEWqF+m^#0S{{l@LO?p&Uq^eX=?UxrD?T$X6 zRlPIzw54iA=U3>DEW;C**24Y9ovMts1rxeI3%{F5AbF$5+ufuqG!|@O0pUBKh!QE= z?#9RkJJY~9mI5u*#ibrbl+1W1Wd#+H&+Fglqb!haYAEr#Gw5&Whi{D3GIZmH5=ZHH zWKG$ms+&IWN}1p&ro|IwAtO#*v-LY#%RTmXJqvG%b`J#ke_nhhxJ0w=#57EqF=|E% zAPhH`*p9VqKv>s+L=_)w?B)&sDJT4{Zy4QZnM5s!=~=CgNv%?;j8L+ZVG{A9T+6r5 znvAJqckw?Xc)A=VE6@N6n=8Ma)P1Fds>ta~CGIxe8OI!Uf%I6i^X1FCe9&qmT643tC zr*arCU{Q`I!G>VCJ~v(6>{;67!S3Kqm{x>_XKAS0Fw9g9$8KPY%xcP-4MGhy-E@@=c(wa@XE+k7oQ>8{dxan!iP`VBm*U!39Y zlyT}u+-3*Gc9eL#hH6g6q+mtF_%?c$J3W}{^{RIi1J17Ik>>STdWq$wc~K+giLho- zV3ti^9JSx=eV^8zlz-q;-wRK|BE$Jf4@eX~?!or6B6*3}X6Q|XOL;n2@+}e;84JBw zK3y*ziFSYxEcfFRnI!|XF)gqkJomvjBxi)u_lDul=gZ4+2T;`)u~{$KJmu0i76St= z%ND8b0x$a@Rzz0~39ti|!ulh?Hyz+g1K7Hi-#h8O^FFjB8idnJ-_1gIogof+HD5p+ zhP|O^)QHo08%Rs?8pcA^ruEuJS&?tJT4|1rg~o7AYUs0*>4**%2k*z}YJ>Opp~J-& z#WIg9>oO{%r&Dt@-dM&f9#opf?Ri3HW;PuqzAJ-o%Ule;TfP!*8^;cG3ZxBtZHpyV zZY}^%Z(H=chH{jBX;8W|wzh%X6286lOWLiwJTp9W;@3yQw{C`bb)x&_7meJ!bB)hrl^1AB}R?sn7RJTHv+tJi~x-a zo6S$}ek=F79jz{(DRGhVIS#-i>xKGJl$S{jAFaOsU@->xJE~r6pMfz3l!t(!-?i6R z27umrA{bZ|`ElZDJLRdN)9j6-)!PdwNLu84E3(r)4cxK*SG&Nx7aM?i^FoBhsl{Ie zkmiI>}FjgnVEWLn2CT1p{*x6nf0SdSLM z9pqlMksW9K{9qh~PToOLGU$L|kxc(!(7?&h_)%}9lxGS-=W$LUj(eavBq|PPvMrLP zvym@_Ex0DdK(+vb+FBtOBfzaZbMcKcxfy(`TA`Yyt0ABI!Dy()#lcr_z54C=6LjlQ z^@{39@lOGhw2$97(ffQ|O5*ef;>?_;%FZ zNu@M0+j3Y$j#i#!ZOBUWJF1NlkP1a(w^hfGE7ZMTHQt3q45|yq*As))z@g&_ks2Q0 zD|Mg^Z~6FWM3d35FC=3ktrw+Uy}{+Ci>8GB*`Ug-D~zMrlX15eYv@7mPe{*JAK~wn zmPzoRM{^Ce`vDeh5q3Xy(Zi<;Ur6hjU#~8V?4%w*&~??`FV4ebGK zPrzhO{`PM-s62M5tS77A&-#A#(j`TV}r27eQf9PvoTI#OdGbZbVOz2jS zYw@1s*fAi`Ie#){U6R&jkne$}@w3FT!ZPNP*}J&KdGAK2Qymt_?xa$?rv+*1(jW2X z7TMh+iMiHmR(*4qeqDEEe^uddKnW`|O zwR=HG?KZ=eS}ZHVA_q8;snsroQdrvqRQH~-HA0DONU9yUA6vvk+x#(D=o*8ZMZ|s9 z5s8lBi(*I1V91+72F;01{fZ|uSwg1r#o?$)`R4*#bSz(s$%z)&*f3*%D$swK8azvU z*v9oJY9j`48)_M~&v2#=N|ORV_O>V3$lwTkvTBt|tzVqFQ0UNMrVDkz< zf77o0Ec9EkYwhi|k7lw16etCW+5QtivnBWtQ(Xf?polUF=#%PN(WGc_^I}&+Z8g<{ zw9_KLi)kqcRK%Tsj-sS)i+2Vd9pAC5Q2&5MiuxI`ni)K|ge?_%muR?1iF6ebv6va< z0_X}#)sH*TYZExjFtNZr{O}qA!+HkrZBZli%_er{Z&r?lMp1pdk`bkhHxNFdFd^qL zgcPYfC}AK9T8gPEGCNq4M9b2RDtEm9`eAvp5z41$YsS}%mW~D)pu4xt{mP>jlPQ9A zNxD`&gSu~8y{JL(`N~%>gbwHD=p|C{x`IQ+H-7TmCSr0eI{3w=1&W+YZ5L91#)m%3 zDLFcjD6Afy`?h7|zJSa>P_8)1tt&+!X%mZkq$l@PdP-a-F@a)i$}QNBf%uq!iwA(p zfAenK5xejC^xEPd29XJDLcvz0EKOYf#|n1hm@uj4{AN` zaShuxNL@A)rTMd`xpFBgLt9p~KT2sS(tuj)kob&-Y8wh{Md>>dy(qmO{nSo3I9TjV z$(NJ89=3^$0g9_1~bcy-{fpA*7kK<0cdX#87EW37UF- z_q4@jeQ=R;B5dBmnqCFf?D=P@x#{m+)D6?czYdk1hBKJ#s)kl4`yzpd7IGT#6<{zO zc_ZTU5_7O5e?>eoL73Ib%O|gH)yt|*tmc4UkSlk9F->e!Rl~jw&MHD?fWIKV(Lj_|wNH06#hjqvw zmH=5MXfsos5RSyur;qYc;FN}`DEcnYRw)QZG`VBsx1BN5m+Mi)K0E8A+DKV`V5Bh+ zDMVkVBeIylrSMXGG96q2-SuPInr24zUZH6V8^`!-UF1xDKi*1>ox$e$n*$p|?6ka5cpCq4izd2@nw|H!I zTdtw{a~yL7Q0@m9gIQ?_;2h_@(Z7*IpA3_GdJ--?0<#B+SSCcJq|Fv<$Wf#Oo7hf7 z4M~PX_)8$Z{3MzvLU{GA(`f>-up>Bf^-t6t%~aw_C!N-p8ItN6NJJ8>zW9>J5f97E zk?}PXhqjQ9j|aAz#9f2{@nAp_aZZ;@l#*WC&Qq#I4vB(nY*w#1RST6g^!S)|LXHyc ztseDC_p6AQQFB;spG=rKJP|NYZj?*tH3g|Gfb@6vBX0cB7GLG9b|8oe}_BL8-%$)PH4v zv@T0^kE5O&D<5>W8?UGbes1=;CcmoLW$>0kBMwz zpyZK%*C7|3JiD<=`N~4U<}y355=!WiQ^&HP^*g~c?T>!IP1Sn=B5EhCu!beRwLK={ zN1P}I3dT#ZLUma2gOCQ7Iu1C*n+dut{FF+2hA(#Pm9!=%5Ib3_t{#FU9K{8R-{w@Z z3un>0B~-2Pad`Vg>Xe_t|OQNIR6oQea=qLRoxBVrXs8 zif8}J#ATMx?-Nrtv#ugx(muGaMVKJBq|^((sKLM@@#5mIv1MRtvs@o$w)Z#E1Gv?Y zo^siD5uea?1qM+|&fKp#)z(lw9G@QWD4tXO9ak&@6f%L9zeEV)S9bzBs8S^fiW9n2 z0d?%#NWj<=0M^VC4{kJ`9pB600{`-<;ml-&H!AINchWFhic_ynCw`W0my(i5GFAxm@vyC!ZFiM^>k)Oq2)BGFePD4+>67T`k76LcA_EP@}?lw5n?yo$?oEpmvDNAGN(DJfK}PGImrGL0ON{NRa}Z!cv!&md5w6 zd378yo~T5^@satr>JPHB^@I9{dP8!`r7%u%yrP=uAYWTOOCXlu9WsfMHl&f5u6K3f zRFF2-O(#gXDi{SW>qmvA+0hMpE@D$$h!#yp&AswE6D-ClI`!etpsS0p)F^F44bwU_ z>XX)CEO~=vjA7o085W^;Xu)W&D0gnN2I&*8Wok^uC!)O)E1x8~RAC0s)R zva$HMC+!SjE~IqpK{Y!&NHaCGxzyAullX=i*)bv4SvhKiccEY8L3SHp{S=|yx2GJS zJ0sHtrgM(lri_g)|MYkJC#-F3J2JUNf()JyFOVZZdIq>-2cSQnBn@Dc7>{5Gu@dP+ z!WKL2;2y%7|3OW<`kKyrRwZRNg;!)dSQ;hq_o{t55EIw7djx{nW=G);$GA zx3NwU-VkEJHybhR8R3%E)T8i%-HQfMUh4@jP<SyjhQzbQ33CKA8OLVB zX5Ome<(T(cF+1rvIP5JqO<@$qyb4flI?R}GJMH-e-Z)DgCSwH-N8(<`atdC;Iy_$j z?kxRvWrHtQ&e}-$u-&n@#z=5jr??o8WujIQkB3twD#BLb0E5Y7Au|%Q?JTkDFgII( z4MS>wLg}9I9#A|jeo4s<2*D8L>NNKc+ zUZ2ZL^O9cr?*pcS z>M{8F+}wrB_wmbdd)SZDFQ|#{vBtsc`U0>#0$6?104|@GfHXY@`4|MRO`(M14SaTP zxFBWy#-#<)#Y6pZXL<})oZz3yHF3UP(UA2nsyY25m2zsEZ;7Y+_~*#9oeA6?q0Ux} zE2(je$p}@u^X#p}AE|>QO7)jLviqgY&QxWWn_4bq-|E85Ec_ifp^t^@M5MxWps)OF zP36~MrY>s~dmoCZV=kb9unJpYWvB=Yds0y=c3oDp5?6?=a0HuTw+<>;=hpaJV1hOl z`m;F{Dbn~^IjvH#QSKhDv%QE`aj!#Xa~mJzuoCvLE91im|AvNO;DJxSq-Fx?2DdCS zTt3b{L9-D!w`HD~M&%BdlU4j)IUS@O%UL%E4y09;iJ)qi+~i{wsv4+x3IjygN_EC_ z@Am=W^Q|L*h#UfQTlejqcNuW>{ZCs77XVCBK=IG^0eEbGbuSa-h*>u(Y(q}U*t`LnLcp6 zzJWKYrTNGn&~$^R;Mr9h!b$Iv)==yg$HnwUG&^$CcN&I^1uO&OW1O|Ts)i7HSqu|7 z*v}|bm3O0KjBzWFq!GvNP}A`y!e#f2?omA7eR5A8wpBteloyxoH?}#57xTgNu`i8Q z(fc$qRih})%C?<34QI_Mo$VbsWAOLtJ^pA&;W;%4Ib_ z5cr|PL#P@{JHd&AXLQ*!t(BG!F8~$!k&5ORBOr^Dlg?Tfr&m4cj5~3*BPew8;h-na z?uBca;Qp1?3)fU&`0U6%6h9%_xM#aj8MB25)yjz+kqe<09yXRyTn9geKl#@7@&ug9-uXWk--FQ^w+-H+=NUjn08L5WG!lsE1=DGZ4uJ?U z0LpMW2xFlb6^)JK%q||@SnDWlbr)&3KDr%)%O@7}szWT&EB0`Vq>12)ERRiR6TWpm zLc5G9kB^D;{G5$92pfJc6iCVqy`K;W-ncaAI5F|vY)}BXwoc`PlQ*9WAk}1u0r0FuB8_OE* z)vN!~MY)hifQnCN__se#fQG(3b6)?TK7yCNhdrv+jICS1wFX5&%#;Iue+S3}V#p0% zn6`MZG(`bu)uj+A<_@XD4qVo%GzP`T4n!znu`@i5{fOe0whUYbpU=>Pv9^kMwi`N` z3&@)X8%F=H2VeL>)%vHvHgU?=j18l#)n@0+G)Bgngp}V>ujYu7jD$n_abam;O(ARu z&8?r^|IAHP_*LTds%?w6<+u~2nw;35(i)xQxF#yd za4o&hvncOmy1ow`n-(>SB+b69)VjO3j#!O3Zf-EK&M@`%yK~$d9PX?&10{sYONrNL zuQLl5l!Dfvet=^aWan3V_R1l9eqD z=vxIpS zP_+sL5QG9p6AKN*z0w4F1Le7u#&5>x@y;N?vy8@X|5(SjkVFQ>oB5d^pI+%Wq_)N*m61g)poHP5TmSmmJyGvv`?nX$FgnL zl04xz8b%>9f&Vk%zpz`N5j4h40Tnej4F$bVP}rA%<6hs|I#%Md)7Ak%{6girAXa0D za`Y(xRM*PDRQHnzYFWc|TDrD?uAfLB#5G0(Q&U5n{^!ShZ-)FJNIxu81Mctw|x~ zLPu}WjBQ$B;PUkAWl)*_BqPx44@8TX9f>;4VO@Ipd(l@&qkbCne_x^a))-4foaUgM`|};EyQJi{Zc6R3Z9|Ef0Zj@tfM) zWxX`B_vHh=2CT<`Q+}Gu0IE+VFMr;3n^L|%o&xPPZjblKr{6u;`m>xu0Gk)k@K5DN z_4_G6e)O0T-*}=w8CyMsXq=|nSdvLi&j%*uCl@P=OiGx+t-HT<7d-QrN=PL0QX_pr zP5|bSZ$_pnF$dW^p29@b$QQv5F62z+Y26*BueyT4F_$0KOLi0KrGnFIcs**b?vn%g z{4N)2nVn&*zr9e;ZehMrs3}w!Q10?V`JC%1!rg_2Z#tP<#*aGxZz zmxQf?tcq8zp>lQc!S=|Wp>uRNY$^~++gm*faS;6~$2_k5s~Gcgn@s!=mds#vVTL#( zy>|uc+Vk+f&(Uj+iXG%3*=-a*MY!&MAmJBb2!JLHs5xFV zmNtYdbJQxPbxvDW-n6QSiskJ_ZCc}5dW6^DqgV%4Yk8=N&Kj>|?baq6#!aA_u!xL* z6?Hh+haD9-`I|~94p$|Mjp>r%#GG0>XADX0t@+7=;Y6lbx%4;c6m3PQKlBVmSwgy=2m*bxl`ymLgjJD z&lm%7i&oKSuEy2HFWUdBuoM}f^nI~)`CY}gZj;J%h@}8Jp0QrWfH8stf~oqZrl8q7 z<-@O+0R!d55z|0fR3kl5r&^w;^;)PP(-?+g-7fE5p_?EfK3)85^f<>rM-$941oIQH zBLG9mEA3`)U;i)Sz~m(es0fgwe43Qy-FW#=gOnWpSJ?%az-sA7nyguw%VTD345Ffz zZk*k!AuqndLo!qkM-!odwLlo)CHw`!D$`*sBc&vVNAT63d`mhf(suHi_%k2(9Ujzs z_dVv4?*Znhn;3+Bbb>`AkgS|flyM9vrdJ;2bTn4Q(#4+(-PBlbpLNT_XVU)i>);XI z?KJ6rl14UTkm%y~G|lgFovepRm{%OEwgQZZl{mT`*pT=2{3L56Oo13{{?!y&404&f2v;yZ!f#Wm^tt2=zj%4xX&NJQ+)gBQZP#@46@%K>{_fzh<9CsZa##wl){pqjwJ>ZwOe zq_*-Tmnijt330IeZRO9@uHpCN}Mg4(+p@A^# zG9>7mVW|9PIVC@EGw8T zp)l~s{m{paYyzH3kTSN~gjiGVidAySg_HIAht;v+=-3}II4q2mGREwGa1Vj}e}B9X zAi;mQnGNRUe(_%Bljsw$e{`NPf7=IURM@{@N0WnA{9AS)Ljv37rEk)s7|tz+TzaH` z2LpBpL7xd`HMCzzFVX|ks1_A=hL*|cC`V8U2R#Mxq+*9tmUx-?*V?ZXbe0cFaA)NC zf0-1uCnnZBT%)4P^QlooQvNUxvW*Tfqn?JoMWW5{>WTifMr8l+bjyT$XvJ3qGul47Avu)R2 zudjvxu>H6HJZ_~3D1;Rq?2{t}8ch7vqIZA| z^0@7o!SXb7n!qu03=}fsaijpJpRg|XiGEV2bH{rfzq~pI# z+@B3&5}S)B(&Qirs>fB|AZ&y96PW{ZQEM&6mR1Qv#@~3=&BPKxIkty1c+87}uTZ!g zx3w@&{y_II)V%rtE%3s|T^iO(^>4&8dqcXid+8Z8M*&8x1}xCaL?b2wbi9)6Rj;5T z;ZBU})I~lL%i@Z7qdL0zc%m~rjt=ym54D&Qdw|6MF1XEJiCp`?`+XgN-Zj7kOdr|) z_ia{W{43o093B9jBY>@_6P`TY$wH)qL|p#QOU=}igSuw1-j|YazNwFmvhg-ONt3Jl zO>)tfkvdZ*?ve1FxwLt(HYu#QH?Br`Q5s^`6MJ3^kKR%ed)Ku z?aC^VBkrrIIZ8IAi>C-IHAqSP!T3tV+B0}>oCF7orm?CTg8v^)SK$_AA8hHCM!Gws z8)=XZX^;*P0Rbt=1qta!x+SDLg$1O$yHmP*+4nBrz4ssBd6=Ew%)DpLoYQ#9CzqX+ z|Ipg~{my1$NhQBq8_ZCaD-n9Ax1>8@OG=_Pbd+w*Xyy$jg|~8#)SD;|5VcqK3vrBk z)%|?8XIfdvVc}!6)YruP2|>4lDmTG{FVEf%253b#2JHj2YQ+1XUPgmnT8reb!N&dE zjfe0xQwh9zKQD!cD3&(>|N7~DNzr=VsR5uEcI~>H1MKea%v=GVY+=XZ!@qX{*&N_Z z0}7sb0CeHbf88jTE}1Sd>Q+nJxL@170`LqUciC`9FyARc_awe{(ZzDNHapbXmi0FX5RIKLorPb1#>uTPP z-Jl#2oKW~_R5Y4A*Rp|b4?M*y64Cyfn3A#wd4A*RMG`;OkUX8j^M6kBlqXVpcg$mP z;;FB`O=KP)>K?nxV>K}>zByh#Ff+)v8mEBq=>v^Q{VuuAn&>uZFF^;ep>1C8t-T7r zeA1RyG;^Hnr+j!Pqp^5ZHXj>@@@BN|Fn0zG=$XPatgZzEwZePG<Gc(!^UW!g#lFrg zXzbFArb*+r1Mu0s?|8|@+U`1jV6dKPxIRnFFHRrot+ zisYi(DoJ5(v!x8wa4bZ*aOZGl%6So&cy=3=bRfm(-I6kdUt@pcgLCZdk+=g0;fWff z);-nrTb$4cDd65$gq#tW3HVFBdE#_<*O{DDfq+HVYdxQ=*ZUrKVaQSs(|Q95DLG$Z zD_Hf-)5OZ137mZfxgH+v;lBXR>rO$urf0()V7&dRBH8#OddofaIbF^zWSomA80*c^ zzf9ZCLyIdAPKPIFALysFt$&d5g|xU%8bg;Bc8{X=*s#C@LvWX8u&ou05eqYDg$2`c z*z)wTjG7r{0o<#-4K(PR(HCTe2;Q&TbMFDD@qmTh!Uo+wP(~sv&`u_S$4eZ@MuOMc z>Y`d5_1D~y!DchOVRzzP(1Xk9L^BuZyVU$*q4A*uw?Zd|x zrGg%a*}a%lz0E!KH{AUM#ZC1@y>x<{2>Xqcy>|bE4hP5NVMRM$q9CzTlXnzg>h~ou zujRj+KsyfSljfg{Dj%&sVw6x|wBL;VzvyulCjmHKP;d_HVt5?TkB<}kp|4F=F3L~x zl}KHYeyg7Q9l2(-C_4A;^UW+PnJZ_kqZdxifvacbl%R_Og1mk1-xAYWJS^U7!cJX= zbt~4Q#6UT@-a+Z#)i}u5D%D0wYz@K88Y#?I@&a(nx+Gp-Do0zwj4Fd3ycYSslewu) zL|AVmo>v*h7z!5Be3I8Lq}i)ks0rvyCFnFV+4iW%$S(K$8~<9`pg)rYMyCT+-*HVe znd)ZIi0(6GnMYG}(yhh?y}HngcdkO>M$b)3FgKBA?5g!H>B-Z()11&dy1jYtPQCiN z#J#_{N#%E`OPh+RUW$fPnT~2TAvHsnj467oxsrM*e;y-q9vYKKF^UMRvb4{o$F3W% z-${y$18Yy<`R|Fq+PYOm%O2!;FLNC-cXtw?@gc%GSp}S7|7lVy&9ABsQRdJ$`gq57 zFqrXa*)3s1o0WpH(N|$K=tnOr%?M0|1h6{?jUt6LqCgYv(8q$-ZES0;edwE2$TqC5BJal%`R=CS2_CQoi^o&(Mgd^yZ-rV5j7 zh|gJu?;ZYm&1oF{Wqad@=rya*W$r1mOoDBMTw{mKYIHLznn;tW-+0V|m7_^%KYmnV zJayE=cutuZp6>rsD`y*p)Nb;vQAP&*{2K;oVl9=Xpx!K$3oMzqysghOt^<4VY&ohs zD%ndY=oE`J`IcQup`JaF2hD?rev7qLnwfg{!4y9#u=cJ(hge$*j+n~KH)(pvGMHvI zI$6FgY_p3Pa!?8WIY?1@afOi-0p^#jqaiq02KWWfxFMoKD zsWI-`y^n(d@+p`WLlEEzI_0Y$yHy+a=QM$s8P{_`z}FLr2c^Za2%UoUT_Cat09J2O zLjI{<0wMaZDfl%HX2B?>1GmM*IC{mFuLf+UdHLfJS|u=9D&1CpB8TLjDh0=@^1$mj z%u-E>Cg`n`B~NYYRC@C}J3=|pKte0EKkpH$#@nTw$( zXYr8{gKkaKgyK6-@^>D+ZA;#i&L^gA-NoER4RGvs6n5g z_zPwOkuD>!jrO;WZ0w9%vvn;zvS8sfnY5%D?MpF<_Dm**FKU6T{66RL`0z*^br-Ve z`-|peTt{I&iCR9*Cjw->LXkCj%V~li3BkP8@z?mgiga0&6;kppEpLXyKW8o$XV{y^ zi*+xDl!&rC)8iA@t(s{F#yfjsdtRnRsd@Im?SCv#Q*4g*omk*p;t~Ewm9W4(ro|I- z4ttj1J5U59C+=fT25O(cIrw_tQkTA$jjnHj{G7r zHH;HvExWXz=L=8G8)sAG-sFy8d&ffBcsuwT40Tn(G9u~^Fk#$Ll}pKjOSQ9vH_FH{ z!J4&7E-@M$5}n0JmRWgVrO&H-)nT$5e zq@NS_)0{gE5^uW1AY~y5)@lb2sc%b!e;m5zEcPZvbW(m+z}pIqv;j_|!k!j3dC{XzZ%q?+e}mn;7nM z*j6$6QjBg0rrU^`afPKzDm9x^b<^RFY1r{l3a(!coq^*!(jTRd{yA! zBVP7O_sL@zgBqWm~osdVbn&FsD{jGrW^}-$l z3$YD8+yqjo$r`DSk7$B?H?jLM{Y4bT_nago|i3|dNs2*DiZqhUMS{5 z3e1A??(hASJZVG)sQF^>3~PO`-?vRgb=rSXqQX-DXyuMQb0L!gv;_|UzSGuXN9aLTze+JVw5`X1ZO9Ys)tJaKg`N>o~57NT$q zFF|`Y9F9KAzdAfp9mt@xn+b*zeP62q0N~Tf}E6S zuL}Kd-R^IB$--wfoASHPUQARL>mQI%bBN^6^PTgvO1{JQ#-v?KyqNl6K-OUgZBTnN zjJUbBj$`$x#FZnRWAGMTE`I!vA;d2$dX=qJ4C;flj&W~bZ!Z*P*zFT6SeQ)L*#zpv z97hL+t&q33Kw_|1!DyN=5=c;JqB;yZFpFdR^p5A*G+JtRcO7wmYZl9?-ys`c-t*^V;INbXqvzip- z7Mf6!n3>7bj^W;*j~vma4KM7z%vdmla+NUG{nAVbuea7!HFQcO;??bSZ&unci=pYZ zvY5gykCz?YUzF7EpU$a6myw~=KzDcpKta<8Z1#dO{2}(>a2#syA<_%rfLJmO3ao#l zk8XlGfyg2K1@E9v_|yycT!L$9y8y9)S7M_sKP zT_OV&TNc5eU5kx&YrYe(4R*|T$;VdA{tDSxMM|RiYvaD~y*+0BV}Wc7g~zf?=0C#Z zLcJ=si1#c;-hU%dyB_clTZi(EIhpy>$hp--xFzY5Y27%YH;sBR_P6Uo{FPj+z2(0~ z+>p#pm5a?ciHhmXE5&@QD_C;yo~4-Oxoycs{J2F$efn_;zFa8Y5?q`R*~zvYR@RbX z3VBN(jrXRL>0M6|2HZ7DBa=^^kK-tJDQA;@TZ&Vwdq)Goi$R5KQU4}joU}5I;;DV8 zza$B5Tk~u1#@zNuDQuCg6t197KcI0I<65nhiNl&q;*%ji_JvUi&$J+1WpLTg^8ou& zk{%WEw*$T$wWc*6q37XP2;QnEWV3%4#xQ)UddSH3vw@Uvd|FS6pfQWWGC$XwY;rx3Dk_|? zHVncKug5N?#EqjzTo04OrW1%+xZ>Jz*wCBO?r4J6*Yrl^C`)If^;{5Fs9qr!Ax4wf&%-ikCj#Rgw6@-5hNO&Np$btGBzxgF{SCc z>+K2rQLJ4vBKLHi-POPdS(@QV)!yDQSdD+t-fMbQJOncGOw9au%nrP%F;qtQ-dsaJ z_(|}(XXrZ8*TsjfW^A-BJ8DSX3X_blUm2t2ppeq|qqw2cxp0HSd zwl4PfSHb%ks^E@L@%dLDe;nBc`l$no#U=$ET+a}Tqn8q#9^q?IiA&XeX%}0DfOQ5! zzRDxGm<9pqR|u-9$%&%*NU0Nu@rh%hC0Cxc83fY!5z{6zqcZUUA(dIb@cF;jsh&|- z)o@a29slFa>KiVS{<)J0tLQ)7!^a6IEQK?_+so9Fd19p;>N$gt$`@0K>;w1R1<@Gr z@H=_lFCQtnmJVq(1c~9e82>lUhyp&#CqCO0v$yd8?)_@@mOs~5iFsC8O9NE%?T{` zM=|I^f!lEnLC3*DxWZB6T>6;^G=$(Qq-THhM}>ss&<#a20NfFS#M`e8$BW!yyLH$A zq}~VcjRRcH&YVMR2xtpYZe`HSv@BQpGS`wow)-+9|~kTEq<9hBPkTSV?_~h?1 zzM{EQW@pa`Z~v|*^?M_ZC7b8RV3dKh^?s#$s&T@z9d^+Y>##9U8?9Yk%|Po)BI>opJ-qM!0Ue!@0Y+Xj2| z&K2D0ZiAkDM=(*2?3jjM#C<$jp*~a_ahgR-WBr2w7q`@GX+V6Aj)8Xe8?oF05xOt} z{@#45aLw1DhbgFjP43U8gNZ2y&O&e@mW)y?-EnB}*S(a|!wkY$aO!xJAy2|W%q@TA z5&~)E;-wwYUy)Rl{i%;3lG^+(kKUhpjfOiBFtLV_PsuJk`p&chA58u?y!tg315Cxu zsLU%@L}kbK0Z%xZKNO|7^POIuQ4gc-J`W^aNm``ESpJBgy_ zFwY3^cz4QhZ#~OWv0M+X7}!27RiRm|$d%G%OqlgY%T&u`q(v&!XLib*$>(x>=GatgEATAlEFzl|V&&opZczM*6 zsfnu#-CcL_)y8Slj7X|3T3^DyVFz__iEg}u6}WrVbQX_Rq2iw#0}?nItfTR*jv*%V za(MMW6VBP+VN`XuHtKTT^5}+V;P1_Ze3!iyF?d`1AQxxXKj(#xL?_tx61w@(;t?Pm z^0Xot3UmogyF?LPte;8+pTll(DcVE-IMjY_O}+=SX2Y}wP;hL`p?%!F!!?q5^E@Dyy14zpO>mOLT-ds|i!3+!Ii!lRxeN;mHZME?Liw|XW} zG`I7RUP`!q*aBKt@$k@L1J!2;c>7=bZuArX(qdDS|4w`HILID_)<^?l=E?Z=JZG*O zbOuuRuP4ooB>|dv=*Tj*lYx3`B2cD~;*IiTZXpyFM67TW^YYdb8LKzZdn5?jj?{}b z#Hh`k3-3fx>G%1+Qy63_F0zjArjt!$l@O&{6Mj|Tc31x$OA=YwR-$ztxrrGv-_jEE z{q&>h|reZv{GC7$5Hv{jxvC_kyPr;x*IK8Tqa44&G(2VAKi=*dowjlA-< z{Q9Xy*r8Q?(DA5dFzl5%Le>i*pu}w;?Ht4-t|=uv)VFw5W!_MMj)?G~mnA9w>Dq=V zb3+Qd7KuSK_`1$1(W=a&M~W$ogEl%&PKqR`jR*N>!S$8xy(*vi{4<8fDTP{N?g7G{ zc8pWJzumE&$U#=Gq})jyLB#S~M=m8E(x;ZWL&o^rzsW6~aO!L|vFQ8O z=;pQV)Fn%-nGP`h-n(Qn+~BVQ1dpRcJv1eQwvA>(kK4xf7j>L9zv(-Wr5w8-LGHX9 z-F1mCZoxu}w;RB_dVvc`XWZfe{=tirP{%rXSF3Nej4T}~(-M?QIb8fS8V}$(!F#7V zCI>Nm;AKEOL_YpxySC=te|QQk!m#Iy9J|r~qTIj~KGNBD1dw&4*q$Ij=^dnl{sPGH-?&2tDSxTgb~dgLb1yns8e${>vu%Dpbq;_@Ay=kh`5DLBjz&oIy%w>@0+v+Lgt4TDlFTEP z`0X>Q;jtJx!eo-;*D*%&_XlE1_*0KWXDQ2=yuO@LEWnqm{T-I0^*-T$)Eg(iKJDY* z5YZ+@utxP^V7a}M`dXG6GQw8cH3}xkuyU~c8|V0zU}^7jZD6Wq$c&tm#{NDA zI#cub-s}MRdd)iW2%c=7> zLuK1RZ&Q!m|64%(z67m0`ay6;$nut?DOg|I$yop4Nm~gDUtW zaT4;6AwtQ;Ctg~W2@#nx^2UiMh6E?s8mU*#D&744n-uC9Y;We66E*~1yf2uiW2Zo6jaSke5@7bltT6fwo zZm?*9qcWc51~2iK&18NE^I*dl*N{#S)J0MX?~*b?nh$`~vZVKCKI+eCrYqLThSODXQx>-`TQa=U}-%5ttDzX^`}^}k|b zERH7MQ0wKE2By=~WVmL237r^Az3^PI~@ou!>^w$V5B& zedXLb+@3YuAt1Xtn30jI^0Tna0UJ900>}vgcOQqK_56_Zhw%zK=HbNaxSHjwB7e^o z-j9nuc~T=Nuvr`r04^MbroRzx%owr${Bv|FHjWA0i9v_cgSuZPMMo;$Eto$Fs4ENa z&{mslRG+job&q03vHf;Q;)>k#AXON%(FOM!t!Re1fG(I9Y2R9v=4&l230l~gS9#or z%eQZ9;L)2q&b#_Yc)e@{;6atFxCS>gS(%DEaud54z73b#37!(LvQA25;w}+BGUw#V zBvBcAqRUsZ)Uuy3!rDrxOC+rP7spVbQbdKxHG-wp&19&jc-pP95!7Ps+H5DOM@!w0 z{)DOiX-ogE9euQ>!qX$hbyXxtX!Sq$>{iJ7uSNheju^knIQ0r$gEQFDlNCL(`on@~ zzqz9?9(s#SHgE)cAN*Tje=e1!{sOqz^@wcfg^yEZ&NwoVMNpr8uaN=UNX-uc`~=JL zsI0&^qN6fIQX9&JRL!NgshF6a9opk*Y3@Ejfv)3X1VSN0>5^5XCo5!=kBgHsgzwZ8 z#qUT#e?^~}U7_g7^hYmIabVD#NLWh8N2%Cf11_#HhvJ-YR_Xgk2Bn@}jKUKobv7}x zGKt3V>83wyHKZrYUkV1IICwrj4;y5w*0L=WLTP&!Vbum&7|7-+NGC6 zFtBQ!=)7?IddyV6CYa@}LhiC-?_=ZX$MF*P*(bfllVe_jD8jY?5i-bK5DGw@0%XkG zW&moat*W)lC$0GdgsL&=aHBe>$)TZ(bo#Xq*WX~$AaFid(AfLf6Gs3ldwHwj8}@Fw zEx8FwEq;5xcqO^yy$z(pxF&n&0L?x?P7Ju*^KAeK^~#vfx>DN4>&*ldg9X3NrKijZ zRdjBrr1-QDOEhr1D)zU6R}|gRRW*MY5^?e+5~p#ZD!E#^hi;;!7GeL2s5Jp=^0cvk%&z787af5xMB zA%0B1mhX!26a)x{Q-yEaOor~(Ivm{B8xvowt~F8s*N-dXjq1>O3tOEy2*u_`j50c@ z)T+mRLB!hQ{8i6TG=kOrD&?-^pwVhPY%s(F-uHo^f$PO>)V=}lG8$hdy^FeV+d&{2 zHWFiF1i^TQ>%I4&Mu^$MR~(#fHfMy9k#+ufZUUq!IHRPKpW-F3$YwZd*!)#q_@G)iw1@ynq8Ou$S+!K< zU%>~XM2T$^yaZ!ItRMMYg#l+MG{WNp$|zroi$>XRc|-}nZ?D)PxA;N$HD=omBeA$X zraly`17DolYKC0kn5B+9k?cG<}kmpPuI*K z;E8&HzNTR=%|G4kKiO}BFsm!MPJ`GBf7K?cdUK>eC-sZ`yx%*L@EOJfb^b2kq+-q^?SM8_h1)D|HbrM-LvI1z)24x`2pHr z1!usBj8Kl)^BKe2IL^dOly+7AeH$MhO`~fAFXD4Sr;^AY1FIVzN@vKRQIoTc@O-l#7X?&1l;?DJ4Ea=7e_dii0i8dK+9^o@0W@IsA&2n1O zH}qT*532trkJLe>&0)5jaZq+d87^cp&);dHU+T2Hp4~=cbNa|UQ6^_5C?{x1!JDtQr$FluY&e3o~0UfM&SIsM6CM5*~BF#+6Z}`z- zDmYl->j%v*{Y!fTNN1&~SBEl#i9FLjNq6Xj;b%>|ib>!C2r zZ%cAv{!z?h>k5K+_Q*&kA21g|1*HZ9RY&`-y+CvnVE_3gyY4PL7@#?C0OHm#&1&yW z8IlM0mOu@4h;Rcu2`alehV>*H2}~@TKsb#Xtq{85nykxC9Mu=u#K+4*!&L67tb|O~ zy`wWF)R`yVu0^&{9YNHDOd*J!2E{6&+hFM?ZIOP3ZAqxpumIv6^SJVhFwo3&nwN= z9+Kf~U{u~pGgBgYDo64$JV1yQ>Lm5zQvj&<0(HB9jJ?mjY9Bufz3@{+!q5ac1^5WB z=+V%&;FrtQwN&k4s&4oScY+j)u-DVEt1`;8jfyfMeLLRrpluGL{%mvEits}K=*hQ}0Tx6GmQ{PY+-ZD+Jr+-B$s(x5h_i~T@GA*i2V8 zBL0$dOLJK+0#vrH=DKXj{)JQI_K@RQRag-Tur5waA}7AJKfXiBJy`vWd5D3ikTGN! z8}&QweNX@KJ@}RhHWv1RMBDGpIQ>Bzd#fhs=ET4qCFuUoIf!5jc!5D+Xk8^NG@Aed z^Hwgx<-B&6Vmwl)RL8hRZsk&FUHA}rP^Z#)Hbp{Cnw?*r#xMawiB>w6pF7KALTqqjXazqTv^j95-R9_Lv=iLjA6=kMwOTA{6(&8@Q zJg9d(mZ-hKnbkn}K&L!E#n5a6fsYdTg7zInT3~_9(&N9t+=IX1TU(#-0e5KCC1oB| zF10l`F)LHn1%?F9d-r&$QR^1Uu)SPpxEpMxO%HwYTDpTlongmY+i(r{HR+!1_gp6w zS0Jmc|2p2sE1+`+Ab$;v{{$i#bV!hxNdpbo8JHAR^I1u|;6AF$H1px&;1^}G0UqDv z-v)V6Y_$15@BfP_xSQgvZ@pN7;J0B=L11k8m1sI z@yy~ID~Dhn<3WknHoD{g%9)8-WVwIy1}sYEpfZIDXDfdF@&yO6U@_8`?MLINOaPOO zaBTQA?}Yo6G;@0T!HDAT=FiWie9~e|b~+l4g+g)O+wA zj1mUhZXm`1PFgR`%`8A#D3A^%s*8vrToK+fc7molRrBC2IU*rS#16kmwqE!MLTT7- zTN<0yy3^3WZa^DYys`(La(x}NhS=5BMvrK6IXiri9mx& zVv?Z0hG}7m%G%!)d8?2M*;AY>#a0(vb@_gCA|jUH)X`GO=GJ|Y*!XlPJo1Ts0y#qV zTr0{HB=?Q>i1#$S&|SdL=!SC>-G$xlUe*Ad8x458vtq7 z_j_aFeUW&OTW=Mcm7DW4;In&Dh7fR!3J5Vns~TFeKb?cf@4*!hKp(6O?EuIG@}n%M zJls~+*(8Z>c42Hth&QI&BN!%Vv+PF57mhHwO~qRCy+MCfsgI55BgF}bM;lg7R=X?Hbe7 zYhsnK;DAKkp-x828qg*vo|CmY_jL|Fdz!iXcrf~2UAf_>sI)zn)P)nJyJ!2KIWWDm z`zos(We8|KK6btz-qiI*Wj6!5mrM?~Q)h282uMPnLG%jLUZ2+Q!8{kB)fkqmZ!f5` z7~V_m!9%>ZN6~Ugijvod^M=9!v304H9|ER`ob5u{?HIo};#VR{I(W*=jIy3_a{d z1j2QOd_VZXsVJxQrl`VC+Pvim7+(W?+y}uXkoiVx*yr3mxDWPvvLNGs`xgXHnP9br z2pETHFqeJ54%$F+jtolg40Mwk(xtBo<%Z*j3+~>sy=X(D2gn7 z4TAgkTU=je`n8|?RTc%#vqsEWmLfBN6eh0JCX!mWjOptmaue=2N*b8i{>m}iXJ{@K z)ry~L4Mde>vG0Em3Vd zn1OTHfCHUsPl*r2J?$r`fT8##)gR^l6C1I3@kAP|m2Xr_dr1#qGZ^^L13KFq*#g?2 zf0=h)YOD3~+N<-*Hc3;{>-nD$Co*835SX8Q0c<3<09CWtzd(rNLR}EYpOHY1{Dn#l zJ`OTBJ?V}}jn;MiAf1Qr;C2r8m9-6T-xC!$hWy!Pj<~ywJ-FHtRT&3|<1y=c*N@|c ziEuU3RivKd!6ow+Ddw9VdZ;IaqGyy8qDGob`zXxG&0T*1EC0wYGpPrDan{MyKW!MH>Uh;ey%pvK1 zH0lOi{Fv{5StT4fY=KaK=js!f5+1Da{`aF3!_NPcTL0uewV~22Tc@UL?ZJuL8>H#n z+Yc&9&%*uQzI$HUua^tDY02`>U$vD?u?)|P4!Tm>_o!`qEBvtvhe7;0>w1P+q?q^P zUCVso#`#--B-FMY-_dZ`1eMGYFA2fAc zRnZVx87^R{xOlK#^1MHFRRg^VSD$-F9`rlhXcDc0bebi=ft2eYm?*sNp>!9g3zXX zpv`Z4v5n`x5ty*G_}atqWAb!tVu(J%IQ{am1*lKq zKs4XQMg5amsZY*9B|tQzIZNi}TaEgRaX+Wb6Ma4rR6qf87W+2i9(27R3z-x9QgC?& zBbqRp_$(5+oz!s_KY!W%0#w{l<*iFx))*maZVCkhF3{F(V96+Jb^~+>6KaQ{=i3mz zO#s=j7K@5DU5Ad%qNI!=7ys#yn2o5*G6p$h)^Wy>&nBmw_LJrpH#DzOsGth-Dbj>_ z$>pMyK~wVIPNa8hEo>w=%Uz1Ur#^>Hfd`qEaQT^CQRdEgYfDur$_exRMm#<)Co%$i z#~(A3#IkvtCdk{OPyVFsk;oP!ePzP?)KMGi`Qzgg!UvAmk)_P8zTXtc-3aL`TP%{N z96vVOs^8CsSxN|r#AL7kw>RkpUVVBR2Prq!cN~W);p;rwFMR4}-y3w^B(U>4PL3mQ zwD`iTfppg}xB-L#`vOqlcGYtZ^n>$Hm}x5;lCk>%pFWW7Zt5>nZM?Joyn*Pp+^(>* zM^C-I?LA42@C6$XVuM!Iyn;?2L*79_)!V=#EMde4Kk#5E7o584X9*RGq{`xUDK+>- zb~i5$CA#INl4yRS<2m&dNA&Ws@#?KQw%?d3kay&kv`uWfZCC80RZw!`UUtBixv>HH z`Lp9bwoo20yS^s=@)K9xc^Vy|_u3{|l0N8A4(r?ze=3j_ulTSwIt3Ze0ekkg zvdzVB%=sMb0h`kvqF|mbe;~f*5Bf`WIgj;5NX?Jud%;k|2xo^4uITrG6R(G>XX2oel! z1eoWK4N{J|2Trgt9AmAW`=@Wz3~gIgBM?)~t7dG~D3?8uyz5vmdRRS1vjhiXkltLLOz5^H9c9QN#r{SLQOQ3Z^u*h?%Ck8N__ zgCh9;szRcY^Y+N)j?!o044eFPVXu+#Kd`4RYPBEM%@ZO0(SF;MVSw#0fEl-gSD(S< zCg9d(s3h|{zlfkpVGI9fDviiw`9^+;ji!-U%xbggg(fNKz&iskxd1mJB5w0#Cl#&q zH4-BISZ&-L0SU`w_C|@B&M$EMp`+@AzLm^rYY&RAN+M>NGE+v$_nY|l^HDq&zs{fT zT&M0dW%b}ImmxTp3^;1=&2m=9Z`@XH&5uxFRX(S>qU>8(1)ifkMil6d*8h3tSLCts zq^uJ)irF9%s4HzOaQTud8oCZm>ovN5=4leqNdi_=h#{ zFwGl0>)CYV;gt(o2(IuEaremOb5Kl?RYynz(J#qJ z0*6?dq8%Tx3qBK5Tv?=2zh_x%U|v*Tf$!d5qnwvJRa7>)`2cvw1w_7mi|RhU;rxw> zjsy)EoB@rLb@W!m=7L6uAUQJ?w#kbV2c?OSC9;p=QQ&72tc&bTig3>ST{7A=d`jJm zdhC0q^goGnhMAwiE*!=I_a^s?^+%T5yDND5L5&iL51{Yi!5DkX-9O0myYzpKJD9`;Q}W=nKIc=CzD6Y1uP9odE`?y|(Rk8sI^ zJ5FV`@4sUbo7$^cT1Kr}qB9vm=18%`_ubOC zIzP%k+eWx=hy6++duVxwfSgZ_dBsiY5K}sAK#6YdB4VOXF(z-sIC;E5{N+i!OB_>bt14XlA*Z@m%0As-YB9SE{zx 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 From cf200a32ae8c7a3cc692f1a11c5e09eea6cd4943 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 18 Jan 2024 12:33:19 -0800 Subject: [PATCH 161/540] fix(docs-website): update copy around the video on index (#9661) --- docs-website/src/pages/index.js | 7 ++++--- metadata-ingestion/docs/sources/mssql/mssql_recipe.yml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs-website/src/pages/index.js b/docs-website/src/pages/index.js index 085680df343a45..07345ac7100fa2 100644 --- a/docs-website/src/pages/index.js +++ b/docs-website/src/pages/index.js @@ -66,12 +66,13 @@ function Home() { width: "18rem", }} > - What Is DataHub? + The Origins of DataHub {/*
*/}

- Explore DataHub's journey from start to present,

- and learn how we empower leaders in today's data-driven world. + Explore DataHub's journey from search and discovery tool at + LinkedIn to the #1 open source metadata platform, through the + lens of its founder and some amazing community members.

diff --git a/metadata-ingestion/docs/sources/mssql/mssql_recipe.yml b/metadata-ingestion/docs/sources/mssql/mssql_recipe.yml index 93be7a86d72cce..5cfc0867560908 100644 --- a/metadata-ingestion/docs/sources/mssql/mssql_recipe.yml +++ b/metadata-ingestion/docs/sources/mssql/mssql_recipe.yml @@ -27,7 +27,7 @@ sink: # ------------------------------------------------------------------------ source: - type: mssql + type: mssql-odbc config: # Coordinates host_port: localhost:1433 From 6cb3dc839cf2c845a8d2f182c20fa68dcc1a66b6 Mon Sep 17 00:00:00 2001 From: juhyun seo Date: Fri, 19 Jan 2024 06:45:00 +0900 Subject: [PATCH 162/540] fix(protobuf): fix reseved field error in fields in nested messages (#9318) --- .../datahub/protobuf/model/ProtobufField.java | 17 ++-- .../protobuf/model/ProtobufFieldTest.java | 32 +++++++ .../extended_protobuf/messageD.proto | 89 ++++++++++++++++++ .../extended_protobuf/messageD.protoc | Bin 0 -> 8749 bytes 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.proto create mode 100644 metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.protoc diff --git a/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/model/ProtobufField.java b/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/model/ProtobufField.java index 5bb41017488f3c..c3ede2e62c3148 100644 --- a/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/model/ProtobufField.java +++ b/metadata-integration/java/datahub-protobuf/src/main/java/datahub/protobuf/model/ProtobufField.java @@ -277,13 +277,18 @@ private FieldDescriptorProto getNestedTypeFields( messageType = messageType.getNestedType(value); } - if (pathList.get(pathSize - 2) == DescriptorProto.FIELD_FIELD_NUMBER - && pathList.get(pathSize - 1) != DescriptorProto.RESERVED_RANGE_FIELD_NUMBER - && pathList.get(pathSize - 1) != DescriptorProto.RESERVED_NAME_FIELD_NUMBER) { - return messageType.getField(pathList.get(pathSize - 1)); - } else { - return null; + int fieldIndex = pathList.get(pathList.size() - 1); + if (isFieldPath(pathList) + && pathSize % 2 == 0 + && fieldIndex < messageType.getFieldList().size()) { + return messageType.getField(fieldIndex); } + + return null; + } + + private boolean isFieldPath(List pathList) { + return pathList.get(pathList.size() - 2) == DescriptorProto.FIELD_FIELD_NUMBER; } private boolean isEnumType(List pathList) { diff --git a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java index 9508f4778e5c88..40d54a86510120 100644 --- a/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java +++ b/metadata-integration/java/datahub-protobuf/src/test/java/datahub/protobuf/model/ProtobufFieldTest.java @@ -323,4 +323,36 @@ public void nestedTypeFieldTest() throws IOException { assertEquals("Zip code, alphanumeric", addressField.getDescription()); } + + @Test + public void nestedTypeReservedFieldsTest() throws IOException { + ProtobufDataset test = getTestProtobufDataset("extended_protobuf", "messageD"); + SchemaMetadata testMetadata = test.getSchemaMetadata(); + + SchemaField msg3Field13 = + testMetadata.getFields().stream() + .filter( + v -> + v.getFieldPath() + .equals( + "[version=2.0].[type=extended_protobuf_MyMsg]." + + "[type=extended_protobuf_MyMsg_Msg3].field3.[type=google_protobuf_StringValue].msg3_13")) + .findFirst() + .orElseThrow(); + + assertEquals("test comment 13", msg3Field13.getDescription()); + + SchemaField msg3Field14 = + testMetadata.getFields().stream() + .filter( + v -> + v.getFieldPath() + .equals( + "[version=2.0].[type=extended_protobuf_MyMsg]." + + "[type=extended_protobuf_MyMsg_Msg3].field3.[type=google_protobuf_StringValue].msg3_14")) + .findFirst() + .orElseThrow(); + + assertEquals("test comment 14", msg3Field14.getDescription()); + } } diff --git a/metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.proto b/metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.proto new file mode 100644 index 00000000000000..4aaf80cf788dd0 --- /dev/null +++ b/metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.proto @@ -0,0 +1,89 @@ +syntax = "proto3"; +package extended_protobuf; + +import "google/protobuf/wrappers.proto"; + +/* + MyMsg Message + */ +message MyMsg { + /* + Message 1 + */ + message Msg1 { + int32 msg1_id = 1; + } + Msg1 msg1_field = 1; + + /* + Message 2 + */ + message Msg2 { + int32 msg2_id = 1; + } + Msg2 msg2_field = 2; + + /* + Message 3 + */ + message Msg3 { + // test comment 1 + google.protobuf.Int64Value msg3_1 = 1; + // test comment 2 + google.protobuf.Int64Value msg3_2 = 2; + // test comment 3 + google.protobuf.Int64Value msg3_3 = 3; + // test comment 4 + google.protobuf.StringValue msg3_4 = 4; + // test comment 5 + reserved 5; + // test comment 6 + reserved 6; + + message Msg4 { + // msg4_1 comment + google.protobuf.Int32Value msg4_1 = 1; + // msg4_2 reserved + reserved 2; + // msg4_3 comment + google.protobuf.Int32Value msg4_3 = 3; + + message Msg5 { + // msg5_1 comment + google.protobuf.Int32Value msg5_1 = 1; + // msg5_2 comment + google.protobuf.Int32Value msg5_2 = 2; + // msg5_3 comment + google.protobuf.Int32Value msg5_3 = 3; + // msg5_4 comment + google.protobuf.Int32Value msg5_4 = 4; + // reserved comment + reserved 5; + // msg5_6 comment + google.protobuf.Int32Value msg5_6 = 6; + } + // msg5 comment + Msg5 msg5 = 4; + } + // test comment 7 + Msg4 msg4 = 7; + // test comment 8 + google.protobuf.StringValue msg3_8 = 8; + // test comment 9 + google.protobuf.StringValue msg3_9 = 9; + // test comment 10 + google.protobuf.StringValue msg3_10 = 10; + // test comment 11 + reserved 11; + // test comment 12 + google.protobuf.StringValue msg3_12 = 12; + // test comment 13 + google.protobuf.StringValue msg3_13 = 13; + // test comment 14 + google.protobuf.StringValue msg3_14 = 14; + // test comment 15 + google.protobuf.StringValue msg3_15 = 15; + } + // field 3 + Msg3 field3 = 3; +} \ No newline at end of file diff --git a/metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.protoc b/metadata-integration/java/datahub-protobuf/src/test/resources/extended_protobuf/messageD.protoc new file mode 100644 index 0000000000000000000000000000000000000000..03cb56b35314a849cb594321a119f3e05965ed3c GIT binary patch literal 8749 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 literal 0 HcmV?d00001 From f2e78db92e050483c851fb9edd4beb251905dfd4 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Fri, 19 Jan 2024 03:56:33 +0530 Subject: [PATCH 163/540] feat(ingest/databricks): ingest hive metastore by default, more docs (#9601) Co-authored-by: Harshal Sheth --- docs/how/updating-datahub.md | 20 + .../sources/databricks/unity-catalog_post.md | 28 +- .../sources/databricks/unity-catalog_pre.md | 5 + .../datahub/ingestion/source/unity/config.py | 10 +- .../unity/unity_catalog_mces_golden.json | 2506 ++++++++--------- .../tests/unit/test_unity_catalog_config.py | 4 + 6 files changed, 1178 insertions(+), 1395 deletions(-) diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index fb082bea7d1517..b671e2fc5d123c 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -10,6 +10,26 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - Neo4j 5.x, may require migration from 4.x - Build requires JDK17 (Runtime Java 11) - Build requires Docker Compose > 2.20 +- #9601 - The Unity Catalog(UC) ingestion source config `include_metastore` is now disabled by default. This change will affect the urns of all entities in the workspace.
+ Entity Hierarchy with `include_metastore: true` (Old) + ``` + - UC Metastore + - Catalog + - Schema + - Table + ``` + + Entity Hierarchy with `include_metastore: false` (New) + ``` + - Catalog + - Schema + - Table + ``` + We recommend using `platform_instance` for differentiating across metastores. + + If stateful ingestion is enabled, running ingestion with latest cli version will perform all required cleanup. Otherwise, we recommend soft deleting all databricks data via the DataHub CLI: + `datahub delete --platform databricks --soft` and then reingesting with latest cli version. +- #9601 - The Unity Catalog(UC) ingestion source config `include_hive_metastore` is now enabled by default. This requires config `warehouse_id` to be set. You can disable `include_hive_metastore` by setting it to `False` to avoid ingesting legacy hive metastore catalog in Databricks. ### Potential Downtime diff --git a/metadata-ingestion/docs/sources/databricks/unity-catalog_post.md b/metadata-ingestion/docs/sources/databricks/unity-catalog_post.md index 472b0a541911a5..df244f7d9ae9ce 100644 --- a/metadata-ingestion/docs/sources/databricks/unity-catalog_post.md +++ b/metadata-ingestion/docs/sources/databricks/unity-catalog_post.md @@ -1,11 +1,33 @@ -#### Troubleshooting -##### No data lineage captured or missing lineage + +### Advanced + +#### Multiple Databricks Workspaces + +If you have multiple databricks workspaces **that point to the same Unity Catalog metastore**, our suggestion is to use separate recipes for ingesting the workspace-specific Hive Metastore catalog and Unity Catalog metastore's information schema. + +To ingest Hive metastore information schema +- Setup one ingestion recipe per workspace +- Use platform instance equivalent to workspace name +- Ingest only hive_metastore catalog in the recipe using config `catalogs: ["hive_metastore"]` + +To ingest Unity Catalog information schema +- Disable hive metastore catalog ingestion in the recipe using config `include_hive_metastore: False` +- Ideally, just ingest from one workspace +- To ingest from both workspaces (e.g. if each workspace has different permissions and therefore restricted view of the UC metastore): + - Use same platform instance for all workspaces using same UC metastore + - Ingest usage from only one workspace (you lose usage from other workspace) + - Use filters to only ingest each catalog once, but shouldn’t be necessary + + +### Troubleshooting + +#### No data lineage captured or missing lineage Check that you meet the [Unity Catalog lineage requirements](https://docs.databricks.com/data-governance/unity-catalog/data-lineage.html#requirements). Also check the [Unity Catalog limitations](https://docs.databricks.com/data-governance/unity-catalog/data-lineage.html#limitations) to make sure that lineage would be expected to exist in this case. -##### Lineage extraction is too slow +#### Lineage extraction is too slow Currently, there is no way to get table or column lineage in bulk from the Databricks Unity Catalog REST api. Table lineage calls require one API call per table, and column lineage calls require one API call per column. If you find metadata extraction taking too long, you can turn off column level lineage extraction via the `include_column_lineage` config flag. diff --git a/metadata-ingestion/docs/sources/databricks/unity-catalog_pre.md b/metadata-ingestion/docs/sources/databricks/unity-catalog_pre.md index 12540e1977f649..22f3f9cb1d276f 100644 --- a/metadata-ingestion/docs/sources/databricks/unity-catalog_pre.md +++ b/metadata-ingestion/docs/sources/databricks/unity-catalog_pre.md @@ -13,6 +13,11 @@ * Ownership of or `SELECT` privilege on any tables and views you want to ingest * [Ownership documentation](https://docs.databricks.com/data-governance/unity-catalog/manage-privileges/ownership.html) * [Privileges documentation](https://docs.databricks.com/data-governance/unity-catalog/manage-privileges/privileges.html) + + To ingest legacy hive_metastore catalog (`include_hive_metastore` - disabled by default), your service principal must have all of the following: + * `READ_METADATA` and `USAGE` privilege on `hive_metastore` catalog + * `READ_METADATA` and `USAGE` privilege on schemas you want to ingest + * `READ_METADATA` and `USAGE` privilege on tables and views you want to ingest + * [Hive Metastore Privileges documentation](https://docs.databricks.com/en/sql/language-manual/sql-ref-privileges-hms.html) + To ingest your workspace's notebooks and respective lineage, your service principal must have `CAN_READ` privileges on the folders containing the notebooks you want to ingest: [guide](https://docs.databricks.com/en/security/auth-authz/access-control/workspace-acl.html#folder-permissions). + To `include_usage_statistics` (enabled by default), your service principal must have `CAN_MANAGE` permissions on any SQL Warehouses you want to ingest: [guide](https://docs.databricks.com/security/auth-authz/access-control/sql-endpoint-acl.html). + To ingest `profiling` information with `method: ge`, you need `SELECT` privileges on all profiled tables. diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py index df36153af9d83b..d933e5a5ff38e8 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/config.py @@ -126,7 +126,7 @@ class UnityCatalogSourceConfig( description="SQL Warehouse id, for running queries. If not set, will use the default warehouse.", ) include_hive_metastore: bool = pydantic.Field( - default=False, + default=True, description="Whether to ingest legacy `hive_metastore` catalog. This requires executing queries on SQL warehouse.", ) workspace_name: Optional[str] = pydantic.Field( @@ -135,12 +135,12 @@ class UnityCatalogSourceConfig( ) include_metastore: bool = pydantic.Field( - default=True, + default=False, description=( "Whether to ingest the workspace's metastore as a container and include it in all urns." " Changing this will affect the urns of all entities in the workspace." - " This will be disabled by default in the future," - " so it is recommended to set this to `False` for new ingestions." + " This config is deprecated and will be removed in the future," + " so it is recommended to not set this to `True` for new ingestions." " If you have an existing unity catalog ingestion, you'll want to avoid duplicates by soft deleting existing data." " If stateful ingestion is enabled, running with `include_metastore: false` should be sufficient." " Otherwise, we recommend deleting via the cli: `datahub delete --platform databricks` and re-ingesting with `include_metastore: false`." @@ -299,7 +299,7 @@ def include_metastore_warning(cls, v: bool) -> bool: if v: msg = ( "`include_metastore` is enabled." - " This is not recommended and will be disabled by default in the future, which is a breaking change." + " This is not recommended and this option will be removed in the future, which is a breaking change." " All databricks urns will change if you re-ingest with this disabled." " We recommend soft deleting all databricks data and re-ingesting with `include_metastore` set to `False`." ) diff --git a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json index 383f94144ffdcc..649212c1041ed7 100644 --- a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json +++ b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json @@ -1,7 +1,7 @@ [ { "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "entityUrn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -9,42 +9,10 @@ "customProperties": { "platform": "databricks", "env": "PROD", - "metastore": "acryl metastore" + "catalog": "hive_metastore" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data", - "name": "acryl metastore" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore", + "name": "hive_metastore" } }, "systemMetadata": { @@ -55,13 +23,13 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "entityUrn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Metastore" + "Catalog" ] } }, @@ -73,37 +41,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:abc@acryl.io", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", + "entityUrn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "status", "aspect": { "json": { - "path": [] + "removed": false } }, "systemMetadata": { @@ -114,19 +57,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "entityUrn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "hive_metastore" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore", - "name": "hive_metastore" + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -137,12 +73,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "entityUrn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "browsePathsV2", "aspect": { "json": { - "removed": false + "path": [] } }, "systemMetadata": { @@ -153,7 +89,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -161,30 +97,11 @@ "customProperties": { "platform": "databricks", "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main" + "catalog": "hive_metastore", + "unity_schema": "bronze_kambi" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main", - "name": "main", - "description": "Main catalog (auto-created)" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Catalog" - ] + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi", + "name": "bronze_kambi" } }, "systemMetadata": { @@ -195,28 +112,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" + "container": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586" } }, "systemMetadata": { @@ -227,28 +128,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -264,7 +144,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -280,13 +160,13 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Catalog" + "Schema" ] } }, @@ -298,96 +178,15 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "hive_metastore", - "unity_schema": "bronze_kambi" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi", - "name": "bronze_kambi" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", + "entityUrn": "urn:li:container:21058fb6993a790a4a43727021e52956", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { "path": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "id": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", + "urn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586" } ] } @@ -398,64 +197,14 @@ "lastRunId": "no-run-id-provided" } }, -{ - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Schema" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", - "aspect": { - "json": { - "platform": "urn:li:dataPlatform:databricks" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" + "container": "urn:li:container:21058fb6993a790a4a43727021e52956" } }, "systemMetadata": { @@ -465,22 +214,21 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "datasetProfile", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" - } - ] + "timestampMillis": 1705308660413, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 } }, "systemMetadata": { @@ -491,7 +239,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -504,7 +252,7 @@ "Last Access": "UNKNOWN", "Created By": "Spark 3.2.1", "Owner": "root", - "table_id": "acryl_metastore.hive_metastore.bronze_kambi.view1", + "table_id": "hive_metastore.bronze_kambi.view1", "created_at": "2022-06-22 05:14:56" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/view1", @@ -527,50 +275,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", - "changeType": "UPSERT", - "aspectName": "viewProperties", - "aspect": { - "json": { - "materialized": false, - "viewLogic": "CREATE VIEW `hive_metastore`.`bronze_kambi`.`view1` AS SELECT * FROM `hive_metastore`.`bronze_kambi`.`bet`", - "viewLanguage": "SQL" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main", - "unity_schema": "default" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default", - "name": "default", - "description": "Default schema (auto-created)" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -588,57 +293,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" - }, - { - "id": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "urn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.hive_metastore.bronze_kambi.view1", + "schemaName": "hive_metastore.bronze_kambi.view1", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1044,19 +704,166 @@ "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" }, { - "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].stake", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "double", - "recursive": false, - "isPartOfKey": false, - "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + "fieldPath": "[version=2.0].[type=struct].[type=struct].combination.[type=double].stake", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "double", + "recursive": false, + "isPartOfKey": false, + "jsonProps": "{\"native_data_type\": \"double\", \"_nullable\": true}" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", + "urn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586" + }, + { + "id": "urn:li:container:21058fb6993a790a4a43727021e52956", + "urn": "urn:li:container:21058fb6993a790a4a43727021e52956" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProperties", + "aspect": { + "json": { + "customProperties": { + "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "data_source_format": "DELTA", + "generation": "2", + "table_type": "MANAGED", + "created_by": "abc@acryl.io", + "delta.lastCommitTimestamp": "1666185711000", + "delta.lastUpdateVersion": "1", + "delta.minReaderVersion": "1", + "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", + "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", + "owner": "account users", + "updated_by": "abc@acryl.io", + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema/quickstart_table", + "name": "quickstart_table", + "qualifiedName": "main.quickstart_schema.quickstart_table", + "created": { + "time": 1666185698688, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "lastModified": { + "time": 1666186049633, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", + "changeType": "UPSERT", + "aspectName": "viewProperties", + "aspect": { + "json": { + "materialized": false, + "viewLogic": "CREATE VIEW `hive_metastore`.`bronze_kambi`.`view1` AS SELECT * FROM `hive_metastore`.`bronze_kambi`.`bet`", + "viewLanguage": "SQL" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:c45a3b960d7503abfb5549f583eb0517" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Table" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" } - ] + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -1067,12 +874,35 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "containerProperties", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "customProperties": { + "platform": "databricks", + "env": "PROD", + "catalog": "main" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main", + "name": "main", + "description": "Main catalog (auto-created)" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false } }, "systemMetadata": { @@ -1083,14 +913,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "status", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "removed": false } }, "systemMetadata": { @@ -1101,21 +929,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:abc@acryl.io", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -1126,12 +945,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" + "container": "urn:li:container:21058fb6993a790a4a43727021e52956" } }, "systemMetadata": { @@ -1142,12 +961,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "browsePathsV2", "aspect": { "json": { - "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "path": [] } }, "systemMetadata": { @@ -1158,37 +977,54 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "schemaMetadata", "aspect": { "json": { - "customProperties": { - "storage_location": "dbfs:/user/hive/warehouse/bronze_kambi.db/bet", - "data_source_format": "DELTA", - "table_type": "HIVE_MANAGED_TABLE", - "Catalog": "hive_metastore", - "Database": "bronze_kambi", - "Table": "bet", - "Last Access": "UNKNOWN", - "Created By": "Spark 3.2.1", - "Statistics": "1024 bytes, 3 rows", - "Owner": "root", - "Is_managed_location": "true", - "Table Properties": "[delta.autoOptimize.autoCompact=true,delta.autoOptimize.optimizeWrite=true,delta.minReaderVersion=1,delta.minWriterVersion=2]", - "table_id": "acryl_metastore.hive_metastore.bronze_kambi.bet", - "created_at": "2022-06-22 05:14:56" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/bet", - "name": "bet", - "qualifiedName": "hive_metastore.bronze_kambi.bet", + "schemaName": "main.quickstart_schema.quickstart_table", + "platform": "urn:li:dataPlatform:databricks", + "version": 0, "created": { - "time": 1655874896000 + "time": 0, + "actor": "urn:li:corpuser:unknown" }, "lastModified": { - "time": 1655874896000 + "time": 0, + "actor": "urn:li:corpuser:unknown" }, - "tags": [] + "hash": "", + "platformSchema": { + "com.linkedin.schema.MySqlDDL": { + "tableSchema": "" + } + }, + "fields": [ + { + "fieldPath": "columnA", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "int", + "recursive": false, + "isPartOfKey": false + }, + { + "fieldPath": "columnB", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false + } + ] } }, "systemMetadata": { @@ -1198,14 +1034,14 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Table" + "Catalog" ] } }, @@ -1216,21 +1052,14 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:5ada0a9773235325e506410c512feabb", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "subTypes", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - } + "typeNames": [ + "Table" ] } }, @@ -1241,26 +1070,22 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:045573d60442121f01b8d66a3eb95622", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "ownership", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202", - "urn": "urn:li:container:6d6f608f9f945f2862d99b855bdd3202" - }, + "owners": [ { - "id": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e", - "urn": "urn:li:container:1b3927f927ada651ce5fe3fb84227f8e" + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" } - ] + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -1271,12 +1096,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.hive_metastore.bronze_kambi.bet", + "schemaName": "hive_metastore.bronze_kambi.bet", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -1705,12 +1530,111 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586", + "urn": "urn:li:container:d91b261e5da1bf1434c6318b8c2ac586" + }, + { + "id": "urn:li:container:21058fb6993a790a4a43727021e52956", + "urn": "urn:li:container:21058fb6993a790a4a43727021e52956" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProperties", + "aspect": { + "json": { + "customProperties": { + "storage_location": "dbfs:/user/hive/warehouse/bronze_kambi.db/bet", + "data_source_format": "DELTA", + "table_type": "HIVE_MANAGED_TABLE", + "Catalog": "hive_metastore", + "Database": "bronze_kambi", + "Table": "bet", + "Last Access": "UNKNOWN", + "Created By": "Spark 3.2.1", + "Statistics": "1024 bytes, 3 rows", + "Owner": "root", + "Is_managed_location": "true", + "Table Properties": "[delta.autoOptimize.autoCompact=true,delta.autoOptimize.optimizeWrite=true,delta.minReaderVersion=1,delta.minWriterVersion=2]", + "table_id": "hive_metastore.bronze_kambi.bet", + "created_at": "2022-06-22 05:14:56" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/hive_metastore/bronze_kambi/bet", + "name": "bet", + "qualifiedName": "hive_metastore.bronze_kambi.bet", + "created": { + "time": 1655874896000 + }, + "lastModified": { + "time": 1655874896000 + }, + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:045573d60442121f01b8d66a3eb95622", + "urn": "urn:li:container:045573d60442121f01b8d66a3eb95622" + }, + { + "id": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", + "urn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProfile", "aspect": { "json": { - "container": "urn:li:container:5ada0a9773235325e506410c512feabb" + "timestampMillis": 1705308660403, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 } }, "systemMetadata": { @@ -1721,7 +1645,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -1744,9 +1668,9 @@ "updated_at": "2022-10-19 13:27:29.633000+00:00", "created_at": "2022-10-19 13:21:38.688000+00:00" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default/quickstart_table", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default/quickstart_table", "name": "quickstart_table", - "qualifiedName": "main.default.quickstart_table", + "qualifiedName": "quickstart_catalog.default.quickstart_table", "created": { "time": 1666185698688, "actor": "urn:li:corpuser:abc@acryl.io" @@ -1766,14 +1690,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "container", "aspect": { "json": { - "typeNames": [ - "Table" - ] + "container": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc" } }, "systemMetadata": { @@ -1784,53 +1706,13 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "schemaMetadata", + "aspectName": "subTypes", "aspect": { "json": { - "schemaName": "acryl_metastore.main.default.quickstart_table", - "platform": "urn:li:dataPlatform:databricks", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.schema.MySqlDDL": { - "tableSchema": "" - } - }, - "fields": [ - { - "fieldPath": "columnA", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "int", - "recursive": false, - "isPartOfKey": false - }, - { - "fieldPath": "columnB", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.StringType": {} - } - }, - "nativeDataType": "string", - "recursive": false, - "isPartOfKey": false - } + "typeNames": [ + "Table" ] } }, @@ -1841,22 +1723,21 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "containerProperties", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "customProperties": { + "platform": "databricks", + "env": "PROD", + "catalog": "main", + "unity_schema": "default" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default", + "name": "default", + "description": "Default schema (auto-created)" } }, "systemMetadata": { @@ -1867,50 +1748,21 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "ownership", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - }, + "owners": [ { - "id": "urn:li:container:5ada0a9773235325e506410c512feabb", - "urn": "urn:li:container:5ada0a9773235325e506410c512feabb" + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "containerProperties", - "aspect": { - "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "main", - "unity_schema": "quickstart_schema" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema", - "name": "quickstart_schema", - "description": "A new Unity Catalog schema called quickstart_schema" + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -1921,7 +1773,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -1937,7 +1789,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -1952,15 +1804,13 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "status", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "removed": false } }, "systemMetadata": { @@ -1970,38 +1820,55 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "schemaMetadata", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], + "schemaName": "quickstart_catalog.default.quickstart_table", + "platform": "urn:li:dataPlatform:databricks", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, "lastModified": { "time": 0, "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + }, + "hash": "", + "platformSchema": { + "com.linkedin.schema.MySqlDDL": { + "tableSchema": "" + } + }, + "fields": [ + { + "fieldPath": "columnA", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "int", + "recursive": false, + "isPartOfKey": false + }, + { + "fieldPath": "columnB", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false + } + ] } }, "systemMetadata": { @@ -2012,19 +1879,15 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:481380c5a355638fc626eca8380cdda9", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { "path": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" + "id": "urn:li:container:045573d60442121f01b8d66a3eb95622", + "urn": "urn:li:container:045573d60442121f01b8d66a3eb95622" } ] } @@ -2037,12 +1900,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:481380c5a355638fc626eca8380cdda9" + "container": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e" } }, "systemMetadata": { @@ -2052,42 +1915,15 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "subTypes", "aspect": { "json": { - "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "spark.sql.statistics.numRows": "10", - "spark.sql.statistics.totalSize": "512", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00", - "created_at": "2022-10-19 13:21:38.688000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "main.quickstart_schema.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "tags": [] + "typeNames": [ + "Schema" + ] } }, "systemMetadata": { @@ -2098,7 +1934,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -2116,12 +1952,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.main.quickstart_schema.quickstart_table", + "schemaName": "main.default.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -2174,21 +2010,66 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "datasetProperties", + "aspect": { + "json": { + "customProperties": { + "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "data_source_format": "DELTA", + "generation": "2", + "table_type": "MANAGED", + "created_by": "abc@acryl.io", + "delta.lastCommitTimestamp": "1666185711000", + "delta.lastUpdateVersion": "1", + "delta.minReaderVersion": "1", + "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", + "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", + "owner": "account users", + "updated_by": "abc@acryl.io", + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/default/quickstart_table", + "name": "quickstart_table", + "qualifiedName": "main.default.quickstart_table", + "created": { + "time": 1666185698688, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "lastModified": { + "time": 1666186049633, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "browsePathsV2", "aspect": { "json": { - "owners": [ + "path": [ { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" + "id": "urn:li:container:045573d60442121f01b8d66a3eb95622", + "urn": "urn:li:container:045573d60442121f01b8d66a3eb95622" + }, + { + "id": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", + "urn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e" } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + ] } }, "systemMetadata": { @@ -2199,25 +2080,21 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "ownership", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:83d98e62e36bddc3596c2b738e23b596", - "urn": "urn:li:container:83d98e62e36bddc3596c2b738e23b596" - }, + "owners": [ { - "id": "urn:li:container:481380c5a355638fc626eca8380cdda9", - "urn": "urn:li:container:481380c5a355638fc626eca8380cdda9" + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" } - ] + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -2228,7 +2105,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -2236,7 +2113,6 @@ "customProperties": { "platform": "databricks", "env": "PROD", - "metastore": "acryl metastore", "catalog": "quickstart_catalog" }, "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog", @@ -2251,13 +2127,22 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "browsePathsV2", "aspect": { "json": { - "removed": false + "path": [ + { + "id": "urn:li:container:730e95cd0271453376b3c1d9623838d6", + "urn": "urn:li:container:730e95cd0271453376b3c1d9623838d6" + }, + { + "id": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", + "urn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc" + } + ] } }, "systemMetadata": { @@ -2268,12 +2153,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "status", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "removed": false } }, "systemMetadata": { @@ -2284,14 +2169,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "typeNames": [ - "Catalog" - ] + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -2302,14 +2185,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:account users", + "owner": "urn:li:corpuser:abc@acryl.io", "type": "DATAOWNER" } ], @@ -2327,33 +2210,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "container", - "entityUrn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - } - ] + "path": [] } }, "systemMetadata": { @@ -2364,21 +2226,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "container", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "quickstart_catalog", - "unity_schema": "default" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default", - "name": "default", - "description": "Default schema (auto-created)" + "container": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" } }, "systemMetadata": { @@ -2389,12 +2242,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "subTypes", "aspect": { "json": { - "removed": false + "typeNames": [ + "Catalog" + ] } }, "systemMetadata": { @@ -2405,12 +2260,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:d6f502d4d8165f68d5b594ab4cb2171e", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "container", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "container": "urn:li:container:045573d60442121f01b8d66a3eb95622" } }, "systemMetadata": { @@ -2421,14 +2276,21 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:730e95cd0271453376b3c1d9623838d6", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "ownership", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -2439,14 +2301,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:abc@acryl.io", + "owner": "urn:li:corpuser:account users", "type": "DATAOWNER" } ], @@ -2464,12 +2326,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "subTypes", "aspect": { "json": { - "container": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "typeNames": [ + "Schema" + ] } }, "systemMetadata": { @@ -2480,21 +2344,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "status", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - } - ] + "removed": false } }, "systemMetadata": { @@ -2504,13 +2359,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "container": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -2520,60 +2375,21 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "containerProperties", "aspect": { "json": { "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "spark.sql.statistics.numRows": "10", - "spark.sql.statistics.totalSize": "512", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00", - "created_at": "2022-10-19 13:21:38.688000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "quickstart_catalog.default.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" + "platform": "databricks", + "env": "PROD", + "catalog": "system", + "unity_schema": "quickstart_schema" }, - "tags": [] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Table" - ] + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/quickstart_schema", + "name": "quickstart_schema", + "description": "A new Unity Catalog schema called quickstart_schema" } }, "systemMetadata": { @@ -2583,53 +2399,16 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:a1123d3ed81951784140565f5085b96d", "changeType": "UPSERT", - "aspectName": "schemaMetadata", + "aspectName": "browsePathsV2", "aspect": { "json": { - "schemaName": "acryl_metastore.quickstart_catalog.default.quickstart_table", - "platform": "urn:li:dataPlatform:databricks", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.schema.MySqlDDL": { - "tableSchema": "" - } - }, - "fields": [ - { - "fieldPath": "columnA", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "int", - "recursive": false, - "isPartOfKey": false - }, + "path": [ { - "fieldPath": "columnB", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.StringType": {} - } - }, - "nativeDataType": "string", - "recursive": false, - "isPartOfKey": false + "id": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", + "urn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" } ] } @@ -2641,15 +2420,15 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:account users", + "owner": "urn:li:corpuser:Service Principal 2", "type": "DATAOWNER" } ], @@ -2666,25 +2445,14 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "subTypes", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - }, - { - "id": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90", - "urn": "urn:li:container:ce568b660cba2e1a4e811b010ac27f90" - } + "typeNames": [ + "Catalog" ] } }, @@ -2696,7 +2464,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", "aspectName": "containerProperties", "aspect": { @@ -2704,11 +2472,10 @@ "customProperties": { "platform": "databricks", "env": "PROD", - "metastore": "acryl metastore", - "catalog": "quickstart_catalog", + "catalog": "main", "unity_schema": "quickstart_schema" }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema", + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/main/quickstart_schema", "name": "quickstart_schema", "description": "A new Unity Catalog schema called quickstart_schema" } @@ -2721,7 +2488,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2737,12 +2504,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "status", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "removed": false } }, "systemMetadata": { @@ -2753,14 +2520,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -2771,21 +2536,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -2796,12 +2552,19 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "containerProperties", "aspect": { "json": { - "container": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "customProperties": { + "platform": "databricks", + "env": "PROD", + "catalog": "system" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system", + "name": "system", + "description": "System catalog (auto-created)" } }, "systemMetadata": { @@ -2812,19 +2575,15 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:47a033e31b92a120f08f297c05d286f1", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { "path": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" + "id": "urn:li:container:045573d60442121f01b8d66a3eb95622", + "urn": "urn:li:container:045573d60442121f01b8d66a3eb95622" } ] } @@ -2836,58 +2595,21 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:47a033e31b92a120f08f297c05d286f1" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "containerProperties", "aspect": { "json": { "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "spark.sql.statistics.numRows": "10", - "spark.sql.statistics.totalSize": "512", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00", - "created_at": "2022-10-19 13:21:38.688000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "quickstart_catalog.quickstart_schema.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" + "platform": "databricks", + "env": "PROD", + "catalog": "quickstart_catalog", + "unity_schema": "quickstart_schema" }, - "tags": [] + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema", + "name": "quickstart_schema", + "description": "A new Unity Catalog schema called quickstart_schema" } }, "systemMetadata": { @@ -2897,14 +2619,14 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { "json": { "typeNames": [ - "Table" + "Schema" ] } }, @@ -2915,55 +2637,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "schemaMetadata", + "aspectName": "status", "aspect": { "json": { - "schemaName": "acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table", - "platform": "urn:li:dataPlatform:databricks", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.schema.MySqlDDL": { - "tableSchema": "" - } - }, - "fields": [ - { - "fieldPath": "columnA", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.NumberType": {} - } - }, - "nativeDataType": "int", - "recursive": false, - "isPartOfKey": false - }, - { - "fieldPath": "columnB", - "nullable": true, - "type": { - "type": { - "com.linkedin.schema.StringType": {} - } - }, - "nativeDataType": "string", - "recursive": false, - "isPartOfKey": false - } - ] + "removed": false } }, "systemMetadata": { @@ -2973,22 +2653,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "dataPlatformInstance", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "platform": "urn:li:dataPlatform:databricks" } }, "systemMetadata": { @@ -2998,26 +2669,13 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965", - "urn": "urn:li:container:079ede9d4f0640985a8ccf8eb180e965" - }, - { - "id": "urn:li:container:47a033e31b92a120f08f297c05d286f1", - "urn": "urn:li:container:47a033e31b92a120f08f297c05d286f1" - } - ] + "path": [] } }, "systemMetadata": { @@ -3028,20 +2686,21 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "ownership", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "system" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system", - "name": "system", - "description": "System catalog (auto-created)" + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3052,12 +2711,17 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "browsePathsV2", "aspect": { "json": { - "removed": false + "path": [ + { + "id": "urn:li:container:730e95cd0271453376b3c1d9623838d6", + "urn": "urn:li:container:730e95cd0271453376b3c1d9623838d6" + } + ] } }, "systemMetadata": { @@ -3068,12 +2732,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "dataPlatformInstance", + "aspectName": "subTypes", "aspect": { "json": { - "platform": "urn:li:dataPlatform:databricks" + "typeNames": [ + "Schema" + ] } }, "systemMetadata": { @@ -3084,14 +2750,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "container", "aspect": { "json": { - "typeNames": [ - "Catalog" - ] + "container": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" } }, "systemMetadata": { @@ -3102,14 +2766,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", "aspectName": "ownership", "aspect": { "json": { "owners": [ { - "owner": "urn:li:corpuser:Service Principal 2", + "owner": "urn:li:corpuser:account users", "type": "DATAOWNER" } ], @@ -3127,12 +2791,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:c45a3b960d7503abfb5549f583eb0517", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "container": "urn:li:container:045573d60442121f01b8d66a3eb95622" } }, "systemMetadata": { @@ -3143,17 +2807,37 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", + "entityUrn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "container", "aspect": { "json": { - "path": [ + "container": "urn:li:container:730e95cd0271453376b3c1d9623838d6" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "owner": "urn:li:corpuser:abc@acryl.io", + "type": "DATAOWNER" } - ] + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3164,21 +2848,14 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "subTypes", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "system", - "unity_schema": "default" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default", - "name": "default", - "description": "Default schema (auto-created)" + "typeNames": [ + "Schema" + ] } }, "systemMetadata": { @@ -3189,7 +2866,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3205,7 +2882,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -3221,14 +2898,20 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "containerProperties", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "customProperties": { + "platform": "databricks", + "env": "PROD", + "catalog": "system", + "unity_schema": "default" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default", + "name": "default", + "description": "Default schema (auto-created)" } }, "systemMetadata": { @@ -3239,21 +2922,17 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "browsePathsV2", "aspect": { "json": { - "owners": [ + "path": [ { - "owner": "urn:li:corpuser:abc@acryl.io", - "type": "DATAOWNER" + "id": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", + "urn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + ] } }, "systemMetadata": { @@ -3264,12 +2943,12 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "container": "urn:li:container:730e95cd0271453376b3c1d9623838d6" } }, "systemMetadata": { @@ -3280,21 +2959,21 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:b330768923270ff5450695bee1c94247", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "ownership", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, + "owners": [ { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "owner": "urn:li:corpuser:abc@acryl.io", + "type": "DATAOWNER" } - ] + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3305,12 +2984,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "container", "aspect": { "json": { - "container": "urn:li:container:b330768923270ff5450695bee1c94247" + "container": "urn:li:container:934b6043df189ef6dc63ac3519be34ac" } }, "systemMetadata": { @@ -3320,42 +2999,15 @@ } }, { - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityType": "container", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", - "aspectName": "datasetProperties", + "aspectName": "subTypes", "aspect": { "json": { - "customProperties": { - "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", - "data_source_format": "DELTA", - "generation": "2", - "table_type": "MANAGED", - "created_by": "abc@acryl.io", - "delta.lastCommitTimestamp": "1666185711000", - "delta.lastUpdateVersion": "1", - "delta.minReaderVersion": "1", - "delta.minWriterVersion": "2", - "spark.sql.statistics.numRows": "10", - "spark.sql.statistics.totalSize": "512", - "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", - "owner": "account users", - "updated_by": "abc@acryl.io", - "updated_at": "2022-10-19 13:27:29.633000+00:00", - "created_at": "2022-10-19 13:21:38.688000+00:00" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default/quickstart_table", - "name": "quickstart_table", - "qualifiedName": "system.default.quickstart_table", - "created": { - "time": 1666185698688, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "tags": [] + "typeNames": [ + "Schema" + ] } }, "systemMetadata": { @@ -3366,7 +3018,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "subTypes", "aspect": { @@ -3384,12 +3036,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.system.default.quickstart_table", + "schemaName": "quickstart_catalog.quickstart_schema.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -3442,21 +3094,41 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "datasetProperties", "aspect": { "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], + "customProperties": { + "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "data_source_format": "DELTA", + "generation": "2", + "table_type": "MANAGED", + "created_by": "abc@acryl.io", + "delta.lastCommitTimestamp": "1666185711000", + "delta.lastUpdateVersion": "1", + "delta.minReaderVersion": "1", + "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", + "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", + "owner": "account users", + "updated_by": "abc@acryl.io", + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/quickstart_schema/quickstart_table", + "name": "quickstart_table", + "qualifiedName": "quickstart_catalog.quickstart_schema.quickstart_table", + "created": { + "time": 1666185698688, + "actor": "urn:li:corpuser:abc@acryl.io" + }, "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + "time": 1666186049633, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "tags": [] } }, "systemMetadata": { @@ -3467,23 +3139,19 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { "path": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "id": "urn:li:container:730e95cd0271453376b3c1d9623838d6", + "urn": "urn:li:container:730e95cd0271453376b3c1d9623838d6" }, { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" - }, - { - "id": "urn:li:container:b330768923270ff5450695bee1c94247", - "urn": "urn:li:container:b330768923270ff5450695bee1c94247" + "id": "urn:li:container:934b6043df189ef6dc63ac3519be34ac", + "urn": "urn:li:container:934b6043df189ef6dc63ac3519be34ac" } ] } @@ -3495,22 +3163,22 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "containerProperties", + "aspectName": "ownership", "aspect": { "json": { - "customProperties": { - "platform": "databricks", - "env": "PROD", - "metastore": "acryl metastore", - "catalog": "system", - "unity_schema": "quickstart_schema" - }, - "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/quickstart_schema", - "name": "quickstart_schema", - "description": "A new Unity Catalog schema called quickstart_schema" + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3521,7 +3189,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -3537,7 +3205,7 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", "aspectName": "dataPlatformInstance", "aspect": { @@ -3553,14 +3221,20 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "containerProperties", "aspect": { "json": { - "typeNames": [ - "Schema" - ] + "customProperties": { + "platform": "databricks", + "env": "PROD", + "catalog": "quickstart_catalog", + "unity_schema": "default" + }, + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/quickstart_catalog/default", + "name": "default", + "description": "Default schema (auto-created)" } }, "systemMetadata": { @@ -3571,21 +3245,17 @@ }, { "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityUrn": "urn:li:container:39e66cf95f60943f217d96142ffd9ddc", "changeType": "UPSERT", - "aspectName": "ownership", + "aspectName": "browsePathsV2", "aspect": { "json": { - "owners": [ + "path": [ { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" + "id": "urn:li:container:730e95cd0271453376b3c1d9623838d6", + "urn": "urn:li:container:730e95cd0271453376b3c1d9623838d6" } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } + ] } }, "systemMetadata": { @@ -3595,13 +3265,36 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "datasetProfile", "aspect": { "json": { - "container": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" + "timestampMillis": 1705308660402, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 3, + "columnCount": 3, + "fieldProfiles": [ + { + "fieldPath": "betStatusId", + "uniqueCount": 1, + "uniqueProportion": 0.3333333333333333, + "nullCount": 0, + "nullProportion": 0.0 + }, + { + "fieldPath": "channelId", + "uniqueCount": 1, + "uniqueProportion": 0.3333333333333333, + "nullCount": 0, + "nullProportion": 0.0 + } + ], + "sizeInBytes": 1024 } }, "systemMetadata": { @@ -3611,22 +3304,13 @@ } }, { - "entityType": "container", - "entityUrn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", "changeType": "UPSERT", - "aspectName": "browsePathsV2", + "aspectName": "status", "aspect": { "json": { - "path": [ - { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" - }, - { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" - } - ] + "removed": false } }, "systemMetadata": { @@ -3637,12 +3321,20 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "container", + "aspectName": "datasetProfile", "aspect": { "json": { - "container": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1" + "timestampMillis": 1705308660401, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 } }, "systemMetadata": { @@ -3653,7 +3345,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProperties", "aspect": { @@ -3684,10 +3376,69 @@ "actor": "urn:li:corpuser:abc@acryl.io" }, "lastModified": { - "time": 1666186049633, - "actor": "urn:li:corpuser:abc@acryl.io" - }, - "tags": [] + "time": 1666186049633, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:a1123d3ed81951784140565f5085b96d" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Table" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3698,14 +3449,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "subTypes", + "aspectName": "status", "aspect": { "json": { - "typeNames": [ - "Table" - ] + "removed": false } }, "systemMetadata": { @@ -3716,12 +3465,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "schemaMetadata", "aspect": { "json": { - "schemaName": "acryl_metastore.system.quickstart_schema.quickstart_table", + "schemaName": "system.quickstart_schema.quickstart_table", "platform": "urn:li:dataPlatform:databricks", "version": 0, "created": { @@ -3774,48 +3523,19 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "ownership", - "aspect": { - "json": { - "owners": [ - { - "owner": "urn:li:corpuser:account users", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "browsePathsV2", "aspect": { "json": { "path": [ { - "id": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb", - "urn": "urn:li:container:22ec33be0e53ba3e61bb6c4ad58f6ffb" + "id": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", + "urn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" }, { - "id": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9", - "urn": "urn:li:container:5f7e6ee26826ba56e6d1d0b94f291fa9" - }, - { - "id": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1", - "urn": "urn:li:container:bd1cfbc8ea4c94a6f8c9b1ddba52a8c1" + "id": "urn:li:container:a1123d3ed81951784140565f5085b96d", + "urn": "urn:li:container:a1123d3ed81951784140565f5085b96d" } ] } @@ -3828,12 +3548,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProfile", "aspect": { "json": { - "timestampMillis": 1703580920011, + "timestampMillis": 1705308660401, "partitionSpec": { "type": "FULL_TABLE", "partition": "FULL_TABLE_SNAPSHOT" @@ -3852,35 +3572,41 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "datasetProfile", + "aspectName": "datasetProperties", "aspect": { "json": { - "timestampMillis": 1703581191932, - "partitionSpec": { - "type": "FULL_TABLE", - "partition": "FULL_TABLE_SNAPSHOT" + "customProperties": { + "storage_location": "s3://db-02eec1f70bfe4115445be9fdb1aac6ac-s3-root-bucket/metastore/2c983545-d403-4f87-9063-5b7e3b6d3736/tables/cff27aa1-1c6a-4d78-b713-562c660c2896", + "data_source_format": "DELTA", + "generation": "2", + "table_type": "MANAGED", + "created_by": "abc@acryl.io", + "delta.lastCommitTimestamp": "1666185711000", + "delta.lastUpdateVersion": "1", + "delta.minReaderVersion": "1", + "delta.minWriterVersion": "2", + "spark.sql.statistics.numRows": "10", + "spark.sql.statistics.totalSize": "512", + "table_id": "cff27aa1-1c6a-4d78-b713-562c660c2896", + "owner": "account users", + "updated_by": "abc@acryl.io", + "updated_at": "2022-10-19 13:27:29.633000+00:00", + "created_at": "2022-10-19 13:21:38.688000+00:00" }, - "rowCount": 3, - "columnCount": 3, - "fieldProfiles": [ - { - "fieldPath": "betStatusId", - "uniqueCount": 1, - "uniqueProportion": 0.3333333333333333, - "nullCount": 0, - "nullProportion": 0.0 - }, - { - "fieldPath": "channelId", - "uniqueCount": 1, - "uniqueProportion": 0.3333333333333333, - "nullCount": 0, - "nullProportion": 0.0 - } - ], - "sizeInBytes": 1024 + "externalUrl": "https://dummy.cloud.databricks.com/explore/data/system/default/quickstart_table", + "name": "quickstart_table", + "qualifiedName": "system.default.quickstart_table", + "created": { + "time": 1666185698688, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "lastModified": { + "time": 1666186049633, + "actor": "urn:li:corpuser:abc@acryl.io" + }, + "tags": [] } }, "systemMetadata": { @@ -3891,12 +3617,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "container", "aspect": { "json": { - "removed": false + "container": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6" } }, "systemMetadata": { @@ -3907,20 +3633,14 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "datasetProfile", + "aspectName": "subTypes", "aspect": { "json": { - "timestampMillis": 1703580406273, - "partitionSpec": { - "type": "FULL_TABLE", - "partition": "FULL_TABLE_SNAPSHOT" - }, - "rowCount": 10, - "columnCount": 2, - "fieldProfiles": [], - "sizeInBytes": 512 + "typeNames": [ + "Table" + ] } }, "systemMetadata": { @@ -3931,12 +3651,21 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "ownership", "aspect": { "json": { - "removed": false + "owners": [ + { + "owner": "urn:li:corpuser:account users", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } } }, "systemMetadata": { @@ -3947,20 +3676,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "datasetProfile", + "aspectName": "status", "aspect": { "json": { - "timestampMillis": 1703580920008, - "partitionSpec": { - "type": "FULL_TABLE", - "partition": "FULL_TABLE_SNAPSHOT" - }, - "rowCount": 10, - "columnCount": 2, - "fieldProfiles": [], - "sizeInBytes": 512 + "removed": false } }, "systemMetadata": { @@ -3971,60 +3692,54 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "datasetProfile", + "aspectName": "schemaMetadata", "aspect": { "json": { - "timestampMillis": 1703580920011, - "partitionSpec": { - "type": "FULL_TABLE", - "partition": "FULL_TABLE_SNAPSHOT" + "schemaName": "system.default.quickstart_table", + "platform": "urn:li:dataPlatform:databricks", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" }, - "rowCount": 10, - "columnCount": 2, - "fieldProfiles": [], - "sizeInBytes": 512 - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.main.default.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "datasetProfile", - "aspect": { - "json": { - "timestampMillis": 1703580920012, - "partitionSpec": { - "type": "FULL_TABLE", - "partition": "FULL_TABLE_SNAPSHOT" + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" }, - "rowCount": 10, - "columnCount": 2, - "fieldProfiles": [], - "sizeInBytes": 512 - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "unity-catalog-test", - "lastRunId": "no-run-id-provided" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.default.quickstart_table,PROD)", - "changeType": "UPSERT", - "aspectName": "status", - "aspect": { - "json": { - "removed": false + "hash": "", + "platformSchema": { + "com.linkedin.schema.MySqlDDL": { + "tableSchema": "" + } + }, + "fields": [ + { + "fieldPath": "columnA", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.NumberType": {} + } + }, + "nativeDataType": "int", + "recursive": false, + "isPartOfKey": false + }, + { + "fieldPath": "columnB", + "nullable": true, + "type": { + "type": { + "com.linkedin.schema.StringType": {} + } + }, + "nativeDataType": "string", + "recursive": false, + "isPartOfKey": false + } + ] } }, "systemMetadata": { @@ -4035,12 +3750,21 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.quickstart_catalog.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.default.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "browsePathsV2", "aspect": { "json": { - "removed": false + "path": [ + { + "id": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4", + "urn": "urn:li:container:94af9873ac56d46a4bcc995836dc15d4" + }, + { + "id": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6", + "urn": "urn:li:container:b84ba0cc36e3b2636942fc34ec5724d6" + } + ] } }, "systemMetadata": { @@ -4051,12 +3775,12 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "datasetProfile", "aspect": { "json": { - "timestampMillis": 1703580920010, + "timestampMillis": 1705308660402, "partitionSpec": { "type": "FULL_TABLE", "partition": "FULL_TABLE_SNAPSHOT" @@ -4075,7 +3799,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.default.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,main.default.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4091,7 +3815,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.system.quickstart_schema.quickstart_table,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4107,12 +3831,20 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.bet,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", - "aspectName": "status", + "aspectName": "datasetProfile", "aspect": { "json": { - "removed": false + "timestampMillis": 1705308660402, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "rowCount": 10, + "columnCount": 2, + "fieldProfiles": [], + "sizeInBytes": 512 } }, "systemMetadata": { @@ -4123,7 +3855,7 @@ }, { "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,dummy.acryl_metastore.hive_metastore.bronze_kambi.view1,PROD)", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,quickstart_catalog.quickstart_schema.quickstart_table,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { diff --git a/metadata-ingestion/tests/unit/test_unity_catalog_config.py b/metadata-ingestion/tests/unit/test_unity_catalog_config.py index 3c0994cde7889f..6b97d06b7ff93e 100644 --- a/metadata-ingestion/tests/unit/test_unity_catalog_config.py +++ b/metadata-ingestion/tests/unit/test_unity_catalog_config.py @@ -15,6 +15,7 @@ def test_within_thirty_days(): "token": "token", "workspace_url": "https://workspace_url", "include_usage_statistics": True, + "include_hive_metastore": False, "start_time": FROZEN_TIME - timedelta(days=30), } ) @@ -38,6 +39,7 @@ def test_profiling_requires_warehouses_id(): { "token": "token", "workspace_url": "https://workspace_url", + "include_hive_metastore": False, "profiling": { "enabled": True, "method": "ge", @@ -51,6 +53,7 @@ def test_profiling_requires_warehouses_id(): { "token": "token", "workspace_url": "https://workspace_url", + "include_hive_metastore": False, "profiling": {"enabled": False, "method": "ge"}, } ) @@ -60,6 +63,7 @@ def test_profiling_requires_warehouses_id(): UnityCatalogSourceConfig.parse_obj( { "token": "token", + "include_hive_metastore": False, "workspace_url": "workspace_url", } ) From 2b744fac7f9856dc84806f7716397edb263542a2 Mon Sep 17 00:00:00 2001 From: Yang Jiandan Date: Fri, 19 Jan 2024 11:40:56 +0800 Subject: [PATCH 164/540] fix(docker):The datahub-frontend service failed to start when executing dev.sh #7616 (#7618) Co-authored-by: yangjd33 Co-authored-by: RyanHolstien Co-authored-by: david-leifker <114954101+david-leifker@users.noreply.github.com> --- docker/datahub-frontend/Dockerfile | 9 ++++----- docker/docker-compose.dev.yml | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/datahub-frontend/Dockerfile b/docker/datahub-frontend/Dockerfile index 17d691177aa345..5563fd6350e20a 100644 --- a/docker/datahub-frontend/Dockerfile +++ b/docker/datahub-frontend/Dockerfile @@ -15,10 +15,13 @@ RUN if [ "${ALPINE_REPO_URL}" != "http://dl-cdn.alpinelinux.org/alpine" ] ; then # Upgrade Alpine and base packages # PFP-260: Upgrade Sqlite to >=3.28.0-r0 to fix https://security.snyk.io/vuln/SNYK-ALPINE39-SQLITE-449762 +ENV JMX_VERSION=0.18.0 RUN apk --no-cache --update-cache --available upgrade \ && apk --no-cache add curl sqlite libc6-compat java-snappy \ && apk --no-cache add openjdk17-jre-headless --repository=${ALPINE_REPO_URL}/edge/community \ - && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ + && apk --no-cache add jattach --repository ${ALPINE_REPO_URL}/edge/community/ \ + && wget ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar -O opentelemetry-javaagent.jar \ + && wget ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar ENV LD_LIBRARY_PATH="/lib:/lib64" @@ -32,10 +35,6 @@ RUN unzip datahub-frontend.zip -d /datahub-frontend \ COPY ./docker/monitoring/client-prometheus-config.yaml /datahub-frontend/ RUN chown -R datahub:datahub /datahub-frontend && chmod 755 /datahub-frontend -ENV JMX_VERSION=0.18.0 -RUN wget ${GITHUB_REPO_URL}/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.24.0/opentelemetry-javaagent.jar -O opentelemetry-javaagent.jar \ - && wget ${MAVEN_CENTRAL_REPO_URL}/io/prometheus/jmx/jmx_prometheus_javaagent/${JMX_VERSION}/jmx_prometheus_javaagent-${JMX_VERSION}.jar -O jmx_prometheus_javaagent.jar - FROM base as dev-install # Dummy stage for development. Assumes code is built on your machine and mounted to this image. # See this excellent thread https://github.com/docker/cli/issues/1134 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 7067b68fba3f9c..23ac821670e44b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -25,6 +25,7 @@ services: - DATAHUB_ANALYTICS_ENABLED=${DATAHUB_ANALYTICS_ENABLED:-true} volumes: - ../datahub-frontend/build/stage/main:/datahub-frontend + - ./monitoring/client-prometheus-config.yaml:/datahub-frontend/client-prometheus-config.yaml datahub-gms: image: linkedin/datahub-gms:debug ports: From 3682c5f1d03c673f72215bd335b17ecacbc33afb Mon Sep 17 00:00:00 2001 From: Indy Prentice Date: Thu, 18 Jan 2024 21:41:08 -0600 Subject: [PATCH 165/540] feat(openapi): Implement getIndexSizes function from rest.li in openapi (#8730) Co-authored-by: Indy Prentice Co-authored-by: David Leifker --- .../elastic/OperationsController.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/operations/elastic/OperationsController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/operations/elastic/OperationsController.java index f7c848f91a64cd..777d65d517b81a 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/operations/elastic/OperationsController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/operations/elastic/OperationsController.java @@ -9,9 +9,12 @@ import com.google.common.collect.ImmutableList; import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.systemmetadata.SystemMetadataService; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.timeseries.TimeseriesIndexSizeResult; import io.datahubproject.openapi.util.ElasticsearchUtils; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.json.JSONObject; import org.opensearch.client.tasks.GetTaskResponse; @@ -44,6 +47,10 @@ public class OperationsController { @Qualifier("elasticSearchSystemMetadataService") private SystemMetadataService _systemMetadataService; + @Autowired + @Qualifier("timeseriesAspectService") + private TimeseriesAspectService _timeseriesAspectService; + public OperationsController(AuthorizerChain authorizerChain) { _authorizerChain = authorizerChain; } @@ -91,4 +98,36 @@ public ResponseEntity getTaskStatus(String task) { j.put("runTimeNanos", res.get().getTaskInfo().getRunningTimeNanos()); return ResponseEntity.ok(j.toString()); } + + @GetMapping(path = "/getIndexSizes", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getIndexSizes() { + Authentication authentication = AuthenticationContext.getAuthentication(); + String actorUrnStr = authentication.getActor().toUrnStr(); + DisjunctivePrivilegeGroup orGroup = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of( + PoliciesConfig.GET_TIMESERIES_INDEX_SIZES_PRIVILEGE.getType())))); + if (restApiAuthorizationEnabled + && !AuthUtil.isAuthorizedForResources( + _authorizerChain, actorUrnStr, List.of(java.util.Optional.empty()), orGroup)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(String.format(actorUrnStr + " is not authorized to get timeseries index sizes")); + } + List indexSizeResults = _timeseriesAspectService.getIndexSizes(); + JSONObject j = new JSONObject(); + j.put( + "sizes", + indexSizeResults.stream() + .map( + timeseriesIndexSizeResult -> + new JSONObject() + .put("aspectName", timeseriesIndexSizeResult.getAspectName()) + .put("entityName", timeseriesIndexSizeResult.getEntityName()) + .put("indexName", timeseriesIndexSizeResult.getIndexName()) + .put("sizeMb", timeseriesIndexSizeResult.getSizeInMb())) + .collect(Collectors.toList())); + return ResponseEntity.ok(j.toString()); + } } From f993f50a0493111e4cfe85409098d844779292c5 Mon Sep 17 00:00:00 2001 From: Andrew Sikowitz Date: Thu, 18 Jan 2024 23:12:20 -0500 Subject: [PATCH 166/540] feat(ingest/sql-parsing): Support file backed dict in SqlParsingBuilder for lineage (#9654) --- .../datahub/emitter/sql_parsing_builder.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/metadata-ingestion/src/datahub/emitter/sql_parsing_builder.py b/metadata-ingestion/src/datahub/emitter/sql_parsing_builder.py index 046b615bd4e9fa..a8fe4f0df83cfd 100644 --- a/metadata-ingestion/src/datahub/emitter/sql_parsing_builder.py +++ b/metadata-ingestion/src/datahub/emitter/sql_parsing_builder.py @@ -20,6 +20,7 @@ UpstreamClass, UpstreamLineageClass, ) +from datahub.utilities.file_backed_collections import FileBackedDict from datahub.utilities.sqlglot_lineage import ColumnLineageInfo, SqlParsingResult logger = logging.getLogger(__name__) @@ -80,10 +81,10 @@ class SqlParsingBuilder: generate_operations: bool = True usage_config: Optional[BaseUsageConfig] = None - # TODO: Make inner dict a FileBackedDict and make LineageEdge frozen + # Maps downstream urn -> upstream urn -> LineageEdge # Builds up a single LineageEdge for each upstream -> downstream pair - _lineage_map: Dict[DatasetUrn, Dict[DatasetUrn, LineageEdge]] = field( - default_factory=lambda: defaultdict(dict), init=False + _lineage_map: FileBackedDict[Dict[DatasetUrn, LineageEdge]] = field( + default_factory=FileBackedDict, init=False ) # TODO: Replace with FileBackedDict approach like in BigQuery usage @@ -128,13 +129,14 @@ def process_sql_parsing_result( if self.generate_lineage: for downstream_urn in downstreams_to_ingest: - _merge_lineage_data( + # Set explicitly so that FileBackedDict registers any mutations + self._lineage_map[downstream_urn] = _merge_lineage_data( downstream_urn=downstream_urn, upstream_urns=result.in_tables, column_lineage=result.column_lineage if include_column_lineage else None, - upstream_edges=self._lineage_map[downstream_urn], + upstream_edges=self._lineage_map.get(downstream_urn, {}), query_timestamp=query_timestamp, is_view_ddl=is_view_ddl, user=user, @@ -170,11 +172,12 @@ def add_lineage( user: Optional[UserUrn] = None, ) -> None: """Manually add a single upstream -> downstream lineage edge, e.g. if sql parsing fails.""" - _merge_lineage_data( + # Set explicitly so that FileBackedDict registers any mutations + self._lineage_map[downstream_urn] = _merge_lineage_data( downstream_urn=downstream_urn, upstream_urns=upstream_urns, column_lineage=None, - upstream_edges=self._lineage_map[downstream_urn], + upstream_edges=self._lineage_map.get(downstream_urn, {}), query_timestamp=timestamp, is_view_ddl=is_view_ddl, user=user, @@ -225,7 +228,7 @@ def _merge_lineage_data( query_timestamp: Optional[datetime], is_view_ddl: bool, user: Optional[UserUrn], -) -> None: +) -> Dict[str, LineageEdge]: for upstream_urn in upstream_urns: edge = upstream_edges.setdefault( upstream_urn, @@ -255,6 +258,8 @@ def _merge_lineage_data( column_map = upstream_edges[upstream_column_info.table].column_map column_map[cl.downstream.column].add(upstream_column_info.column) + return upstream_edges + def _compute_upstream_fields( result: SqlParsingResult, From 45236a89aa1fcafcc5fef61994c5c4a498ebfe69 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Fri, 19 Jan 2024 15:38:50 +0530 Subject: [PATCH 167/540] feat(posts): add edit support for posts (#9666) --- .../src/app/settings/posts/CreatePostForm.tsx | 11 ++- .../app/settings/posts/CreatePostModal.tsx | 90 ++++++++++++++++--- .../src/app/settings/posts/PostItemMenu.tsx | 8 +- .../src/app/settings/posts/PostsList.tsx | 19 +++- .../app/settings/posts/PostsListColumns.tsx | 11 ++- .../src/graphql/mutations.graphql | 4 + 6 files changed, 125 insertions(+), 18 deletions(-) diff --git a/datahub-web-react/src/app/settings/posts/CreatePostForm.tsx b/datahub-web-react/src/app/settings/posts/CreatePostForm.tsx index a8d6cfa64c9c14..ee7f50a0589578 100644 --- a/datahub-web-react/src/app/settings/posts/CreatePostForm.tsx +++ b/datahub-web-react/src/app/settings/posts/CreatePostForm.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Form, Input, Typography, FormInstance, Radio } from 'antd'; import styled from 'styled-components'; import { @@ -21,11 +21,18 @@ const SubFormItem = styled(Form.Item)` type Props = { setCreateButtonEnabled: (isEnabled: boolean) => void; form: FormInstance; + contentType: PostContentType; }; -export default function CreatePostForm({ setCreateButtonEnabled, form }: Props) { +export default function CreatePostForm({ setCreateButtonEnabled, form, contentType }: Props) { const [postType, setPostType] = useState(PostContentType.Text); + useEffect(() => { + if (contentType) { + setPostType(contentType); + } + }, [contentType]); + return (
void; onCreate: ( contentType: string, @@ -22,12 +24,27 @@ type Props = { link: string | undefined, location: string | undefined, ) => void; + onEdit: () => void; }; -export default function CreatePostModal({ onClose, onCreate }: Props) { +export default function CreatePostModal({ onClose, onCreate, editData, onEdit }: Props) { const [createPostMutation] = useCreatePostMutation(); + const [updatePostMutation] = useUpdatePostMutation(); const [createButtonEnabled, setCreateButtonEnabled] = useState(false); const [form] = Form.useForm(); + + useEffect(() => { + if (editData) { + form.setFieldsValue({ + description: editData.description, + title: editData.title, + link: editData.link, + location: editData.imageUrl, + type: editData.contentType, + }); + } + }, [editData, form]); + const onCreatePost = () => { const contentTypeValue = form.getFieldValue(TYPE_FIELD_NAME) ?? PostContentType.Text; const mediaValue = @@ -75,33 +92,86 @@ export default function CreatePostModal({ onClose, onCreate }: Props) { onClose(); }; + const onUpdatePost = () => { + const contentTypeValue = form.getFieldValue(TYPE_FIELD_NAME) ?? PostContentType.Text; + const mediaValue = + form.getFieldValue(TYPE_FIELD_NAME) && form.getFieldValue(LOCATION_FIELD_NAME) + ? { + type: MediaType.Image, + location: form.getFieldValue(LOCATION_FIELD_NAME) ?? null, + } + : null; + updatePostMutation({ + variables: { + input: { + urn: editData?.urn, + postType: PostType.HomePageAnnouncement, + content: { + contentType: contentTypeValue, + title: form.getFieldValue(TITLE_FIELD_NAME), + description: form.getFieldValue(DESCRIPTION_FIELD_NAME) ?? null, + link: form.getFieldValue(LINK_FIELD_NAME) ?? null, + media: mediaValue, + }, + }, + }, + }) + .then(({ errors }) => { + if (!errors) { + message.success({ + content: `Updated Post!`, + duration: 3, + }); + onEdit(); + form.resetFields(); + } + }) + .catch((e) => { + message.destroy(); + message.error({ content: 'Failed to update Post! An unknown error occured.', duration: 3 }); + console.error('Failed to update Post:', e.message); + }); + onClose(); + }; + // Handle the Enter press useEnterKeyListener({ querySelectorToExecuteClick: '#createPostButton', }); + const onCloseModal = () => { + form.resetFields(); + onClose(); + }; + + const titleText = editData ? 'Edit Post' : 'Create new Post'; + return ( - } > - + ); } diff --git a/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx b/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx index e3fc424a47ef28..3708c04ab1ad33 100644 --- a/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx +++ b/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { DeleteOutlined } from '@ant-design/icons'; +import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { Dropdown, Menu, message, Modal } from 'antd'; import { MenuIcon } from '../../entity/shared/EntityDropdown/EntityDropdown'; import { useDeletePostMutation } from '../../../graphql/post.generated'; @@ -8,9 +8,10 @@ type Props = { urn: string; title: string; onDelete?: () => void; + onEdit?: () => void; }; -export default function PostItemMenu({ title, urn, onDelete }: Props) { +export default function PostItemMenu({ title, urn, onDelete, onEdit }: Props) { const [deletePostMutation] = useDeletePostMutation(); const deletePost = () => { @@ -53,6 +54,9 @@ export default function PostItemMenu({ title, urn, onDelete }: Props) {  Delete + +  Edit + } > diff --git a/datahub-web-react/src/app/settings/posts/PostsList.tsx b/datahub-web-react/src/app/settings/posts/PostsList.tsx index 849a3765a94b0f..b71f06c83c17fc 100644 --- a/datahub-web-react/src/app/settings/posts/PostsList.tsx +++ b/datahub-web-react/src/app/settings/posts/PostsList.tsx @@ -51,6 +51,7 @@ export const PostList = () => { const [page, setPage] = useState(1); const [isCreatingPost, setIsCreatingPost] = useState(false); + const [editData, setEditData] = useState(undefined); const pageSize = DEFAULT_PAGE_SIZE; const start = (page - 1) * pageSize; @@ -82,6 +83,16 @@ export const PostList = () => { }, 2000); }; + const handleEdit = (post: PostEntry) => { + setEditData(post); + setIsCreatingPost(true); + }; + + const handleClose = () => { + setEditData(undefined); + setIsCreatingPost(false); + }; + const allColumns = [ { title: 'Title', @@ -113,7 +124,7 @@ export const PostList = () => { width: '5%', align: 'right' as AlignType, key: 'menu', - render: PostListMenuColumn(handleDelete), + render: PostListMenuColumn(handleDelete, handleEdit), }, ]; @@ -123,6 +134,8 @@ export const PostList = () => { title: post.content.title, description: post.content.description, contentType: post.content.contentType, + link: post.content.link, + imageUrl: post.content.media?.location, }; }); @@ -181,7 +194,9 @@ export const PostList = () => { )} {isCreatingPost && ( setIsCreatingPost(false)} + editData={editData as PostEntry} + onClose={handleClose} + onEdit={() => setTimeout(() => refetch(), 2000)} onCreate={(urn, title, description) => { addToListPostCache( client, diff --git a/datahub-web-react/src/app/settings/posts/PostsListColumns.tsx b/datahub-web-react/src/app/settings/posts/PostsListColumns.tsx index 38f910baf8f412..ee680cbec7396c 100644 --- a/datahub-web-react/src/app/settings/posts/PostsListColumns.tsx +++ b/datahub-web-react/src/app/settings/posts/PostsListColumns.tsx @@ -9,15 +9,22 @@ export interface PostEntry { contentType: string; description: Maybe; urn: string; + link: string; + imageUrl: string; } const PostText = styled.div<{ minWidth?: number }>` ${(props) => props.minWidth !== undefined && `min-width: ${props.minWidth}px;`} `; -export function PostListMenuColumn(handleDelete: (urn: string) => void) { +export function PostListMenuColumn(handleDelete: (urn: string) => void, handleEdit: (urn: PostEntry) => void) { return (record: PostEntry) => ( - handleDelete(record.urn)} /> + handleDelete(record.urn)} + onEdit={() => handleEdit(record)} + /> ); } diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index 439d20810ef7c9..077922cee45fb8 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -120,6 +120,10 @@ mutation createPost($input: CreatePostInput!) { createPost(input: $input) } +mutation updatePost($input: UpdatePostInput!) { + updatePost(input: $input) +} + mutation updateLineage($input: UpdateLineageInput!) { updateLineage(input: $input) } From 4138b2f72442a72d84d9b12fac04abf8144ba1cf Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Fri, 19 Jan 2024 11:48:12 +0000 Subject: [PATCH 168/540] feat(roles): Add support for roles in groups in GMS (#9659) Co-authored-by: Aseem Bansal --- .../resolvers/group/CreateGroupResolver.java | 3 +- .../com/linkedin/identity/RoleMembership.pdl | 2 +- .../src/main/resources/entity-registry.yml | 1 + .../datahub/authorization/PolicyEngine.java | 75 +++++++++++++++++-- .../authorization/DataHubAuthorizerTest.java | 73 +++++++++++++++--- .../authorization/PolicyEngineTest.java | 12 ++- .../tests/privileges/test_privileges.py | 62 ++++++++++++++- smoke-test/tests/privileges/utils.py | 61 +++++++++++++++ 8 files changed, 266 insertions(+), 23 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java index e487ee00608d4a..cde7d4958a25ba 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/CreateGroupResolver.java @@ -43,9 +43,10 @@ public CompletableFuture get(final DataFetchingEnvironment environment) // Create the Group key. final CorpGroupKey key = new CorpGroupKey(); final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString(); + final String description = input.getDescription() != null ? input.getDescription() : ""; key.setName(id); // 'name' in the key really reflects nothing more than a stable "id". return _groupService.createNativeGroup( - key, input.getName(), input.getDescription(), authentication); + key, input.getName(), description, authentication); } catch (Exception e) { throw new RuntimeException("Failed to create group", e); } diff --git a/metadata-models/src/main/pegasus/com/linkedin/identity/RoleMembership.pdl b/metadata-models/src/main/pegasus/com/linkedin/identity/RoleMembership.pdl index ec1e472545c1c2..2f0ebbb58d05f7 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/identity/RoleMembership.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/identity/RoleMembership.pdl @@ -3,7 +3,7 @@ namespace com.linkedin.identity import com.linkedin.common.Urn /** - * Carries information about which roles a user is assigned to. + * Carries information about which roles a user or group is assigned to. */ @Aspect = { "name": "roleMembership" diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index f275d41e786c2f..9d8c4bfdab0da9 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -184,6 +184,7 @@ entities: - ownership - status - origin + - roleMembership - name: domain doc: A data domain within an organization. category: core diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java index 123e5f3c559321..f078d2d316cae2 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java @@ -3,8 +3,10 @@ import static com.linkedin.metadata.Constants.*; import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableSet; import com.linkedin.common.Owner; import com.linkedin.common.Ownership; +import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.StringArray; @@ -12,6 +14,8 @@ import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; +import com.linkedin.identity.GroupMembership; +import com.linkedin.identity.NativeGroupMembership; import com.linkedin.identity.RoleMembership; import com.linkedin.metadata.Constants; import com.linkedin.metadata.authorization.PoliciesConfig; @@ -26,6 +30,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -393,7 +398,6 @@ private Set resolveRoles( Set roles = new HashSet<>(); final EnvelopedAspectMap aspectMap; - try { Urn actorUrn = Urn.createFromString(actor); final EntityResponse corpUser = @@ -401,7 +405,10 @@ private Set resolveRoles( .batchGetV2( CORP_USER_ENTITY_NAME, Collections.singleton(actorUrn), - Collections.singleton(ROLE_MEMBERSHIP_ASPECT_NAME), + ImmutableSet.of( + ROLE_MEMBERSHIP_ASPECT_NAME, + GROUP_MEMBERSHIP_ASPECT_NAME, + NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME), _systemAuthentication) .get(actorUrn); if (corpUser == null || !corpUser.hasAspects()) { @@ -414,19 +421,71 @@ private Set resolveRoles( return roles; } - if (!aspectMap.containsKey(ROLE_MEMBERSHIP_ASPECT_NAME)) { - return roles; + if (aspectMap.containsKey(ROLE_MEMBERSHIP_ASPECT_NAME)) { + RoleMembership roleMembership = + new RoleMembership(aspectMap.get(ROLE_MEMBERSHIP_ASPECT_NAME).getValue().data()); + if (roleMembership.hasRoles()) { + roles.addAll(roleMembership.getRoles()); + } } - RoleMembership roleMembership = - new RoleMembership(aspectMap.get(ROLE_MEMBERSHIP_ASPECT_NAME).getValue().data()); - if (roleMembership.hasRoles()) { - roles.addAll(roleMembership.getRoles()); + List groups = new ArrayList<>(); + if (aspectMap.containsKey(GROUP_MEMBERSHIP_ASPECT_NAME)) { + GroupMembership groupMembership = + new GroupMembership(aspectMap.get(GROUP_MEMBERSHIP_ASPECT_NAME).getValue().data()); + groups.addAll(groupMembership.getGroups()); + } + if (aspectMap.containsKey(NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)) { + NativeGroupMembership nativeGroupMembership = + new NativeGroupMembership( + aspectMap.get(NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME).getValue().data()); + groups.addAll(nativeGroupMembership.getNativeGroups()); + } + if (!groups.isEmpty()) { + GroupMembership memberships = new GroupMembership(); + memberships.setGroups(new UrnArray(groups)); + roles.addAll(getRolesFromGroups(memberships)); + } + + if (!roles.isEmpty()) { context.setRoles(roles); } + return roles; } + private Set getRolesFromGroups(final GroupMembership groupMembership) { + + HashSet groups = new HashSet<>(groupMembership.getGroups()); + try { + Map responseMap = + _entityClient.batchGetV2( + CORP_GROUP_ENTITY_NAME, + groups, + ImmutableSet.of(ROLE_MEMBERSHIP_ASPECT_NAME), + _systemAuthentication); + + return responseMap.keySet().stream() + .filter(Objects::nonNull) + .filter(key -> responseMap.get(key) != null) + .filter(key -> responseMap.get(key).hasAspects()) + .map(key -> responseMap.get(key).getAspects()) + .filter(aspectMap -> aspectMap.containsKey(ROLE_MEMBERSHIP_ASPECT_NAME)) + .map( + aspectMap -> + new RoleMembership(aspectMap.get(ROLE_MEMBERSHIP_ASPECT_NAME).getValue().data())) + .filter(RoleMembership::hasRoles) + .map(RoleMembership::getRoles) + .flatMap(List::stream) + .collect(Collectors.toSet()); + + } catch (Exception e) { + log.error( + String.format("Failed to fetch %s for urns %s", ROLE_MEMBERSHIP_ASPECT_NAME, groups), e); + return new HashSet<>(); + } + } + private Set resolveGroups( ResolvedEntitySpec resolvedActorSpec, PolicyEvaluationContext context) { if (context.groups != null) { diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java index ffee378a363c7e..588cdf57269ef3 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java @@ -28,6 +28,7 @@ import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringArray; import com.linkedin.domain.DomainProperties; import com.linkedin.domain.Domains; @@ -36,6 +37,7 @@ import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; +import com.linkedin.identity.GroupMembership; import com.linkedin.identity.RoleMembership; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.search.ScrollResult; @@ -254,10 +256,14 @@ public void setupTest() throws Exception { when(_entityClient.batchGetV2( any(), eq(Collections.singleton(USER_WITH_ADMIN_ROLE)), - eq(Collections.singleton(ROLE_MEMBERSHIP_ASPECT_NAME)), + eq( + ImmutableSet.of( + ROLE_MEMBERSHIP_ASPECT_NAME, + GROUP_MEMBERSHIP_ASPECT_NAME, + NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)), any())) .thenReturn( - createUserRoleMembershipBatchResponse( + createRoleMembershipBatchResponse( USER_WITH_ADMIN_ROLE, UrnUtils.getUrn("urn:li:dataHubRole:Admin"))); final Authentication systemAuthentication = @@ -460,6 +466,49 @@ public void testAuthorizationOnDomainWithoutPrivilegeIsDenied() { assertEquals(_dataHubAuthorizer.authorize(request).getType(), AuthorizationResult.Type.DENY); } + @Test + public void testAuthorizationGrantedBasedOnGroupRole() throws Exception { + final EntitySpec resourceSpec = new EntitySpec("dataset", "urn:li:dataset:custom"); + + final Urn userUrnWithoutPermissions = UrnUtils.getUrn("urn:li:corpuser:userWithoutRole"); + final Urn groupWithAdminPermission = UrnUtils.getUrn("urn:li:corpGroup:groupWithRole"); + final UrnArray groups = new UrnArray(List.of(groupWithAdminPermission)); + final GroupMembership groupMembership = new GroupMembership(); + groupMembership.setGroups(groups); + + // User has no role associated but is part of 1 group + when(_entityClient.batchGetV2( + any(), + eq(Collections.singleton(userUrnWithoutPermissions)), + eq( + ImmutableSet.of( + ROLE_MEMBERSHIP_ASPECT_NAME, + GROUP_MEMBERSHIP_ASPECT_NAME, + NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)), + any())) + .thenReturn( + createEntityBatchResponse( + userUrnWithoutPermissions, GROUP_MEMBERSHIP_ASPECT_NAME, groupMembership)); + + // Group has a role + when(_entityClient.batchGetV2( + any(), + eq(Collections.singleton(groupWithAdminPermission)), + eq(Collections.singleton(ROLE_MEMBERSHIP_ASPECT_NAME)), + any())) + .thenReturn( + createRoleMembershipBatchResponse( + groupWithAdminPermission, UrnUtils.getUrn("urn:li:dataHubRole:Admin"))); + + // This request should only be valid for actor with the admin role. + // Which the urn:li:corpuser:userWithoutRole does not have + AuthorizationRequest request = + new AuthorizationRequest( + userUrnWithoutPermissions.toString(), "EDIT_USER_PROFILE", Optional.of(resourceSpec)); + + assertEquals(_dataHubAuthorizer.authorize(request).getType(), AuthorizationResult.Type.ALLOW); + } + private DataHubPolicyInfo createDataHubPolicyInfo( boolean active, List privileges, @Nullable final Urn domain) throws Exception { @@ -575,20 +624,24 @@ private Map createDomainPropertiesBatchResponse( return batchResponse; } - private Map createUserRoleMembershipBatchResponse( - final Urn userUrn, @Nullable final Urn roleUrn) { - final Map batchResponse = new HashMap<>(); - final EntityResponse response = new EntityResponse(); - EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + private Map createRoleMembershipBatchResponse( + final Urn actorUrn, @Nullable final Urn roleUrn) { final RoleMembership membership = new RoleMembership(); if (roleUrn != null) { membership.setRoles(new UrnArray(roleUrn)); } + return createEntityBatchResponse(actorUrn, ROLE_MEMBERSHIP_ASPECT_NAME, membership); + } + + private Map createEntityBatchResponse( + final Urn actorUrn, final String aspectName, final RecordTemplate aspect) { + final Map batchResponse = new HashMap<>(); + final EntityResponse response = new EntityResponse(); + EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); aspectMap.put( - ROLE_MEMBERSHIP_ASPECT_NAME, - new EnvelopedAspect().setValue(new com.linkedin.entity.Aspect(membership.data()))); + aspectName, new EnvelopedAspect().setValue(new com.linkedin.entity.Aspect(aspect.data()))); response.setAspects(aspectMap); - batchResponse.put(userUrn, response); + batchResponse.put(actorUrn, response); return batchResponse; } diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java index 08ec91d5287dc5..c7f06eeba6e85f 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java @@ -83,7 +83,11 @@ public void setupTest() throws Exception { when(_entityClient.batchGetV2( eq(CORP_USER_ENTITY_NAME), eq(Collections.singleton(authorizedUserUrn)), - eq(Collections.singleton(ROLE_MEMBERSHIP_ASPECT_NAME)), + eq( + ImmutableSet.of( + ROLE_MEMBERSHIP_ASPECT_NAME, + GROUP_MEMBERSHIP_ASPECT_NAME, + NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)), any())) .thenReturn(authorizedEntityResponseMap); @@ -94,7 +98,11 @@ public void setupTest() throws Exception { when(_entityClient.batchGetV2( eq(CORP_USER_ENTITY_NAME), eq(Collections.singleton(unauthorizedUserUrn)), - eq(Collections.singleton(ROLE_MEMBERSHIP_ASPECT_NAME)), + eq( + ImmutableSet.of( + ROLE_MEMBERSHIP_ASPECT_NAME, + GROUP_MEMBERSHIP_ASPECT_NAME, + NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)), any())) .thenReturn(unauthorizedEntityResponseMap); diff --git a/smoke-test/tests/privileges/test_privileges.py b/smoke-test/tests/privileges/test_privileges.py index 75e2265f1f5551..e1cb848cccf8eb 100644 --- a/smoke-test/tests/privileges/test_privileges.py +++ b/smoke-test/tests/privileges/test_privileges.py @@ -450,4 +450,64 @@ def test_privilege_to_create_and_manage_policies(): # Ensure that user can't create a policy after privilege is removed by admin - _ensure_cant_perform_action(user_session, create_policy,"createPolicy") \ No newline at end of file + _ensure_cant_perform_action(user_session, create_policy,"createPolicy") + + +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_privilege_from_group_role_can_create_and_manage_secret(): + + (admin_user, admin_pass) = get_admin_credentials() + admin_session = login_as(admin_user, admin_pass) + user_session = login_as("user", "user") + secret_urn = "urn:li:dataHubSecret:TestSecretName" + + # Verify new user can't create secrets + create_secret = { + "query": """mutation createSecret($input: CreateSecretInput!) {\n + createSecret(input: $input)\n}""", + "variables": { + "input":{ + "name":"TestSecretName", + "value":"Test Secret Value", + "description":"Test Secret Description" + } + }, + } + _ensure_cant_perform_action(user_session, create_secret,"createSecret") + + # Create group and grant it the admin role. + group_urn = create_group(admin_session, "Test Group") + + # Assign admin role to group + assign_role(admin_session,"urn:li:dataHubRole:Admin", [group_urn]) + + # Assign user to group + assign_user_to_group(admin_session, group_urn, ["urn:li:corpuser:user"]) + + # Verify new user with admin group can create and manage secrets + # Create a secret + _ensure_can_create_secret(user_session, create_secret, secret_urn) + + # Remove a secret + remove_secret = { + "query": """mutation deleteSecret($urn: String!) {\n + deleteSecret(urn: $urn)\n}""", + "variables": { + "urn": secret_urn + }, + } + + remove_secret_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_secret) + remove_secret_response.raise_for_status() + secret_data = remove_secret_response.json() + + assert secret_data + assert secret_data["data"] + assert secret_data["data"]["deleteSecret"] + assert secret_data["data"]["deleteSecret"] == secret_urn + + # Delete group which removes the user's admin capabilities + remove_group(admin_session, group_urn) + + # Ensure user can't create secret after policy is removed + _ensure_cant_perform_action(user_session, create_secret,"createSecret") diff --git a/smoke-test/tests/privileges/utils.py b/smoke-test/tests/privileges/utils.py index ea1f565f6f5acd..eeb385a243a901 100644 --- a/smoke-test/tests/privileges/utils.py +++ b/smoke-test/tests/privileges/utils.py @@ -170,6 +170,67 @@ def remove_user(session, urn): response.raise_for_status() return response.json() +def create_group(session, name): + json = { + "query": """mutation createGroup($input: CreateGroupInput!) {\n + createGroup(input: $input) + }""", + "variables": {"input": {"name": name}}, + } + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + assert res_data + assert res_data["data"] + assert res_data["data"]["createGroup"] + return res_data["data"]["createGroup"] + +def remove_group(session, urn): + json = { + "query": """mutation removeGroup($urn: String!) {\n + removeGroup(urn: $urn) + }""", + "variables": {"urn": urn}, + } + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + assert res_data + assert res_data["data"] + assert res_data["data"]["removeGroup"] + return res_data["data"]["removeGroup"] + +def assign_user_to_group(session, group_urn, user_urns): + json = { + "query": """mutation addGroupMembers($groupUrn: String!, $userUrns: [String!]!) {\n + addGroupMembers(input: { groupUrn: $groupUrn, userUrns: $userUrns }) + }""", + "variables": {"groupUrn": group_urn, "userUrns": user_urns}, + } + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + assert res_data + assert res_data["data"] + assert res_data["data"]["addGroupMembers"] + return res_data["data"]["addGroupMembers"] + +def assign_role(session, role_urn, actor_urns): + json = { + "query": """mutation batchAssignRole($input: BatchAssignRoleInput!) {\n + batchAssignRole(input: $input) + }""", + "variables": {"input": {"roleUrn": role_urn, "actors": actor_urns}}, + } + + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) + response.raise_for_status() + res_data = response.json() + assert res_data + assert res_data["data"] + assert res_data["data"]["batchAssignRole"] + return res_data["data"]["batchAssignRole"] + def create_user_policy(user_urn, privileges, session): policy = { "query": """mutation createPolicy($input: PolicyUpdateInput!) {\n From 0b66e5e452140b158fd350b4b769d4b7792db073 Mon Sep 17 00:00:00 2001 From: Ingthor Birkir Arnason Date: Fri, 19 Jan 2024 15:03:56 +0000 Subject: [PATCH 169/540] fix(frontend): Add fallback for image load error on Avatar (#9501) Co-authored-by: Ingthor Birkir Arnason Co-authored-by: Harshal Sheth --- .../src/app/shared/avatar/CustomAvatar.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/datahub-web-react/src/app/shared/avatar/CustomAvatar.tsx b/datahub-web-react/src/app/shared/avatar/CustomAvatar.tsx index 320b2441253157..2ec5a1e77fe223 100644 --- a/datahub-web-react/src/app/shared/avatar/CustomAvatar.tsx +++ b/datahub-web-react/src/app/shared/avatar/CustomAvatar.tsx @@ -1,6 +1,6 @@ import { Avatar, Tooltip } from 'antd'; import { TooltipPlacement } from 'antd/lib/tooltip'; -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; @@ -50,6 +50,8 @@ export default function CustomAvatar({ isRole = false, hideTooltip = false, }: Props) { + const [imageError, setImageError] = useState(false); + const avatarWithInitial = name ? ( {name.charAt(0).toUpperCase()} @@ -62,8 +64,15 @@ export default function CustomAvatar({ ) : ( avatarWithInitial ); + + const handleImageError = () => { + setImageError(true); + // To prevent fallback error handling from Ant Design + return false; + }; + const avatar = - photoUrl && photoUrl !== '' ? : avatarWithDefault; + photoUrl && photoUrl !== '' && !imageError ? : avatarWithDefault; if (!name) { return url ? {avatar} : avatar; } From a60df52cd2d1abd0ab5e9b6d05f6094a7d3e58f0 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Fri, 19 Jan 2024 22:06:50 +0530 Subject: [PATCH 170/540] fix(user-removal): resolve user removal list update issue (#9671) --- datahub-web-react/src/app/identity/user/UserList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datahub-web-react/src/app/identity/user/UserList.tsx b/datahub-web-react/src/app/identity/user/UserList.tsx index 22b44e5f2d6258..178f54325ecde0 100644 --- a/datahub-web-react/src/app/identity/user/UserList.tsx +++ b/datahub-web-react/src/app/identity/user/UserList.tsx @@ -82,7 +82,7 @@ export const UserList = () => { }); const totalUsers = usersData?.listUsers?.total || 0; - useEffect(()=> { + useEffect(() => { setUsersList(usersData?.listUsers?.users || []); }, [usersData]); const onChangePage = (newPage: number) => { @@ -92,6 +92,7 @@ export const UserList = () => { const handleDelete = (urn: string) => { removeUserFromListUsersCache(urn, client, page, pageSize); + usersRefetch(); }; const { From 0c940c7b2b9ba2e4e79ead7973045775882460a3 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Fri, 19 Jan 2024 13:38:48 -0500 Subject: [PATCH 171/540] feat(ui) Add standardized GQL error handling function to FE (#9470) --- datahub-web-react/src/App.tsx | 3 +- .../Dataset/Queries/QueryCardDetailsMenu.tsx | 10 +++-- datahub-web-react/src/app/shared/constants.ts | 8 ++++ .../src/app/shared/handleGraphQLError.ts | 40 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 datahub-web-react/src/app/shared/handleGraphQLError.ts diff --git a/datahub-web-react/src/App.tsx b/datahub-web-react/src/App.tsx index 895c2a4781e42e..79c9ee91ceaa12 100644 --- a/datahub-web-react/src/App.tsx +++ b/datahub-web-react/src/App.tsx @@ -14,6 +14,7 @@ import { PageRoutes } from './conf/Global'; import { isLoggedInVar } from './app/auth/checkAuthStatus'; import { GlobalCfg } from './conf'; import possibleTypesResult from './possibleTypes.generated'; +import { ErrorCodes } from './app/shared/constants'; /* Construct Apollo Client @@ -24,7 +25,7 @@ const errorLink = onError((error) => { const { networkError, graphQLErrors } = error; if (networkError) { const serverError = networkError as ServerError; - if (serverError.statusCode === 401) { + if (serverError.statusCode === ErrorCodes.Unauthorized) { isLoggedInVar(false); Cookies.remove(GlobalCfg.CLIENT_AUTH_COOKIE); const currentPath = window.location.pathname + window.location.search; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/QueryCardDetailsMenu.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/QueryCardDetailsMenu.tsx index d17f0d12b3cf31..a663dfffaaabf0 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/QueryCardDetailsMenu.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Queries/QueryCardDetailsMenu.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { DeleteOutlined, MoreOutlined } from '@ant-design/icons'; import { Dropdown, Menu, message, Modal } from 'antd'; import { useDeleteQueryMutation } from '../../../../../../graphql/query.generated'; +import handleGraphQLError from '../../../../../shared/handleGraphQLError'; const StyledMoreOutlined = styled(MoreOutlined)` font-size: 14px; @@ -28,9 +29,12 @@ export default function QueryCardDetailsMenu({ urn, onDeleted, index }: Props) { onDeleted?.(urn); } }) - .catch(() => { - message.destroy(); - message.error({ content: 'Failed to delete Query! An unexpected error occurred' }); + .catch((error) => { + handleGraphQLError({ + error, + defaultMessage: 'Failed to delete Query! An unexpected error occurred', + permissionMessage: 'Unauthorized to delete Query. Please contact your DataHub administrator.', + }); }); }; diff --git a/datahub-web-react/src/app/shared/constants.ts b/datahub-web-react/src/app/shared/constants.ts index dc04372b43607f..1cd9077ab8cdf8 100644 --- a/datahub-web-react/src/app/shared/constants.ts +++ b/datahub-web-react/src/app/shared/constants.ts @@ -1 +1,9 @@ export const ENTER_KEY_CODE = 13; + +export enum ErrorCodes { + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + ServerError = 500, +} diff --git a/datahub-web-react/src/app/shared/handleGraphQLError.ts b/datahub-web-react/src/app/shared/handleGraphQLError.ts new file mode 100644 index 00000000000000..f129fef34c7ca9 --- /dev/null +++ b/datahub-web-react/src/app/shared/handleGraphQLError.ts @@ -0,0 +1,40 @@ +import { ErrorResponse } from '@apollo/client/link/error'; +import { message } from 'antd'; +import { ErrorCodes } from './constants'; + +interface Props { + error: ErrorResponse; + defaultMessage: string; + permissionMessage?: string; + badRequestMessage?: string; + serverErrorMessage?: string; +} + +export default function handleGraphQLError({ + error, + defaultMessage, + permissionMessage = 'Unauthorized. Please contact your DataHub administrator.', + badRequestMessage, + serverErrorMessage, +}: Props) { + // destroy the default error message from errorLink in App.tsx + message.destroy(); + const { graphQLErrors } = error; + if (graphQLErrors && graphQLErrors.length) { + const { extensions } = graphQLErrors[0]; + const errorCode = extensions && (extensions.code as number); + if (errorCode === ErrorCodes.Forbidden) { + message.error(permissionMessage); + return; + } + if (errorCode === ErrorCodes.BadRequest && badRequestMessage) { + message.error(badRequestMessage); + return; + } + if (errorCode === ErrorCodes.ServerError && serverErrorMessage) { + message.error(serverErrorMessage); + return; + } + } + message.error(defaultMessage); +} From 131c8f878c9f0fe872c9cb4faa4de22c57922c31 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Fri, 19 Jan 2024 13:44:48 -0600 Subject: [PATCH 172/540] feat(kafka): add health indicator for kafka (#9662) --- ...docker-compose.consumers-without-neo4j.yml | 2 + docker/docker-compose.consumers.yml | 2 + ...ose.consumers-without-neo4j.quickstart.yml | 2 + .../docker-compose.consumers.quickstart.yml | 2 + metadata-jobs/common/build.gradle | 16 ++++ .../health/kafka/KafkaHealthIndicator.java | 75 +++++++++++++++++++ metadata-jobs/mae-consumer-job/build.gradle | 1 + .../kafka/MaeConsumerApplication.java | 3 +- .../kafka/MaeConsumerApplicationTest.java | 4 + metadata-jobs/mce-consumer-job/build.gradle | 1 + .../kafka/MceConsumerApplication.java | 3 +- .../kafka/MceConsumerApplicationTest.java | 10 ++- .../config/kafka/ConsumerConfiguration.java | 1 + .../src/main/resources/application.yml | 1 + .../boot/OnBootApplicationListener.java | 10 +-- .../kafka/DataHubUpgradeKafkaListener.java | 2 +- settings.gradle | 1 + 17 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 metadata-jobs/common/build.gradle create mode 100644 metadata-jobs/common/src/main/java/io/datahubproject/metadata/jobs/common/health/kafka/KafkaHealthIndicator.java diff --git a/docker/docker-compose.consumers-without-neo4j.yml b/docker/docker-compose.consumers-without-neo4j.yml index f1be585232a1a8..792c212e9be9ac 100644 --- a/docker/docker-compose.consumers-without-neo4j.yml +++ b/docker/docker-compose.consumers-without-neo4j.yml @@ -17,6 +17,7 @@ services: env_file: datahub-mae-consumer/env/docker-without-neo4j.env environment: - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} datahub-mce-consumer: container_name: datahub-mce-consumer hostname: datahub-mce-consumer @@ -31,3 +32,4 @@ services: - DATAHUB_SERVER_TYPE=${DATAHUB_SERVER_TYPE:-quickstart} - DATAHUB_TELEMETRY_ENABLED=${DATAHUB_TELEMETRY_ENABLED:-true} - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} diff --git a/docker/docker-compose.consumers.yml b/docker/docker-compose.consumers.yml index 8d331cea2f0b95..0a7cbe4dbe3d7a 100644 --- a/docker/docker-compose.consumers.yml +++ b/docker/docker-compose.consumers.yml @@ -17,6 +17,7 @@ services: env_file: datahub-mae-consumer/env/docker.env environment: - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} depends_on: neo4j: condition: service_healthy @@ -39,6 +40,7 @@ services: - NEO4J_PASSWORD=datahub - GRAPH_SERVICE_IMPL=neo4j - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} depends_on: neo4j: condition: service_healthy diff --git a/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml b/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml index 4ed57dca1f080a..c66931cabd7a4c 100644 --- a/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml +++ b/docker/quickstart/docker-compose.consumers-without-neo4j.quickstart.yml @@ -7,6 +7,7 @@ services: container_name: datahub-mae-consumer environment: - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} - DATAHUB_UPGRADE_HISTORY_KAFKA_CONSUMER_GROUP_ID=generic-duhe-consumer-job-client-mcl - DATAHUB_GMS_HOST=datahub-gms - DATAHUB_GMS_PORT=8080 @@ -45,6 +46,7 @@ services: - GRAPH_SERVICE_IMPL=elasticsearch - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=false diff --git a/docker/quickstart/docker-compose.consumers.quickstart.yml b/docker/quickstart/docker-compose.consumers.quickstart.yml index ba8432d8a89afe..b8106ef096952d 100644 --- a/docker/quickstart/docker-compose.consumers.quickstart.yml +++ b/docker/quickstart/docker-compose.consumers.quickstart.yml @@ -10,6 +10,7 @@ services: condition: service_healthy environment: - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} - DATAHUB_UPGRADE_HISTORY_KAFKA_CONSUMER_GROUP_ID=generic-duhe-consumer-job-client-mcl - DATAHUB_GMS_HOST=datahub-gms - DATAHUB_GMS_PORT=8080 @@ -55,6 +56,7 @@ services: - GRAPH_SERVICE_IMPL=neo4j - JAVA_OPTS=-Xms1g -Xmx1g - KAFKA_BOOTSTRAP_SERVER=broker:29092 + - KAFKA_CONSUMER_HEALTH_CHECK_ENABLED=${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:-true} - KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR=${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:-true} - KAFKA_SCHEMAREGISTRY_URL=http://schema-registry:8081 - MAE_CONSUMER_ENABLED=false diff --git a/metadata-jobs/common/build.gradle b/metadata-jobs/common/build.gradle new file mode 100644 index 00000000000000..bdc3b7a44a98af --- /dev/null +++ b/metadata-jobs/common/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'java' +} + +dependencies { + implementation(project(':metadata-service:factories')) { + exclude group: 'org.neo4j.test' + } + implementation externalDependency.springActuator + implementation externalDependency.springKafka + implementation externalDependency.slf4jApi + + compileOnly externalDependency.lombok + + annotationProcessor externalDependency.lombok +} \ No newline at end of file diff --git a/metadata-jobs/common/src/main/java/io/datahubproject/metadata/jobs/common/health/kafka/KafkaHealthIndicator.java b/metadata-jobs/common/src/main/java/io/datahubproject/metadata/jobs/common/health/kafka/KafkaHealthIndicator.java new file mode 100644 index 00000000000000..305d33d2a09d1f --- /dev/null +++ b/metadata-jobs/common/src/main/java/io/datahubproject/metadata/jobs/common/health/kafka/KafkaHealthIndicator.java @@ -0,0 +1,75 @@ +package io.datahubproject.metadata.jobs.common.health.kafka; + +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.boot.kafka.DataHubUpgradeKafkaListener; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.common.TopicPartition; +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; +import org.springframework.kafka.config.KafkaListenerEndpointRegistry; +import org.springframework.kafka.listener.ConcurrentMessageListenerContainer; +import org.springframework.kafka.listener.MessageListenerContainer; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class KafkaHealthIndicator extends AbstractHealthIndicator { + + private final KafkaListenerEndpointRegistry listenerRegistry; + private final ConfigurationProvider configurationProvider; + + public KafkaHealthIndicator( + KafkaListenerEndpointRegistry listenerRegistry, ConfigurationProvider configurationProvider) { + this.listenerRegistry = listenerRegistry; + this.configurationProvider = configurationProvider; + } + + @Override + protected void doHealthCheck(Health.Builder builder) throws Exception { + Status kafkaStatus = Status.UP; + boolean isContainerDown = + listenerRegistry.getAllListenerContainers().stream() + .filter( + container -> + !DataHubUpgradeKafkaListener.CONSUMER_GROUP.equals(container.getGroupId())) + .anyMatch(container -> !container.isRunning()); + Map details = + listenerRegistry.getAllListenerContainers().stream() + .collect( + Collectors.toMap( + MessageListenerContainer::getListenerId, this::buildConsumerDetails)); + if (isContainerDown && configurationProvider.getKafka().getConsumer().isHealthCheckEnabled()) { + kafkaStatus = Status.DOWN; + } + builder.status(kafkaStatus).withDetails(details).build(); + } + + private ConsumerDetails buildConsumerDetails(MessageListenerContainer container) { + Collection partitionDetails = container.getAssignedPartitions(); + int concurrency = 1; + if (container + instanceof ConcurrentMessageListenerContainer concurrentMessageListenerContainer) { + concurrency = concurrentMessageListenerContainer.getConcurrency(); + } + return new ConsumerDetails( + partitionDetails, + container.getListenerId(), + container.getGroupId(), + concurrency, + container.isRunning()); + } + + @Value + private static class ConsumerDetails { + Collection partitionDetails; + String listenerId; + String groupId; + int concurrency; + boolean isRunning; + } +} diff --git a/metadata-jobs/mae-consumer-job/build.gradle b/metadata-jobs/mae-consumer-job/build.gradle index a8920d50b068e9..f3d1ca9885044b 100644 --- a/metadata-jobs/mae-consumer-job/build.gradle +++ b/metadata-jobs/mae-consumer-job/build.gradle @@ -15,6 +15,7 @@ dependencies { implementation project(':metadata-jobs:mae-consumer') // TODO: Extract PE consumer into separate pod. implementation project(':metadata-jobs:pe-consumer') + implementation project(':metadata-jobs:common') implementation(externalDependency.springBootStarterWeb) { exclude module: "spring-boot-starter-tomcat" diff --git a/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java b/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java index eef513f8b91e04..e695788e09726c 100644 --- a/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java +++ b/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java @@ -22,7 +22,8 @@ "com.linkedin.metadata.dao.producer", "com.linkedin.gms.factory.config", "com.linkedin.gms.factory.entity.update.indices", - "com.linkedin.gms.factory.timeline.eventgenerator" + "com.linkedin.gms.factory.timeline.eventgenerator", + "io.datahubproject.metadata.jobs.common.health.kafka" }, excludeFilters = { @ComponentScan.Filter( diff --git a/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTest.java b/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTest.java index 69288cec8220a2..22fbe7fc6b6ca8 100644 --- a/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTest.java +++ b/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTest.java @@ -3,6 +3,7 @@ import static org.testng.AssertJUnit.*; import com.linkedin.metadata.entity.EntityService; +import io.datahubproject.metadata.jobs.common.health.kafka.KafkaHealthIndicator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -16,8 +17,11 @@ public class MaeConsumerApplicationTest extends AbstractTestNGSpringContextTests @Autowired private EntityService _mockEntityService; + @Autowired private KafkaHealthIndicator kafkaHealthIndicator; + @Test public void testMaeConsumerAutoWiring() { assertNotNull(_mockEntityService); + assertNotNull(kafkaHealthIndicator); } } diff --git a/metadata-jobs/mce-consumer-job/build.gradle b/metadata-jobs/mce-consumer-job/build.gradle index 2f60d1ae985fb5..3370838974bf74 100644 --- a/metadata-jobs/mce-consumer-job/build.gradle +++ b/metadata-jobs/mce-consumer-job/build.gradle @@ -15,6 +15,7 @@ dependencies { implementation project(':metadata-service:auth-filter') implementation project(':metadata-jobs:mce-consumer') implementation project(':entity-registry') + implementation project(':metadata-jobs:common') implementation(externalDependency.springBootStarterWeb) { exclude module: "spring-boot-starter-tomcat" diff --git a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java index 05bcd556debe93..181a723e1cd25b 100644 --- a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java +++ b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java @@ -29,7 +29,8 @@ "com.linkedin.restli.server", "com.linkedin.metadata.restli", "com.linkedin.metadata.kafka", - "com.linkedin.metadata.dao.producer" + "com.linkedin.metadata.dao.producer", + "io.datahubproject.metadata.jobs.common.health.kafka" }, excludeFilters = { @ComponentScan.Filter( diff --git a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java index 714c7b899ff49d..6d19db97fb39f0 100644 --- a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java +++ b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java @@ -2,10 +2,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.testng.AssertJUnit.assertTrue; +import static org.testng.AssertJUnit.*; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesResult; +import io.datahubproject.metadata.jobs.common.health.kafka.KafkaHealthIndicator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -23,6 +24,8 @@ public class MceConsumerApplicationTest extends AbstractTestNGSpringContextTests @Autowired private EntityService _mockEntityService; + @Autowired private KafkaHealthIndicator kafkaHealthIndicator; + @Test public void testRestliServletConfig() { RestoreIndicesResult mockResult = new RestoreIndicesResult(); @@ -34,4 +37,9 @@ public void testRestliServletConfig() { "/gms/aspects?action=restoreIndices", "{\"urn\":\"\"}", String.class); assertTrue(response.contains(mockResult.toString())); } + + @Test + public void testHealthIndicator() { + assertNotNull(kafkaHealthIndicator); + } } diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java index 61b9d5c8167900..60f3e1b4fef76f 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/kafka/ConsumerConfiguration.java @@ -7,4 +7,5 @@ public class ConsumerConfiguration { private int maxPartitionFetchBytes; private boolean stopOnDeserializationError; + private boolean healthCheckEnabled; } diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index 36498f7c45fea1..cfc84491ab0aec 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -237,6 +237,7 @@ kafka: consumer: maxPartitionFetchBytes: ${KAFKA_CONSUMER_MAX_PARTITION_FETCH_BYTES:5242880} # the max bytes consumed per partition stopOnDeserializationError: ${KAFKA_CONSUMER_STOP_ON_DESERIALIZATION_ERROR:true} # Stops kafka listener container on deserialization error, allows user to fix problems before moving past problematic offset. If false will log and move forward past the offset + healthCheckEnabled: ${KAFKA_CONSUMER_HEALTH_CHECK_ENABLED:true} # Sets the health indicator to down when a message listener container has stopped due to a deserialization failure, will force consumer apps to restart through k8s and docker-compose health mechanisms schemaRegistry: type: ${SCHEMA_REGISTRY_TYPE:KAFKA} # INTERNAL or KAFKA or AWS_GLUE url: ${KAFKA_SCHEMAREGISTRY_URL:http://localhost:8081} diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/OnBootApplicationListener.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/OnBootApplicationListener.java index 801a902b7f8354..0750dfca865c2e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/OnBootApplicationListener.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/OnBootApplicationListener.java @@ -16,14 +16,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.web.context.WebApplicationContext; /** Responsible for coordinating starting steps that happen before the application starts up. */ -@Configuration @Slf4j @Component public class OnBootApplicationListener { @@ -73,22 +71,22 @@ public Runnable isSchemaRegistryAPIServletReady() { return () -> { final HttpGet request = new HttpGet(provider.getKafka().getSchemaRegistry().getUrl()); int timeouts = _servletsWaitTimeout; - boolean openAPIServeletReady = false; - while (!openAPIServeletReady && timeouts > 0) { + boolean openAPIServletReady = false; + while (!openAPIServletReady && timeouts > 0) { try { log.info("Sleeping for 1 second"); Thread.sleep(1000); StatusLine statusLine = httpClient.execute(request).getStatusLine(); if (ACCEPTED_HTTP_CODES.contains(statusLine.getStatusCode())) { log.info("Connected! Authentication not tested."); - openAPIServeletReady = true; + openAPIServletReady = true; } } catch (IOException | InterruptedException e) { log.info("Failed to connect to open servlet: {}", e.getMessage()); } timeouts--; } - if (!openAPIServeletReady) { + if (!openAPIServletReady) { log.error( "Failed to bootstrap DataHub, OpenAPI servlet was not ready after {} seconds", timeouts); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/DataHubUpgradeKafkaListener.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/DataHubUpgradeKafkaListener.java index b2b6fb5e5cb7e2..e69ab342740e43 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/DataHubUpgradeKafkaListener.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/DataHubUpgradeKafkaListener.java @@ -36,7 +36,7 @@ public class DataHubUpgradeKafkaListener implements ConsumerSeekAware, Bootstrap private final KafkaListenerEndpointRegistry registry; - private static final String CONSUMER_GROUP = + public static final String CONSUMER_GROUP = "${DATAHUB_UPGRADE_HISTORY_KAFKA_CONSUMER_GROUP_ID:generic-duhe-consumer-job-client}"; private static final String SUFFIX = "temp"; public static final String TOPIC_NAME = diff --git a/settings.gradle b/settings.gradle index d2844fe00cdbc3..4614b6ed4ccaf0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -63,3 +63,4 @@ include 'metadata-integration:java:examples' include 'mock-entity-registry' include 'metadata-service:services' include 'metadata-service:configuration' +include ':metadata-jobs:common' From bd9b0c49fbf26a344a0f2f626b8b33a04cefacd5 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:32:22 -0600 Subject: [PATCH 173/540] perf(entity-service): batch exists calls (#9663) --- .../assertion/DeleteAssertionResolver.java | 6 +- .../UpdateDeprecationResolver.java | 7 +- .../resolvers/domain/SetDomainResolver.java | 8 +- .../resolvers/domain/UnsetDomainResolver.java | 6 +- .../resolvers/embed/UpdateEmbedResolver.java | 2 +- .../entity/EntityExistsResolver.java | 6 +- .../glossary/AddRelatedTermsResolver.java | 6 +- .../DeleteGlossaryEntityResolver.java | 6 +- .../glossary/RemoveRelatedTermsResolver.java | 4 +- .../lineage/UpdateLineageResolver.java | 10 +- .../BatchUpdateSoftDeletedResolver.java | 4 +- .../resolvers/mutate/DescriptionUtils.java | 52 ++++----- .../resolvers/mutate/MoveDomainResolver.java | 4 +- .../resolvers/mutate/UpdateNameResolver.java | 4 +- .../mutate/UpdateParentNodeResolver.java | 6 +- .../resolvers/mutate/util/DomainUtils.java | 11 +- .../resolvers/mutate/util/LabelUtils.java | 61 ++++++---- .../resolvers/mutate/util/LinkUtils.java | 12 +- .../resolvers/mutate/util/OwnerUtils.java | 23 ++-- .../resolvers/tag/SetTagColorResolver.java | 4 +- .../HyperParameterValueTypeMapper.java | 2 +- .../resolvers/UpdateLineageResolverTest.java | 42 +++---- .../DeleteAssertionResolverTest.java | 59 +++++----- .../BatchUpdateSoftDeletedResolverTest.java | 19 ++- .../BatchUpdateDeprecationResolverTest.java | 19 ++- .../UpdateDeprecationResolverTest.java | 46 ++++---- .../domain/BatchSetDomainResolverTest.java | 56 ++++++--- .../domain/MoveDomainResolverTest.java | 13 ++- .../domain/SetDomainResolverTest.java | 33 ++++-- .../domain/UnsetDomainResolverTest.java | 14 ++- .../embed/UpdateEmbedResolverTest.java | 14 ++- .../entity/EntityExistsResolverTest.java | 3 +- .../glossary/AddRelatedTermsResolverTest.java | 86 ++++++++------ .../DeleteGlossaryEntityResolverTest.java | 7 +- .../RemoveRelatedTermsResolverTest.java | 19 +-- .../glossary/UpdateNameResolverTest.java | 11 +- .../UpdateParentNodeResolverTest.java | 27 +++-- .../load/BatchGetEntitiesResolverTest.java | 7 +- .../mutate/UpdateUserSettingResolverTest.java | 4 +- .../owner/AddOwnersResolverTest.java | 110 +++++++++++------- .../owner/BatchAddOwnersResolverTest.java | 78 ++++++++----- .../owner/BatchRemoveOwnersResolverTest.java | 80 +++++++------ .../resolvers/tag/AddTagsResolverTest.java | 39 ++++--- .../tag/BatchAddTagsResolverTest.java | 48 +++++--- .../tag/BatchRemoveTagsResolverTest.java | 34 ++++-- .../tag/SetTagColorResolverTest.java | 12 +- .../resolvers/term/AddTermsResolverTest.java | 79 +++++++------ .../term/BatchAddTermsResolverTest.java | 96 ++++++++------- .../term/BatchRemoveTermsResolverTest.java | 80 +++++++------ .../metadata/client/JavaEntityClient.java | 2 +- .../metadata/entity/EntityServiceImpl.java | 70 +++++++---- .../linkedin/metadata/entity/EntityUtils.java | 23 ---- .../candidatesource/MostPopularSource.java | 40 +++---- .../candidatesource/RecentlyEditedSource.java | 40 +++---- .../candidatesource/RecentlyViewedSource.java | 40 +++---- .../sibling/SiblingGraphServiceTest.java | 6 +- .../SampleDataFixtureConfiguration.java | 17 ++- .../authentication/group/GroupService.java | 8 +- .../token/StatefulTokenService.java | 2 +- .../user/NativeUserService.java | 4 +- .../datahub/telemetry/TrackingService.java | 2 +- .../DataHubTokenAuthenticatorTest.java | 3 +- .../group/GroupServiceTest.java | 8 +- .../token/StatefulTokenServiceTest.java | 3 +- .../user/NativeUserServiceTest.java | 4 +- .../telemetry/TrackingServiceTest.java | 6 +- .../steps/IngestRetentionPoliciesStep.java | 6 +- .../boot/steps/RemoveClientIdAspectStep.java | 4 +- .../delegates/EntityApiDelegateImpl.java | 2 +- .../src/test/java/mock/MockEntityService.java | 5 +- .../resources/entity/EntityResource.java | 2 +- .../metadata/entity/EntityService.java | 23 +++- .../EntityRecommendationSource.java | 37 ++++++ .../metadata/shared/ValidationUtils.java | 79 +++++++++---- 74 files changed, 1064 insertions(+), 751 deletions(-) create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolver.java index 89912b2814e400..cbf685e9f45bd1 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolver.java @@ -24,10 +24,10 @@ public class DeleteAssertionResolver implements DataFetcher> { private final EntityClient _entityClient; - private final EntityService _entityService; + private final EntityService _entityService; public DeleteAssertionResolver( - final EntityClient entityClient, final EntityService entityService) { + final EntityClient entityClient, final EntityService entityService) { _entityClient = entityClient; _entityService = entityService; } @@ -41,7 +41,7 @@ public CompletableFuture get(final DataFetchingEnvironment environment) () -> { // 1. check the entity exists. If not, return false. - if (!_entityService.exists(assertionUrn)) { + if (!_entityService.exists(assertionUrn, true)) { return true; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolver.java index 62c88c506ba613..be887d845f3853 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolver.java @@ -37,7 +37,7 @@ public class UpdateDeprecationResolver implements DataFetcher _entityService; // TODO: Remove this when 'exists' added to EntityClient @Override @@ -101,9 +101,10 @@ private boolean isAuthorizedToUpdateDeprecationForEntity( orPrivilegeGroups); } - public static Boolean validateUpdateDeprecationInput(Urn entityUrn, EntityService entityService) { + public static Boolean validateUpdateDeprecationInput( + Urn entityUrn, EntityService entityService) { - if (!entityService.exists(entityUrn)) { + if (!entityService.exists(entityUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to update deprecation for Entity %s. Entity does not exist.", entityUrn)); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java index 1c52f707c61a4a..4d2e93be42fcba 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java @@ -28,7 +28,7 @@ public class SetDomainResolver implements DataFetcher> { private final EntityClient _entityClient; - private final EntityService + private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient @Override @@ -74,16 +74,16 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw } public static Boolean validateSetDomainInput( - Urn entityUrn, Urn domainUrn, EntityService entityService) { + Urn entityUrn, Urn domainUrn, EntityService entityService) { - if (!entityService.exists(domainUrn)) { + if (!entityService.exists(domainUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to add Entity %s to Domain %s. Domain does not exist.", entityUrn, domainUrn)); } - if (!entityService.exists(entityUrn)) { + if (!entityService.exists(entityUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to add Entity %s to Domain %s. Entity does not exist.", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolver.java index b2a82ac7608d89..c415d933e4a3a4 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolver.java @@ -29,7 +29,7 @@ public class UnsetDomainResolver implements DataFetcher> { private final EntityClient _entityClient; - private final EntityService + private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient @Override @@ -71,9 +71,9 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw }); } - public static Boolean validateUnsetDomainInput(Urn entityUrn, EntityService entityService) { + public static Boolean validateUnsetDomainInput(Urn entityUrn, EntityService entityService) { - if (!entityService.exists(entityUrn)) { + if (!entityService.exists(entityUrn, true)) { throw new IllegalArgumentException( String.format("Failed to add Entity %s to Domain %s. Entity does not exist.", entityUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolver.java index e1b264606074c6..caaf76b0d1dc81 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolver.java @@ -82,7 +82,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw */ private static void validateUpdateEmbedInput( @Nonnull final UpdateEmbedInput input, @Nonnull final EntityService entityService) { - if (!entityService.exists(UrnUtils.getUrn(input.getUrn()))) { + if (!entityService.exists(UrnUtils.getUrn(input.getUrn()), true)) { throw new IllegalArgumentException( String.format( "Failed to update embed for entity with urn %s. Entity does not exist!", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolver.java index d2bd2f3fb8a17d..257f0a4efd2601 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolver.java @@ -12,9 +12,9 @@ /** Resolver responsible for returning whether an entity exists. */ public class EntityExistsResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; - public EntityExistsResolver(final EntityService entityService) { + public EntityExistsResolver(final EntityService entityService) { _entityService = entityService; } @@ -32,7 +32,7 @@ public CompletableFuture get(final DataFetchingEnvironment environment) return CompletableFuture.supplyAsync( () -> { try { - return _entityService.exists(entityUrn); + return _entityService.exists(entityUrn, true); } catch (Exception e) { throw new RuntimeException( String.format("Failed to check whether entity %s exists", entityUrn.toString())); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolver.java index 535dbbf70a4cbc..31aa8b2ab9ddf8 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolver.java @@ -29,7 +29,7 @@ @RequiredArgsConstructor public class AddRelatedTermsResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -91,7 +91,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw public Boolean validateRelatedTermsInput(Urn urn, List termUrns) { if (!urn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME) - || !_entityService.exists(urn)) { + || !_entityService.exists(urn, true)) { throw new IllegalArgumentException( String.format( "Failed to update %s. %s either does not exist or is not a glossaryTerm.", urn, urn)); @@ -104,7 +104,7 @@ public Boolean validateRelatedTermsInput(Urn urn, List termUrns) { } else if (!termUrn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s is not a glossaryTerm.", urn, termUrn)); - } else if (!_entityService.exists(termUrn)) { + } else if (!_entityService.exists(termUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", urn, termUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolver.java index f623f0e34b3669..3dc3e93260665e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolver.java @@ -15,10 +15,10 @@ public class DeleteGlossaryEntityResolver implements DataFetcher> { private final EntityClient _entityClient; - private final EntityService _entityService; + private final EntityService _entityService; public DeleteGlossaryEntityResolver( - final EntityClient entityClient, EntityService entityService) { + final EntityClient entityClient, EntityService entityService) { _entityClient = entityClient; _entityService = entityService; } @@ -33,7 +33,7 @@ public CompletableFuture get(final DataFetchingEnvironment environment) return CompletableFuture.supplyAsync( () -> { if (GlossaryUtils.canManageChildrenEntities(context, parentNodeUrn, _entityClient)) { - if (!_entityService.exists(entityUrn)) { + if (!_entityService.exists(entityUrn, true)) { throw new RuntimeException(String.format("This urn does not exist: %s", entityUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolver.java index 8c9b792b74e0dd..b1dd404e124653 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolver.java @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class RemoveRelatedTermsResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -46,7 +46,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw input.getTermUrns().stream().map(UrnUtils::getUrn).collect(Collectors.toList()); if (!urn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME) - || !_entityService.exists(urn)) { + || !_entityService.exists(urn, true)) { throw new IllegalArgumentException( String.format( "Failed to update %s. %s either does not exist or is not a glossaryTerm.", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/lineage/UpdateLineageResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/lineage/UpdateLineageResolver.java index a0caef20a4755e..804bd6ca054319 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/lineage/UpdateLineageResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/lineage/UpdateLineageResolver.java @@ -35,7 +35,7 @@ @RequiredArgsConstructor public class UpdateLineageResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; private final LineageService _lineageService; @Override @@ -60,9 +60,11 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw return CompletableFuture.supplyAsync( () -> { + final Set existingDownstreamUrns = _entityService.exists(downstreamUrns, true); + // build MCP for every downstreamUrn for (Urn downstreamUrn : downstreamUrns) { - if (!_entityService.exists(downstreamUrn)) { + if (!existingDownstreamUrns.contains(downstreamUrn)) { throw new IllegalArgumentException( String.format( "Cannot upsert lineage as downstream urn %s doesn't exist", downstreamUrn)); @@ -128,9 +130,11 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw upstreamUrns.addAll(upstreamToDownstreamsToAdd.keySet()); upstreamUrns.addAll(upstreamToDownstreamsToRemove.keySet()); + final Set existingUpstreamUrns = _entityService.exists(upstreamUrns, true); + // build MCP for upstreamUrn if necessary for (Urn upstreamUrn : upstreamUrns) { - if (!_entityService.exists(upstreamUrn)) { + if (!existingUpstreamUrns.contains(upstreamUrn)) { throw new IllegalArgumentException( String.format( "Cannot upsert lineage as downstream urn %s doesn't exist", upstreamUrn)); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java index 5a25e6d83e648a..aa7c1b152790f1 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java @@ -20,7 +20,7 @@ @RequiredArgsConstructor public class BatchUpdateSoftDeletedResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -65,7 +65,7 @@ private void validateInputUrn(String urnStr, QueryContext context) { throw new AuthorizationException( "Unauthorized to perform this action. Please contact your DataHub administrator."); } - if (!_entityService.exists(urn)) { + if (!_entityService.exists(urn, true)) { throw new IllegalArgumentException( String.format("Failed to soft delete entity with urn %s. Entity does not exist.", urn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java index d0796389d22808..ab151d6244f489 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java @@ -44,7 +44,7 @@ public static void updateFieldDescription( Urn resourceUrn, String fieldPath, Urn actor, - EntityService entityService) { + EntityService entityService) { EditableSchemaMetadata editableSchemaMetadata = (EditableSchemaMetadata) EntityUtils.getAspectFromEntity( @@ -66,7 +66,7 @@ public static void updateFieldDescription( } public static void updateContainerDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableContainerProperties containerProperties = (EditableContainerProperties) EntityUtils.getAspectFromEntity( @@ -84,7 +84,7 @@ public static void updateContainerDescription( } public static void updateDomainDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { DomainProperties domainProperties = (DomainProperties) EntityUtils.getAspectFromEntity( @@ -107,7 +107,7 @@ public static void updateDomainDescription( } public static void updateTagDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { TagProperties tagProperties = (TagProperties) EntityUtils.getAspectFromEntity( @@ -123,7 +123,7 @@ public static void updateTagDescription( } public static void updateCorpGroupDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { CorpGroupEditableInfo corpGroupEditableInfo = (CorpGroupEditableInfo) EntityUtils.getAspectFromEntity( @@ -143,7 +143,7 @@ public static void updateCorpGroupDescription( } public static void updateGlossaryTermDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { GlossaryTermInfo glossaryTermInfo = (GlossaryTermInfo) EntityUtils.getAspectFromEntity( @@ -168,7 +168,7 @@ public static void updateGlossaryTermDescription( } public static void updateGlossaryNodeDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { GlossaryNodeInfo glossaryNodeInfo = (GlossaryNodeInfo) EntityUtils.getAspectFromEntity( @@ -189,7 +189,7 @@ public static void updateGlossaryNodeDescription( } public static void updateNotebookDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableNotebookProperties notebookProperties = (EditableNotebookProperties) EntityUtils.getAspectFromEntity( @@ -212,8 +212,8 @@ public static Boolean validateFieldDescriptionInput( Urn resourceUrn, String subResource, SubResourceType subResourceType, - EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", resourceUrn, resourceUrn)); } @@ -223,8 +223,8 @@ public static Boolean validateFieldDescriptionInput( return true; } - public static Boolean validateDomainInput(Urn resourceUrn, EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + public static Boolean validateDomainInput(Urn resourceUrn, EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", resourceUrn, resourceUrn)); } @@ -232,8 +232,8 @@ public static Boolean validateDomainInput(Urn resourceUrn, EntityService entityS return true; } - public static Boolean validateContainerInput(Urn resourceUrn, EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + public static Boolean validateContainerInput(Urn resourceUrn, EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", resourceUrn, resourceUrn)); } @@ -241,24 +241,24 @@ public static Boolean validateContainerInput(Urn resourceUrn, EntityService enti return true; } - public static Boolean validateLabelInput(Urn resourceUrn, EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + public static Boolean validateLabelInput(Urn resourceUrn, EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", resourceUrn, resourceUrn)); } return true; } - public static Boolean validateCorpGroupInput(Urn corpUserUrn, EntityService entityService) { - if (!entityService.exists(corpUserUrn)) { + public static Boolean validateCorpGroupInput(Urn corpUserUrn, EntityService entityService) { + if (!entityService.exists(corpUserUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", corpUserUrn, corpUserUrn)); } return true; } - public static Boolean validateNotebookInput(Urn notebookUrn, EntityService entityService) { - if (!entityService.exists(notebookUrn)) { + public static Boolean validateNotebookInput(Urn notebookUrn, EntityService entityService) { + if (!entityService.exists(notebookUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", notebookUrn, notebookUrn)); } @@ -335,7 +335,7 @@ public static boolean isAuthorizedToUpdateDescription( } public static void updateMlModelDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableMLModelProperties editableProperties = (EditableMLModelProperties) EntityUtils.getAspectFromEntity( @@ -355,7 +355,7 @@ public static void updateMlModelDescription( } public static void updateMlModelGroupDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableMLModelGroupProperties editableProperties = (EditableMLModelGroupProperties) EntityUtils.getAspectFromEntity( @@ -375,7 +375,7 @@ public static void updateMlModelGroupDescription( } public static void updateMlFeatureDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableMLFeatureProperties editableProperties = (EditableMLFeatureProperties) EntityUtils.getAspectFromEntity( @@ -395,7 +395,7 @@ public static void updateMlFeatureDescription( } public static void updateMlFeatureTableDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableMLFeatureTableProperties editableProperties = (EditableMLFeatureTableProperties) EntityUtils.getAspectFromEntity( @@ -415,7 +415,7 @@ public static void updateMlFeatureTableDescription( } public static void updateMlPrimaryKeyDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { EditableMLPrimaryKeyProperties editableProperties = (EditableMLPrimaryKeyProperties) EntityUtils.getAspectFromEntity( @@ -435,7 +435,7 @@ public static void updateMlPrimaryKeyDescription( } public static void updateDataProductDescription( - String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { + String newDescription, Urn resourceUrn, Urn actor, EntityService entityService) { DataProductProperties properties = (DataProductProperties) EntityUtils.getAspectFromEntity( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/MoveDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/MoveDomainResolver.java index e4c5c132be4f7d..dab8cfffd54e3d 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/MoveDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/MoveDomainResolver.java @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class MoveDomainResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; private final EntityClient _entityClient; @Override @@ -67,7 +67,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw if (!newParentDomainUrn.getEntityType().equals(Constants.DOMAIN_ENTITY_NAME)) { throw new IllegalArgumentException("Parent entity is not a domain."); } - if (!_entityService.exists(newParentDomainUrn)) { + if (!_entityService.exists(newParentDomainUrn, true)) { throw new IllegalArgumentException("Parent entity does not exist."); } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateNameResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateNameResolver.java index dd44c2718b3a43..8e4a96637e04dc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateNameResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateNameResolver.java @@ -35,7 +35,7 @@ @RequiredArgsConstructor public class UpdateNameResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; private final EntityClient _entityClient; @Override @@ -47,7 +47,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw return CompletableFuture.supplyAsync( () -> { - if (!_entityService.exists(targetUrn)) { + if (!_entityService.exists(targetUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", targetUrn, targetUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateParentNodeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateParentNodeResolver.java index 848118e6cc0f6f..2fcec54978b4f0 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateParentNodeResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateParentNodeResolver.java @@ -26,7 +26,7 @@ @RequiredArgsConstructor public class UpdateParentNodeResolver implements DataFetcher> { - private final EntityService _entityService; + private final EntityService _entityService; private final EntityClient _entityClient; @Override @@ -37,7 +37,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw Urn targetUrn = Urn.createFromString(input.getResourceUrn()); log.info("Updating parent node. input: {}", input.toString()); - if (!_entityService.exists(targetUrn)) { + if (!_entityService.exists(targetUrn, true)) { throw new IllegalArgumentException( String.format("Failed to update %s. %s does not exist.", targetUrn, targetUrn)); } @@ -45,7 +45,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw GlossaryNodeUrn parentNodeUrn = null; if (input.getParentNode() != null) { parentNodeUrn = GlossaryNodeUrn.createFromString(input.getParentNode()); - if (!_entityService.exists(parentNodeUrn) + if (!_entityService.exists(parentNodeUrn, true) || !parentNodeUrn.getEntityType().equals(Constants.GLOSSARY_NODE_ENTITY_NAME)) { throw new IllegalArgumentException( String.format( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java index fb88d6c29f6621..5dbd282580c870 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java @@ -77,7 +77,7 @@ public static void setDomainForResources( @Nullable Urn domainUrn, List resources, Urn actor, - EntityService entityService) + EntityService entityService) throws Exception { final List changes = new ArrayList<>(); for (ResourceRefInput resource : resources) { @@ -87,7 +87,10 @@ public static void setDomainForResources( } private static MetadataChangeProposal buildSetDomainProposal( - @Nullable Urn domainUrn, ResourceRefInput resource, Urn actor, EntityService entityService) { + @Nullable Urn domainUrn, + ResourceRefInput resource, + Urn actor, + EntityService entityService) { Domains domains = (Domains) EntityUtils.getAspectFromEntity( @@ -104,8 +107,8 @@ private static MetadataChangeProposal buildSetDomainProposal( UrnUtils.getUrn(resource.getResourceUrn()), Constants.DOMAINS_ASPECT_NAME, domains); } - public static void validateDomain(Urn domainUrn, EntityService entityService) { - if (!entityService.exists(domainUrn)) { + public static void validateDomain(Urn domainUrn, EntityService entityService) { + if (!entityService.exists(domainUrn, true)) { throw new IllegalArgumentException( String.format("Failed to validate Domain with urn %s. Urn does not exist.", domainUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java index 8765b91f65d9d3..09323fdfc83778 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java @@ -42,7 +42,11 @@ public class LabelUtils { private LabelUtils() {} public static void removeTermFromResource( - Urn labelUrn, Urn resourceUrn, String subResource, Urn actor, EntityService entityService) { + Urn labelUrn, + Urn resourceUrn, + String subResource, + Urn actor, + EntityService entityService) { if (subResource == null || subResource.equals("")) { com.linkedin.common.GlossaryTerms terms = (com.linkedin.common.GlossaryTerms) @@ -80,7 +84,7 @@ public static void removeTermFromResource( } public static void removeTagsFromResources( - List tags, List resources, Urn actor, EntityService entityService) + List tags, List resources, Urn actor, EntityService entityService) throws Exception { final List changes = new ArrayList<>(); for (ResourceRefInput resource : resources) { @@ -90,7 +94,10 @@ public static void removeTagsFromResources( } public static void addTagsToResources( - List tagUrns, List resources, Urn actor, EntityService entityService) + List tagUrns, + List resources, + Urn actor, + EntityService entityService) throws Exception { final List changes = new ArrayList<>(); for (ResourceRefInput resource : resources) { @@ -100,7 +107,10 @@ public static void addTagsToResources( } public static void removeTermsFromResources( - List termUrns, List resources, Urn actor, EntityService entityService) + List termUrns, + List resources, + Urn actor, + EntityService entityService) throws Exception { final List changes = new ArrayList<>(); for (ResourceRefInput resource : resources) { @@ -110,7 +120,10 @@ public static void removeTermsFromResources( } public static void addTermsToResources( - List termUrns, List resources, Urn actor, EntityService entityService) + List termUrns, + List resources, + Urn actor, + EntityService entityService) throws Exception { final List changes = new ArrayList<>(); for (ResourceRefInput resource : resources) { @@ -124,7 +137,7 @@ public static void addTermsToResource( Urn resourceUrn, String subResource, Urn actor, - EntityService entityService) + EntityService entityService) throws URISyntaxException { if (subResource == null || subResource.equals("")) { com.linkedin.common.GlossaryTerms terms = @@ -248,7 +261,7 @@ public static void validateResourceAndLabel( String subResource, SubResourceType subResourceType, String labelEntityType, - EntityService entityService, + EntityService entityService, Boolean isRemoving) { for (Urn urn : labelUrns) { validateResourceAndLabel( @@ -263,14 +276,14 @@ public static void validateResourceAndLabel( } public static void validateLabel( - Urn labelUrn, String labelEntityType, EntityService entityService) { + Urn labelUrn, String labelEntityType, EntityService entityService) { if (!labelUrn.getEntityType().equals(labelEntityType)) { throw new IllegalArgumentException( String.format( "Failed to validate label with urn %s. Urn type does not match entity type %s..", labelUrn, labelEntityType)); } - if (!entityService.exists(labelUrn)) { + if (!entityService.exists(labelUrn, true)) { throw new IllegalArgumentException( String.format("Failed to validate label with urn %s. Urn does not exist.", labelUrn)); } @@ -281,8 +294,8 @@ public static void validateResource( Urn resourceUrn, String subResource, SubResourceType subResourceType, - EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to update resource with urn %s. Entity does not exist.", resourceUrn)); @@ -310,7 +323,7 @@ public static void validateResourceAndLabel( String subResource, SubResourceType subResourceType, String labelEntityType, - EntityService entityService, + EntityService entityService, Boolean isRemoving) { if (!isRemoving) { validateLabel(labelUrn, labelEntityType, entityService); @@ -319,7 +332,7 @@ public static void validateResourceAndLabel( } private static MetadataChangeProposal buildAddTagsProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { if (resource.getSubResource() == null || resource.getSubResource().equals("")) { // Case 1: Adding tags to a top-level entity @@ -331,7 +344,7 @@ private static MetadataChangeProposal buildAddTagsProposal( } private static MetadataChangeProposal buildRemoveTagsProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { if (resource.getSubResource() == null || resource.getSubResource().equals("")) { // Case 1: Adding tags to a top-level entity @@ -343,7 +356,7 @@ private static MetadataChangeProposal buildRemoveTagsProposal( } private static MetadataChangeProposal buildRemoveTagsToEntityProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { com.linkedin.common.GlobalTags tags = (com.linkedin.common.GlobalTags) EntityUtils.getAspectFromEntity( @@ -361,7 +374,7 @@ private static MetadataChangeProposal buildRemoveTagsToEntityProposal( } private static MetadataChangeProposal buildRemoveTagsToSubResourceProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = (com.linkedin.schema.EditableSchemaMetadata) EntityUtils.getAspectFromEntity( @@ -383,7 +396,7 @@ private static MetadataChangeProposal buildRemoveTagsToSubResourceProposal( } private static MetadataChangeProposal buildAddTagsToEntityProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { com.linkedin.common.GlobalTags tags = (com.linkedin.common.GlobalTags) @@ -402,7 +415,7 @@ private static MetadataChangeProposal buildAddTagsToEntityProposal( } private static MetadataChangeProposal buildAddTagsToSubResourceProposal( - List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List tagUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = (com.linkedin.schema.EditableSchemaMetadata) @@ -455,7 +468,7 @@ private static void addTagsIfNotExists(GlobalTags tags, List tagUrns) } private static MetadataChangeProposal buildAddTermsProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { if (resource.getSubResource() == null || resource.getSubResource().equals("")) { // Case 1: Adding terms to a top-level entity @@ -467,7 +480,7 @@ private static MetadataChangeProposal buildAddTermsProposal( } private static MetadataChangeProposal buildRemoveTermsProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { if (resource.getSubResource() == null || resource.getSubResource().equals("")) { // Case 1: Removing terms from a top-level entity @@ -479,7 +492,7 @@ private static MetadataChangeProposal buildRemoveTermsProposal( } private static MetadataChangeProposal buildAddTermsToEntityProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { com.linkedin.common.GlossaryTerms terms = (com.linkedin.common.GlossaryTerms) @@ -500,7 +513,7 @@ private static MetadataChangeProposal buildAddTermsToEntityProposal( } private static MetadataChangeProposal buildAddTermsToSubResourceProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) throws URISyntaxException { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = (com.linkedin.schema.EditableSchemaMetadata) @@ -526,7 +539,7 @@ private static MetadataChangeProposal buildAddTermsToSubResourceProposal( } private static MetadataChangeProposal buildRemoveTermsToEntityProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { com.linkedin.common.GlossaryTerms terms = (com.linkedin.common.GlossaryTerms) EntityUtils.getAspectFromEntity( @@ -542,7 +555,7 @@ private static MetadataChangeProposal buildRemoveTermsToEntityProposal( } private static MetadataChangeProposal buildRemoveTermsToSubResourceProposal( - List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { + List termUrns, ResourceRefInput resource, Urn actor, EntityService entityService) { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = (com.linkedin.schema.EditableSchemaMetadata) EntityUtils.getAspectFromEntity( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java index b93c72edbcfc51..d82b8c17ff1b7e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LinkUtils.java @@ -28,7 +28,11 @@ public class LinkUtils { private LinkUtils() {} public static void addLink( - String linkUrl, String linkLabel, Urn resourceUrn, Urn actor, EntityService entityService) { + String linkUrl, + String linkLabel, + Urn resourceUrn, + Urn actor, + EntityService entityService) { InstitutionalMemory institutionalMemoryAspect = (InstitutionalMemory) EntityUtils.getAspectFromEntity( @@ -46,7 +50,7 @@ public static void addLink( } public static void removeLink( - String linkUrl, Urn resourceUrn, Urn actor, EntityService entityService) { + String linkUrl, Urn resourceUrn, Urn actor, EntityService entityService) { InstitutionalMemory institutionalMemoryAspect = (InstitutionalMemory) EntityUtils.getAspectFromEntity( @@ -109,7 +113,7 @@ public static boolean isAuthorizedToUpdateLinks(@Nonnull QueryContext context, U } public static Boolean validateAddRemoveInput( - String linkUrl, Urn resourceUrn, EntityService entityService) { + String linkUrl, Urn resourceUrn, EntityService entityService) { try { new Url(linkUrl); @@ -120,7 +124,7 @@ public static Boolean validateAddRemoveInput( resourceUrn)); } - if (!entityService.exists(resourceUrn)) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to change institutional memory for resource %s. Resource does not exist.", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java index 55d408d3f7aab3..1dd9da97cb2f7b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java @@ -202,16 +202,16 @@ public static void validateAuthorizedToUpdateOwners( } public static void validateAddOwnerInput( - List owners, Urn resourceUrn, EntityService entityService) { + List owners, Urn resourceUrn, EntityService entityService) { for (OwnerInput owner : owners) { validateAddOwnerInput(owner, resourceUrn, entityService); } } public static void validateAddOwnerInput( - OwnerInput owner, Urn resourceUrn, EntityService entityService) { + OwnerInput owner, Urn resourceUrn, EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to change ownership for resource %s. Resource does not exist.", resourceUrn)); @@ -220,7 +220,7 @@ public static void validateAddOwnerInput( validateOwner(owner, entityService); } - public static void validateOwner(OwnerInput owner, EntityService entityService) { + public static void validateOwner(OwnerInput owner, EntityService entityService) { OwnerEntityType ownerEntityType = owner.getOwnerEntityType(); Urn ownerUrn = UrnUtils.getUrn(owner.getOwnerUrn()); @@ -241,7 +241,7 @@ public static void validateOwner(OwnerInput owner, EntityService entityService) ownerUrn)); } - if (!entityService.exists(ownerUrn)) { + if (!entityService.exists(ownerUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to change ownership for resource(s). Owner with urn %s does not exist.", @@ -249,7 +249,7 @@ public static void validateOwner(OwnerInput owner, EntityService entityService) } if (owner.getOwnershipTypeUrn() != null - && !entityService.exists(UrnUtils.getUrn(owner.getOwnershipTypeUrn()))) { + && !entityService.exists(UrnUtils.getUrn(owner.getOwnershipTypeUrn()), true)) { throw new IllegalArgumentException( String.format( "Failed to change ownership for resource(s). Custom Ownership type with " @@ -264,8 +264,8 @@ public static void validateOwner(OwnerInput owner, EntityService entityService) } } - public static void validateRemoveInput(Urn resourceUrn, EntityService entityService) { - if (!entityService.exists(resourceUrn)) { + public static void validateRemoveInput(Urn resourceUrn, EntityService entityService) { + if (!entityService.exists(resourceUrn, true)) { throw new IllegalArgumentException( String.format( "Failed to change ownership for resource %s. Resource does not exist.", resourceUrn)); @@ -276,17 +276,18 @@ public static void addCreatorAsOwner( QueryContext context, String urn, OwnerEntityType ownerEntityType, - EntityService entityService) { + EntityService entityService) { try { Urn actorUrn = CorpuserUrn.createFromString(context.getActorUrn()); OwnershipType ownershipType = OwnershipType.TECHNICAL_OWNER; - if (!entityService.exists(UrnUtils.getUrn(mapOwnershipTypeToEntity(ownershipType.name())))) { + if (!entityService.exists( + UrnUtils.getUrn(mapOwnershipTypeToEntity(ownershipType.name())), true)) { log.warn("Technical owner does not exist, defaulting to None ownership."); ownershipType = OwnershipType.NONE; } String ownershipTypeUrn = mapOwnershipTypeToEntity(ownershipType.name()); - if (!entityService.exists(UrnUtils.getUrn(ownershipTypeUrn))) { + if (!entityService.exists(UrnUtils.getUrn(ownershipTypeUrn), true)) { throw new RuntimeException( String.format("Unknown ownership type urn %s", ownershipTypeUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java index 7b9290b4532b58..e548c6f3eae073 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java @@ -33,7 +33,7 @@ public class SetTagColorResolver implements DataFetcher> { private final EntityClient _entityClient; - private final EntityService + private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient @Override @@ -53,7 +53,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw } // If tag does not exist, then throw exception. - if (!_entityService.exists(tagUrn)) { + if (!_entityService.exists(tagUrn, true)) { throw new IllegalArgumentException( String.format("Failed to set Tag %s color. Tag does not exist.", tagUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/HyperParameterValueTypeMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/HyperParameterValueTypeMapper.java index f60f34dd7a085f..81849df320e57a 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/HyperParameterValueTypeMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/HyperParameterValueTypeMapper.java @@ -33,7 +33,7 @@ public HyperParameterValueType apply( } else if (input.isDouble()) { result = new FloatBox(input.getDouble()); } else if (input.isFloat()) { - result = new FloatBox(new Double(input.getFloat())); + result = new FloatBox(Double.valueOf(input.getFloat())); } else { throw new RuntimeException("Type is not one of the Union Types, Type: " + input.toString()); } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/UpdateLineageResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/UpdateLineageResolverTest.java index 0d87ce4b2e2adf..3f228efafac429 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/UpdateLineageResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/UpdateLineageResolverTest.java @@ -2,10 +2,11 @@ import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; -import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.LineageEdge; @@ -16,8 +17,10 @@ import graphql.schema.DataFetchingEnvironment; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletionException; import org.joda.time.DateTimeUtils; import org.mockito.Mockito; @@ -64,10 +67,8 @@ public void testUpdateDatasetLineage() throws Exception { mockInputAndContext(edgesToAdd, edgesToRemove); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_3))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_4))).thenReturn(true); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))) + .thenAnswer(args -> args.getArgument(0)); assertTrue(resolver.get(_mockEnv).get()); } @@ -79,8 +80,7 @@ public void testFailUpdateWithMissingDownstream() throws Exception { mockInputAndContext(edgesToAdd, new ArrayList<>()); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(false); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(false); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))).thenAnswer(args -> Set.of()); assertThrows(CompletionException.class, () -> resolver.get(_mockEnv).join()); } @@ -93,9 +93,8 @@ public void testUpdateChartLineage() throws Exception { mockInputAndContext(edgesToAdd, edgesToRemove); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(CHART_URN))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(true); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))) + .thenAnswer(args -> args.getArgument(0)); assertTrue(resolver.get(_mockEnv).get()); } @@ -112,10 +111,8 @@ public void testUpdateDashboardLineage() throws Exception { mockInputAndContext(edgesToAdd, edgesToRemove); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(DASHBOARD_URN))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(CHART_URN))).thenReturn(true); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))) + .thenAnswer(args -> args.getArgument(0)); assertTrue(resolver.get(_mockEnv).get()); } @@ -133,11 +130,8 @@ public void testUpdateDataJobLineage() throws Exception { mockInputAndContext(edgesToAdd, edgesToRemove); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(DATAJOB_URN_1))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATAJOB_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_3))).thenReturn(true); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))) + .thenAnswer(args -> args.getArgument(0)); assertTrue(resolver.get(_mockEnv).get()); } @@ -153,15 +147,13 @@ public void testFailUpdateLineageNoPermissions() throws Exception { QueryContext mockContext = getMockDenyContext(); UpdateLineageInput input = new UpdateLineageInput(edgesToAdd, edgesToRemove); - Mockito.when(_mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(_mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(_mockEnv.getContext()).thenReturn(mockContext); UpdateLineageResolver resolver = new UpdateLineageResolver(_mockService, _lineageService); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_1))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_2))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_3))).thenReturn(true); - Mockito.when(_mockService.exists(Urn.createFromString(DATASET_URN_4))).thenReturn(true); + Mockito.when(_mockService.exists(any(Collection.class), eq(true))) + .thenAnswer(args -> args.getArgument(0)); assertThrows(AuthorizationException.class, () -> resolver.get(_mockEnv).join()); } @@ -169,7 +161,7 @@ public void testFailUpdateLineageNoPermissions() throws Exception { private void mockInputAndContext(List edgesToAdd, List edgesToRemove) { QueryContext mockContext = getMockAllowContext(); UpdateLineageInput input = new UpdateLineageInput(edgesToAdd, edgesToRemove); - Mockito.when(_mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(_mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(_mockEnv.getContext()).thenReturn(mockContext); } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolverTest.java index 019d254ffdaac7..f09ead41e5c467 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/assertion/DeleteAssertionResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.assertion; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -31,7 +32,8 @@ public void testGetSuccess() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ASSERTION_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.getAspect( Urn.createFromString(TEST_ASSERTION_URN), Constants.ASSERTION_INFO_ASPECT_NAME, 0L)) @@ -49,24 +51,23 @@ public void testGetSuccess() throws Exception { // Execute resolver QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ASSERTION_URN); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); Mockito.verify(mockClient, Mockito.times(1)) .deleteEntity( - Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN)), - Mockito.any(Authentication.class)); + eq(Urn.createFromString(TEST_ASSERTION_URN)), Mockito.any(Authentication.class)); Mockito.verify(mockService, Mockito.times(1)) .getAspect( - Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN)), - Mockito.eq(Constants.ASSERTION_INFO_ASPECT_NAME), - Mockito.eq(0L)); + eq(Urn.createFromString(TEST_ASSERTION_URN)), + eq(Constants.ASSERTION_INFO_ASPECT_NAME), + eq(0L)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN))); + .exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true)); } @Test @@ -74,7 +75,8 @@ public void testGetSuccessNoAssertionInfoFound() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ASSERTION_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.getAspect( Urn.createFromString(TEST_ASSERTION_URN), Constants.ASSERTION_INFO_ASPECT_NAME, 0L)) @@ -85,24 +87,23 @@ public void testGetSuccessNoAssertionInfoFound() throws Exception { // Execute resolver QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ASSERTION_URN); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); Mockito.verify(mockClient, Mockito.times(1)) .deleteEntity( - Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN)), - Mockito.any(Authentication.class)); + eq(Urn.createFromString(TEST_ASSERTION_URN)), Mockito.any(Authentication.class)); Mockito.verify(mockService, Mockito.times(1)) .getAspect( - Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN)), - Mockito.eq(Constants.ASSERTION_INFO_ASPECT_NAME), - Mockito.eq(0L)); + eq(Urn.createFromString(TEST_ASSERTION_URN)), + eq(Constants.ASSERTION_INFO_ASPECT_NAME), + eq(0L)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN))); + .exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true)); } @Test @@ -111,32 +112,32 @@ public void testGetSuccessAssertionAlreadyRemoved() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ASSERTION_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true))) + .thenReturn(false); DeleteAssertionResolver resolver = new DeleteAssertionResolver(mockClient, mockService); // Execute resolver QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ASSERTION_URN); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); Mockito.verify(mockClient, Mockito.times(0)) .deleteEntity( - Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN)), - Mockito.any(Authentication.class)); + eq(Urn.createFromString(TEST_ASSERTION_URN)), Mockito.any(Authentication.class)); Mockito.verify(mockClient, Mockito.times(0)) .batchGetV2( - Mockito.eq(Constants.ASSERTION_ENTITY_NAME), - Mockito.eq(ImmutableSet.of(Urn.createFromString(TEST_ASSERTION_URN))), - Mockito.eq(ImmutableSet.of(Constants.ASSERTION_INFO_ASPECT_NAME)), + eq(Constants.ASSERTION_ENTITY_NAME), + eq(ImmutableSet.of(Urn.createFromString(TEST_ASSERTION_URN))), + eq(ImmutableSet.of(Constants.ASSERTION_INFO_ASPECT_NAME)), Mockito.any(Authentication.class)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ASSERTION_URN))); + .exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true)); } @Test @@ -144,7 +145,8 @@ public void testGetUnauthorized() throws Exception { // Create resolver EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ASSERTION_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.getAspect( Urn.createFromString(TEST_ASSERTION_URN), Constants.ASSERTION_INFO_ASPECT_NAME, 0L)) @@ -161,7 +163,7 @@ public void testGetUnauthorized() throws Exception { // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ASSERTION_URN); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -178,14 +180,15 @@ public void testGetEntityClientException() throws Exception { .deleteEntity(Mockito.any(), Mockito.any(Authentication.class)); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ASSERTION_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ASSERTION_URN)), eq(true))) + .thenReturn(true); DeleteAssertionResolver resolver = new DeleteAssertionResolver(mockClient, mockService); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); - Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ASSERTION_URN); + Mockito.when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ASSERTION_URN); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/delete/BatchUpdateSoftDeletedResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/delete/BatchUpdateSoftDeletedResolverTest.java index 56b01be29e1633..f83adf33d521ae 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/delete/BatchUpdateSoftDeletedResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/delete/BatchUpdateSoftDeletedResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -47,8 +48,10 @@ public void testGetSuccessNoExistingStatus() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateSoftDeletedResolver resolver = new BatchUpdateSoftDeletedResolver(mockService); @@ -94,8 +97,10 @@ public void testGetSuccessExistingStatus() throws Exception { Mockito.eq(0L))) .thenReturn(originalStatus); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateSoftDeletedResolver resolver = new BatchUpdateSoftDeletedResolver(mockService); @@ -138,8 +143,10 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateSoftDeletedResolver resolver = new BatchUpdateSoftDeletedResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java index be7f200a6b9d72..f25d5a4cbbf045 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/BatchUpdateDeprecationResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -48,8 +49,10 @@ public void testGetSuccessNoExistingDeprecation() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); @@ -109,8 +112,10 @@ public void testGetSuccessExistingDeprecation() throws Exception { Mockito.eq(0L))) .thenReturn(originalDeprecation); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); @@ -163,8 +168,10 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); BatchUpdateDeprecationResolver resolver = new BatchUpdateDeprecationResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolverTest.java index e4be330f5ba2ac..f4b45b3dc8f290 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/deprecation/UpdateDeprecationResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -45,9 +46,9 @@ public void testGetSuccessNoExistingDeprecation() throws Exception { Mockito.when( mockClient.batchGetV2( - Mockito.eq(Constants.DATASET_ENTITY_NAME), - Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), - Mockito.eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), Mockito.any(Authentication.class))) .thenReturn( ImmutableMap.of( @@ -58,7 +59,8 @@ public void testGetSuccessNoExistingDeprecation() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UpdateDeprecationResolver resolver = new UpdateDeprecationResolver(mockClient, mockService); @@ -66,7 +68,7 @@ public void testGetSuccessNoExistingDeprecation() throws Exception { QueryContext mockContext = getMockAllowContext(); Mockito.when(mockContext.getActorUrn()).thenReturn(TEST_ACTOR_URN.toString()); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); resolver.get(mockEnv).get(); @@ -81,10 +83,10 @@ public void testGetSuccessNoExistingDeprecation() throws Exception { UrnUtils.getUrn(TEST_ENTITY_URN), DEPRECATION_ASPECT_NAME, newDeprecation); Mockito.verify(mockClient, Mockito.times(1)) - .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); + .ingestProposal(eq(proposal), Mockito.any(Authentication.class), eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -101,9 +103,9 @@ public void testGetSuccessExistingDeprecation() throws Exception { Mockito.when( mockClient.batchGetV2( - Mockito.eq(Constants.DATASET_ENTITY_NAME), - Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), - Mockito.eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), Mockito.any(Authentication.class))) .thenReturn( ImmutableMap.of( @@ -119,7 +121,8 @@ public void testGetSuccessExistingDeprecation() throws Exception { .setValue(new Aspect(originalDeprecation.data()))))))); EntityService mockService = Mockito.mock(EntityService.class); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UpdateDeprecationResolver resolver = new UpdateDeprecationResolver(mockClient, mockService); @@ -127,7 +130,7 @@ public void testGetSuccessExistingDeprecation() throws Exception { QueryContext mockContext = getMockAllowContext(); Mockito.when(mockContext.getActorUrn()).thenReturn(TEST_ACTOR_URN.toString()); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); resolver.get(mockEnv).get(); @@ -142,10 +145,10 @@ public void testGetSuccessExistingDeprecation() throws Exception { UrnUtils.getUrn(TEST_ENTITY_URN), DEPRECATION_ASPECT_NAME, newDeprecation); Mockito.verify(mockClient, Mockito.times(1)) - .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); + .ingestProposal(eq(proposal), Mockito.any(Authentication.class), eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -155,9 +158,9 @@ public void testGetFailureEntityDoesNotExist() throws Exception { Mockito.when( mockClient.batchGetV2( - Mockito.eq(Constants.DATASET_ENTITY_NAME), - Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), - Mockito.eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + eq(ImmutableSet.of(Constants.DEPRECATION_ASPECT_NAME)), Mockito.any(Authentication.class))) .thenReturn( ImmutableMap.of( @@ -168,7 +171,8 @@ public void testGetFailureEntityDoesNotExist() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = Mockito.mock(EntityService.class); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); UpdateDeprecationResolver resolver = new UpdateDeprecationResolver(mockClient, mockService); @@ -176,7 +180,7 @@ public void testGetFailureEntityDoesNotExist() throws Exception { QueryContext mockContext = getMockAllowContext(); Mockito.when(mockContext.getActorUrn()).thenReturn(TEST_ACTOR_URN.toString()); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -193,7 +197,7 @@ public void testGetUnauthorized() throws Exception { // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -214,7 +218,7 @@ public void testGetEntityClientException() throws Exception { // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(TEST_DEPRECATION_INPUT); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/BatchSetDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/BatchSetDomainResolverTest.java index 32f0d30e7751a0..81343b75f7d7e6 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/BatchSetDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/BatchSetDomainResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -53,11 +54,15 @@ public void testGetSuccessNoExistingDomains() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_2_URN)), eq(true))) + .thenReturn(true); BatchSetDomainResolver resolver = new BatchSetDomainResolver(mockService); @@ -88,7 +93,7 @@ public void testGetSuccessNoExistingDomains() throws Exception { verifyIngestProposal(mockService, 1, List.of(proposal1, proposal2)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_DOMAIN_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_DOMAIN_2_URN)), eq(true)); } @Test @@ -113,11 +118,15 @@ public void testGetSuccessExistingDomains() throws Exception { Mockito.eq(0L))) .thenReturn(originalDomain); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_2_URN)), eq(true))) + .thenReturn(true); BatchSetDomainResolver resolver = new BatchSetDomainResolver(mockService); @@ -153,7 +162,7 @@ public void testGetSuccessExistingDomains() throws Exception { verifyIngestProposal(mockService, 1, List.of(proposal1, proposal2)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_DOMAIN_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_DOMAIN_2_URN)), eq(true)); } @Test @@ -178,11 +187,15 @@ public void testGetSuccessUnsetDomains() throws Exception { Mockito.eq(0L))) .thenReturn(originalDomain); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_2_URN)), eq(true))) + .thenReturn(true); BatchSetDomainResolver resolver = new BatchSetDomainResolver(mockService); @@ -222,8 +235,10 @@ public void testGetFailureDomainDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_1_URN)), eq(true))) + .thenReturn(false); BatchSetDomainResolver resolver = new BatchSetDomainResolver(mockService); @@ -260,9 +275,12 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_DOMAIN_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_DOMAIN_1_URN)), eq(true))) + .thenReturn(true); BatchSetDomainResolver resolver = new BatchSetDomainResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/MoveDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/MoveDomainResolverTest.java index a0eff5d0574dbc..1aa7f5aef467ce 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/MoveDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/MoveDomainResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -73,7 +74,8 @@ private MetadataChangeProposal setupTests( public void testGetSuccess() throws Exception { EntityService mockService = Mockito.mock(EntityService.class); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(PARENT_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(PARENT_DOMAIN_URN)), eq(true))) + .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -92,7 +94,8 @@ public void testGetSuccess() throws Exception { public void testGetFailureEntityDoesNotExist() throws Exception { EntityService mockService = Mockito.mock(EntityService.class); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(PARENT_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(PARENT_DOMAIN_URN)), eq(true))) + .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -115,7 +118,8 @@ public void testGetFailureEntityDoesNotExist() throws Exception { public void testGetFailureParentDoesNotExist() throws Exception { EntityService mockService = Mockito.mock(EntityService.class); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(PARENT_DOMAIN_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(PARENT_DOMAIN_URN)), eq(true))) + .thenReturn(false); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -130,7 +134,8 @@ public void testGetFailureParentDoesNotExist() throws Exception { public void testGetFailureParentIsNotDomain() throws Exception { EntityService mockService = Mockito.mock(EntityService.class); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(PARENT_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(PARENT_DOMAIN_URN)), eq(true))) + .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INVALID_INPUT); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java index ad5ad2315ce43c..7b8d11802792b6 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -58,8 +59,10 @@ public void testGetSuccessNoExistingDomains() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_NEW_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); @@ -82,10 +85,10 @@ public void testGetSuccessNoExistingDomains() throws Exception { .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_NEW_DOMAIN_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true)); } @Test @@ -119,8 +122,10 @@ public void testGetSuccessExistingDomains() throws Exception { .setValue(new Aspect(originalDomains.data()))))))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_NEW_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); @@ -143,10 +148,10 @@ public void testGetSuccessExistingDomains() throws Exception { .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_NEW_DOMAIN_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true)); } @Test @@ -170,8 +175,10 @@ public void testGetFailureDomainDoesNotExist() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_NEW_DOMAIN_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(false); SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); @@ -208,8 +215,10 @@ public void testGetFailureEntityDoesNotExist() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_NEW_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolverTest.java index 7e6e2581688985..7ac45fe98b131f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/UnsetDomainResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -57,7 +58,8 @@ public void testGetSuccessNoExistingDomains() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UnsetDomainResolver resolver = new UnsetDomainResolver(mockClient, mockService); @@ -77,7 +79,7 @@ public void testGetSuccessNoExistingDomains() throws Exception { .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -111,7 +113,8 @@ public void testGetSuccessExistingDomains() throws Exception { .setValue(new Aspect(originalDomains.data()))))))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UnsetDomainResolver resolver = new UnsetDomainResolver(mockClient, mockService); @@ -131,7 +134,7 @@ public void testGetSuccessExistingDomains() throws Exception { .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -155,7 +158,8 @@ public void testGetFailureEntityDoesNotExist() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); UnsetDomainResolver resolver = new UnsetDomainResolver(mockClient, mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolverTest.java index 241951319c75ed..ed04a14ed7c3a5 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/embed/UpdateEmbedResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -47,7 +48,8 @@ public void testGetSuccessNoExistingEmbed() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UpdateEmbedResolver resolver = new UpdateEmbedResolver(mockService); @@ -68,7 +70,7 @@ public void testGetSuccessNoExistingEmbed() throws Exception { ; Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -85,7 +87,8 @@ public void testGetSuccessExistingEmbed() throws Exception { Mockito.eq(0L))) .thenReturn(originalEmbed); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); UpdateEmbedResolver resolver = new UpdateEmbedResolver(mockService); @@ -105,7 +108,7 @@ public void testGetSuccessExistingEmbed() throws Exception { verifySingleIngestProposal(mockService, 1, proposal); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -128,7 +131,8 @@ public void testGetFailureEntityDoesNotExist() throws Exception { .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); UpdateEmbedResolver resolver = new UpdateEmbedResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolverTest.java index fa8b1d6a747ca2..c3c9ccea6d2703 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/entity/EntityExistsResolverTest.java @@ -3,6 +3,7 @@ import static org.mockito.Mockito.*; import static org.testng.Assert.*; +import com.linkedin.common.urn.Urn; import com.linkedin.metadata.entity.EntityService; import graphql.schema.DataFetchingEnvironment; import org.testng.annotations.BeforeMethod; @@ -33,7 +34,7 @@ public void testFailsNullEntity() { @Test public void testPasses() throws Exception { when(_dataFetchingEnvironment.getArgument("urn")).thenReturn(ENTITY_URN_STRING); - when(_entityService.exists(any())).thenReturn(true); + when(_entityService.exists(any(Urn.class), eq(true))).thenReturn(true); assertTrue(_resolver.get(_dataFetchingEnvironment).join()); } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolverTest.java index 287d270ab569ca..8c5b1d7607027f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/AddRelatedTermsResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -28,9 +29,9 @@ private EntityService setUpService() { EntityService mockService = getMockEntityService(); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), - Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME), + eq(0L))) .thenReturn(null); return mockService; } @@ -39,9 +40,12 @@ private EntityService setUpService() { public void testGetSuccessIsRelatedNonExistent() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -52,26 +56,29 @@ public void testGetSuccessIsRelatedNonExistent() throws Exception { TEST_ENTITY_URN, ImmutableList.of(TEST_TERM_1_URN, TEST_TERM_2_URN), TermRelationshipType.isA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); verifySingleIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true)); } @Test public void testGetSuccessHasRelatedNonExistent() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -82,24 +89,25 @@ public void testGetSuccessHasRelatedNonExistent() throws Exception { TEST_ENTITY_URN, ImmutableList.of(TEST_TERM_1_URN, TEST_TERM_2_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); verifySingleIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true)); } @Test public void testGetFailAddSelfAsRelatedTerm() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -108,7 +116,7 @@ public void testGetFailAddSelfAsRelatedTerm() throws Exception { RelatedTermsInput input = new RelatedTermsInput( TEST_ENTITY_URN, ImmutableList.of(TEST_ENTITY_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); @@ -119,7 +127,8 @@ public void testGetFailAddSelfAsRelatedTerm() throws Exception { public void testGetFailAddNonTermAsRelatedTerm() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -128,7 +137,7 @@ public void testGetFailAddNonTermAsRelatedTerm() throws Exception { RelatedTermsInput input = new RelatedTermsInput( TEST_ENTITY_URN, ImmutableList.of(DATASET_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); @@ -139,8 +148,10 @@ public void testGetFailAddNonTermAsRelatedTerm() throws Exception { public void testGetFailAddNonExistentTermAsRelatedTerm() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(false); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -149,7 +160,7 @@ public void testGetFailAddNonExistentTermAsRelatedTerm() throws Exception { RelatedTermsInput input = new RelatedTermsInput( TEST_ENTITY_URN, ImmutableList.of(TEST_TERM_1_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); @@ -160,8 +171,10 @@ public void testGetFailAddNonExistentTermAsRelatedTerm() throws Exception { public void testGetFailAddToNonExistentUrn() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -170,7 +183,7 @@ public void testGetFailAddToNonExistentUrn() throws Exception { RelatedTermsInput input = new RelatedTermsInput( TEST_ENTITY_URN, ImmutableList.of(TEST_TERM_1_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); @@ -181,8 +194,10 @@ public void testGetFailAddToNonExistentUrn() throws Exception { public void testGetFailAddToNonTerm() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(DATASET_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(DATASET_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -191,7 +206,7 @@ public void testGetFailAddToNonTerm() throws Exception { RelatedTermsInput input = new RelatedTermsInput( DATASET_URN, ImmutableList.of(TEST_TERM_1_URN), TermRelationshipType.hasA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); @@ -202,9 +217,12 @@ public void testGetFailAddToNonTerm() throws Exception { public void testFailNoPermissions() throws Exception { EntityService mockService = setUpService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService); @@ -215,7 +233,7 @@ public void testFailNoPermissions() throws Exception { TEST_ENTITY_URN, ImmutableList.of(TEST_TERM_1_URN, TEST_TERM_2_URN), TermRelationshipType.isA); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolverTest.java index 7229d2acf763d5..f4d4c528dc0c69 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/DeleteGlossaryEntityResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; import static com.linkedin.datahub.graphql.TestUtils.getMockEntityService; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -26,7 +27,8 @@ public void testGetSuccess() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_URN)), eq(true))) + .thenReturn(true); QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -50,7 +52,8 @@ public void testGetEntityClientException() throws Exception { .deleteEntity(Mockito.any(), Mockito.any(Authentication.class)); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_URN)), eq(true))) + .thenReturn(true); DeleteGlossaryEntityResolver resolver = new DeleteGlossaryEntityResolver(mockClient, mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolverTest.java index 47de668b2c9dc3..e46d8b1503d9ef 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/RemoveRelatedTermsResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.glossary; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -41,7 +42,8 @@ public void testGetSuccessIsA() throws Exception { Mockito.eq(0L))) .thenReturn(relatedTerms); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService); @@ -56,7 +58,7 @@ public void testGetSuccessIsA() throws Exception { assertTrue(resolver.get(mockEnv).get()); verifySingleIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -73,7 +75,8 @@ public void testGetSuccessHasA() throws Exception { Mockito.eq(0L))) .thenReturn(relatedTerms); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService); @@ -88,7 +91,7 @@ public void testGetSuccessHasA() throws Exception { assertTrue(resolver.get(mockEnv).get()); verifySingleIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -101,7 +104,8 @@ public void testFailAspectDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService); @@ -131,7 +135,8 @@ public void testFailNoPermissions() throws Exception { Mockito.eq(0L))) .thenReturn(relatedTerms); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService); @@ -146,6 +151,6 @@ public void testFailNoPermissions() throws Exception { assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get()); verifyNoIngestProposal(mockService); Mockito.verify(mockService, Mockito.times(0)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateNameResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateNameResolverTest.java index 3972715fcefb17..062c1da5e038dc 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateNameResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateNameResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -61,7 +62,7 @@ private MetadataChangeProposal setupTests( public void testGetSuccess() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))).thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -76,7 +77,7 @@ public void testGetSuccess() throws Exception { public void testGetSuccessForNode() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(NODE_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(NODE_URN)), eq(true))).thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT_FOR_NODE); @@ -106,7 +107,8 @@ public void testGetSuccessForNode() throws Exception { public void testGetSuccessForDomain() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(DOMAIN_URN)), eq(true))) + .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT_FOR_DOMAIN); @@ -148,7 +150,8 @@ public void testGetSuccessForDomain() throws Exception { public void testGetFailureEntityDoesNotExist() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))) + .thenReturn(false); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateParentNodeResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateParentNodeResolverTest.java index 74a59b10a40b01..cdab78023b846d 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateParentNodeResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/glossary/UpdateParentNodeResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -63,8 +64,9 @@ private MetadataChangeProposal setupTests( public void testGetSuccess() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(true); - Mockito.when(mockService.exists(GlossaryNodeUrn.createFromString(PARENT_NODE_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))).thenReturn(true); + Mockito.when( + mockService.exists(eq(GlossaryNodeUrn.createFromString(PARENT_NODE_URN)), eq(true))) .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -80,8 +82,9 @@ public void testGetSuccess() throws Exception { public void testGetSuccessForNode() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(NODE_URN))).thenReturn(true); - Mockito.when(mockService.exists(GlossaryNodeUrn.createFromString(PARENT_NODE_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(NODE_URN)), eq(true))).thenReturn(true); + Mockito.when( + mockService.exists(eq(GlossaryNodeUrn.createFromString(PARENT_NODE_URN)), eq(true))) .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT_WITH_NODE); @@ -114,8 +117,10 @@ public void testGetSuccessForNode() throws Exception { public void testGetFailureEntityDoesNotExist() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(false); - Mockito.when(mockService.exists(GlossaryNodeUrn.createFromString(PARENT_NODE_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))) + .thenReturn(false); + Mockito.when( + mockService.exists(eq(GlossaryNodeUrn.createFromString(PARENT_NODE_URN)), eq(true))) .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -131,8 +136,9 @@ public void testGetFailureEntityDoesNotExist() throws Exception { public void testGetFailureNodeDoesNotExist() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(true); - Mockito.when(mockService.exists(GlossaryNodeUrn.createFromString(PARENT_NODE_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))).thenReturn(true); + Mockito.when( + mockService.exists(eq(GlossaryNodeUrn.createFromString(PARENT_NODE_URN)), eq(true))) .thenReturn(false); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INPUT); @@ -148,8 +154,9 @@ public void testGetFailureNodeDoesNotExist() throws Exception { public void testGetFailureParentIsNotNode() throws Exception { EntityService mockService = getMockEntityService(); EntityClient mockClient = Mockito.mock(EntityClient.class); - Mockito.when(mockService.exists(Urn.createFromString(TERM_URN))).thenReturn(true); - Mockito.when(mockService.exists(GlossaryNodeUrn.createFromString(PARENT_NODE_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TERM_URN)), eq(true))).thenReturn(true); + Mockito.when( + mockService.exists(eq(GlossaryNodeUrn.createFromString(PARENT_NODE_URN)), eq(true))) .thenReturn(true); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument("input")).thenReturn(INVALID_INPUT); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java index 6bd5b4f8c3f386..21d1e0caa1bb21 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java @@ -13,6 +13,7 @@ import com.linkedin.metadata.entity.EntityService; import graphql.schema.DataFetchingEnvironment; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.stream.Collectors; @@ -79,7 +80,8 @@ public void testReordering() throws Exception { CompletableFuture.completedFuture( ImmutableList.of(mockResponseEntity2, mockResponseEntity1)); when(mockDataLoader.loadMany(any())).thenReturn(mockFuture); - when(_entityService.exists(any())).thenReturn(true); + when(_entityService.exists(any(List.class), eq(true))) + .thenAnswer(args -> Set.of(args.getArgument(0))); List batchGetResponse = resolver.get(_dataFetchingEnvironment).join(); assertEquals(batchGetResponse.size(), 2); assertEquals(batchGetResponse.get(0), mockResponseEntity1); @@ -108,7 +110,8 @@ public void testDuplicateUrns() throws Exception { CompletableFuture mockFuture = CompletableFuture.completedFuture(ImmutableList.of(mockResponseEntity)); when(mockDataLoader.loadMany(any())).thenReturn(mockFuture); - when(_entityService.exists(any())).thenReturn(true); + when(_entityService.exists(any(List.class), eq(true))) + .thenAnswer(args -> Set.of(args.getArgument(0))); List batchGetResponse = resolver.get(_dataFetchingEnvironment).join(); assertEquals(batchGetResponse.size(), 2); assertEquals(batchGetResponse.get(0), mockResponseEntity); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateUserSettingResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateUserSettingResolverTest.java index 3fee28bc317257..f8fe38187b30dd 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateUserSettingResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateUserSettingResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; @@ -22,7 +23,8 @@ public class UpdateUserSettingResolverTest { @Test public void testWriteCorpUserSettings() throws Exception { EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_USER_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_USER_URN)), eq(true))) + .thenReturn(true); UpdateUserSettingResolver resolver = new UpdateUserSettingResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java index 5e199f2c6b2c71..4bd16d5311818c 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java @@ -1,6 +1,8 @@ package com.linkedin.datahub.graphql.resolvers.owner; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -45,16 +47,21 @@ public void testGetSuccessNoExistingOwners() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_2_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER + .name()))), + eq(true))) .thenReturn(true); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -84,10 +91,10 @@ public void testGetSuccessNoExistingOwners() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN)), eq(true)); } @Test @@ -112,15 +119,19 @@ public void testGetSuccessExistingOwnerNewType() throws Exception { Mockito.eq(0L))) .thenReturn(oldOwnership); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER + .name()))), + eq(true))) .thenReturn(true); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -147,7 +158,7 @@ public void testGetSuccessExistingOwnerNewType() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true)); } @Test @@ -172,15 +183,16 @@ public void testGetSuccessDeprecatedTypeToOwnershipType() throws Exception { Mockito.eq(0L))) .thenReturn(oldOwnership); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(any(Urn.class), eq(true))).thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER + .name()))), + eq(true))) .thenReturn(true); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -207,7 +219,7 @@ public void testGetSuccessDeprecatedTypeToOwnershipType() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true)); } @Test @@ -232,24 +244,32 @@ public void testGetSuccessMultipleOwnerTypes() throws Exception { Mockito.eq(0L))) .thenReturn(oldOwnership); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_2_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_3_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_2_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_3_URN)), eq(true))) + .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER + .name()))), + eq(true))) .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER + .name()))), + eq(true))) .thenReturn(true); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -288,13 +308,13 @@ public void testGetSuccessMultipleOwnerTypes() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_3_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_3_URN)), eq(true)); } @Test @@ -308,8 +328,10 @@ public void testGetFailureOwnerDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true))) + .thenReturn(false); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -343,8 +365,10 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_1_URN)), eq(true))) + .thenReturn(true); AddOwnersResolver resolver = new AddOwnersResolver(mockService); @@ -398,7 +422,7 @@ public void testGetEntityClientException() throws Exception { Mockito.doThrow(RuntimeException.class) .when(mockService) - .ingestProposal(Mockito.any(AspectsBatchImpl.class), Mockito.anyBoolean()); + .ingestProposal(any(AspectsBatchImpl.class), Mockito.anyBoolean()); AddOwnersResolver resolver = new AddOwnersResolver(Mockito.mock(EntityService.class)); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchAddOwnersResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchAddOwnersResolverTest.java index 92960f45232b5a..cb607adf45c0a5 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchAddOwnersResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchAddOwnersResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.owner; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -52,18 +53,24 @@ public void testGetSuccessNoExistingOwners() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true))) + .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER + .name()))), + eq(true))) .thenReturn(true); BatchAddOwnersResolver resolver = new BatchAddOwnersResolver(mockService); @@ -99,10 +106,10 @@ public void testGetSuccessNoExistingOwners() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_1))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_2))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true)); } @Test @@ -131,26 +138,34 @@ public void testGetSuccessExistingOwners() throws Exception { Mockito.eq(0L))) .thenReturn(originalOwnership); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true))) + .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER + .name()))), + eq(true))) .thenReturn(true); Mockito.when( mockService.exists( - Urn.createFromString( - OwnerUtils.mapOwnershipTypeToEntity( - com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER - .name())))) + eq( + Urn.createFromString( + OwnerUtils.mapOwnershipTypeToEntity( + com.linkedin.datahub.graphql.generated.OwnershipType.BUSINESS_OWNER + .name()))), + eq(true))) .thenReturn(true); BatchAddOwnersResolver resolver = new BatchAddOwnersResolver(mockService); @@ -186,10 +201,10 @@ public void testGetSuccessExistingOwners() throws Exception { verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_1))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_2))); + .exists(Mockito.eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true)); } @Test @@ -203,8 +218,10 @@ public void testGetFailureOwnerDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(false); BatchAddOwnersResolver resolver = new BatchAddOwnersResolver(mockService); @@ -256,9 +273,12 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); BatchAddOwnersResolver resolver = new BatchAddOwnersResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchRemoveOwnersResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchRemoveOwnersResolverTest.java index 10c95c1bac648e..84e0f6f282a7b3 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchRemoveOwnersResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/BatchRemoveOwnersResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.owner; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -37,22 +38,26 @@ public void testGetSuccessNoExistingOwners() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true))) + .thenReturn(true); BatchRemoveOwnersResolver resolver = new BatchRemoveOwnersResolver(mockService); @@ -66,7 +71,7 @@ public void testGetSuccessNoExistingOwners() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); @@ -88,9 +93,9 @@ public void testGetSuccessExistingOwners() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(oldOwners1); final Ownership oldOwners2 = @@ -104,16 +109,20 @@ public void testGetSuccessExistingOwners() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(oldOwners2); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_2)), eq(true))) + .thenReturn(true); BatchRemoveOwnersResolver resolver = new BatchRemoveOwnersResolver(mockService); @@ -127,7 +136,7 @@ public void testGetSuccessExistingOwners() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); @@ -140,20 +149,23 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.OWNERSHIP_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_URN_1))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_OWNER_URN_1)), eq(true))) + .thenReturn(true); BatchRemoveOwnersResolver resolver = new BatchRemoveOwnersResolver(mockService); @@ -167,7 +179,7 @@ public void testGetFailureResourceDoesNotExist() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -189,7 +201,7 @@ public void testGetUnauthorized() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -217,7 +229,7 @@ public void testGetEntityClientException() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java index 2468cef0e1216f..1898753e5ae76f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -42,9 +43,12 @@ public void testGetSuccessNoExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); AddTagsResolver resolver = new AddTagsResolver(mockService); @@ -73,10 +77,10 @@ public void testGetSuccessNoExistingTags() throws Exception { verifyIngestProposal(mockService, 1, proposal); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true)); } @Test @@ -97,9 +101,12 @@ public void testGetSuccessExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(originalTags); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); AddTagsResolver resolver = new AddTagsResolver(mockService); @@ -128,10 +135,10 @@ public void testGetSuccessExistingTags() throws Exception { verifyIngestProposal(mockService, 1, proposal); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true)); } @Test @@ -145,8 +152,10 @@ public void testGetFailureTagDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(false); AddTagsResolver resolver = new AddTagsResolver(mockService); @@ -173,8 +182,10 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); AddTagsResolver resolver = new AddTagsResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchAddTagsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchAddTagsResolverTest.java index c174d917748eba..82dd13ee29e8a5 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchAddTagsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchAddTagsResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -53,11 +54,15 @@ public void testGetSuccessNoExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); BatchAddTagsResolver resolver = new BatchAddTagsResolver(mockService); @@ -92,10 +97,10 @@ public void testGetSuccessNoExistingTags() throws Exception { verifyIngestProposal(mockService, 1, List.of(proposal1, proposal2)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true)); } @Test @@ -123,11 +128,15 @@ public void testGetSuccessExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(originalTags); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); BatchAddTagsResolver resolver = new BatchAddTagsResolver(mockService); @@ -162,10 +171,10 @@ public void testGetSuccessExistingTags() throws Exception { verifyIngestProposal(mockService, 1, List.of(proposal1, proposal2)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true)); } @Test @@ -179,8 +188,10 @@ public void testGetFailureTagDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(false); BatchAddTagsResolver resolver = new BatchAddTagsResolver(mockService); @@ -216,9 +227,12 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); BatchAddTagsResolver resolver = new BatchAddTagsResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchRemoveTagsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchRemoveTagsResolverTest.java index ba75b41388587c..83de3acfb4c94d 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchRemoveTagsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/BatchRemoveTagsResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -55,11 +56,15 @@ public void testGetSuccessNoExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); BatchRemoveTagsResolver resolver = new BatchRemoveTagsResolver(mockService); @@ -127,11 +132,15 @@ public void testGetSuccessExistingTags() throws Exception { Mockito.eq(0L))) .thenReturn(oldTags2); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_2_URN)), eq(true))) + .thenReturn(true); BatchRemoveTagsResolver resolver = new BatchRemoveTagsResolver(mockService); @@ -178,9 +187,12 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TAG_1_URN)), eq(true))) + .thenReturn(true); BatchRemoveTagsResolver resolver = new BatchRemoveTagsResolver(mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java index 6ae72fcbb72688..f7929012ccb688 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.TestUtils.*; import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -47,7 +48,8 @@ public void testGetSuccessExistingProperties() throws Exception { Mockito.eq(0L))) .thenReturn(oldTagProperties); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService); @@ -69,7 +71,7 @@ public void testGetSuccessExistingProperties() throws Exception { .ingestProposal(Mockito.eq(proposal), Mockito.any(Authentication.class), Mockito.eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))); + .exists(Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true)); } @Test @@ -86,7 +88,8 @@ public void testGetFailureNoExistingProperties() throws Exception { Mockito.eq(0))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService); @@ -131,7 +134,8 @@ public void testGetFailureTagDoesNotExist() throws Exception { Constants.TAG_PROPERTIES_ASPECT_NAME, oldTagPropertiesAspect))))); EntityService mockService = getMockEntityService(); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java index 397bb533ff871b..d0697df3f2f6c0 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.term; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -34,14 +35,17 @@ public void testGetSuccessNoExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); AddTermsResolver resolver = new AddTermsResolver(mockService); @@ -51,19 +55,19 @@ public void testGetSuccessNoExistingTerms() throws Exception { AddTermsInput input = new AddTermsInput( ImmutableList.of(TEST_TERM_1_URN, TEST_TERM_2_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); // Unable to easily validate exact payload due to the injected timestamp Mockito.verify(mockService, Mockito.times(1)) - .ingestProposal(Mockito.any(AspectsBatchImpl.class), Mockito.eq(false)); + .ingestProposal(Mockito.any(AspectsBatchImpl.class), eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true)); } @Test @@ -80,14 +84,17 @@ public void testGetSuccessExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(originalTerms); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); AddTermsResolver resolver = new AddTermsResolver(mockService); @@ -97,19 +104,19 @@ public void testGetSuccessExistingTerms() throws Exception { AddTermsInput input = new AddTermsInput( ImmutableList.of(TEST_TERM_1_URN, TEST_TERM_2_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); // Unable to easily validate exact payload due to the injected timestamp Mockito.verify(mockService, Mockito.times(1)) - .ingestProposal(Mockito.any(AspectsBatchImpl.class), Mockito.eq(false)); + .ingestProposal(Mockito.any(AspectsBatchImpl.class), eq(false)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true)); } @Test @@ -118,13 +125,15 @@ public void testGetFailureTermDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(false); AddTermsResolver resolver = new AddTermsResolver(mockService); @@ -133,7 +142,7 @@ public void testGetFailureTermDoesNotExist() throws Exception { DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); AddTermsInput input = new AddTermsInput(ImmutableList.of(TEST_TERM_1_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -147,13 +156,15 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); AddTermsResolver resolver = new AddTermsResolver(mockService); @@ -162,7 +173,7 @@ public void testGetFailureResourceDoesNotExist() throws Exception { DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); AddTermsInput input = new AddTermsInput(ImmutableList.of(TEST_TERM_1_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -180,7 +191,7 @@ public void testGetUnauthorized() throws Exception { DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); AddTermsInput input = new AddTermsInput(ImmutableList.of(TEST_TERM_1_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -204,7 +215,7 @@ public void testGetEntityClientException() throws Exception { QueryContext mockContext = getMockAllowContext(); AddTermsInput input = new AddTermsInput(ImmutableList.of(TEST_TERM_1_URN), TEST_ENTITY_URN, null, null); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchAddTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchAddTermsResolverTest.java index 2c85e870dd6acb..b3700632f56cdd 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchAddTermsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchAddTermsResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.term; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -37,24 +38,26 @@ public void testGetSuccessNoExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true))) .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN)), eq(true))) .thenReturn(true); BatchAddTermsResolver resolver = new BatchAddTermsResolver(mockService); @@ -68,17 +71,17 @@ public void testGetSuccessNoExistingTerms() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN)), eq(true)); } @Test @@ -95,24 +98,26 @@ public void testGetSuccessExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(originalTerms); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(originalTerms); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true))) .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN)), eq(true))) .thenReturn(true); BatchAddTermsResolver resolver = new BatchAddTermsResolver(mockService); @@ -126,17 +131,17 @@ public void testGetSuccessExistingTerms() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); verifyIngestProposal(mockService, 1); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))); + .exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true)); Mockito.verify(mockService, Mockito.times(1)) - .exists(Mockito.eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN))); + .exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_2_URN)), eq(true)); } @Test @@ -145,13 +150,14 @@ public void testGetFailureTagDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true))) .thenReturn(false); BatchAddTermsResolver resolver = new BatchAddTermsResolver(mockService); @@ -163,7 +169,7 @@ public void testGetFailureTagDoesNotExist() throws Exception { new BatchAddTermsInput( ImmutableList.of(TEST_GLOSSARY_TERM_1_URN, TEST_GLOSSARY_TERM_2_URN), ImmutableList.of(new ResourceRefInput(TEST_ENTITY_URN_1, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -176,20 +182,22 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN))) + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_GLOSSARY_TERM_1_URN)), eq(true))) .thenReturn(true); BatchAddTermsResolver resolver = new BatchAddTermsResolver(mockService); @@ -203,7 +211,7 @@ public void testGetFailureResourceDoesNotExist() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -224,7 +232,7 @@ public void testGetUnauthorized() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -249,7 +257,7 @@ public void testGetEntityClientException() throws Exception { new BatchAddTermsInput( ImmutableList.of(TEST_GLOSSARY_TERM_1_URN), ImmutableList.of(new ResourceRefInput(TEST_ENTITY_URN_1, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchRemoveTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchRemoveTermsResolverTest.java index c2520f4dfb7121..a76a813802b94a 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchRemoveTermsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/BatchRemoveTermsResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.term; import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; @@ -37,22 +38,26 @@ public void testGetSuccessNoExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); BatchRemoveTermsResolver resolver = new BatchRemoveTermsResolver(mockService); @@ -65,7 +70,7 @@ public void testGetSuccessNoExistingTerms() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); @@ -88,9 +93,9 @@ public void testGetSuccessExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(oldTerms1); final GlossaryTerms oldTerms2 = @@ -103,16 +108,20 @@ public void testGetSuccessExistingTerms() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(oldTerms2); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_2_URN)), eq(true))) + .thenReturn(true); BatchRemoveTermsResolver resolver = new BatchRemoveTermsResolver(mockService); @@ -125,7 +134,7 @@ public void testGetSuccessExistingTerms() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertTrue(resolver.get(mockEnv).get()); @@ -138,20 +147,23 @@ public void testGetFailureResourceDoesNotExist() throws Exception { Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_1)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); Mockito.when( mockService.getAspect( - Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), - Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), - Mockito.eq(0L))) + eq(UrnUtils.getUrn(TEST_ENTITY_URN_2)), + eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + eq(0L))) .thenReturn(null); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_1))).thenReturn(false); - Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN_2))).thenReturn(true); - Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_1)), eq(true))) + .thenReturn(false); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_ENTITY_URN_2)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(eq(Urn.createFromString(TEST_TERM_1_URN)), eq(true))) + .thenReturn(true); BatchRemoveTermsResolver resolver = new BatchRemoveTermsResolver(mockService); @@ -164,7 +176,7 @@ public void testGetFailureResourceDoesNotExist() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); @@ -185,7 +197,7 @@ public void testGetUnauthorized() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); QueryContext mockContext = getMockDenyContext(); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); @@ -212,7 +224,7 @@ public void testGetEntityClientException() throws Exception { ImmutableList.of( new ResourceRefInput(TEST_ENTITY_URN_1, null, null), new ResourceRefInput(TEST_ENTITY_URN_2, null, null))); - Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getArgument(eq("input"))).thenReturn(input); Mockito.when(mockEnv.getContext()).thenReturn(mockContext); assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java index 34921e4182b10d..9b3f42a37b45d5 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java @@ -625,7 +625,7 @@ public SearchResult filter( @Override public boolean exists(@Nonnull Urn urn, @Nonnull final Authentication authentication) throws RemoteInvocationException { - return _entityService.exists(urn); + return _entityService.exists(urn, true); } @SneakyThrows diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index 2e19916ee3c8f2..ed69e919a7b242 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -93,6 +93,7 @@ import java.sql.Timestamp; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -1782,7 +1783,8 @@ private EntityResponse toEntityResponse( return response; } - private Map> buildEntityToValidAspects(final EntityRegistry entityRegistry) { + private static Map> buildEntityToValidAspects( + final EntityRegistry entityRegistry) { return entityRegistry.getEntitySpecs().values().stream() .collect( Collectors.toMap( @@ -1950,36 +1952,54 @@ public RollbackRunResult deleteUrn(Urn urn) { } /** - * Returns true if the entity exists (has materialized aspects) + * Returns a set of urns of entities that exist (has materialized aspects). * - * @param urn the urn of the entity to check - * @return true if the entity exists, false otherwise + * @param urns the list of urns of the entities to check + * @param includeSoftDeleted whether to consider soft delete + * @return a set of urns of entities that exist. */ @Override - public Boolean exists(Urn urn) { - final Set aspectsToFetch = getEntityAspectNames(urn); - final List dbKeys = - aspectsToFetch.stream() + public Set exists(@Nonnull final Collection urns, boolean includeSoftDeleted) { + final Set dbKeys = + urns.stream() .map( - aspectName -> - new EntityAspectIdentifier(urn.toString(), aspectName, ASPECT_LATEST_VERSION)) - .collect(Collectors.toList()); + urn -> + new EntityAspectIdentifier( + urn.toString(), + _entityRegistry + .getEntitySpec(urn.getEntityType()) + .getKeyAspectSpec() + .getName(), + ASPECT_LATEST_VERSION)) + .collect(Collectors.toSet()); - Map aspects = _aspectDao.batchGet(new HashSet(dbKeys)); - return aspects.values().stream().anyMatch(aspect -> aspect != null); - } + final Map aspects = _aspectDao.batchGet(dbKeys); + final Set existingUrnStrings = + aspects.values().stream() + .filter(aspect -> aspect != null) + .map(aspect -> aspect.getUrn()) + .collect(Collectors.toSet()); - /** - * Returns true if an entity is soft-deleted. - * - * @param urn the urn to check - * @return true is the entity is soft deleted, false otherwise. - */ - @Override - public Boolean isSoftDeleted(@Nonnull final Urn urn) { - Objects.requireNonNull(urn, "urn is required"); - final RecordTemplate statusAspect = getLatestAspect(urn, STATUS_ASPECT_NAME); - return statusAspect != null && ((Status) statusAspect).isRemoved(); + Set existing = + urns.stream() + .filter(urn -> existingUrnStrings.contains(urn.toString())) + .collect(Collectors.toSet()); + + if (includeSoftDeleted) { + return existing; + } else { + // Additionally exclude status.removed == true + Map> statusResult = + getLatestAspects(existing, Set.of(STATUS_ASPECT_NAME)); + return existing.stream() + .filter( + urn -> + // key aspect is always returned, make sure to only consider the status aspect + statusResult.getOrDefault(urn, List.of()).stream() + .filter(aspect -> STATUS_ASPECT_NAME.equals(aspect.schema().getName())) + .noneMatch(aspect -> ((Status) aspect).isRemoved())) + .collect(Collectors.toSet()); + } } @Override diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java index 459b2d183d7ac2..4d3ac9a550553e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java @@ -6,11 +6,9 @@ import com.datahub.util.RecordUtils; import com.google.common.base.Preconditions; import com.linkedin.common.AuditStamp; -import com.linkedin.common.Status; import com.linkedin.common.urn.Urn; import com.linkedin.data.schema.RecordDataSchema; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.entity.EnvelopedAspect; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.entity.validation.EntityRegistryUrnValidator; import com.linkedin.metadata.entity.validation.RecordTemplateValidator; @@ -157,27 +155,6 @@ public static SystemMetadata parseSystemMetadata(String jsonSystemMetadata) { return RecordUtils.toRecordTemplate(SystemMetadata.class, jsonSystemMetadata); } - /** Check if entity is removed (removed=true in Status aspect) and exists */ - public static boolean checkIfRemoved(EntityService entityService, Urn entityUrn) { - try { - - if (!entityService.exists(entityUrn)) { - return false; - } - - EnvelopedAspect statusAspect = - entityService.getLatestEnvelopedAspect(entityUrn.getEntityType(), entityUrn, "status"); - if (statusAspect == null) { - return false; - } - Status status = new Status(statusAspect.getValue().data()); - return status.isRemoved(); - } catch (Exception e) { - log.error("Error while checking if {} is removed", entityUrn, e); - return false; - } - } - public static RecordTemplate buildKeyAspect( @Nonnull EntityRegistry entityRegistry, @Nonnull final Urn urn) { final EntitySpec spec = entityRegistry.getEntitySpec(urnToEntityName(urn)); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/MostPopularSource.java b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/MostPopularSource.java index 35d75de482007c..f5c783014caa13 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/MostPopularSource.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/MostPopularSource.java @@ -4,15 +4,11 @@ import com.datahub.util.exception.ESQueryException; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; -import com.linkedin.common.urn.UrnUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.datahubusage.DataHubUsageEventConstants; import com.linkedin.metadata.datahubusage.DataHubUsageEventType; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.EntityUtils; -import com.linkedin.metadata.recommendation.EntityProfileParams; import com.linkedin.metadata.recommendation.RecommendationContent; -import com.linkedin.metadata.recommendation.RecommendationParams; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -22,7 +18,6 @@ import io.opentelemetry.extension.annotations.WithSpan; import java.io.IOException; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -37,12 +32,13 @@ import org.opensearch.index.query.QueryBuilders; import org.opensearch.search.aggregations.AggregationBuilder; import org.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; import org.opensearch.search.aggregations.bucket.terms.ParsedTerms; import org.opensearch.search.builder.SearchSourceBuilder; @Slf4j @RequiredArgsConstructor -public class MostPopularSource implements RecommendationSource { +public class MostPopularSource implements EntityRecommendationSource { /** Entity Types that should be in scope for this type of recommendation. */ private static final Set SUPPORTED_ENTITY_TYPES = ImmutableSet.of( @@ -59,7 +55,7 @@ public class MostPopularSource implements RecommendationSource { private final RestHighLevelClient _searchClient; private final IndexConvention _indexConvention; - private final EntityService _entityService; + private final EntityService _entityService; private static final String DATAHUB_USAGE_INDEX = "datahub_usage_event"; private static final String ENTITY_AGG_NAME = "entity"; @@ -107,10 +103,11 @@ public List getRecommendations( _searchClient.search(searchRequest, RequestOptions.DEFAULT); // extract results ParsedTerms parsedTerms = searchResponse.getAggregations().get(ENTITY_AGG_NAME); - return parsedTerms.getBuckets().stream() - .map(bucket -> buildContent(bucket.getKeyAsString())) - .filter(Optional::isPresent) - .map(Optional::get) + List bucketUrns = + parsedTerms.getBuckets().stream() + .map(MultiBucketsAggregation.Bucket::getKeyAsString) + .collect(Collectors.toList()); + return buildContent(bucketUrns, _entityService) .limit(MAX_CONTENT) .collect(Collectors.toList()); } catch (Exception e) { @@ -119,6 +116,11 @@ public List getRecommendations( } } + @Override + public Set getSupportedEntityTypes() { + return SUPPORTED_ENTITY_TYPES; + } + private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { // TODO: Proactively filter for entity types in the supported set. SearchRequest request = new SearchRequest(); @@ -142,20 +144,4 @@ private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { request.indices(_indexConvention.getIndexName(DATAHUB_USAGE_INDEX)); return request; } - - private Optional buildContent(@Nonnull String entityUrn) { - Urn entity = UrnUtils.getUrn(entityUrn); - if (EntityUtils.checkIfRemoved(_entityService, entity) - || !RecommendationUtils.isSupportedEntityType(entity, SUPPORTED_ENTITY_TYPES)) { - return Optional.empty(); - } - - return Optional.of( - new RecommendationContent() - .setEntity(entity) - .setValue(entityUrn) - .setParams( - new RecommendationParams() - .setEntityProfileParams(new EntityProfileParams().setUrn(entity)))); - } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyEditedSource.java b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyEditedSource.java index 0815ffadd05c1d..127b0f5c342c70 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyEditedSource.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyEditedSource.java @@ -4,15 +4,11 @@ import com.datahub.util.exception.ESQueryException; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; -import com.linkedin.common.urn.UrnUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.datahubusage.DataHubUsageEventConstants; import com.linkedin.metadata.datahubusage.DataHubUsageEventType; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.EntityUtils; -import com.linkedin.metadata.recommendation.EntityProfileParams; import com.linkedin.metadata.recommendation.RecommendationContent; -import com.linkedin.metadata.recommendation.RecommendationParams; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -22,7 +18,6 @@ import io.opentelemetry.extension.annotations.WithSpan; import java.io.IOException; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -38,12 +33,13 @@ import org.opensearch.search.aggregations.AggregationBuilder; import org.opensearch.search.aggregations.AggregationBuilders; import org.opensearch.search.aggregations.BucketOrder; +import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; import org.opensearch.search.aggregations.bucket.terms.ParsedTerms; import org.opensearch.search.builder.SearchSourceBuilder; @Slf4j @RequiredArgsConstructor -public class RecentlyEditedSource implements RecommendationSource { +public class RecentlyEditedSource implements EntityRecommendationSource { /** Entity Types that should be in scope for this type of recommendation. */ private static final Set SUPPORTED_ENTITY_TYPES = ImmutableSet.of( @@ -60,7 +56,7 @@ public class RecentlyEditedSource implements RecommendationSource { private final RestHighLevelClient _searchClient; private final IndexConvention _indexConvention; - private final EntityService _entityService; + private final EntityService _entityService; private static final String DATAHUB_USAGE_INDEX = "datahub_usage_event"; private static final String ENTITY_AGG_NAME = "entity"; @@ -108,10 +104,11 @@ public List getRecommendations( _searchClient.search(searchRequest, RequestOptions.DEFAULT); // extract results ParsedTerms parsedTerms = searchResponse.getAggregations().get(ENTITY_AGG_NAME); - return parsedTerms.getBuckets().stream() - .map(bucket -> buildContent(bucket.getKeyAsString())) - .filter(Optional::isPresent) - .map(Optional::get) + List bucketUrns = + parsedTerms.getBuckets().stream() + .map(MultiBucketsAggregation.Bucket::getKeyAsString) + .collect(Collectors.toList()); + return buildContent(bucketUrns, _entityService) .limit(MAX_CONTENT) .collect(Collectors.toList()); } catch (Exception e) { @@ -120,6 +117,11 @@ public List getRecommendations( } } + @Override + public Set getSupportedEntityTypes() { + return SUPPORTED_ENTITY_TYPES; + } + private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { // TODO: Proactively filter for entity types in the supported set. SearchRequest request = new SearchRequest(); @@ -147,20 +149,4 @@ private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { request.indices(_indexConvention.getIndexName(DATAHUB_USAGE_INDEX)); return request; } - - private Optional buildContent(@Nonnull String entityUrn) { - Urn entity = UrnUtils.getUrn(entityUrn); - if (EntityUtils.checkIfRemoved(_entityService, entity) - || !RecommendationUtils.isSupportedEntityType(entity, SUPPORTED_ENTITY_TYPES)) { - return Optional.empty(); - } - - return Optional.of( - new RecommendationContent() - .setEntity(entity) - .setValue(entityUrn) - .setParams( - new RecommendationParams() - .setEntityProfileParams(new EntityProfileParams().setUrn(entity)))); - } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyViewedSource.java b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyViewedSource.java index 47ffebee2e9479..0ab5cf40cf4e5a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyViewedSource.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/recommendation/candidatesource/RecentlyViewedSource.java @@ -4,15 +4,11 @@ import com.datahub.util.exception.ESQueryException; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; -import com.linkedin.common.urn.UrnUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.datahubusage.DataHubUsageEventConstants; import com.linkedin.metadata.datahubusage.DataHubUsageEventType; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.EntityUtils; -import com.linkedin.metadata.recommendation.EntityProfileParams; import com.linkedin.metadata.recommendation.RecommendationContent; -import com.linkedin.metadata.recommendation.RecommendationParams; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -22,7 +18,6 @@ import io.opentelemetry.extension.annotations.WithSpan; import java.io.IOException; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -38,12 +33,13 @@ import org.opensearch.search.aggregations.AggregationBuilder; import org.opensearch.search.aggregations.AggregationBuilders; import org.opensearch.search.aggregations.BucketOrder; +import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; import org.opensearch.search.aggregations.bucket.terms.ParsedTerms; import org.opensearch.search.builder.SearchSourceBuilder; @Slf4j @RequiredArgsConstructor -public class RecentlyViewedSource implements RecommendationSource { +public class RecentlyViewedSource implements EntityRecommendationSource { /** Entity Types that should be in scope for this type of recommendation. */ private static final Set SUPPORTED_ENTITY_TYPES = ImmutableSet.of( @@ -60,7 +56,7 @@ public class RecentlyViewedSource implements RecommendationSource { private final RestHighLevelClient _searchClient; private final IndexConvention _indexConvention; - private final EntityService _entityService; + private final EntityService _entityService; private static final String DATAHUB_USAGE_INDEX = "datahub_usage_event"; private static final String ENTITY_AGG_NAME = "entity"; @@ -108,10 +104,11 @@ public List getRecommendations( _searchClient.search(searchRequest, RequestOptions.DEFAULT); // extract results ParsedTerms parsedTerms = searchResponse.getAggregations().get(ENTITY_AGG_NAME); - return parsedTerms.getBuckets().stream() - .map(bucket -> buildContent(bucket.getKeyAsString())) - .filter(Optional::isPresent) - .map(Optional::get) + List bucketUrns = + parsedTerms.getBuckets().stream() + .map(MultiBucketsAggregation.Bucket::getKeyAsString) + .collect(Collectors.toList()); + return buildContent(bucketUrns, _entityService) .limit(MAX_CONTENT) .collect(Collectors.toList()); } catch (Exception e) { @@ -120,6 +117,11 @@ public List getRecommendations( } } + @Override + public Set getSupportedEntityTypes() { + return SUPPORTED_ENTITY_TYPES; + } + private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { // TODO: Proactively filter for entity types in the supported set. SearchRequest request = new SearchRequest(); @@ -151,20 +153,4 @@ private SearchRequest buildSearchRequest(@Nonnull Urn userUrn) { request.indices(_indexConvention.getIndexName(DATAHUB_USAGE_INDEX)); return request; } - - private Optional buildContent(@Nonnull String entityUrn) { - Urn entity = UrnUtils.getUrn(entityUrn); - if (EntityUtils.checkIfRemoved(_entityService, entity) - || !RecommendationUtils.isSupportedEntityType(entity, SUPPORTED_ENTITY_TYPES)) { - return Optional.empty(); - } - - return Optional.of( - new RecommendationContent() - .setEntity(entity) - .setValue(entityUrn) - .setParams( - new RecommendationParams() - .setEntityProfileParams(new EntityProfileParams().setUrn(entity)))); - } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/sibling/SiblingGraphServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/sibling/SiblingGraphServiceTest.java index df332cacaa7513..a2cb9b7412a8e1 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/sibling/SiblingGraphServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/sibling/SiblingGraphServiceTest.java @@ -19,6 +19,7 @@ import com.linkedin.metadata.graph.LineageRelationshipArray; import com.linkedin.metadata.graph.SiblingGraphService; import java.net.URISyntaxException; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -60,12 +61,13 @@ public class SiblingGraphServiceTest { private GraphService _graphService; private SiblingGraphService _client; - EntityService _mockEntityService; + EntityService _mockEntityService; @BeforeClass public void setup() { _mockEntityService = Mockito.mock(EntityService.class); - when(_mockEntityService.exists(any())).thenReturn(true); + when(_mockEntityService.exists(any(Collection.class), any(Boolean.class))) + .thenAnswer(args -> new HashSet<>(args.getArgument(0))); _graphService = Mockito.mock(GraphService.class); _client = new SiblingGraphService(_mockEntityService, _graphService); } diff --git a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java index 14e5259f90097e..84433a2b439f41 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java +++ b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java @@ -40,6 +40,8 @@ import java.io.IOException; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import org.opensearch.client.RestHighLevelClient; import org.springframework.beans.factory.annotation.Autowired; @@ -276,7 +278,20 @@ private EntityClient entityClientHelper( AspectDao mockAspectDao = mock(AspectDao.class); when(mockAspectDao.batchGet(anySet())) - .thenReturn(Map.of(mock(EntityAspectIdentifier.class), mock(EntityAspect.class))); + .thenAnswer( + args -> { + Set ids = args.getArgument(0); + return ids.stream() + .map( + id -> { + EntityAspect mockEntityAspect = mock(EntityAspect.class); + when(mockEntityAspect.getUrn()).thenReturn(id.getUrn()); + when(mockEntityAspect.getAspect()).thenReturn(id.getAspect()); + when(mockEntityAspect.getVersion()).thenReturn(id.getVersion()); + return Map.entry(id, mockEntityAspect); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + }); PreProcessHooks preProcessHooks = new PreProcessHooks(); preProcessHooks.setUiEnabled(true); diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java index f33ae5de130da4..8ce7675edf5805 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java @@ -39,12 +39,12 @@ public class GroupService { private final EntityClient _entityClient; - private final EntityService _entityService; + private final EntityService _entityService; private final GraphClient _graphClient; public GroupService( @Nonnull EntityClient entityClient, - @Nonnull EntityService entityService, + @Nonnull EntityService entityService, @Nonnull GraphClient graphClient) { Objects.requireNonNull(entityClient, "entityClient must not be null!"); Objects.requireNonNull(entityService, "entityService must not be null!"); @@ -57,7 +57,7 @@ public GroupService( public boolean groupExists(@Nonnull Urn groupUrn) { Objects.requireNonNull(groupUrn, "groupUrn must not be null"); - return _entityService.exists(groupUrn); + return _entityService.exists(groupUrn, true); } public Origin getGroupOrigin(@Nonnull final Urn groupUrn) { @@ -73,7 +73,7 @@ public void addUserToNativeGroup( Objects.requireNonNull(groupUrn, "groupUrn must not be null"); // Verify the user exists - if (!_entityService.exists(userUrn)) { + if (!_entityService.exists(userUrn, true)) { throw new RuntimeException("Failed to add member to group. User does not exist."); } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java index c631bede453642..40555107f4c79e 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java @@ -63,7 +63,7 @@ public StatefulTokenService( public Boolean load(final String key) { final Urn accessUrn = Urn.createFromTuple(Constants.ACCESS_TOKEN_ENTITY_NAME, key); - return !_entityService.exists(accessUrn); + return !_entityService.exists(accessUrn, true); } }); this.salt = salt; diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java index 741d176f98c1bb..ff46642827b30b 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/user/NativeUserService.java @@ -30,7 +30,7 @@ public class NativeUserService { private static final long ONE_DAY_MILLIS = TimeUnit.DAYS.toMillis(1); - private final EntityService _entityService; + private final EntityService _entityService; private final EntityClient _entityClient; private final SecretService _secretService; private final AuthenticationConfiguration _authConfig; @@ -51,7 +51,7 @@ public void createNativeUser( Objects.requireNonNull(authentication, "authentication must not be null!"); final Urn userUrn = Urn.createFromString(userUrnString); - if (_entityService.exists(userUrn) + if (_entityService.exists(userUrn, true) // Should never fail these due to Controller level check, but just in case more usages get // put in || userUrn.toString().equals(SYSTEM_ACTOR) diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/telemetry/TrackingService.java b/metadata-service/auth-impl/src/main/java/com/datahub/telemetry/TrackingService.java index dc63b5e4a28973..de2c18782d3d81 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/telemetry/TrackingService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/telemetry/TrackingService.java @@ -146,7 +146,7 @@ public String getClientId() { Urn clientIdUrn = UrnUtils.getUrn(CLIENT_ID_URN); // Create a new client id if it doesn't exist - if (!_entityService.exists(clientIdUrn)) { + if (!_entityService.exists(clientIdUrn, true)) { return createClientIdIfNotPresent(_entityService); } diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/authenticator/DataHubTokenAuthenticatorTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/authenticator/DataHubTokenAuthenticatorTest.java index 5bd273f3bacf89..f9a762b2dd02a5 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/authenticator/DataHubTokenAuthenticatorTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/authenticator/DataHubTokenAuthenticatorTest.java @@ -8,6 +8,7 @@ import static com.datahub.authentication.token.TokenClaims.ACTOR_TYPE_CLAIM_NAME; import static com.datahub.authentication.token.TokenClaims.TOKEN_TYPE_CLAIM_NAME; import static com.datahub.authentication.token.TokenClaims.TOKEN_VERSION_CLAIM_NAME; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertThrows; @@ -151,7 +152,7 @@ public void testAuthenticateSuccess() throws Exception { configEntityRegistry.getEntitySpec(Constants.ACCESS_TOKEN_ENTITY_NAME).getKeyAspectSpec(); Mockito.when(mockService.getKeyAspectSpec(Mockito.eq(Constants.ACCESS_TOKEN_ENTITY_NAME))) .thenReturn(keyAspectSpec); - Mockito.when(mockService.exists(Mockito.any(Urn.class))).thenReturn(true); + Mockito.when(mockService.exists(Mockito.any(Urn.class), eq(true))).thenReturn(true); Mockito.when(mockService.getEntityRegistry()).thenReturn(configEntityRegistry); final DataHubTokenAuthenticator authenticator = new DataHubTokenAuthenticator(); diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/group/GroupServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/group/GroupServiceTest.java index 6d0678d4f3558e..2c1ab6f12efa16 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/group/GroupServiceTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/group/GroupServiceTest.java @@ -55,7 +55,7 @@ public class GroupServiceTest { private static EntityRelationships _entityRelationships; private EntityClient _entityClient; - private EntityService _entityService; + private EntityService _entityService; private GraphClient _graphClient; private GroupService _groupService; @@ -121,7 +121,7 @@ public void testGroupExistsNullArguments() { @Test public void testGroupExistsPasses() { - when(_entityService.exists(_groupUrn)).thenReturn(true); + when(_entityService.exists(eq(_groupUrn), eq(true))).thenReturn(true); assertTrue(_groupService.groupExists(_groupUrn)); } @@ -147,7 +147,7 @@ public void testAddUserToNativeGroupNullArguments() { @Test public void testAddUserToNativeGroupPasses() throws Exception { - when(_entityService.exists(USER_URN)).thenReturn(true); + when(_entityService.exists(eq(USER_URN), eq(true))).thenReturn(true); when(_entityClient.batchGetV2( eq(CORP_USER_ENTITY_NAME), any(), any(), eq(SYSTEM_AUTHENTICATION))) .thenReturn(_entityResponseMap); @@ -232,7 +232,7 @@ public void testMigrateGroupMembershipToNativeGroupMembershipPasses() throws Exc when(_entityClient.batchGetV2( eq(CORP_USER_ENTITY_NAME), any(), any(), eq(SYSTEM_AUTHENTICATION))) .thenReturn(_entityResponseMap); - when(_entityService.exists(USER_URN)).thenReturn(true); + when(_entityService.exists(eq(USER_URN), eq(true))).thenReturn(true); _groupService.migrateGroupMembershipToNativeGroupMembership( Urn.createFromString(EXTERNAL_GROUP_URN_STRING), diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/token/StatefulTokenServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/token/StatefulTokenServiceTest.java index ed10022632a569..63ac0f048ad0a0 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/token/StatefulTokenServiceTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/token/StatefulTokenServiceTest.java @@ -1,6 +1,7 @@ package com.datahub.authentication.token; import static com.datahub.authentication.token.TokenClaims.*; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; import com.datahub.authentication.Actor; @@ -180,7 +181,7 @@ public void generateRevokeToken() throws TokenException { Mockito.when(mockService.getEntityRegistry()).thenReturn(configEntityRegistry); Mockito.when(mockService.getKeyAspectSpec(Mockito.eq(Constants.ACCESS_TOKEN_ENTITY_NAME))) .thenReturn(keyAspectSpec); - Mockito.when(mockService.exists(Mockito.any(Urn.class))).thenReturn(true); + Mockito.when(mockService.exists(Mockito.any(Urn.class), eq(true))).thenReturn(true); final RollbackRunResult result = new RollbackRunResult(ImmutableList.of(), 0); Mockito.when(mockService.deleteUrn(Mockito.any(Urn.class))).thenReturn(result); diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java index 9cb5d5cb697ccc..2b584c3461452e 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/user/NativeUserServiceTest.java @@ -85,7 +85,7 @@ public void testCreateNativeUserNullArguments() { expectedExceptionsMessageRegExp = "This user already exists! Cannot create a new user.") public void testCreateNativeUserUserAlreadyExists() throws Exception { // The user already exists - when(_entityService.exists(any())).thenReturn(true); + when(_entityService.exists(any(Urn.class), eq(true))).thenReturn(true); _nativeUserService.createNativeUser( USER_URN_STRING, FULL_NAME, EMAIL, TITLE, PASSWORD, SYSTEM_AUTHENTICATION); @@ -109,7 +109,7 @@ public void testCreateNativeUserUserSystemUser() throws Exception { @Test public void testCreateNativeUserPasses() throws Exception { - when(_entityService.exists(any())).thenReturn(false); + when(_entityService.exists(any(), any())).thenReturn(false); when(_secretService.generateSalt(anyInt())).thenReturn(SALT); when(_secretService.encrypt(any())).thenReturn(ENCRYPTED_SALT); when(_secretService.getHashedPassword(any(), any())).thenReturn(HASHED_PASSWORD); diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/telemetry/TrackingServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/telemetry/TrackingServiceTest.java index a0bbe69691db4a..8baeb7d3f84434 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/telemetry/TrackingServiceTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/telemetry/TrackingServiceTest.java @@ -76,7 +76,7 @@ public void setupTest() { @Test public void testEmitAnalyticsEvent() throws IOException { when(_secretService.hashString(eq(ACTOR_URN_STRING))).thenReturn(HASHED_ACTOR_URN_STRING); - when(_entityService.exists(_clientIdUrn)).thenReturn(true); + when(_entityService.exists(eq(_clientIdUrn), eq(true))).thenReturn(true); when(_entityService.getLatestAspect(eq(_clientIdUrn), eq(CLIENT_ID_ASPECT))) .thenReturn(TELEMETRY_CLIENT_ID); when(_mixpanelMessageBuilder.event(eq(CLIENT_ID), eq(EVENT_TYPE), any())) @@ -99,7 +99,7 @@ public void testEmitAnalyticsEvent() throws IOException { @Test public void testGetClientIdAlreadyExists() { - when(_entityService.exists(_clientIdUrn)).thenReturn(true); + when(_entityService.exists(eq(_clientIdUrn), eq(true))).thenReturn(true); when(_entityService.getLatestAspect(eq(_clientIdUrn), eq(CLIENT_ID_ASPECT))) .thenReturn(TELEMETRY_CLIENT_ID); @@ -108,7 +108,7 @@ public void testGetClientIdAlreadyExists() { @Test public void testGetClientIdDoesNotExist() { - when(_entityService.exists(_clientIdUrn)).thenReturn(false); + when(_entityService.exists(eq(_clientIdUrn), eq(true))).thenReturn(false); assertNotNull(_trackingService.getClientId()); verify(_entityService, times(1)) diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRetentionPoliciesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRetentionPoliciesStep.java index ab86749eb431d4..e3a3c452b85f12 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRetentionPoliciesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRetentionPoliciesStep.java @@ -28,8 +28,8 @@ @RequiredArgsConstructor public class IngestRetentionPoliciesStep implements BootstrapStep { - private final RetentionService _retentionService; - private final EntityService _entityService; + private final RetentionService _retentionService; + private final EntityService _entityService; private final boolean _enableRetention; private final boolean _applyOnBootstrap; private final String pluginPath; @@ -63,7 +63,7 @@ public String name() { @Override public void execute() throws IOException, URISyntaxException { // 0. Execute preflight check to see whether we need to ingest policies - if (_entityService.exists(UPGRADE_ID_URN)) { + if (_entityService.exists(UPGRADE_ID_URN, true)) { log.info("Retention was applied. Skipping."); return; } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RemoveClientIdAspectStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RemoveClientIdAspectStep.java index 3c62f695ddd5f9..7e232f939dc087 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RemoveClientIdAspectStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RemoveClientIdAspectStep.java @@ -13,7 +13,7 @@ @RequiredArgsConstructor public class RemoveClientIdAspectStep implements BootstrapStep { - private final EntityService _entityService; + private final EntityService _entityService; private static final String UPGRADE_ID = "remove-unknown-aspects"; private static final String INVALID_TELEMETRY_ASPECT_NAME = "clientId"; @@ -27,7 +27,7 @@ public String name() { @Override public void execute() throws Exception { try { - if (_entityService.exists(REMOVE_UNKNOWN_ASPECTS_URN)) { + if (_entityService.exists(REMOVE_UNKNOWN_ASPECTS_URN, true)) { log.info("Unknown aspects have been removed. Skipping..."); return; } diff --git a/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java b/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java index fc935514f4138f..d7c82689035082 100644 --- a/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java +++ b/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java @@ -136,7 +136,7 @@ public ResponseEntity delete(String urn) { public ResponseEntity head(String urn) { try { Urn entityUrn = Urn.createFromString(urn); - if (_entityService.exists(entityUrn)) { + if (_entityService.exists(entityUrn, true)) { return new ResponseEntity<>(HttpStatus.NO_CONTENT); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); diff --git a/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java b/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java index fdf99cdc303c19..b082a735e8084b 100644 --- a/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java +++ b/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java @@ -42,6 +42,7 @@ import com.linkedin.schema.StringType; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -212,7 +213,7 @@ public RollbackRunResult deleteUrn(Urn urn) { } @Override - public Boolean exists(Urn urn) { - return null; + public Set exists(@NotNull Collection urns) { + return Set.of(); } } diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java index dfd986c2ebea08..afdaf06802a11d 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java @@ -1057,6 +1057,6 @@ public Task exists(@ActionParam(PARAM_URN) @Nonnull String urnStr) } log.info("EXISTS for {}", urnStr); return RestliUtil.toTask( - () -> _entityService.exists(urn), MetricRegistry.name(this.getClass(), "exists")); + () -> _entityService.exists(urn, true), MetricRegistry.name(this.getClass(), "exists")); } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java index 89b0e5ba9a5587..71573aa2b10e04 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java @@ -25,6 +25,7 @@ import com.linkedin.mxe.SystemMetadata; import com.linkedin.util.Pair; import java.net.URISyntaxException; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; @@ -312,9 +313,27 @@ RollbackRunResult rollbackWithConditions( IngestResult ingestProposal( MetadataChangeProposal proposal, AuditStamp auditStamp, final boolean async); - Boolean exists(Urn urn); + /** + * Returns a set of urns of entities that exist (has materialized aspects). + * + * @param urns the list of urns of the entities to check + * @return a set of urns of entities that exist. + */ + Set exists(@Nonnull final Collection urns, boolean includeSoftDelete); + + /** + * Returns a set of urns of entities that exist (has materialized aspects). + * + * @param urns the list of urns of the entities to check + * @return a set of urns of entities that exist. + */ + default Set exists(@Nonnull final Collection urns) { + return exists(urns, true); + } - Boolean isSoftDeleted(@Nonnull final Urn urn); + default boolean exists(@Nonnull Urn urn, boolean includeSoftDelete) { + return exists(List.of(urn), includeSoftDelete).contains(urn); + } void setWritable(boolean canWrite); diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java new file mode 100644 index 00000000000000..546c2856c28ac3 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java @@ -0,0 +1,37 @@ +package com.linkedin.metadata.recommendation.candidatesource; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.recommendation.EntityProfileParams; +import com.linkedin.metadata.recommendation.RecommendationContent; +import com.linkedin.metadata.recommendation.RecommendationParams; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +public interface EntityRecommendationSource extends RecommendationSource { + Set getSupportedEntityTypes(); + + default RecommendationContent buildContent(@Nonnull Urn urn) { + return new RecommendationContent() + .setEntity(urn) + .setValue(urn.toString()) + .setParams( + new RecommendationParams() + .setEntityProfileParams(new EntityProfileParams().setUrn(urn))); + } + + default Stream buildContent( + @Nonnull List entityUrns, EntityService entityService) { + List entities = + entityUrns.stream() + .map(UrnUtils::getUrn) + .filter(urn -> getSupportedEntityTypes().contains(urn.getEntityType())) + .toList(); + Set existingNonRemoved = entityService.exists(entities, false); + + return entities.stream().filter(existingNonRemoved::contains).map(this::buildContent); + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java index c618db801d9d63..71c4d357ad1eb6 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java @@ -1,20 +1,29 @@ package com.linkedin.metadata.shared; import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.AbstractArrayTemplate; import com.linkedin.metadata.browse.BrowseResult; +import com.linkedin.metadata.browse.BrowseResultEntity; import com.linkedin.metadata.browse.BrowseResultEntityArray; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.EntityLineageResult; +import com.linkedin.metadata.graph.LineageRelationship; import com.linkedin.metadata.graph.LineageRelationshipArray; import com.linkedin.metadata.query.ListResult; import com.linkedin.metadata.search.LineageScrollResult; +import com.linkedin.metadata.search.LineageSearchEntity; import com.linkedin.metadata.search.LineageSearchEntityArray; import com.linkedin.metadata.search.LineageSearchResult; import com.linkedin.metadata.search.ScrollResult; +import com.linkedin.metadata.search.SearchEntity; import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; import java.util.Objects; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -23,7 +32,7 @@ public class ValidationUtils { public static SearchResult validateSearchResult( - final SearchResult searchResult, @Nonnull final EntityService entityService) { + final SearchResult searchResult, @Nonnull final EntityService entityService) { if (searchResult == null) { return null; } @@ -37,16 +46,16 @@ public static SearchResult validateSearchResult( .setNumEntities(searchResult.getNumEntities()); SearchEntityArray validatedEntities = - searchResult.getEntities().stream() - .filter(searchEntity -> entityService.exists(searchEntity.getEntity())) + validatedUrns(searchResult.getEntities(), SearchEntity::getEntity, entityService, true) .collect(Collectors.toCollection(SearchEntityArray::new)); + validatedSearchResult.setEntities(validatedEntities); return validatedSearchResult; } public static ScrollResult validateScrollResult( - final ScrollResult scrollResult, @Nonnull final EntityService entityService) { + final ScrollResult scrollResult, @Nonnull final EntityService entityService) { if (scrollResult == null) { return null; } @@ -62,16 +71,16 @@ public static ScrollResult validateScrollResult( } SearchEntityArray validatedEntities = - scrollResult.getEntities().stream() - .filter(searchEntity -> entityService.exists(searchEntity.getEntity())) + validatedUrns(scrollResult.getEntities(), SearchEntity::getEntity, entityService, true) .collect(Collectors.toCollection(SearchEntityArray::new)); + validatedScrollResult.setEntities(validatedEntities); return validatedScrollResult; } public static BrowseResult validateBrowseResult( - final BrowseResult browseResult, @Nonnull final EntityService entityService) { + final BrowseResult browseResult, @Nonnull final EntityService entityService) { if (browseResult == null) { return null; } @@ -88,16 +97,16 @@ public static BrowseResult validateBrowseResult( .setNumElements(browseResult.getNumElements()); BrowseResultEntityArray validatedEntities = - browseResult.getEntities().stream() - .filter(browseResultEntity -> entityService.exists(browseResultEntity.getUrn())) + validatedUrns(browseResult.getEntities(), BrowseResultEntity::getUrn, entityService, true) .collect(Collectors.toCollection(BrowseResultEntityArray::new)); + validatedBrowseResult.setEntities(validatedEntities); return validatedBrowseResult; } public static ListResult validateListResult( - final ListResult listResult, @Nonnull final EntityService entityService) { + final ListResult listResult, @Nonnull final EntityService entityService) { if (listResult == null) { return null; } @@ -110,16 +119,17 @@ public static ListResult validateListResult( .setTotal(listResult.getTotal()); UrnArray validatedEntities = - listResult.getEntities().stream() - .filter(entityService::exists) + validatedUrns(listResult.getEntities(), Function.identity(), entityService, true) .collect(Collectors.toCollection(UrnArray::new)); + validatedListResult.setEntities(validatedEntities); return validatedListResult; } public static LineageSearchResult validateLineageSearchResult( - final LineageSearchResult lineageSearchResult, @Nonnull final EntityService entityService) { + final LineageSearchResult lineageSearchResult, + @Nonnull final EntityService entityService) { if (lineageSearchResult == null) { return null; } @@ -133,9 +143,13 @@ public static LineageSearchResult validateLineageSearchResult( .setNumEntities(lineageSearchResult.getNumEntities()); LineageSearchEntityArray validatedEntities = - lineageSearchResult.getEntities().stream() - .filter(entity -> entityService.exists(entity.getEntity())) + validatedUrns( + lineageSearchResult.getEntities(), + LineageSearchEntity::getEntity, + entityService, + true) .collect(Collectors.toCollection(LineageSearchEntityArray::new)); + validatedLineageSearchResult.setEntities(validatedEntities); return validatedLineageSearchResult; @@ -143,7 +157,7 @@ public static LineageSearchResult validateLineageSearchResult( public static EntityLineageResult validateEntityLineageResult( @Nullable final EntityLineageResult entityLineageResult, - @Nonnull final EntityService entityService) { + @Nonnull final EntityService entityService) { if (entityLineageResult == null) { return null; } @@ -155,10 +169,12 @@ public static EntityLineageResult validateEntityLineageResult( .setCount(entityLineageResult.getCount()) .setTotal(entityLineageResult.getTotal()); - final LineageRelationshipArray validatedRelationships = - entityLineageResult.getRelationships().stream() - .filter(relationship -> entityService.exists(relationship.getEntity())) - .filter(relationship -> !entityService.isSoftDeleted(relationship.getEntity())) + LineageRelationshipArray validatedRelationships = + validatedUrns( + entityLineageResult.getRelationships(), + LineageRelationship::getEntity, + entityService, + false) .collect(Collectors.toCollection(LineageRelationshipArray::new)); validatedEntityLineageResult.setFiltered( @@ -173,7 +189,8 @@ public static EntityLineageResult validateEntityLineageResult( } public static LineageScrollResult validateLineageScrollResult( - final LineageScrollResult lineageScrollResult, @Nonnull final EntityService entityService) { + final LineageScrollResult lineageScrollResult, + @Nonnull final EntityService entityService) { if (lineageScrollResult == null) { return null; } @@ -189,13 +206,29 @@ public static LineageScrollResult validateLineageScrollResult( } LineageSearchEntityArray validatedEntities = - lineageScrollResult.getEntities().stream() - .filter(entity -> entityService.exists(entity.getEntity())) + validatedUrns( + lineageScrollResult.getEntities(), + LineageSearchEntity::getEntity, + entityService, + true) .collect(Collectors.toCollection(LineageSearchEntityArray::new)); + validatedLineageScrollResult.setEntities(validatedEntities); return validatedLineageScrollResult; } + private static Stream validatedUrns( + final AbstractArrayTemplate array, + Function urnFunction, + @Nonnull final EntityService entityService, + boolean includeSoftDeleted) { + + Set existingUrns = + entityService.exists( + array.stream().map(urnFunction).collect(Collectors.toList()), includeSoftDeleted); + return array.stream().filter(item -> existingUrns.contains(urnFunction.apply(item))); + } + private ValidationUtils() {} } From 14dee5723de8aac6ae8e566988f4bbcf3fac98ea Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 19 Jan 2024 16:18:15 -0800 Subject: [PATCH 174/540] feat(ingest/airflow): support airflow 2.8 dataset listeners (#9664) --- .github/workflows/airflow-plugin.yml | 9 +++++--- .../datahub_airflow_plugin/_airflow_shims.py | 3 +++ .../datahub_listener.py | 22 +++++++++++++++++++ .../airflow-plugin/tox.ini | 8 +++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index 158d3416bc2a9d..7ae7b87b0f5ceb 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -40,13 +40,16 @@ jobs: extra_pip_requirements: "apache-airflow~=2.2.4" extra_pip_extras: plugin-v1 - python-version: "3.10" - extra_pip_requirements: 'apache-airflow~=2.4.0 pluggy==1.0.0 "pendulum<3.0"' + extra_pip_requirements: 'apache-airflow~=2.4.0 pluggy==1.0.0 "pendulum<3.0" "Flask-Session<0.6.0"' extra_pip_extras: plugin-v2 - python-version: "3.10" - extra_pip_requirements: 'apache-airflow~=2.6.0 "pendulum<3.0"' + extra_pip_requirements: 'apache-airflow~=2.6.0 "pendulum<3.0" "Flask-Session<0.6.0"' extra_pip_extras: plugin-v2 - python-version: "3.10" - extra_pip_requirements: "apache-airflow>=2.7.0 pydantic==2.4.2" + extra_pip_requirements: 'apache-airflow~=2.7.0 pydantic==2.4.2 "Flask-Session<0.6.0"' + extra_pip_extras: plugin-v2 + - python-version: "3.10" + extra_pip_requirements: 'apache-airflow>=2.8.0 pydantic>=2.4.2 "Flask-Session<0.6.0"' extra_pip_extras: plugin-v2 fail-fast: false steps: diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_airflow_shims.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_airflow_shims.py index d384958cf3ddb5..c1e2dd4cc422d0 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_airflow_shims.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_airflow_shims.py @@ -32,6 +32,9 @@ HAS_AIRFLOW_STANDALONE_CMD = AIRFLOW_VERSION >= packaging.version.parse("2.2.0.dev0") HAS_AIRFLOW_LISTENER_API = AIRFLOW_VERSION >= packaging.version.parse("2.3.0.dev0") HAS_AIRFLOW_DAG_LISTENER_API = AIRFLOW_VERSION >= packaging.version.parse("2.5.0.dev0") +HAS_AIRFLOW_DATASET_LISTENER_API = AIRFLOW_VERSION >= packaging.version.parse( + "2.8.0.dev0" +) NEEDS_AIRFLOW_LISTENER_MODULE = AIRFLOW_VERSION < packaging.version.parse( "2.5.0.dev0" ) or PLUGGY_VERSION <= packaging.version.parse("1.0.0") diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py index debc91700d3db7..a7f588a166dde9 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py @@ -24,6 +24,7 @@ from datahub_airflow_plugin._airflow_shims import ( HAS_AIRFLOW_DAG_LISTENER_API, + HAS_AIRFLOW_DATASET_LISTENER_API, Operator, get_task_inlets, get_task_outlets, @@ -40,6 +41,7 @@ _F = TypeVar("_F", bound=Callable[..., None]) if TYPE_CHECKING: + from airflow.datasets import Dataset from airflow.models import DAG, DagRun, TaskInstance from sqlalchemy.orm import Session @@ -502,3 +504,23 @@ def on_dag_run_running(self, dag_run: "DagRun", msg: str) -> None: self.emitter.flush() # TODO: Add hooks for on_dag_run_success, on_dag_run_failed -> call AirflowGenerator.complete_dataflow + + if HAS_AIRFLOW_DATASET_LISTENER_API: + + @hookimpl + @run_in_thread + def on_dataset_created(self, dataset: "Dataset") -> None: + self._set_log_level() + + logger.debug( + f"DataHub listener got notification about dataset create for {dataset}" + ) + + @hookimpl + @run_in_thread + def on_dataset_changed(self, dataset: "Dataset") -> None: + self._set_log_level() + + logger.debug( + f"DataHub listener got notification about dataset change for {dataset}" + ) diff --git a/metadata-ingestion-modules/airflow-plugin/tox.ini b/metadata-ingestion-modules/airflow-plugin/tox.ini index 27ae2ce65ba658..154ced6b8deb94 100644 --- a/metadata-ingestion-modules/airflow-plugin/tox.ini +++ b/metadata-ingestion-modules/airflow-plugin/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py38-airflow21, py38-airflow22, py310-airflow24, py310-airflow26, py310-airflow27 +envlist = py38-airflow21, py38-airflow22, py310-airflow24, py310-airflow26, py310-airflow27, py310-airflow28 [testenv] use_develop = true @@ -30,10 +30,14 @@ deps = # constraints file is overly restrictive. airflow27: apache-airflow~=2.7.0 airflow27: pydantic==2.4.2 + airflow28: apache-airflow~=2.8.0 + # Apparently Flask-Session 0.6.0 was released by accident. + # See https://github.com/pallets-eco/flask-session/issues/209 + airflow24,airflow26,airflow27,airflow28: Flask-Session<0.6.0 commands = pytest --cov-append {posargs} # For Airflow 2.4+, add the plugin-v2 extra. -[testenv:py310-airflow{24,26,27}] +[testenv:py310-airflow{24,26,27,28}] extras = dev,integration-tests,plugin-v2 From 0906ce832d2bdc3f9b63e3f52cff56772fb8ecf9 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Sat, 20 Jan 2024 00:45:22 +0000 Subject: [PATCH 175/540] fix(docs): Correct Kafka Connect sink documentation (#9672) --- metadata-ingestion/docs/sources/kafka-connect/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-ingestion/docs/sources/kafka-connect/README.md b/metadata-ingestion/docs/sources/kafka-connect/README.md index e4f64c62914c57..5535f892590820 100644 --- a/metadata-ingestion/docs/sources/kafka-connect/README.md +++ b/metadata-ingestion/docs/sources/kafka-connect/README.md @@ -21,4 +21,4 @@ This ingestion source maps the following Source System Concepts to DataHub Conce Works only for - Source connectors: JDBC, Debezium, Mongo and Generic connectors with user-defined lineage graph -- Sink connectors: BigQuery, Confluent S3, Snowflake +- Sink connectors: BigQuery, Confluent, S3, Snowflake From 9168c4550a1553c1efd205662181df6ae3ca3e86 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Sat, 20 Jan 2024 06:36:36 +0530 Subject: [PATCH 176/540] docs(observability): add databricks as supported source (#9619) --- docs/managed-datahub/observe/column-assertions.md | 1 + docs/managed-datahub/observe/custom-sql-assertions.md | 1 + docs/managed-datahub/observe/freshness-assertions.md | 1 + docs/managed-datahub/observe/volume-assertions.md | 1 + 4 files changed, 4 insertions(+) diff --git a/docs/managed-datahub/observe/column-assertions.md b/docs/managed-datahub/observe/column-assertions.md index 99a764f7716766..8ef32e73b4b729 100644 --- a/docs/managed-datahub/observe/column-assertions.md +++ b/docs/managed-datahub/observe/column-assertions.md @@ -35,6 +35,7 @@ Column Assertions are currently supported for: 1. Snowflake 2. Redshift 3. BigQuery +4. Databricks Note that an Ingestion Source _must_ be configured with the data platform of your choice in Acryl DataHub's **Ingestion** tab. diff --git a/docs/managed-datahub/observe/custom-sql-assertions.md b/docs/managed-datahub/observe/custom-sql-assertions.md index d4a09b434ca799..11e9aa807b6167 100644 --- a/docs/managed-datahub/observe/custom-sql-assertions.md +++ b/docs/managed-datahub/observe/custom-sql-assertions.md @@ -43,6 +43,7 @@ Custom SQL Assertions are currently supported for: 1. Snowflake 2. Redshift 3. BigQuery +4. Databricks Note that an Ingestion Source _must_ be configured with the data platform of your choice in Acryl DataHub's **Ingestion** tab. diff --git a/docs/managed-datahub/observe/freshness-assertions.md b/docs/managed-datahub/observe/freshness-assertions.md index 82de423f6f2de2..416db6a65343eb 100644 --- a/docs/managed-datahub/observe/freshness-assertions.md +++ b/docs/managed-datahub/observe/freshness-assertions.md @@ -43,6 +43,7 @@ Freshness Assertions are currently supported for: 1. Snowflake 2. Redshift 3. BigQuery +4. Databricks Note that an Ingestion Source _must_ be configured with the data platform of your choice in Acryl DataHub's **Ingestion** tab. diff --git a/docs/managed-datahub/observe/volume-assertions.md b/docs/managed-datahub/observe/volume-assertions.md index 5f5aff33a5ce21..67971d0c20037a 100644 --- a/docs/managed-datahub/observe/volume-assertions.md +++ b/docs/managed-datahub/observe/volume-assertions.md @@ -44,6 +44,7 @@ Volume Assertions are currently supported for: 1. Snowflake 2. Redshift 3. BigQuery +4. Databricks Note that an Ingestion Source _must_ be configured with the data platform of your choice in Acryl DataHub's **Ingestion** tab. From 087d3fdb0dba1fccd802161e44a74a22edc211ac Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Fri, 19 Jan 2024 23:27:44 -0600 Subject: [PATCH 177/540] feat(build): conditional ci (#9673) --- .github/actions/ci-optimization/action.yml | 79 ++++++++++++ .github/workflows/build-and-test.yml | 34 +++++- .github/workflows/docker-unified.yml | 132 +++++++++++++++++---- 3 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 .github/actions/ci-optimization/action.yml diff --git a/.github/actions/ci-optimization/action.yml b/.github/actions/ci-optimization/action.yml new file mode 100644 index 00000000000000..404e0bab814e82 --- /dev/null +++ b/.github/actions/ci-optimization/action.yml @@ -0,0 +1,79 @@ +name: 'Identify CI Optimizations' +description: 'Determine if code changes are specific to certain modules.' + +outputs: + frontend-only: + description: "Frontend only change" + value: ${{ steps.filter.outputs.frontend == 'true' && steps.filter.outputs.ingestion == 'false' && steps.filter.outputs.backend == 'false' }} + ingestion-only: + description: "Ingestion only change" + value: ${{ steps.filter.outputs.frontend == 'false' && steps.filter.outputs.ingestion == 'true' && steps.filter.outputs.backend == 'false' }} + backend-only: + description: "Backend only change" + value: ${{ steps.filter.outputs.frontend == 'false' && steps.filter.outputs.ingestion == 'false' && steps.filter.outputs.backend == 'true' }} + backend-change: + description: "Backend code has changed" + value: ${{ steps.filter.outputs.backend == 'true' }} + ingestion-change: + description: "Ingestion code has changed" + value: ${{ steps.filter.outputs.ingestion == 'true' }} + frontend-change: + description: "Frontend code has changed" + value: ${{ steps.filter.outputs.frontend == 'true' }} + docker-change: + description: "Docker code has changed" + value: ${{ steps.filter.outputs.docker == 'true' }} + kafka-setup-change: + description: "Kafka setup docker change" + value: ${{ steps.filter.outputs.kafka-setup == 'true' }} + mysql-setup-change: + description: "Mysql setup docker change" + value: ${{ steps.filter.outputs.mysql-setup == 'true' }} + postgres-setup-change: + description: "Postgres setup docker change" + value: ${{ steps.filter.outputs.postgres-setup == 'true' }} + elasticsearch-setup-change: + description: "Elasticsearch setup docker change" + value: ${{ steps.filter.outputs.elasticsearch-setup == 'true' }} +runs: + using: "composite" + steps: + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + frontend: + - "datahub-frontend/**" + - "datahub-web-react/**" + - "smoke-test/tests/cypress/**" + - "docker/datahub-frontend/**" + ingestion: + - "metadata-ingestion-modules/airflow-plugin/**" + - "metadata-ingestion/**" + - "metadata-models/**" + - "smoke-test/**" + - "docker/datahub-ingestion-**" + docker: + - "docker/**" + backend: + - "metadata-models/**" + - "datahub-upgrade/**" + - "entity-registry/**" + - "li-utils/**" + - "metadata-auth/**" + - "metadata-dao-impl/**" + - "metadata-events/**" + - "metadata-io/**" + - "metadata-jobs/**" + - "metadata-service/**" + - "metadata-utils/**" + - "smoke-test/**" + - "docker/**" + kafka-setup: + - "docker/kafka-setup/**" + mysql-setup: + - "docker/mysql-setup/**" + postgres-setup: + - "docker/postgres-setup/**" + elasticsearch-setup: + - "docker/elasticsearch-setup/**" \ No newline at end of file diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 64493e99211b46..180e0472a8d99b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,6 +20,25 @@ concurrency: cancel-in-progress: true jobs: + setup: + runs-on: ubuntu-latest + outputs: + frontend_change: ${{ steps.ci-optimize.outputs.frontend-change == 'true' }} + ingestion_change: ${{ steps.ci-optimize.outputs.ingestion-change == 'true' }} + backend_change: ${{ steps.ci-optimize.outputs.backend-change == 'true' }} + docker_change: ${{ steps.ci-optimize.outputs.docker-change == 'true' }} + frontend_only: ${{ steps.ci-optimize.outputs.frontend-only == 'true' }} + ingestion_only: ${{ steps.ci-optimize.outputs.ingestion-only == 'true' }} + kafka_setup_change: ${{ steps.ci-optimize.outputs.kafka-setup-change == 'true' }} + mysql_setup_change: ${{ steps.ci-optimize.outputs.mysql-setup-change == 'true' }} + postgres_setup_change: ${{ steps.ci-optimize.outputs.postgres-setup-change == 'true' }} + elasticsearch_setup_change: ${{ steps.ci-optimize.outputs.elasticsearch-setup-change == 'true' }} + steps: + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 + - uses: ./.github/actions/ci-optimization + id: ci-optimize + build: strategy: fail-fast: false @@ -36,11 +55,13 @@ jobs: timezone: "America/New_York" runs-on: ubuntu-latest timeout-minutes: 60 + needs: setup steps: - uses: szenius/set-timezone@v1.2 with: timezoneLinux: ${{ matrix.timezone }} - - uses: hsheth2/sane-checkout-action@v1 + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 - name: Set up JDK 17 uses: actions/setup-java@v3 with: @@ -51,12 +72,12 @@ jobs: with: python-version: "3.10" cache: pip - - name: Gradle build (and test) for metadata ingestion - if: ${{ matrix.command == 'except_metadata_ingestion' }} + - name: Gradle build (and test) for NOT metadata ingestion + if: ${{ matrix.command == 'except_metadata_ingestion' && needs.setup.outputs.backend_change == 'true' }} run: | ./gradlew build -x :metadata-ingestion:build -x :metadata-ingestion:check -x docs-website:build -x :metadata-integration:java:spark-lineage:test -x :metadata-io:test -x :metadata-ingestion-modules:airflow-plugin:build -x :metadata-ingestion-modules:airflow-plugin:check -x :datahub-frontend:build -x :datahub-web-react:build --parallel - name: Gradle build (and test) for frontend - if: ${{ matrix.command == 'frontend' }} + if: ${{ matrix.command == 'frontend' && needs.setup.outputs.frontend_change == 'true' }} run: | ./gradlew :datahub-frontend:build :datahub-web-react:build --parallel env: @@ -75,8 +96,11 @@ jobs: quickstart-compose-validation: runs-on: ubuntu-latest + needs: setup + if: ${{ needs.setup.outputs.docker_change == 'true' }} steps: - - uses: actions/checkout@v3 + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index d246bf329bcb07..5e9112726b010e 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -51,9 +51,19 @@ jobs: short_sha: ${{ steps.tag.outputs.short_sha }} branch_name: ${{ steps.tag.outputs.branch_name }} repository_name: ${{ steps.tag.outputs.repository_name }} + frontend_change: ${{ steps.ci-optimize.outputs.frontend-change == 'true' }} + ingestion_change: ${{ steps.ci-optimize.outputs.ingestion-change == 'true' }} + backend_change: ${{ steps.ci-optimize.outputs.backend-change == 'true' }} + frontend_only: ${{ steps.ci-optimize.outputs.frontend-only == 'true' }} + ingestion_only: ${{ steps.ci-optimize.outputs.ingestion-only == 'true' }} + backend_only: ${{ steps.ci-optimize.outputs.backend-only == 'true' }} + kafka_setup_change: ${{ steps.ci-optimize.outputs.kafka-setup-change == 'true' }} + mysql_setup_change: ${{ steps.ci-optimize.outputs.mysql-setup-change == 'true' }} + postgres_setup_change: ${{ steps.ci-optimize.outputs.postgres-setup-change == 'true' }} + elasticsearch_setup_change: ${{ steps.ci-optimize.outputs.elasticsearch-setup-change == 'true' }} steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 - name: Compute Tag id: tag run: | @@ -75,11 +85,14 @@ jobs: run: | echo "Enable publish: ${{ env.ENABLE_PUBLISH }}" echo "publish=${{ env.ENABLE_PUBLISH }}" >> $GITHUB_OUTPUT + - uses: ./.github/actions/ci-optimization + id: ci-optimize gms_build: name: Build and Push DataHub GMS Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -113,6 +126,7 @@ jobs: name: "[Monitoring] Scan GMS images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, gms_build] + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Checkout # adding checkout step just to make trivy upload happy uses: actions/checkout@v3 @@ -142,6 +156,7 @@ jobs: name: Build and Push DataHub MAE Consumer Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -171,6 +186,7 @@ jobs: name: "[Monitoring] Scan MAE consumer images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, mae_consumer_build] + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} permissions: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results @@ -204,6 +220,7 @@ jobs: name: Build and Push DataHub MCE Consumer Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -233,6 +250,7 @@ jobs: name: "[Monitoring] Scan MCE consumer images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, mce_consumer_build] + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} permissions: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results @@ -266,6 +284,7 @@ jobs: name: Build and Push DataHub Upgrade Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -295,6 +314,7 @@ jobs: name: "[Monitoring] Scan DataHub Upgrade images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, datahub_upgrade_build] + if: ${{ needs.setup.outputs.backend_change == 'true' || needs.setup.outputs.publish == 'true' }} permissions: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results @@ -328,6 +348,7 @@ jobs: name: Build and Push DataHub Frontend Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.frontend_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -359,6 +380,7 @@ jobs: name: "[Monitoring] Scan Frontend images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, frontend_build] + if: ${{ needs.setup.outputs.frontend_change == 'true' || needs.setup.outputs.publish == 'true' }} permissions: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results @@ -392,6 +414,7 @@ jobs: name: Build and Push DataHub Kafka Setup Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.kafka_setup_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -412,6 +435,7 @@ jobs: name: Build and Push DataHub MySQL Setup Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.mysql_setup_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -432,6 +456,7 @@ jobs: name: Build and Push DataHub Elasticsearch Setup Docker Image runs-on: ubuntu-latest needs: setup + if: ${{ needs.setup.outputs.elasticsearch_setup_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -454,6 +479,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} needs: setup + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -486,6 +512,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} needs: [setup, datahub_ingestion_base_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -526,6 +553,7 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} needs: [setup, datahub_ingestion_base_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 @@ -568,6 +596,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} needs_artifact_download: ${{ (steps.filter.outputs.datahub-ingestion-base == 'true' || steps.filter.outputs.datahub-ingestion == 'true') && needs.setup.outputs.publish != 'true' }} needs: [setup, datahub_ingestion_base_slim_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -623,6 +652,7 @@ jobs: name: "[Monitoring] Scan Datahub Ingestion Slim images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, datahub_ingestion_slim_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Checkout # adding checkout step just to make trivy upload happy uses: actions/checkout@v3 @@ -655,6 +685,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} needs_artifact_download: ${{ (steps.filter.outputs.datahub-ingestion-base == 'true' || steps.filter.outputs.datahub-ingestion == 'true') && needs.setup.outputs.publish != 'true' }} needs: [setup, datahub_ingestion_base_full_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Set up JDK 17 uses: actions/setup-java@v3 @@ -709,6 +740,7 @@ jobs: name: "[Monitoring] Scan Datahub Ingestion images for vulnerabilities" runs-on: ubuntu-latest needs: [setup, datahub_ingestion_full_build] + if: ${{ needs.setup.outputs.ingestion_change == 'true' || needs.setup.outputs.publish == 'true' }} steps: - name: Checkout # adding checkout step just to make trivy upload happy uses: actions/checkout@v3 @@ -734,22 +766,31 @@ jobs: with: sarif_file: "trivy-results.sarif" + smoke_test_matrix: + runs-on: ubuntu-latest + needs: setup + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - id: set-matrix + run: | + if [ '${{ needs.setup.outputs.frontend_only }}' == 'true' ]; then + echo 'matrix=["cypress_suite1","cypress_rest"]' >> $GITHUB_OUTPUT + elif [ '${{ needs.setup.outputs.ingestion_only }}' == 'true' ]; then + echo 'matrix=["no_cypress_suite0","no_cypress_suite1"]' >> $GITHUB_OUTPUT + elif [ '${{ needs.setup.outputs.backend_change }}' == 'true' ]; then + echo 'matrix=["no_cypress_suite0","no_cypress_suite1","cypress_suite1","cypress_rest"]' >> $GITHUB_OUTPUT + else + echo 'matrix=[]' >> $GITHUB_OUTPUT + fi + smoke_test: name: Run Smoke Tests runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - test_strategy: - [ - "no_cypress_suite0", - "no_cypress_suite1", - "cypress_suite1", - "cypress_rest", - ] needs: [ setup, + smoke_test_matrix, gms_build, frontend_build, kafka_setup_build, @@ -760,6 +801,11 @@ jobs: datahub_upgrade_build, datahub_ingestion_slim_build, ] + strategy: + fail-fast: false + matrix: + test_strategy: ${{ fromJson(needs.smoke_test_matrix.outputs.matrix) }} + if: ${{ always() && !failure() && !cancelled() && needs.smoke_test_matrix.outputs.matrix != '[]' }} steps: - name: Disk Check run: df -h . && docker images @@ -788,57 +834,99 @@ jobs: run: df -h . && docker images - name: Download GMS image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.gms_build.result == 'success' }} with: image: ${{ env.DATAHUB_GMS_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download Frontend image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.frontend_build.result == 'success' }} with: image: ${{ env.DATAHUB_FRONTEND_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download Kafka Setup image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.kafka_setup_build.result == 'success' }} with: image: ${{ env.DATAHUB_KAFKA_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download Mysql Setup image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.mysql_setup_build.result == 'success' }} with: image: ${{ env.DATAHUB_MYSQL_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download Elastic Setup image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.elasticsearch_setup_build.result == 'success' }} with: image: ${{ env.DATAHUB_ELASTIC_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download MCE Consumer image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.mce_consumer_build.result == 'success' }} with: image: ${{ env.DATAHUB_MCE_CONSUMER_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download MAE Consumer image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.mae_consumer_build.result == 'success' }} with: image: ${{ env.DATAHUB_MAE_CONSUMER_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download upgrade image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.setup.outputs.publish != 'true' }} + if: ${{ needs.setup.outputs.publish != 'true' && needs.datahub_upgrade_build.result == 'success' }} with: image: ${{ env.DATAHUB_UPGRADE_IMAGE }}:${{ needs.setup.outputs.unique_tag }} - name: Download datahub-ingestion-slim image uses: ishworkh/docker-image-artifact-download@v1 - if: ${{ needs.datahub_ingestion_slim_build.outputs.needs_artifact_download == 'true' }} + if: ${{ needs.datahub_ingestion_slim_build.outputs.needs_artifact_download == 'true' && needs.datahub_ingestion_slim_build.result == 'success' }} with: image: ${{ env.DATAHUB_INGESTION_IMAGE }}:${{ needs.datahub_ingestion_slim_build.outputs.tag }} - name: Disk Check run: df -h . && docker images + - name: CI Optimization Head Images + # When publishing all tests/images are built (no optimizations) + if: ${{ needs.setup.outputs.publish != 'true' }} + run: | + if [ '${{ needs.setup.outputs.backend_change }}' == 'false' ]; then + echo 'GMS/Upgrade/MCE/MAE head images' + docker pull '${{ env.DATAHUB_GMS_IMAGE }}:head' + docker pull '${{ env.DATAHUB_MCE_CONSUMER_IMAGE }}:head' + docker pull '${{ env.DATAHUB_MAE_CONSUMER_IMAGE }}:head' + docker pull '${{ env.DATAHUB_UPGRADE_IMAGE }}:head' + docker tag '${{ env.DATAHUB_GMS_IMAGE }}:head' '${{ env.DATAHUB_GMS_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + docker tag '${{ env.DATAHUB_MCE_CONSUMER_IMAGE }}:head' '${{ env.DATAHUB_MCE_CONSUMER_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + docker tag '${{ env.DATAHUB_MAE_CONSUMER_IMAGE }}:head' '${{ env.DATAHUB_MAE_CONSUMER_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + docker tag '${{ env.DATAHUB_UPGRADE_IMAGE }}:head' '${{ env.DATAHUB_UPGRADE_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + fi + if [ '${{ needs.setup.outputs.frontend_change }}' == 'false' ]; then + echo 'Frontend head images' + docker pull '${{ env.DATAHUB_FRONTEND_IMAGE }}:head' + docker tag '${{ env.DATAHUB_FRONTEND_IMAGE }}:head' '${{ env.DATAHUB_FRONTEND_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + fi + if [ '${{ needs.setup.outputs.kafka_setup_change }}' == 'false' ]; then + echo 'kafka-setup head images' + docker pull '${{ env.DATAHUB_KAFKA_SETUP_IMAGE }}:head' + docker tag '${{ env.DATAHUB_KAFKA_SETUP_IMAGE }}:head' '${{ env.DATAHUB_KAFKA_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + fi + if [ '${{ needs.setup.outputs.mysql_setup_change }}' == 'false' ]; then + echo 'mysql-setup head images' + docker pull '${{ env.DATAHUB_MYSQL_SETUP_IMAGE }}:head' + docker tag '${{ env.DATAHUB_MYSQL_SETUP_IMAGE }}:head' '${{ env.DATAHUB_MYSQL_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + fi + if [ '${{ needs.setup.outputs.elasticsearch_setup_change }}' == 'false' ]; then + echo 'elasticsearch-setup head images' + docker pull '${{ env.DATAHUB_ELASTIC_SETUP_IMAGE }}:head' + docker tag '${{ env.DATAHUB_ELASTIC_SETUP_IMAGE }}:head' '${{ env.DATAHUB_ELASTIC_SETUP_IMAGE }}:${{ needs.setup.outputs.unique_tag }}' + fi + if [ '${{ needs.setup.outputs.ingestion_change }}' == 'false' ]; then + echo 'datahub-ingestion head-slim images' + docker pull '${{ env.DATAHUB_INGESTION_IMAGE }}:head-slim' + if [ '${{ needs.datahub_ingestion_slim_build.outputs.tag || 'head-slim' }}' != 'head-slim' ]; then + docker tag '${{ env.DATAHUB_INGESTION_IMAGE }}:head-slim' '${{ env.DATAHUB_INGESTION_IMAGE }}:${{ needs.datahub_ingestion_slim_build.outputs.tag }}' + fi + fi - name: run quickstart env: DATAHUB_TELEMETRY_ENABLED: false DATAHUB_VERSION: ${{ needs.setup.outputs.unique_tag }} DATAHUB_ACTIONS_IMAGE: ${{ env.DATAHUB_INGESTION_IMAGE }} - ACTIONS_VERSION: ${{ needs.datahub_ingestion_slim_build.outputs.tag }} + ACTIONS_VERSION: ${{ needs.datahub_ingestion_slim_build.outputs.tag || 'head-slim' }} ACTIONS_EXTRA_PACKAGES: "acryl-datahub-actions[executor]==0.0.13 acryl-datahub-actions==0.0.13 acryl-datahub==0.10.5" ACTIONS_CONFIG: "https://raw.githubusercontent.com/acryldata/datahub-actions/main/docker/config/executor.yaml" run: | From d78db0abee07e33b44342ce1920889324303b137 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jan 2024 14:53:50 +0530 Subject: [PATCH 178/540] build(deps-dev): bump vite from 4.5.0 to 4.5.2 in /datahub-web-react (#9676) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- datahub-web-react/package.json | 2 +- datahub-web-react/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index cf63d5c313bdbe..97830cec4e164e 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -126,7 +126,7 @@ "less": "^4.2.0", "prettier": "^2.8.8", "source-map-explorer": "^2.5.2", - "vite": "^4.5.0", + "vite": "^4.5.2", "vite-plugin-babel-macros": "^1.0.6", "vite-plugin-static-copy": "^0.17.0", "vite-plugin-svgr": "^4.1.0", diff --git a/datahub-web-react/yarn.lock b/datahub-web-react/yarn.lock index 9ea6c58eadc6b8..37801e42b3eab9 100644 --- a/datahub-web-react/yarn.lock +++ b/datahub-web-react/yarn.lock @@ -10835,10 +10835,10 @@ vite-plugin-svgr@^4.1.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.0.tgz#ec406295b4167ac3bc23e26f9c8ff559287cff26" - integrity sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw== +"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.2.tgz#d6ea8610e099851dad8c7371599969e0f8b97e82" + integrity sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w== dependencies: esbuild "^0.18.10" postcss "^8.4.27" From 20ad83d0b1be4fbb54e10aa183afba64674deecf Mon Sep 17 00:00:00 2001 From: Quentin FLEURENT NAMBOT Date: Sat, 20 Jan 2024 10:24:10 +0100 Subject: [PATCH 179/540] feat(superset): add some custom properties for dashboards (#9670) --- .../src/datahub/ingestion/source/superset.py | 24 ++++++++++++- .../superset/golden_test_ingest.json | 16 +++++++-- .../superset/golden_test_stateful_ingest.json | 9 ++++- .../integration/superset/test_superset.py | 35 +++++++++++++++++-- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/superset.py b/metadata-ingestion/src/datahub/ingestion/source/superset.py index 7f607666db3136..931069a9210580 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/superset.py +++ b/metadata-ingestion/src/datahub/ingestion/source/superset.py @@ -267,13 +267,35 @@ def construct_dashboard_from_api_data(self, dashboard_data): f"urn:li:chart:({self.platform},{value.get('meta', {}).get('chartId', 'unknown')})" ) + # Build properties + custom_properties = { + "Status": str(dashboard_data.get("status")), + "IsPublished": str(dashboard_data.get("published", False)).lower(), + "Owners": ", ".join( + map( + lambda owner: owner.get("username", "unknown"), + dashboard_data.get("owners", []), + ) + ), + "IsCertified": str( + True if dashboard_data.get("certified_by") else False + ).lower(), + } + + if dashboard_data.get("certified_by"): + custom_properties["CertifiedBy"] = dashboard_data.get("certified_by") + custom_properties["CertificationDetails"] = str( + dashboard_data.get("certification_details") + ) + + # Create DashboardInfo object dashboard_info = DashboardInfoClass( description="", title=title, charts=chart_urns, lastModified=last_modified, dashboardUrl=dashboard_url, - customProperties={}, + customProperties=custom_properties, ) dashboard_snapshot.aspects.append(dashboard_info) return dashboard_snapshot diff --git a/metadata-ingestion/tests/integration/superset/golden_test_ingest.json b/metadata-ingestion/tests/integration/superset/golden_test_ingest.json index 6a522281f1c9de..74312940f06e78 100644 --- a/metadata-ingestion/tests/integration/superset/golden_test_ingest.json +++ b/metadata-ingestion/tests/integration/superset/golden_test_ingest.json @@ -11,7 +11,14 @@ }, { "com.linkedin.pegasus2avro.dashboard.DashboardInfo": { - "customProperties": {}, + "customProperties": { + "Status": "published", + "IsPublished": "true", + "Owners": "test_username_1, test_username_2", + "IsCertified": "true", + "CertifiedBy": "Certification team", + "CertificationDetails": "Approved" + }, "title": "test_dashboard_title_1", "description": "", "charts": [ @@ -52,7 +59,12 @@ }, { "com.linkedin.pegasus2avro.dashboard.DashboardInfo": { - "customProperties": {}, + "customProperties": { + "Status": "draft", + "IsPublished": "false", + "Owners": "unknown", + "IsCertified": "false" + }, "title": "test_dashboard_title_2", "description": "", "charts": [ diff --git a/metadata-ingestion/tests/integration/superset/golden_test_stateful_ingest.json b/metadata-ingestion/tests/integration/superset/golden_test_stateful_ingest.json index 268fa37396245e..cf38341085c1b0 100644 --- a/metadata-ingestion/tests/integration/superset/golden_test_stateful_ingest.json +++ b/metadata-ingestion/tests/integration/superset/golden_test_stateful_ingest.json @@ -11,7 +11,14 @@ }, { "com.linkedin.pegasus2avro.dashboard.DashboardInfo": { - "customProperties": {}, + "customProperties": { + "Status": "published", + "IsPublished": "true", + "Owners": "test_username_1, test_username_2", + "IsCertified": "true", + "CertifiedBy": "Certification team", + "CertificationDetails": "Approved" + }, "title": "test_dashboard_title_1", "description": "", "charts": [ diff --git a/metadata-ingestion/tests/integration/superset/test_superset.py b/metadata-ingestion/tests/integration/superset/test_superset.py index bc299e36515e18..b3b59820161467 100644 --- a/metadata-ingestion/tests/integration/superset/test_superset.py +++ b/metadata-ingestion/tests/integration/superset/test_superset.py @@ -41,6 +41,18 @@ def register_mock_api(request_mock: Any, override_data: dict = {}) -> None: "dashboard_title": "test_dashboard_title_1", "url": "/dashboard/test_dashboard_url_1", "position_json": '{"CHART-test-1": {"meta": { "chartId": "10" }}, "CHART-test-2": {"meta": { "chartId": "11" }}}', + "status": "published", + "published": True, + "owners": [ + { + "username": "test_username_1", + }, + { + "username": "test_username_2", + }, + ], + "certified_by": "Certification team", + "certification_details": "Approved", }, { "id": "2", @@ -51,6 +63,15 @@ def register_mock_api(request_mock: Any, override_data: dict = {}) -> None: "dashboard_title": "test_dashboard_title_2", "url": "/dashboard/test_dashboard_url_2", "position_json": '{"CHART-test-3": {"meta": { "chartId": "12" }}, "CHART-test-4": {"meta": { "chartId": "13" }}}', + "status": "draft", + "published": False, + "owners": [ + { + "first_name": "name", + }, + ], + "certified_by": "", + "certification_details": "", }, ], }, @@ -151,7 +172,6 @@ def register_mock_api(request_mock: Any, override_data: dict = {}) -> None: @freeze_time(FROZEN_TIME) @pytest.mark.integration def test_superset_ingest(pytestconfig, tmp_path, mock_time, requests_mock): - test_resources_dir = pytestconfig.rootpath / "tests/integration/superset" register_mock_api(request_mock=requests_mock) @@ -193,7 +213,6 @@ def test_superset_ingest(pytestconfig, tmp_path, mock_time, requests_mock): def test_superset_stateful_ingest( pytestconfig, tmp_path, mock_time, requests_mock, mock_datahub_graph ): - test_resources_dir = pytestconfig.rootpath / "tests/integration/superset" register_mock_api(request_mock=requests_mock) @@ -241,6 +260,18 @@ def test_superset_stateful_ingest( "dashboard_title": "test_dashboard_title_1", "url": "/dashboard/test_dashboard_url_1", "position_json": '{"CHART-test-1": {"meta": { "chartId": "10" }}, "CHART-test-2": {"meta": { "chartId": "11" }}}', + "status": "published", + "published": True, + "owners": [ + { + "username": "test_username_1", + }, + { + "username": "test_username_2", + }, + ], + "certified_by": "Certification team", + "certification_details": "Approved", }, ], }, From 61165994587b1bd9f430d693aa9a73948903a68f Mon Sep 17 00:00:00 2001 From: Quentin FLEURENT NAMBOT Date: Sat, 20 Jan 2024 10:24:23 +0100 Subject: [PATCH 180/540] fix(superset): handle comma in dataset table name (#9656) --- .../src/datahub/ingestion/source/superset.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/superset.py b/metadata-ingestion/src/datahub/ingestion/source/superset.py index 931069a9210580..827c630cfa1483 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/superset.py +++ b/metadata-ingestion/src/datahub/ingestion/source/superset.py @@ -9,7 +9,7 @@ from pydantic.fields import Field from datahub.configuration import ConfigModel -from datahub.emitter.mce_builder import DEFAULT_ENV +from datahub.emitter.mce_builder import DEFAULT_ENV, make_dataset_urn from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.decorators import ( SourceCapability, @@ -223,15 +223,13 @@ def get_datasource_urn_from_id(self, datasource_id): database_name = self.config.database_alias.get(database_name, database_name) if database_id and table_name: - platform = self.get_platform_from_database_id(database_id) - platform_urn = f"urn:li:dataPlatform:{platform}" - dataset_urn = ( - f"urn:li:dataset:(" - f"{platform_urn},{database_name + '.' if database_name else ''}" - f"{schema_name + '.' if schema_name else ''}" - f"{table_name},{self.config.env})" + return make_dataset_urn( + platform=self.get_platform_from_database_id(database_id), + name=".".join( + name for name in [database_name, schema_name, table_name] if name + ), + env=self.config.env, ) - return dataset_urn return None def construct_dashboard_from_api_data(self, dashboard_data): From ce65b9cb3fe344da20dfd1b222a6d2a80f2fc2d8 Mon Sep 17 00:00:00 2001 From: Dimitri <36767102+dim-ops@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:25:40 +0100 Subject: [PATCH 181/540] feat(openapi): allow swagger 3.x (#9646) Co-authored-by: Dimitri GRISARD --- .../src/datahub/ingestion/source/openapi_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py b/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py index 84bb3ad4526117..c1caca18fefe3f 100755 --- a/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py +++ b/metadata-ingestion/src/datahub/ingestion/source/openapi_parser.py @@ -111,8 +111,8 @@ def check_sw_version(sw_dict: dict) -> None: version = [int(v) for v in v_split] if version[0] == 3 and version[1] > 0: - raise NotImplementedError( - "This plugin is not compatible with Swagger version >3.0" + logger.warning( + "This plugin has not been fully tested with Swagger version >3.0" ) From 344eeaebc7ddff275cce5c62e2fd31f2bff6b8d6 Mon Sep 17 00:00:00 2001 From: Quentin FLEURENT NAMBOT Date: Sun, 21 Jan 2024 21:38:44 +0100 Subject: [PATCH 182/540] feat(ingest/superset): add domains and platform_instance support (#9647) --- .../src/datahub/ingestion/source/superset.py | 75 ++++++++++++++++--- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/superset.py b/metadata-ingestion/src/datahub/ingestion/source/superset.py index 827c630cfa1483..18f8e3709a648e 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/superset.py +++ b/metadata-ingestion/src/datahub/ingestion/source/superset.py @@ -8,8 +8,18 @@ from pydantic.class_validators import root_validator, validator from pydantic.fields import Field -from datahub.configuration import ConfigModel -from datahub.emitter.mce_builder import DEFAULT_ENV, make_dataset_urn +from datahub.configuration.common import AllowDenyPattern +from datahub.configuration.source_common import ( + EnvConfigMixin, + PlatformInstanceConfigMixin, +) +from datahub.emitter.mce_builder import ( + make_chart_urn, + make_dashboard_urn, + make_dataset_urn, + make_domain_urn, +) +from datahub.emitter.mcp_builder import add_domain_to_entity_wu from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.decorators import ( SourceCapability, @@ -49,6 +59,7 @@ DashboardInfoClass, ) from datahub.utilities import config_clean +from datahub.utilities.registries.domain_registry import DomainRegistry logger = logging.getLogger(__name__) @@ -72,7 +83,9 @@ } -class SupersetConfig(StatefulIngestionConfigBase, ConfigModel): +class SupersetConfig( + StatefulIngestionConfigBase, EnvConfigMixin, PlatformInstanceConfigMixin +): # See the Superset /security/login endpoint for details # https://superset.apache.org/docs/rest-api connect_uri: str = Field( @@ -82,6 +95,10 @@ class SupersetConfig(StatefulIngestionConfigBase, ConfigModel): default=None, description="optional URL to use in links (if `connect_uri` is only for ingestion)", ) + domain: Dict[str, AllowDenyPattern] = Field( + default=dict(), + description="regex patterns for tables to filter to assign domain_key. ", + ) username: Optional[str] = Field(default=None, description="Superset username.") password: Optional[str] = Field(default=None, description="Superset password.") @@ -92,10 +109,7 @@ class SupersetConfig(StatefulIngestionConfigBase, ConfigModel): provider: str = Field(default="db", description="Superset provider.") options: Dict = Field(default={}, description="") - env: str = Field( - default=DEFAULT_ENV, - description="Environment to use in namespace when constructing URNs", - ) + # TODO: Check and remove this if no longer needed. # Config database_alias is removed from sql sources. database_alias: Dict[str, str] = Field( @@ -188,6 +202,12 @@ def __init__(self, ctx: PipelineContext, config: SupersetConfig): } ) + if self.config.domain: + self.domain_registry = DomainRegistry( + cached_domains=[domain_id for domain_id in self.config.domain], + graph=self.ctx.graph, + ) + # Test the connection test_response = self.session.get(f"{self.config.connect_uri}/api/v1/dashboard/") if test_response.status_code == 200: @@ -233,7 +253,11 @@ def get_datasource_urn_from_id(self, datasource_id): return None def construct_dashboard_from_api_data(self, dashboard_data): - dashboard_urn = f"urn:li:dashboard:({self.platform},{dashboard_data['id']})" + dashboard_urn = make_dashboard_urn( + platform=self.platform, + name=dashboard_data["id"], + platform_instance=self.config.platform_instance, + ) dashboard_snapshot = DashboardSnapshot( urn=dashboard_urn, aspects=[Status(removed=False)], @@ -262,7 +286,11 @@ def construct_dashboard_from_api_data(self, dashboard_data): if not key.startswith("CHART-"): continue chart_urns.append( - f"urn:li:chart:({self.platform},{value.get('meta', {}).get('chartId', 'unknown')})" + make_chart_urn( + platform=self.platform, + name=value.get("meta", {}).get("chartId", "unknown"), + platform_instance=self.config.platform_instance, + ) ) # Build properties @@ -325,9 +353,17 @@ def emit_dashboard_mces(self) -> Iterable[MetadataWorkUnit]: ) mce = MetadataChangeEvent(proposedSnapshot=dashboard_snapshot) yield MetadataWorkUnit(id=dashboard_snapshot.urn, mce=mce) + yield from self._get_domain_wu( + title=dashboard_data.get("dashboard_title", ""), + entity_urn=dashboard_snapshot.urn, + ) def construct_chart_from_chart_data(self, chart_data): - chart_urn = f"urn:li:chart:({self.platform},{chart_data['id']})" + chart_urn = make_chart_urn( + platform=self.platform, + name=chart_data["id"], + platform_instance=self.config.platform_instance, + ) chart_snapshot = ChartSnapshot( urn=chart_urn, aspects=[Status(removed=False)], @@ -424,6 +460,10 @@ def emit_chart_mces(self) -> Iterable[MetadataWorkUnit]: mce = MetadataChangeEvent(proposedSnapshot=chart_snapshot) yield MetadataWorkUnit(id=chart_snapshot.urn, mce=mce) + yield from self._get_domain_wu( + title=chart_data.get("slice_name", ""), + entity_urn=chart_snapshot.urn, + ) def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: yield from self.emit_dashboard_mces() @@ -439,3 +479,18 @@ def get_workunit_processors(self) -> List[Optional[MetadataWorkUnitProcessor]]: def get_report(self) -> StaleEntityRemovalSourceReport: return self.report + + def _get_domain_wu(self, title: str, entity_urn: str) -> Iterable[MetadataWorkUnit]: + domain_urn = None + for domain, pattern in self.config.domain.items(): + if pattern.allowed(title): + domain_urn = make_domain_urn( + self.domain_registry.get_domain_urn(domain) + ) + break + + if domain_urn: + yield from add_domain_to_entity_wu( + entity_urn=entity_urn, + domain_urn=domain_urn, + ) From ad65c36ddcb253dd3f8b22dc01465de134b006b1 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Sun, 21 Jan 2024 14:39:31 -0600 Subject: [PATCH 183/540] fix(workflow): workflow tweaks (#9678) --- .github/actions/ci-optimization/action.yml | 2 +- .github/workflows/build-and-test.yml | 1 + .github/workflows/metadata-io.yml | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/actions/ci-optimization/action.yml b/.github/actions/ci-optimization/action.yml index 404e0bab814e82..f6160fdbcff675 100644 --- a/.github/actions/ci-optimization/action.yml +++ b/.github/actions/ci-optimization/action.yml @@ -48,7 +48,7 @@ runs: - "smoke-test/tests/cypress/**" - "docker/datahub-frontend/**" ingestion: - - "metadata-ingestion-modules/airflow-plugin/**" + - "metadata-ingestion-modules/**" - "metadata-ingestion/**" - "metadata-models/**" - "smoke-test/**" diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 180e0472a8d99b..060d345a6b7d9a 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -69,6 +69,7 @@ jobs: java-version: 17 - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 + if: ${{ needs.setup.outputs.ingestion_change == 'true' }} with: python-version: "3.10" cache: pip diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index eb5822b5b480d0..243bd90cd60036 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -24,9 +24,28 @@ concurrency: cancel-in-progress: true jobs: + setup: + runs-on: ubuntu-latest + outputs: + frontend_change: ${{ steps.ci-optimize.outputs.frontend-change == 'true' }} + ingestion_change: ${{ steps.ci-optimize.outputs.ingestion-change == 'true' }} + backend_change: ${{ steps.ci-optimize.outputs.backend-change == 'true' }} + docker_change: ${{ steps.ci-optimize.outputs.docker-change == 'true' }} + frontend_only: ${{ steps.ci-optimize.outputs.frontend-only == 'true' }} + ingestion_only: ${{ steps.ci-optimize.outputs.ingestion-only == 'true' }} + kafka_setup_change: ${{ steps.ci-optimize.outputs.kafka-setup-change == 'true' }} + mysql_setup_change: ${{ steps.ci-optimize.outputs.mysql-setup-change == 'true' }} + postgres_setup_change: ${{ steps.ci-optimize.outputs.postgres-setup-change == 'true' }} + elasticsearch_setup_change: ${{ steps.ci-optimize.outputs.elasticsearch-setup-change == 'true' }} + steps: + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 + - uses: ./.github/actions/ci-optimization + id: ci-optimize build: runs-on: ubuntu-latest timeout-minutes: 60 + needs: setup steps: - uses: actions/checkout@v3 - name: Set up JDK 17 @@ -36,6 +55,7 @@ jobs: java-version: 17 - uses: gradle/gradle-build-action@v2 - uses: actions/setup-python@v4 + if: ${{ needs.setup.outputs.ingestion_change == 'true' }} with: python-version: "3.10" cache: "pip" From 77df9ec9262047e0e314c5a7a80f0eca3854ef35 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Mon, 22 Jan 2024 18:07:43 +0530 Subject: [PATCH 184/540] feat(ingest/databricks): view upstream lineage for hive metastore (#9657) --- .../source/unity/hive_metastore_proxy.py | 1 - .../datahub/ingestion/source/unity/source.py | 101 +++++++++++++++++- .../unity/unity_catalog_mces_golden.json | 60 +++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py index 814d86a2f3234b..2a98dda1c79c57 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/hive_metastore_proxy.py @@ -55,7 +55,6 @@ class HiveMetastoreProxy(Closeable): - # TODO: Support for view lineage using SQL parsing # Why not use hive ingestion source directly here ? # 1. hive ingestion source assumes 2-level namespace heirarchy and currently # there is no other intermediate interface except sqlalchemy inspector diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py index 1bc47c63078490..7a47b1181ae36b 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/source.py @@ -1,7 +1,7 @@ import logging import re from concurrent.futures import ThreadPoolExecutor -from typing import Dict, Iterable, List, Optional, Set, Union +from typing import Dict, Iterable, List, Optional, Set, Tuple, Union from urllib.parse import urljoin from datahub.emitter.mce_builder import ( @@ -24,6 +24,7 @@ add_dataset_to_container, gen_containers, ) +from datahub.emitter.sql_parsing_builder import SqlParsingBuilder from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.decorators import ( SupportStatus, @@ -67,6 +68,7 @@ DATA_TYPE_REGISTRY, Catalog, Column, + CustomCatalogType, Metastore, Notebook, NotebookId, @@ -104,6 +106,12 @@ from datahub.utilities.file_backed_collections import FileBackedDict from datahub.utilities.hive_schema_to_avro import get_schema_fields_for_hive_column from datahub.utilities.registries.domain_registry import DomainRegistry +from datahub.utilities.sqlglot_lineage import ( + SchemaResolver, + SqlParsingResult, + sqlglot_lineage, + view_definition_lineage_helper, +) logger: logging.Logger = logging.getLogger(__name__) @@ -137,6 +145,7 @@ class UnityCatalogSource(StatefulIngestionSourceBase, TestableSource): unity_catalog_api_proxy: UnityCatalogApiProxy platform: str = "databricks" platform_instance_name: Optional[str] + sql_parser_schema_resolver: Optional[SchemaResolver] = None def get_report(self) -> UnityCatalogReport: return self.report @@ -179,6 +188,9 @@ def __init__(self, ctx: PipelineContext, config: UnityCatalogSourceConfig): self.table_refs: Set[TableReference] = set() self.view_refs: Set[TableReference] = set() self.notebooks: FileBackedDict[Notebook] = FileBackedDict() + self.view_definitions: FileBackedDict[ + Tuple[TableReference, str] + ] = FileBackedDict() # Global map of tables, for profiling self.tables: FileBackedDict[Table] = FileBackedDict() @@ -191,6 +203,13 @@ def init_hive_metastore_proxy(self): self.config.get_sql_alchemy_url(HIVE_METASTORE), self.config.options ) self.report.hive_metastore_catalog_found = True + + if self.config.include_table_lineage: + self.sql_parser_schema_resolver = SchemaResolver( + platform=self.platform, + platform_instance=self.config.platform_instance, + env=self.config.env, + ) except Exception as e: logger.debug("Exception", exc_info=True) self.warn( @@ -243,6 +262,8 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: yield from self.process_metastores() + yield from self.get_view_lineage() + if self.config.include_notebooks: self.report.report_ingestion_stage_start("Notebook lineage") for notebook in self.notebooks.values(): @@ -304,7 +325,6 @@ def process_notebooks(self) -> Iterable[MetadataWorkUnit]: yield from self._gen_notebook_workunits(notebook) def _gen_notebook_workunits(self, notebook: Notebook) -> Iterable[MetadataWorkUnit]: - properties = {"path": notebook.path} if notebook.language: properties["language"] = notebook.language.value @@ -449,6 +469,17 @@ def process_table(self, table: Table, schema: Schema) -> Iterable[MetadataWorkUn table.ref, self.notebooks[str(notebook_id)] ) + # Sql parsing is required only for hive metastore view lineage + if ( + self.sql_parser_schema_resolver + and table.schema.catalog.type == CustomCatalogType.HIVE_METASTORE_CATALOG + ): + self.sql_parser_schema_resolver.add_schema_metadata( + dataset_urn, schema_metadata + ) + if table.view_definition: + self.view_definitions[dataset_urn] = (table.ref, table.view_definition) + yield from [ mcp.as_workunit() for mcp in MetadataChangeProposalWrapper.construct_many( @@ -828,8 +859,74 @@ def _create_schema_field(column: Column) -> List[SchemaFieldClass]: ) ] + def _run_sql_parser( + self, view_ref: TableReference, query: str, schema_resolver: SchemaResolver + ) -> Optional[SqlParsingResult]: + raw_lineage = sqlglot_lineage( + query, + schema_resolver=schema_resolver, + default_db=view_ref.catalog, + default_schema=view_ref.schema, + ) + view_urn = self.gen_dataset_urn(view_ref) + + if raw_lineage.debug_info.table_error: + logger.debug( + f"Failed to parse lineage for view {view_ref}: " + f"{raw_lineage.debug_info.table_error}" + ) + self.report.num_view_definitions_failed_parsing += 1 + self.report.view_definitions_parsing_failures.append( + f"Table-level sql parsing error for view {view_ref}: {raw_lineage.debug_info.table_error}" + ) + return None + + elif raw_lineage.debug_info.column_error: + self.report.num_view_definitions_failed_column_parsing += 1 + self.report.view_definitions_parsing_failures.append( + f"Column-level sql parsing error for view {view_ref}: {raw_lineage.debug_info.column_error}" + ) + else: + self.report.num_view_definitions_parsed += 1 + return view_definition_lineage_helper(raw_lineage, view_urn) + + def get_view_lineage(self) -> Iterable[MetadataWorkUnit]: + if not ( + self.config.include_hive_metastore + and self.config.include_table_lineage + and self.sql_parser_schema_resolver + ): + return + # This is only used for parsing view lineage. Usage, Operations are emitted elsewhere + builder = SqlParsingBuilder( + generate_lineage=True, + generate_usage_statistics=False, + generate_operations=False, + ) + for dataset_name in self.view_definitions.keys(): + view_ref, view_definition = self.view_definitions[dataset_name] + result = self._run_sql_parser( + view_ref, + view_definition, + self.sql_parser_schema_resolver, + ) + if result and result.out_tables: + # This does not yield any workunits but we use + # yield here to execute this method + yield from builder.process_sql_parsing_result( + result=result, + query=view_definition, + is_view_ddl=True, + include_column_lineage=self.config.include_view_column_lineage, + ) + yield from builder.gen_workunits() + def close(self): if self.hive_metastore_proxy: self.hive_metastore_proxy.close() + if self.view_definitions: + self.view_definitions.close() + if self.sql_parser_schema_resolver: + self.sql_parser_schema_resolver.close() super().close() diff --git a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json index 649212c1041ed7..7cc0f84ee51777 100644 --- a/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json +++ b/metadata-ingestion/tests/integration/unity/unity_catalog_mces_golden.json @@ -3463,6 +3463,66 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD)", + "type": "VIEW" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD),betStatusId)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD),betStatusId)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD),channelId)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD),channelId)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.bet,PROD),combination)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:databricks,hive_metastore.bronze_kambi.view1,PROD),combination)" + ], + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "unity-catalog-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "dataset", "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:databricks,system.quickstart_schema.quickstart_table,PROD)", From 1d16e4296497d5e9525cfebaf89344dd18fd247d Mon Sep 17 00:00:00 2001 From: Dimitri <36767102+dim-ops@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:46:57 +0100 Subject: [PATCH 185/540] feat(ingest/dynamodb): add domain arg (#9658) Co-authored-by: Dimitri GRISARD --- .../ingestion/source/dynamodb/dynamodb.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/dynamodb/dynamodb.py b/metadata-ingestion/src/datahub/ingestion/source/dynamodb/dynamodb.py index d7f3dfb9279fbb..972eb60ff5b05d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dynamodb/dynamodb.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dynamodb/dynamodb.py @@ -13,8 +13,10 @@ make_data_platform_urn, make_dataplatform_instance_urn, make_dataset_urn_with_platform_instance, + make_domain_urn, ) from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.emitter.mcp_builder import add_domain_to_entity_wu from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.api.decorators import ( SupportStatus, @@ -53,6 +55,7 @@ DataPlatformInstanceClass, DatasetPropertiesClass, ) +from datahub.utilities.registries.domain_registry import DomainRegistry MAX_ITEMS_TO_RETRIEVE = 100 PAGE_SIZE = 100 @@ -68,6 +71,11 @@ class DynamoDBConfig(DatasetSourceConfigMixin, StatefulIngestionConfigBase): aws_access_key_id: str = Field(description="AWS Access Key ID.") aws_secret_access_key: pydantic.SecretStr = Field(description="AWS Secret Key.") + domain: Dict[str, AllowDenyPattern] = Field( + default=dict(), + description="regex patterns for tables to filter to assign domain_key. ", + ) + # This config option allows user to include a list of items from a table when we scan and construct the schema, # the key of this dict is table name and the value is the list of item primary keys in dynamodb format, # if the table use composite key then the value should have partition key and sort key present @@ -155,6 +163,12 @@ def __init__(self, ctx: PipelineContext, config: DynamoDBConfig, platform: str): self.report = DynamoDBSourceReport() self.platform = platform + if self.config.domain: + self.domain_registry = DomainRegistry( + cached_domains=[domain_id for domain_id in self.config.domain], + graph=self.ctx.graph, + ) + @classmethod def create(cls, config_dict: dict, ctx: PipelineContext) -> "DynamoDBSource": config = DynamoDBConfig.parse_obj(config_dict) @@ -234,6 +248,11 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: aspect=dataset_properties, ).as_workunit() + yield from self._get_domain_wu( + dataset_name=table_name, + entity_urn=dataset_urn, + ) + platform_instance_aspect = DataPlatformInstanceClass( platform=make_data_platform_urn(self.platform), instance=make_dataplatform_instance_urn( @@ -480,3 +499,20 @@ def get_field_type( def get_report(self) -> DynamoDBSourceReport: return self.report + + def _get_domain_wu( + self, dataset_name: str, entity_urn: str + ) -> Iterable[MetadataWorkUnit]: + domain_urn = None + for domain, pattern in self.config.domain.items(): + if pattern.allowed(dataset_name): + domain_urn = make_domain_urn( + self.domain_registry.get_domain_urn(domain) + ) + break + + if domain_urn: + yield from add_domain_to_entity_wu( + entity_urn=entity_urn, + domain_urn=domain_urn, + ) From 943bb57cbcf22db12c092a7f9a30c762aa2bf6e5 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Mon, 22 Jan 2024 11:46:04 -0600 Subject: [PATCH 186/540] feat(backend): structured properties and forms (#9626) Co-authored-by: Chris Collins Co-authored-by: RyanHolstien --- .../GenerateJsonSchemaTask.java | 15 +- .../io/datahubproject/OpenApiEntities.java | 30 +- .../app/config/ConfigurationProvider.java | 4 + .../app/controllers/Application.java | 7 + .../app/controllers/RedirectController.java | 25 + datahub-frontend/conf/routes | 4 + datahub-frontend/public | 1 + .../resources/public/logos/datahub-logo.png | Bin 0 -> 53563 bytes datahub-graphql-core/build.gradle | 3 + .../linkedin/datahub/graphql/Constants.java | 3 +- .../datahub/graphql/GmsGraphQLEngine.java | 235 +++- .../datahub/graphql/GmsGraphQLEngineArgs.java | 2 + .../datahub/graphql/GmsGraphQLPlugin.java | 4 + .../datahub/graphql/SubTypesResolver.java | 55 + .../graphql/WeaklyTypedAspectsResolver.java | 2 +- .../GetMetadataAnalyticsResolver.java | 2 +- .../analytics/service/AnalyticsService.java | 2 +- .../graphql/featureflags/FeatureFlags.java | 1 + .../resolvers/chart/BrowseV2Resolver.java | 10 +- .../resolvers/config/AppConfigResolver.java | 12 +- .../ListDataProductAssetsResolver.java | 2 +- .../domain/DomainEntitiesResolver.java | 2 +- .../form/BatchAssignFormResolver.java | 52 + .../CreateDynamicFormAssignmentResolver.java | 50 + .../form/IsFormAssignedToMeResolver.java | 80 ++ .../form/SubmitFormPromptResolver.java | 89 ++ .../resolvers/form/VerifyFormResolver.java | 63 + .../glossary/CreateGlossaryNodeResolver.java | 1 - .../resolvers/group/EntityCountsResolver.java | 2 +- .../execution/RollbackIngestionResolver.java | 3 +- .../resolvers/mutate/util/FormUtils.java | 105 ++ .../policy/GetGrantedPrivilegesResolver.java | 2 +- .../ListRecommendationsResolver.java | 2 +- .../AggregateAcrossEntitiesResolver.java | 8 +- .../AutoCompleteForMultipleResolver.java | 8 +- .../search/GetQuickFiltersResolver.java | 2 +- .../search/ScrollAcrossEntitiesResolver.java | 2 +- .../search/ScrollAcrossLineageResolver.java | 2 +- .../search/SearchAcrossLineageResolver.java | 2 +- .../resolvers/search/SearchResolver.java | 2 +- .../graphql/resolvers/search/SearchUtils.java | 2 +- .../resolvers/type/PropertyValueResolver.java | 25 + .../graphql/resolvers/view/ViewUtils.java | 2 +- .../common/mappers/UrnToEntityMapper.java | 6 + .../graphql/types/dataset/DatasetType.java | 2 + .../dataset/mappers/DatasetFilterMapper.java | 24 + .../types/dataset/mappers/DatasetMapper.java | 13 + .../dataset/mappers/SchemaFieldMapper.java | 14 + .../dataset/mappers/SchemaMetadataMapper.java | 7 +- .../types/datatype/DataTypeEntityMapper.java | 51 + .../graphql/types/datatype/DataTypeType.java | 78 ++ .../types/datatype/DataTypeUrnMapper.java | 40 + .../entitytype/EntityTypeEntityMapper.java | 54 + .../entitytype}/EntityTypeMapper.java | 8 +- .../types/entitytype/EntityTypeType.java | 78 ++ .../types/entitytype/EntityTypeUrnMapper.java | 85 ++ .../graphql/types/form/FormMapper.java | 129 ++ .../datahub/graphql/types/form/FormType.java | 76 ++ .../graphql/types/form/FormsMapper.java | 133 ++ .../glossary/mappers/GlossaryNodeMapper.java | 11 +- .../glossary/mappers/GlossaryTermsMapper.java | 11 +- .../graphql/types/mappers/MapperUtils.java | 2 +- .../types/schemafield/SchemaFieldMapper.java | 54 + .../types/schemafield/SchemaFieldType.java | 70 +- .../StructuredPropertiesMapper.java | 80 ++ .../StructuredPropertyMapper.java | 124 ++ .../StructuredPropertyType.java | 79 ++ .../graphql/types/view/DataHubViewMapper.java | 2 +- .../src/main/resources/app.graphql | 10 + .../src/main/resources/entity.graphql | 336 ++++- .../src/main/resources/forms.graphql | 407 ++++++ .../src/main/resources/properties.graphql | 243 ++++ .../src/main/resources/search.graphql | 6 +- .../src/main/resources/tests.graphql | 2 + .../linkedin/datahub/graphql/TestUtils.java | 8 +- .../browse/BrowseV2ResolverTest.java | 13 +- .../domain/DomainEntitiesResolverTest.java | 2 +- .../form/IsFormAssignedToMeResolverTest.java | 167 +++ .../form/VerifyFormResolverTest.java | 122 ++ .../RollbackIngestionResolverTest.java | 7 +- .../mutate/MutableTypeBatchResolverTest.java | 5 +- .../AggregateAcrossEntitiesResolverTest.java | 21 +- .../search/GetQuickFiltersResolverTest.java | 2 +- .../SearchAcrossEntitiesResolverTest.java | 2 +- .../upgrade/UpgradeCliApplication.java | 8 +- .../common/steps/GMSDisableWriteModeStep.java | 10 +- .../common/steps/GMSEnableWriteModeStep.java | 10 +- .../config/BackfillBrowsePathsV2Config.java | 2 +- .../upgrade/config/BuildIndicesConfig.java | 10 +- .../upgrade/config/NoCodeUpgradeConfig.java | 9 +- .../config/RemoveUnknownAspectsConfig.java | 2 +- .../upgrade/config/RestoreBackupConfig.java | 9 +- .../upgrade/config/RestoreIndicesConfig.java | 11 +- .../upgrade/impl/DefaultUpgradeContext.java | 46 +- .../upgrade/impl/DefaultUpgradeManager.java | 3 + .../datahub/upgrade/nocode/NoCodeUpgrade.java | 10 +- .../RemoveClientIdAspectStep.java | 2 +- .../RemoveUnknownAspects.java | 4 +- .../upgrade/restorebackup/RestoreBackup.java | 10 +- .../restorebackup/RestoreStorageStep.java | 2 +- .../backupreader/BackupReader.java | 1 + .../restoreindices/RestoreIndices.java | 11 +- .../upgrade/restoreindices/SendMAEStep.java | 26 +- .../system/elasticsearch/BuildIndices.java | 24 +- .../steps/BuildIndicesPreStep.java | 29 +- .../system/elasticsearch/util/IndexUtils.java | 19 + .../entity/steps/BackfillBrowsePathsV2.java | 2 +- .../steps/BackfillBrowsePathsV2Step.java | 4 +- .../src/main/resources/application.properties | 5 + ...pgradeCliApplicationTestConfiguration.java | 2 +- datahub-web-react/build.gradle | 1 - datahub-web-react/index.html | 3 +- .../public/assets/{ => icons}/favicon.ico | Bin datahub-web-react/public/assets/logo.png | Bin 53563 -> 22 bytes .../public/assets/logos/datahub-logo.png | Bin 0 -> 53563 bytes datahub-web-react/public/browserconfig.xml | 9 + datahub-web-react/public/manifest.json | 2 +- docker/build.gradle | 5 + .../datahub-ingestion-base/smoke.Dockerfile | 4 +- docker/datahub-ingestion/Dockerfile | 4 +- docker/datahub-ingestion/Dockerfile-slim-only | 2 +- docker/elasticsearch-setup/Dockerfile | 2 +- docker/profiles/docker-compose.gms.yml | 4 + docs-website/graphql/generateGraphQLSchema.sh | 2 + docs-website/sidebars.js | 16 +- .../TownhallButton/townhallbutton.module.scss | 2 +- .../openapi/openapi-structured-properties.md | 284 +++++ .../metadata/aspect/batch/AspectsBatch.java | 11 +- .../metadata/aspect/batch/MCPBatchItem.java | 2 +- .../metadata/aspect/batch/PatchItem.java | 6 +- .../metadata/aspect/batch/UpsertItem.java | 5 +- .../aspect/patch/GenericJsonPatch.java | 34 + .../aspect}/patch/PatchOperationType.java | 2 +- .../AbstractMultiFieldPatchBuilder.java | 7 +- .../patch/builder}/ChartInfoPatchBuilder.java | 10 +- .../CustomPropertiesPatchBuilder.java | 9 +- .../builder}/DashboardInfoPatchBuilder.java | 12 +- .../builder}/DataFlowInfoPatchBuilder.java | 13 +- .../builder}/DataJobInfoPatchBuilder.java | 13 +- .../DataJobInputOutputPatchBuilder.java | 13 +- .../DatasetPropertiesPatchBuilder.java | 13 +- .../EditableSchemaMetadataPatchBuilder.java | 10 +- .../builder}/GlobalTagsPatchBuilder.java | 9 +- .../builder}/GlossaryTermsPatchBuilder.java | 9 +- .../patch/builder}/OwnershipPatchBuilder.java | 9 +- .../aspect/patch/builder}/PatchUtil.java | 6 +- .../StructuredPropertiesPatchBuilder.java | 110 ++ .../builder}/UpstreamLineagePatchBuilder.java | 11 +- .../CustomPropertiesPatchBuilderSupport.java | 4 +- .../IntermediatePatchBuilder.java | 4 +- .../patch}/template/ArrayMergingTemplate.java | 4 +- .../patch}/template/AspectTemplateEngine.java | 19 +- .../patch/template/CompoundKeyTemplate.java | 23 + .../patch}/template/Template.java | 30 +- .../aspect/patch/template/TemplateUtil.java | 97 ++ .../template/chart/ChartInfoTemplate.java | 6 +- .../template/common/GenericPatchTemplate.java | 59 + .../template/common/GlobalTagsTemplate.java | 4 +- .../common/GlossaryTermsTemplate.java | 8 +- .../template/common/OwnershipTemplate.java | 6 +- .../common/StructuredPropertiesTemplate.java | 56 + .../dashboard/DashboardInfoTemplate.java | 6 +- .../dataflow/DataFlowInfoTemplate.java | 4 +- .../template/datajob/DataJobInfoTemplate.java | 4 +- .../datajob/DataJobInputOutputTemplate.java | 4 +- .../DataProductPropertiesTemplate.java | 4 +- .../dataset/DatasetPropertiesTemplate.java | 4 +- .../EditableSchemaMetadataTemplate.java | 10 +- .../dataset/UpstreamLineageTemplate.java | 11 +- .../aspect/plugins/PluginFactory.java | 29 +- .../metadata/aspect/plugins/PluginSpec.java | 8 +- .../aspect/plugins/hooks/MCLSideEffect.java | 11 +- .../aspect/plugins/hooks/MCPSideEffect.java | 9 +- .../plugins/validation/AspectRetriever.java | 25 +- .../PropertyDefinitionValidator.java | 91 ++ .../StructuredPropertiesValidator.java | 264 ++++ .../metadata/models/LogicalValueType.java | 10 + .../models/StructuredPropertyUtils.java | 45 + .../models/registry/ConfigEntityRegistry.java | 2 +- .../models/registry/EntityRegistry.java | 7 +- .../models/registry/MergedEntityRegistry.java | 2 +- .../models/registry/PatchEntityRegistry.java | 2 +- .../registry/SnapshotEntityRegistry.java | 31 +- .../template/CompoundKeyTemplate.java | 52 - .../registry/template/util/TemplateUtil.java | 39 - .../template}/ChartInfoTemplateTest.java | 4 +- .../template}/DashboardInfoTemplateTest.java | 4 +- .../UpstreamLineageTemplateTest.java | 4 +- .../metadata/aspect/plugins/PluginsTest.java | 28 +- .../plugins/hooks/MCLSideEffectTest.java | 5 +- .../plugins/hooks/MCPSideEffectTest.java | 3 +- .../validation/ValidatorPluginTest.java | 2 +- .../PropertyDefinitionValidatorTest.java | 212 ++++ .../StructuredPropertiesValidatorTest.java | 246 ++++ .../models/EntitySpecBuilderTest.java | 2 +- .../PluginEntityRegistryLoaderTest.java | 2 +- .../ingestion/IngestionSchedulerTest.java | 4 +- .../java/com/linkedin/metadata/Constants.java | 23 + .../airflow-plugin/scripts/release.sh | 2 +- .../bootstrap_data/business_glossary.yml | 2 + metadata-ingestion/examples/forms/forms.yaml | 54 + .../mce_files/test_structured_properties.json | 218 ++++ .../examples/structured_properties/README.md | 51 + .../structured_properties/click_event.avsc | 14 + .../structured_properties/dataset.yaml | 45 + .../structured_properties.yaml | 68 + metadata-ingestion/scripts/docgen.sh | 2 +- metadata-ingestion/scripts/modeldocgen.py | 22 + metadata-ingestion/scripts/release.sh | 2 +- .../datahub/api/entities/dataset/__init__.py | 0 .../datahub/api/entities/dataset/dataset.py | 466 +++++++ .../datahub/api/entities/forms/__init__.py | 0 .../src/datahub/api/entities/forms/forms.py | 353 ++++++ .../entities/forms/forms_graphql_constants.py | 27 + .../entities/structuredproperties/__init__.py | 0 .../structuredproperties.py | 185 +++ .../src/datahub/cli/docker_check.py | 37 + .../datahub/cli/specific/dataproduct_cli.py | 3 +- .../src/datahub/cli/specific/dataset_cli.py | 67 + .../src/datahub/cli/specific/forms_cli.py | 53 + .../cli/specific/structuredproperties_cli.py | 62 + metadata-ingestion/src/datahub/entrypoints.py | 6 + .../source/metadata/business_glossary.py | 2 + .../src/datahub/specific/dataset.py | 32 + .../datahub/specific/structured_properties.py | 53 + .../src/datahub/utilities/urn_encoder.py | 1 + .../src/datahub/utilities/urns/_urn_base.py | 40 + .../urns/structured_properties_urn.py | 5 + .../business-glossary/business_glossary.yml | 2 + .../glossary_events_auto_id_golden.json | 93 +- .../glossary_events_golden.json | 93 +- .../remote/content/business_glossary.yml | 2 + .../remote/golden/remote_glossary_golden.json | 93 +- .../java/datahub-client/build.gradle | 12 +- .../java/datahub-client/scripts/check_jar.sh | 3 +- .../java/datahub/client/patch/PatchTest.java | 18 +- .../datahub-protobuf/scripts/check_jar.sh | 3 +- .../java/examples/build.gradle | 3 - .../examples/DataJobLineageAdd.java | 2 +- .../examples/DatasetCustomPropertiesAdd.java | 2 +- .../DatasetCustomPropertiesAddRemove.java | 2 +- .../DatasetCustomPropertiesReplace.java | 2 +- .../java/spark-lineage/build.gradle | 5 + .../setup_spark_smoke_test.sh | 2 +- .../client/EntityClientAspectRetriever.java | 35 + .../metadata/client/JavaEntityClient.java | 19 +- .../client/SystemJavaEntityClient.java | 14 +- .../metadata/entity/EntityServiceImpl.java | 52 +- .../linkedin/metadata/entity/EntityUtils.java | 12 +- .../cassandra/CassandraRetentionService.java | 16 +- .../entity/ebean/EbeanRetentionService.java | 16 +- .../entity/ebean/batch/AspectsBatchImpl.java | 31 +- .../entity/ebean/batch/MCLBatchItemImpl.java | 23 +- .../entity/ebean/batch/MCPPatchBatchItem.java | 11 +- .../ebean/batch/MCPUpsertBatchItem.java | 63 +- .../entity/validation/ValidationUtils.java | 3 +- .../graph/dgraph/DgraphGraphService.java | 19 + .../graph/elastic/ESGraphQueryDAO.java | 92 +- .../elastic/ElasticSearchGraphService.java | 123 +- .../graph/neo4j/Neo4jGraphService.java | 19 + .../elasticsearch/ElasticSearchService.java | 9 + .../indexbuilder/ESIndexBuilder.java | 46 +- .../indexbuilder/EntityIndexBuilders.java | 48 + .../indexbuilder/MappingsBuilder.java | 79 ++ .../indexbuilder/ReindexConfig.java | 33 + .../elasticsearch/query/ESSearchDAO.java | 4 +- .../request/AggregationQueryBuilder.java | 17 +- .../query/request/SearchAfterWrapper.java | 2 +- .../query/request/SearchRequestHandler.java | 1 + .../metadata/search/features/Features.java | 3 +- .../SearchDocumentTransformer.java | 114 +- .../metadata/search/utils/ESUtils.java | 102 +- .../service/UpdateIndicesService.java | 82 +- .../metadata/shared/ElasticSearchIndexed.java | 11 + .../ElasticSearchSystemMetadataService.java | 8 + .../ElasticSearchTimeseriesAspectService.java | 159 ++- .../TimeseriesAspectIndexBuilders.java | 8 + .../metadata/AspectIngestionUtils.java | 6 +- .../metadata/client/JavaEntityClientTest.java | 10 +- .../entity/EbeanEntityServiceTest.java | 20 +- .../metadata/entity/EntityServiceTest.java | 228 +++- .../metadata/entity/TestEntityRegistry.java | 2 +- .../search/SearchGraphServiceTestBase.java | 28 +- .../search/fixtures/GoldenTestBase.java | 2 +- .../indexbuilder/IndexBuilderTestBase.java | 118 ++ .../indexbuilder/MappingsBuilderTest.java | 137 +- .../request/AggregationQueryBuilderTest.java | 97 +- .../request/CustomizedQueryHandlerTest.java | 10 + .../query/request/SearchQueryBuilderTest.java | 19 +- .../metadata/search/utils/ESUtilsTest.java | 71 ++ .../test/search/SearchTestUtils.java | 2 +- ...rm_assignment_test_definition_complex.json | 145 +++ ...orm_assignment_test_definition_simple.json | 67 + .../forms/form_prompt_test_definition.json | 39 + .../kafka/MaeConsumerApplication.java | 4 +- .../src/main/resources/application.properties | 2 +- ...eConsumerApplicationTestConfiguration.java | 3 - .../kafka/MetadataChangeLogProcessor.java | 7 +- .../kafka/config/EntityHydratorConfig.java | 12 +- .../event/EntityChangeEventGeneratorHook.java | 9 +- .../kafka/hook/form/FormAssignmentHook.java | 130 ++ .../hook/siblings/SiblingAssociationHook.java | 8 +- .../kafka/hydrator/EntityHydrator.java | 6 +- .../kafka/hook/UpdateIndicesHookTest.java | 7 +- .../EntityChangeEventGeneratorHookTest.java | 10 +- .../siblings/SiblingAssociationHookTest.java | 6 +- .../spring/MCLSpringTestConfiguration.java | 19 +- .../kafka/MceConsumerApplication.java | 6 +- .../metadata/restli/RestliServletConfig.java | 13 - .../kafka/MceConsumerApplicationTest.java | 2 +- ...eConsumerApplicationTestConfiguration.java | 21 +- metadata-jobs/mce-consumer/build.gradle | 2 +- .../kafka/MetadataChangeEventsProcessor.java | 6 +- .../MetadataChangeProposalsProcessor.java | 6 +- .../datahub/event/PlatformEventProcessor.java | 2 +- metadata-models-custom/README.md | 20 + .../CustomDataQualityRulesMCLSideEffect.java | 9 +- .../CustomDataQualityRulesMCPSideEffect.java | 5 +- metadata-models/build.gradle | 3 +- .../com/linkedin/common/CustomProperties.pdl | 1 + .../common/FieldFormPromptAssociation.pdl | 17 + .../com/linkedin/common/FormAssociation.pdl | 21 + .../linkedin/common/FormPromptAssociation.pdl | 23 + .../common/FormPromptFieldAssociations.pdl | 16 + .../common/FormVerificationAssociation.pdl | 17 + .../pegasus/com/linkedin/common/Forms.pdl | 66 + .../common/GlossaryTermAssociation.pdl | 6 + .../com/linkedin/common/PropertyValue.pdl | 13 + .../linkedin/datahub/DataHubSearchConfig.pdl | 87 ++ .../com/linkedin/datatype/DataTypeInfo.pdl | 21 + .../com/linkedin/datatype/DataTypeKey.pdl | 11 + .../linkedin/entitytype/EntityTypeInfo.pdl | 22 + .../com/linkedin/entitytype/EntityTypeKey.pdl | 11 + .../linkedin/form/DynamicFormAssignment.pdl | 19 + .../com/linkedin/form/FormActorAssignment.pdl | 21 + .../pegasus/com/linkedin/form/FormInfo.pdl | 51 + .../pegasus/com/linkedin/form/FormPrompt.pdl | 53 + .../linkedin/glossary/GlossaryNodeInfo.pdl | 3 +- .../linkedin/glossary/GlossaryTermInfo.pdl | 2 + .../com/linkedin/metadata/key/FormKey.pdl | 14 + .../structured/PrimitivePropertyValue.pdl | 9 + .../com/linkedin/structured/PropertyValue.pdl | 10 + .../structured/StructuredProperties.pdl | 14 + .../StructuredPropertyDefinition.pdl | 74 ++ .../structured/StructuredPropertyKey.pdl | 11 + .../StructuredPropertyValueAssignment.pdl | 29 + .../src/main/resources/entity-registry.yml | 55 +- .../authentication/group/GroupService.java | 42 +- .../token/StatefulTokenService.java | 11 +- .../metadata/config/VisualConfiguration.java | 9 + .../src/main/resources/application.yml | 20 +- .../factory/auth/AuthorizerChainFactory.java | 31 +- .../auth/DataHubAuthorizerFactory.java | 22 +- .../auth/DataHubTokenServiceFactory.java | 6 +- .../gms/factory/auth/GroupServiceFactory.java | 13 +- .../auth/InviteTokenServiceFactory.java | 10 +- .../auth/NativeUserServiceFactory.java | 15 +- .../gms/factory/auth/PostServiceFactory.java | 11 +- .../gms/factory/auth/RoleServiceFactory.java | 12 +- .../auth/SystemAuthenticationFactory.java | 4 +- .../common/SiblingGraphServiceFactory.java | 2 +- .../factory/config/ConfigurationProvider.java | 6 + .../StructuredPropertiesConfiguration.java | 10 + .../DataProductServiceFactory.java | 10 +- .../entity/CassandraSessionFactory.java | 4 +- .../entity/DeleteEntityServiceFactory.java | 4 +- .../factory/entity/EntityServiceFactory.java | 18 +- .../entity/JavaEntityClientFactory.java | 100 -- .../entity/RetentionServiceFactory.java | 12 +- .../entity/RollbackServiceFactory.java | 27 + .../indices/UpdateIndicesServiceFactory.java | 18 +- .../EntityClientConfigFactory.java | 20 + .../entityclient/JavaEntityClientFactory.java | 85 ++ .../RestliEntityClientFactory.java | 60 +- .../gms/factory/form/FormServiceFactory.java | 21 + .../factory/graphql/GraphQLEngineFactory.java | 30 +- .../ingestion/IngestionSchedulerFactory.java | 20 +- .../kafka/KafkaEventConsumerFactory.java | 1 - .../AwsGlueSchemaRegistryFactory.java | 2 +- .../lineage/LineageServiceFactory.java | 11 +- .../OwnershipTypeServiceFactory.java | 17 +- .../factory/query/QueryServiceFactory.java | 16 +- .../MostPopularCandidateSourceFactory.java | 2 +- .../RecentlyEditedCandidateSourceFactory.java | 2 +- .../RecentlyViewedCandidateSourceFactory.java | 2 +- .../TopPlatformsCandidateSourceFactory.java | 2 +- .../search/ElasticSearchServiceFactory.java | 13 + .../search/LineageSearchServiceFactory.java | 4 +- .../search/views/ViewServiceFactory.java | 16 +- .../settings/SettingsServiceFactory.java | 17 +- .../gms/factory/telemetry/DailyReport.java | 4 +- .../telemetry/ScheduledAnalyticsFactory.java | 2 +- .../gms/factory/telemetry/TelemetryUtils.java | 4 +- .../telemetry/TrackingServiceFactory.java | 2 +- ...tyChangeEventGeneratorRegistryFactory.java | 9 +- .../linkedin/metadata/boot/BootstrapStep.java | 2 +- .../linkedin/metadata/boot/UpgradeStep.java | 5 +- .../factories/BootstrapManagerFactory.java | 10 +- .../IngestRetentionPoliciesStepFactory.java | 2 +- .../boot/steps/BackfillBrowsePathsV2Step.java | 2 +- .../boot/steps/IndexDataPlatformsStep.java | 2 +- .../IngestDataPlatformInstancesStep.java | 5 +- .../boot/steps/IngestDataPlatformsStep.java | 6 +- .../boot/steps/IngestDataTypesStep.java | 103 ++ .../IngestDefaultGlobalSettingsStep.java | 6 +- .../boot/steps/IngestEntityTypesStep.java | 88 ++ .../boot/steps/IngestOwnershipTypesStep.java | 8 +- .../boot/steps/IngestPoliciesStep.java | 5 +- .../metadata/boot/steps/IngestRolesStep.java | 5 +- .../boot/steps/IngestRootUserStep.java | 2 +- .../steps/RestoreColumnLineageIndices.java | 4 +- .../boot/steps/RestoreDbtSiblingsIndices.java | 3 +- .../boot/steps/RestoreGlossaryIndices.java | 2 +- .../steps/UpgradeDefaultBrowsePathsStep.java | 2 +- ...SearchIndexBuilderFactoryDefaultsTest.java | 27 + .../steps/BackfillBrowsePathsV2StepTest.java | 8 +- .../IngestDataPlatformInstancesStepTest.java | 12 +- .../boot/steps/IngestDataTypesStepTest.java | 81 ++ .../IngestDefaultGlobalSettingsStepTest.java | 10 +- .../boot/steps/IngestEntityTypesStepTest.java | 91 ++ .../RestoreColumnLineageIndicesTest.java | 13 +- .../steps/RestoreGlossaryIndicesTest.java | 10 +- .../UpgradeDefaultBrowsePathsStepTest.java | 12 +- .../telemetry/TelemetryUtilsTest.java | 2 +- .../boot/test_data_types_invalid.json | 9 + .../resources/boot/test_data_types_valid.json | 10 + .../test/resources/test-entity-registry.yaml | 18 +- .../openapi-entity-servlet/build.gradle | 2 +- .../delegates/EntityApiDelegateImpl.java | 119 +- .../JavaSpring/apiController.mustache | 2 +- .../delegates/EntityApiDelegateImplTest.java | 8 +- .../GlobalControllerExceptionHandler.java | 15 +- .../openapi/config/SpringWebConfig.java | 31 +- .../openapi/util/MappingUtil.java | 6 +- .../openapi/util/ReflectionCache.java | 27 +- .../v2/controller/EntityController.java | 507 ++++++++ .../v2/controller/RelationshipController.java | 228 ++++ .../v2/controller/TimeseriesController.java | 115 ++ .../openapi/v2/models/GenericEntity.java | 57 + .../v2/models/GenericRelationship.java | 36 + .../v2/models/GenericScrollResult.java | 12 + .../v2/models/GenericTimeseriesAspect.java | 18 + .../openapi/v2/models/PatchOperation.java | 26 + .../openapi/v2/utils/ControllerUtil.java | 67 + .../com.linkedin.entity.aspects.snapshot.json | 7 + ...com.linkedin.entity.entities.snapshot.json | 7 + .../com.linkedin.entity.runs.snapshot.json | 7 + ...nkedin.operations.operations.snapshot.json | 7 + ...m.linkedin.platform.platform.snapshot.json | 7 + .../linkedin/entity/client/EntityClient.java | 30 +- .../entity/client/RestliEntityClient.java | 10 +- .../entity/client/SystemEntityClient.java | 55 +- .../client/SystemRestliEntityClient.java | 2 +- .../resources/entity/AspectResource.java | 4 +- .../entity/BatchIngestionRunResource.java | 309 +---- .../resources/entity/AspectResourceTest.java | 2 +- .../mock/MockTimeseriesAspectService.java | 15 + metadata-service/services/build.gradle | 6 +- .../linkedin/metadata/entity/AspectUtils.java | 2 +- .../metadata/entity/EntityService.java | 16 +- .../linkedin/metadata/graph/GraphService.java | 15 + .../metadata/graph/RelatedEntities.java | 31 + .../graph/RelatedEntitiesScrollResult.java | 16 + .../metadata/search/EntitySearchService.java | 2 +- .../metadata/service/FormService.java | 1107 +++++++++++++++++ .../metadata/service/RollbackService.java | 328 +++++ .../SearchBasedFormAssignmentManager.java | 94 ++ .../util/SearchBasedFormAssignmentRunner.java | 45 + .../metadata/shared/ValidationUtils.java | 167 +-- .../timeseries/GenericTimeseriesDocument.java | 26 + .../timeseries/TimeseriesAspectService.java | 11 + .../timeseries/TimeseriesScrollResult.java | 18 + .../gms/servlet/ConfigSearchExport.java | 2 +- .../src/main/resources/boot/data_types.json | 42 + .../authorization/OwnershipUtils.java | 20 + .../metadata/utils/AuditStampUtils.java | 10 + .../linkedin/metadata/utils/FormUtils.java | 49 + .../metadata/utils/GenericRecordUtils.java | 21 + .../metadata/utils/SchemaFieldUtils.java | 22 + .../linkedin/metadata/utils/SearchUtil.java | 26 + .../main/java/mock/MockEntityRegistry.java | 2 +- smoke-test/cypress-dev.sh | 2 +- smoke-test/requests_wrapper/__init__.py | 1 + .../cypress/cypress/e2e/siblings/siblings.js | 2 +- .../tests/structured_properties/__init__.py | 0 .../structured_properties/click_event.avsc | 14 + .../structured_properties/test_dataset.yaml | 19 + .../test_structured_properties.py | 577 +++++++++ .../test_structured_properties.yaml | 33 + smoke-test/tests/telemetry/telemetry_test.py | 14 +- smoke-test/tests/utilities/__init__.py | 0 smoke-test/tests/utilities/file_emitter.py | 21 + 492 files changed, 15378 insertions(+), 1931 deletions(-) create mode 100644 datahub-frontend/app/controllers/RedirectController.java create mode 120000 datahub-frontend/public create mode 100644 datahub-frontend/test/resources/public/logos/datahub-logo.png create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/SubTypesResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/BatchAssignFormResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateDynamicFormAssignmentResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/SubmitFormPromptResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/type/PropertyValueResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetFilterMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeEntityMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeType.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeUrnMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeEntityMapper.java rename datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/{resolvers => types/entitytype}/EntityTypeMapper.java (91%) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeType.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormType.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormsMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java create mode 100644 datahub-graphql-core/src/main/resources/forms.graphql create mode 100644 datahub-graphql-core/src/main/resources/properties.graphql create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolverTest.java create mode 100644 datahub-upgrade/src/main/resources/application.properties rename datahub-web-react/public/assets/{ => icons}/favicon.ico (100%) mode change 100644 => 120000 datahub-web-react/public/assets/logo.png create mode 100644 datahub-web-react/public/assets/logos/datahub-logo.png create mode 100644 datahub-web-react/public/browserconfig.xml create mode 100644 docs/api/openapi/openapi-structured-properties.md create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java rename {metadata-integration/java/datahub-client/src/main/java/datahub/client => entity-registry/src/main/java/com/linkedin/metadata/aspect}/patch/PatchOperationType.java (81%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/AbstractMultiFieldPatchBuilder.java (95%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/ChartInfoPatchBuilder.java (75%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/CustomPropertiesPatchBuilder.java (90%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/DashboardInfoPatchBuilder.java (86%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataflow => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/DataFlowInfoPatchBuilder.java (92%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/DataJobInfoPatchBuilder.java (93%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/DataJobInputOutputPatchBuilder.java (93%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/DatasetPropertiesPatchBuilder.java (91%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/EditableSchemaMetadataPatchBuilder.java (90%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/GlobalTagsPatchBuilder.java (88%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/GlossaryTermsPatchBuilder.java (89%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/OwnershipPatchBuilder.java (91%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/PatchUtil.java (96%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/StructuredPropertiesPatchBuilder.java rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/UpstreamLineagePatchBuilder.java (96%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/subtypesupport/CustomPropertiesPatchBuilderSupport.java (81%) rename {metadata-integration/java/datahub-client/src/main/java/datahub/client/patch => entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder}/subtypesupport/IntermediatePatchBuilder.java (83%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/ArrayMergingTemplate.java (98%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/AspectTemplateEngine.java (71%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/CompoundKeyTemplate.java rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/Template.java (69%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/TemplateUtil.java rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/chart/ChartInfoTemplate.java (92%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GenericPatchTemplate.java rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/common/GlobalTagsTemplate.java (90%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/common/GlossaryTermsTemplate.java (92%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/common/OwnershipTemplate.java (89%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/StructuredPropertiesTemplate.java rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dashboard/DashboardInfoTemplate.java (94%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dataflow/DataFlowInfoTemplate.java (89%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/datajob/DataJobInfoTemplate.java (89%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/datajob/DataJobInputOutputTemplate.java (96%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dataproduct/DataProductPropertiesTemplate.java (91%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dataset/DatasetPropertiesTemplate.java (91%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dataset/EditableSchemaMetadataTemplate.java (92%) rename entity-registry/src/main/java/com/linkedin/metadata/{models/registry => aspect/patch}/template/dataset/UpstreamLineageTemplate.java (96%) create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/PropertyDefinitionValidator.java create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/StructuredPropertiesValidator.java create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/LogicalValueType.java create mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java delete mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/CompoundKeyTemplate.java delete mode 100644 entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/util/TemplateUtil.java rename entity-registry/src/test/java/com/linkedin/metadata/{models/registry/patch => aspect/patch/template}/ChartInfoTemplateTest.java (92%) rename entity-registry/src/test/java/com/linkedin/metadata/{models/registry/patch => aspect/patch/template}/DashboardInfoTemplateTest.java (91%) rename entity-registry/src/test/java/com/linkedin/metadata/{models/registry/patch => aspect/patch/template}/UpstreamLineageTemplateTest.java (99%) create mode 100644 entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/PropertyDefinitionValidatorTest.java create mode 100644 entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/StructuredPropertiesValidatorTest.java create mode 100644 metadata-ingestion/examples/forms/forms.yaml create mode 100644 metadata-ingestion/examples/mce_files/test_structured_properties.json create mode 100644 metadata-ingestion/examples/structured_properties/README.md create mode 100644 metadata-ingestion/examples/structured_properties/click_event.avsc create mode 100644 metadata-ingestion/examples/structured_properties/dataset.yaml create mode 100644 metadata-ingestion/examples/structured_properties/structured_properties.yaml create mode 100644 metadata-ingestion/src/datahub/api/entities/dataset/__init__.py create mode 100644 metadata-ingestion/src/datahub/api/entities/dataset/dataset.py create mode 100644 metadata-ingestion/src/datahub/api/entities/forms/__init__.py create mode 100644 metadata-ingestion/src/datahub/api/entities/forms/forms.py create mode 100644 metadata-ingestion/src/datahub/api/entities/forms/forms_graphql_constants.py create mode 100644 metadata-ingestion/src/datahub/api/entities/structuredproperties/__init__.py create mode 100644 metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py create mode 100644 metadata-ingestion/src/datahub/cli/specific/dataset_cli.py create mode 100644 metadata-ingestion/src/datahub/cli/specific/forms_cli.py create mode 100644 metadata-ingestion/src/datahub/cli/specific/structuredproperties_cli.py create mode 100644 metadata-ingestion/src/datahub/specific/structured_properties.py create mode 100644 metadata-ingestion/src/datahub/utilities/urns/structured_properties_urn.py create mode 100644 metadata-io/src/main/java/com/linkedin/metadata/client/EntityClientAspectRetriever.java create mode 100644 metadata-io/src/test/resources/forms/form_assignment_test_definition_complex.json create mode 100644 metadata-io/src/test/resources/forms/form_assignment_test_definition_simple.json create mode 100644 metadata-io/src/test/resources/forms/form_prompt_test_definition.json create mode 100644 metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/form/FormAssignmentHook.java create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/FieldFormPromptAssociation.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/FormAssociation.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/FormPromptAssociation.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/FormPromptFieldAssociations.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/FormVerificationAssociation.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/Forms.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/common/PropertyValue.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/datahub/DataHubSearchConfig.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeInfo.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeKey.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeInfo.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeKey.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/form/DynamicFormAssignment.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/form/FormInfo.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/form/FormPrompt.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/metadata/key/FormKey.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/PrimitivePropertyValue.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/PropertyValue.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/StructuredProperties.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyKey.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyValueAssignment.pdl create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/StructuredPropertiesConfiguration.java delete mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/JavaEntityClientFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RollbackServiceFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/EntityClientConfigFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/JavaEntityClientFactory.java rename metadata-service/factories/src/main/java/com/linkedin/gms/factory/{entity => entityclient}/RestliEntityClientFactory.java (53%) create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/form/FormServiceFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java create mode 100644 metadata-service/factories/src/test/java/com/linkedin/gms/factory/search/ElasticSearchIndexBuilderFactoryDefaultsTest.java create mode 100644 metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java create mode 100644 metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStepTest.java create mode 100644 metadata-service/factories/src/test/resources/boot/test_data_types_invalid.json create mode 100644 metadata-service/factories/src/test/resources/boot/test_data_types_valid.json rename metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/{ => v2}/delegates/EntityApiDelegateImpl.java (86%) rename metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/{ => v2}/delegates/EntityApiDelegateImplTest.java (97%) create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/RelationshipController.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/TimeseriesController.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericEntity.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericRelationship.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericScrollResult.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericTimeseriesAspect.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/PatchOperation.java create mode 100644 metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/utils/ControllerUtil.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntitiesScrollResult.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentManager.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentRunner.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/GenericTimeseriesDocument.java create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesScrollResult.java create mode 100644 metadata-service/war/src/main/resources/boot/data_types.json create mode 100644 metadata-utils/src/main/java/com/linkedin/metadata/authorization/OwnershipUtils.java create mode 100644 metadata-utils/src/main/java/com/linkedin/metadata/utils/FormUtils.java create mode 100644 metadata-utils/src/main/java/com/linkedin/metadata/utils/SchemaFieldUtils.java create mode 100644 smoke-test/tests/structured_properties/__init__.py create mode 100644 smoke-test/tests/structured_properties/click_event.avsc create mode 100644 smoke-test/tests/structured_properties/test_dataset.yaml create mode 100644 smoke-test/tests/structured_properties/test_structured_properties.py create mode 100644 smoke-test/tests/structured_properties/test_structured_properties.yaml create mode 100644 smoke-test/tests/utilities/__init__.py create mode 100644 smoke-test/tests/utilities/file_emitter.py diff --git a/buildSrc/src/main/java/io/datahubproject/GenerateJsonSchemaTask.java b/buildSrc/src/main/java/io/datahubproject/GenerateJsonSchemaTask.java index 25bf239ab835b7..1c9dfd46866102 100644 --- a/buildSrc/src/main/java/io/datahubproject/GenerateJsonSchemaTask.java +++ b/buildSrc/src/main/java/io/datahubproject/GenerateJsonSchemaTask.java @@ -183,6 +183,7 @@ private void generateSchema(final File file) { final String fileBaseName; try { final JsonNode schema = JsonLoader.fromFile(file); + final JsonNode result = buildResult(schema.toString()); String prettySchema = JacksonUtils.prettyPrint(result); Path absolutePath = file.getAbsoluteFile().toPath(); @@ -195,11 +196,21 @@ private void generateSchema(final File file) { } else { fileBaseName = getBaseName(file.getName()); } - Files.write(Paths.get(jsonDirectory + sep + fileBaseName + ".json"), + + final String targetName; + if (schema.has("Aspect") && schema.get("Aspect").has("name") && + !schema.get("Aspect").get("name").asText().equalsIgnoreCase(fileBaseName)) { + targetName = OpenApiEntities.toUpperFirst(schema.get("Aspect").get("name").asText()); + prettySchema = prettySchema.replaceAll(fileBaseName, targetName); + } else { + targetName = fileBaseName; + } + + Files.write(Paths.get(jsonDirectory + sep + targetName + ".json"), prettySchema.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); if (schema.has("Aspect")) { - aspectType.add(NODE_FACTORY.objectNode().put("$ref", "#/definitions/" + getBaseName(file.getName()))); + aspectType.add(NODE_FACTORY.objectNode().put("$ref", "#/definitions/" + targetName)); } } catch (IOException | ProcessingException e) { throw new RuntimeException(e); diff --git a/buildSrc/src/main/java/io/datahubproject/OpenApiEntities.java b/buildSrc/src/main/java/io/datahubproject/OpenApiEntities.java index 888c4a0e999311..04cbadcdc6b7ba 100644 --- a/buildSrc/src/main/java/io/datahubproject/OpenApiEntities.java +++ b/buildSrc/src/main/java/io/datahubproject/OpenApiEntities.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.linkedin.metadata.models.registry.config.Entities; import com.linkedin.metadata.models.registry.config.Entity; @@ -58,8 +59,12 @@ public class OpenApiEntities { .add("notebookInfo").add("editableNotebookProperties") .add("dataProductProperties") .add("institutionalMemory") + .add("forms").add("formInfo").add("dynamicFormAssignment") .build(); + private final static ImmutableSet ENTITY_EXCLUSIONS = ImmutableSet.builder() + .add("structuredProperty") + .build(); public OpenApiEntities(JsonNodeFactory NODE_FACTORY) { this.NODE_FACTORY = NODE_FACTORY; @@ -117,14 +122,27 @@ public ObjectNode entityExtension(List nodesList, ObjectNode schemas return componentsNode; } - private static String toUpperFirst(String s) { - return s.substring(0, 1).toUpperCase() + s.substring(1); + /** + * Convert the pdl model names to desired class names. Upper case first letter unless the 3rd character is upper case. + * i.e. mlModel -> MLModel + * dataset -> Dataset + * dataProduct -> DataProduct + * @param s input string + * @return class name + */ + public static String toUpperFirst(String s) { + if (s.length() > 2 && s.substring(2, 3).equals(s.substring(2, 3).toUpperCase())) { + return s.substring(0, 2).toUpperCase() + s.substring(2); + } else { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } } private Set withEntitySchema(ObjectNode schemasNode, Set definitions) { return entityMap.values().stream() // Make sure the primary key is defined .filter(entity -> definitions.contains(toUpperFirst(entity.getKeyAspect()))) + .filter(entity -> !ENTITY_EXCLUSIONS.contains(entity.getName())) .map(entity -> { final String upperName = toUpperFirst(entity.getName()); @@ -547,7 +565,7 @@ private ObjectNode buildSingleEntityAspectPath(Entity entity, String aspect) { ObjectNode getMethod = NODE_FACTORY.objectNode() .put("summary", String.format("Get %s for %s.", aspect, entity.getName())) - .put("operationId", String.format("get%s", upperFirstAspect, upperFirstEntity)); + .put("operationId", String.format("get%s", upperFirstAspect)); getMethod.set("tags", tagsNode); ArrayNode singlePathParametersNode = NODE_FACTORY.arrayNode(); getMethod.set("parameters", singlePathParametersNode); @@ -575,13 +593,13 @@ private ObjectNode buildSingleEntityAspectPath(Entity entity, String aspect) { .set("application/json", NODE_FACTORY.objectNode()))); ObjectNode headMethod = NODE_FACTORY.objectNode() .put("summary", String.format("%s on %s existence.", aspect, upperFirstEntity)) - .put("operationId", String.format("head%s", upperFirstAspect, upperFirstEntity)) + .put("operationId", String.format("head%s", upperFirstAspect)) .set("responses", headResponses); headMethod.set("tags", tagsNode); ObjectNode deleteMethod = NODE_FACTORY.objectNode() .put("summary", String.format("Delete %s on entity %s", aspect, upperFirstEntity)) - .put("operationId", String.format("delete%s", upperFirstAspect, upperFirstEntity)) + .put("operationId", String.format("delete%s", upperFirstAspect)) .set("responses", NODE_FACTORY.objectNode() .set("200", NODE_FACTORY.objectNode() .put("description", String.format("Delete %s on %s entity.", aspect, upperFirstEntity)) @@ -591,7 +609,7 @@ private ObjectNode buildSingleEntityAspectPath(Entity entity, String aspect) { ObjectNode postMethod = NODE_FACTORY.objectNode() .put("summary", String.format("Create aspect %s on %s ", aspect, upperFirstEntity)) - .put("operationId", String.format("create%s", upperFirstAspect, upperFirstEntity)); + .put("operationId", String.format("create%s", upperFirstAspect)); postMethod.set("requestBody", NODE_FACTORY.objectNode() .put("description", String.format("Create aspect %s on %s entity.", aspect, upperFirstEntity)) .put("required", true).set("content", NODE_FACTORY.objectNode() diff --git a/datahub-frontend/app/config/ConfigurationProvider.java b/datahub-frontend/app/config/ConfigurationProvider.java index 3d87267f8ebe38..0f2945d5d2393b 100644 --- a/datahub-frontend/app/config/ConfigurationProvider.java +++ b/datahub-frontend/app/config/ConfigurationProvider.java @@ -1,5 +1,6 @@ package config; +import com.linkedin.metadata.config.VisualConfiguration; import com.linkedin.metadata.config.cache.CacheConfiguration; import com.linkedin.metadata.config.kafka.KafkaConfiguration; import com.linkedin.metadata.spring.YamlPropertySourceFactory; @@ -22,4 +23,7 @@ public class ConfigurationProvider { /** Configuration for caching */ private CacheConfiguration cache; + + /** Configuration for the view layer */ + private VisualConfiguration visualConfig; } diff --git a/datahub-frontend/app/controllers/Application.java b/datahub-frontend/app/controllers/Application.java index 60971bf06e27bd..df0cd4f4ff82fc 100644 --- a/datahub-frontend/app/controllers/Application.java +++ b/datahub-frontend/app/controllers/Application.java @@ -13,6 +13,7 @@ import com.linkedin.util.Pair; import com.typesafe.config.Config; import java.io.InputStream; +import java.net.URI; import java.time.Duration; import java.util.List; import java.util.Map; @@ -125,6 +126,12 @@ public CompletableFuture proxy(String path, Http.Request request) headers.put(Http.HeaderNames.X_FORWARDED_HOST, headers.get(Http.HeaderNames.HOST)); } + if (!headers.containsKey(Http.HeaderNames.X_FORWARDED_PROTO)) { + final String schema = + Optional.ofNullable(URI.create(request.uri()).getScheme()).orElse("http"); + headers.put(Http.HeaderNames.X_FORWARDED_PROTO, List.of(schema)); + } + return _ws.url( String.format( "%s://%s:%s%s", protocol, metadataServiceHost, metadataServicePort, resolvedUri)) diff --git a/datahub-frontend/app/controllers/RedirectController.java b/datahub-frontend/app/controllers/RedirectController.java new file mode 100644 index 00000000000000..17f86b7fbffae3 --- /dev/null +++ b/datahub-frontend/app/controllers/RedirectController.java @@ -0,0 +1,25 @@ +package controllers; + +import config.ConfigurationProvider; +import javax.inject.Inject; +import javax.inject.Singleton; +import play.mvc.Controller; +import play.mvc.Http; +import play.mvc.Result; + +@Singleton +public class RedirectController extends Controller { + + @Inject ConfigurationProvider config; + + public Result favicon(Http.Request request) { + if (config.getVisualConfig().getAssets().getFaviconUrl().startsWith("http")) { + return permanentRedirect(config.getVisualConfig().getAssets().getFaviconUrl()); + } else { + final String prefix = config.getVisualConfig().getAssets().getFaviconUrl().startsWith("/") ? "/public" : "/public/"; + return ok(Application.class.getResourceAsStream( + prefix + config.getVisualConfig().getAssets().getFaviconUrl())) + .as("image/x-icon"); + } + } +} diff --git a/datahub-frontend/conf/routes b/datahub-frontend/conf/routes index 6b53a2789e7ccc..9eac7aa34c3e37 100644 --- a/datahub-frontend/conf/routes +++ b/datahub-frontend/conf/routes @@ -36,9 +36,13 @@ PUT /openapi/*path c HEAD /openapi/*path controllers.Application.proxy(path: String, request: Request) PATCH /openapi/*path controllers.Application.proxy(path: String, request: Request) + # Analytics route POST /track controllers.TrackingController.track(request: Request) +# Map static resources from the /public folder to the /assets URL path +GET /assets/icons/favicon.ico controllers.RedirectController.favicon(request: Request) + # Known React asset routes GET /assets/*file controllers.Assets.at(path="/public/assets", file) GET /node_modules/*file controllers.Assets.at(path="/public/node_modules", file) diff --git a/datahub-frontend/public b/datahub-frontend/public new file mode 120000 index 00000000000000..60c68c7b4b1bc3 --- /dev/null +++ b/datahub-frontend/public @@ -0,0 +1 @@ +../datahub-web-react/public \ No newline at end of file diff --git a/datahub-frontend/test/resources/public/logos/datahub-logo.png b/datahub-frontend/test/resources/public/logos/datahub-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5e34e6425d23fa1a19ca3c89dae7acdfb2902e86 GIT binary patch literal 53563 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

?*5y(jBpY@{v`pxiG=JUUwS>Js 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 JC{{KL{C{3veEk3b literal 0 HcmV?d00001 diff --git a/datahub-graphql-core/build.gradle b/datahub-graphql-core/build.gradle index 3d5a961d6f7c7e..f273a4dd0eea5f 100644 --- a/datahub-graphql-core/build.gradle +++ b/datahub-graphql-core/build.gradle @@ -12,6 +12,7 @@ dependencies { implementation project(':metadata-service:services') implementation project(':metadata-io') implementation project(':metadata-utils') + implementation project(':metadata-models') implementation externalDependency.graphqlJava implementation externalDependency.graphqlJavaScalars @@ -40,8 +41,10 @@ graphqlCodegen { "$projectDir/src/main/resources/auth.graphql".toString(), "$projectDir/src/main/resources/timeline.graphql".toString(), "$projectDir/src/main/resources/tests.graphql".toString(), + "$projectDir/src/main/resources/properties.graphql".toString(), "$projectDir/src/main/resources/step.graphql".toString(), "$projectDir/src/main/resources/lineage.graphql".toString(), + "$projectDir/src/main/resources/forms.graphql".toString() ] outputDir = new File("$projectDir/src/mainGeneratedGraphQL/java") packageName = "com.linkedin.datahub.graphql.generated" diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java index e45bed33eb0236..2bde7cb61047b3 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java @@ -19,9 +19,10 @@ private Constants() {} public static final String TESTS_SCHEMA_FILE = "tests.graphql"; public static final String STEPS_SCHEMA_FILE = "step.graphql"; public static final String LINEAGE_SCHEMA_FILE = "lineage.graphql"; + public static final String PROPERTIES_SCHEMA_FILE = "properties.graphql"; + public static final String FORMS_SCHEMA_FILE = "forms.graphql"; public static final String BROWSE_PATH_DELIMITER = "/"; public static final String BROWSE_PATH_V2_DELIMITER = "␟"; public static final String VERSION_STAMP_FIELD_NAME = "versionStamp"; - public static final String ENTITY_FILTER_NAME = "_entityType"; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index f61d76e72e8bd3..4819510d340180 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -57,6 +57,7 @@ import com.linkedin.datahub.graphql.generated.EntityRelationship; import com.linkedin.datahub.graphql.generated.EntityRelationshipLegacy; import com.linkedin.datahub.graphql.generated.ForeignKeyConstraint; +import com.linkedin.datahub.graphql.generated.FormActorAssignment; import com.linkedin.datahub.graphql.generated.GetRootGlossaryNodesResult; import com.linkedin.datahub.graphql.generated.GetRootGlossaryTermsResult; import com.linkedin.datahub.graphql.generated.GlossaryNode; @@ -91,12 +92,17 @@ import com.linkedin.datahub.graphql.generated.QuerySubject; import com.linkedin.datahub.graphql.generated.QuickFilter; import com.linkedin.datahub.graphql.generated.RecommendationContent; +import com.linkedin.datahub.graphql.generated.SchemaField; import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; import com.linkedin.datahub.graphql.generated.SearchAcrossLineageResult; import com.linkedin.datahub.graphql.generated.SearchResult; import com.linkedin.datahub.graphql.generated.SiblingProperties; +import com.linkedin.datahub.graphql.generated.StructuredPropertiesEntry; +import com.linkedin.datahub.graphql.generated.StructuredPropertyDefinition; +import com.linkedin.datahub.graphql.generated.StructuredPropertyParams; import com.linkedin.datahub.graphql.generated.Test; import com.linkedin.datahub.graphql.generated.TestResult; +import com.linkedin.datahub.graphql.generated.TypeQualifier; import com.linkedin.datahub.graphql.generated.UserUsageCounts; import com.linkedin.datahub.graphql.resolvers.MeResolver; import com.linkedin.datahub.graphql.resolvers.assertion.AssertionRunEventResolver; @@ -135,6 +141,11 @@ import com.linkedin.datahub.graphql.resolvers.embed.UpdateEmbedResolver; import com.linkedin.datahub.graphql.resolvers.entity.EntityExistsResolver; import com.linkedin.datahub.graphql.resolvers.entity.EntityPrivilegesResolver; +import com.linkedin.datahub.graphql.resolvers.form.BatchAssignFormResolver; +import com.linkedin.datahub.graphql.resolvers.form.CreateDynamicFormAssignmentResolver; +import com.linkedin.datahub.graphql.resolvers.form.IsFormAssignedToMeResolver; +import com.linkedin.datahub.graphql.resolvers.form.SubmitFormPromptResolver; +import com.linkedin.datahub.graphql.resolvers.form.VerifyFormResolver; import com.linkedin.datahub.graphql.resolvers.glossary.AddRelatedTermsResolver; import com.linkedin.datahub.graphql.resolvers.glossary.CreateGlossaryNodeResolver; import com.linkedin.datahub.graphql.resolvers.glossary.CreateGlossaryTermResolver; @@ -254,6 +265,7 @@ import com.linkedin.datahub.graphql.resolvers.type.EntityInterfaceTypeResolver; import com.linkedin.datahub.graphql.resolvers.type.HyperParameterValueTypeResolver; import com.linkedin.datahub.graphql.resolvers.type.PlatformSchemaUnionTypeResolver; +import com.linkedin.datahub.graphql.resolvers.type.PropertyValueResolver; import com.linkedin.datahub.graphql.resolvers.type.ResultsTypeResolver; import com.linkedin.datahub.graphql.resolvers.type.TimeSeriesAspectInterfaceTypeResolver; import com.linkedin.datahub.graphql.resolvers.user.CreateNativeUserResetTokenResolver; @@ -288,7 +300,10 @@ import com.linkedin.datahub.graphql.types.dataset.DatasetType; import com.linkedin.datahub.graphql.types.dataset.VersionedDatasetType; import com.linkedin.datahub.graphql.types.dataset.mappers.DatasetProfileMapper; +import com.linkedin.datahub.graphql.types.datatype.DataTypeType; import com.linkedin.datahub.graphql.types.domain.DomainType; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeType; +import com.linkedin.datahub.graphql.types.form.FormType; import com.linkedin.datahub.graphql.types.glossary.GlossaryNodeType; import com.linkedin.datahub.graphql.types.glossary.GlossaryTermType; import com.linkedin.datahub.graphql.types.mlmodel.MLFeatureTableType; @@ -303,6 +318,7 @@ import com.linkedin.datahub.graphql.types.role.DataHubRoleType; import com.linkedin.datahub.graphql.types.rolemetadata.RoleType; import com.linkedin.datahub.graphql.types.schemafield.SchemaFieldType; +import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertyType; import com.linkedin.datahub.graphql.types.tag.TagType; import com.linkedin.datahub.graphql.types.test.TestType; import com.linkedin.datahub.graphql.types.view.DataHubViewType; @@ -323,6 +339,7 @@ import com.linkedin.metadata.recommendation.RecommendationsService; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; import com.linkedin.metadata.service.OwnershipTypeService; import com.linkedin.metadata.service.QueryService; @@ -392,6 +409,7 @@ public class GmsGraphQLEngine { private final LineageService lineageService; private final QueryService queryService; private final DataProductService dataProductService; + private final FormService formService; private final FeatureFlags featureFlags; @@ -439,6 +457,10 @@ public class GmsGraphQLEngine { private final QueryType queryType; private final DataProductType dataProductType; private final OwnershipType ownershipType; + private final StructuredPropertyType structuredPropertyType; + private final DataTypeType dataTypeType; + private final EntityTypeType entityTypeType; + private final FormType formType; /** A list of GraphQL Plugins that extend the core engine */ private final List graphQLPlugins; @@ -494,6 +516,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.lineageService = args.lineageService; this.queryService = args.queryService; this.dataProductService = args.dataProductService; + this.formService = args.formService; this.ingestionConfiguration = Objects.requireNonNull(args.ingestionConfiguration); this.authenticationConfiguration = Objects.requireNonNull(args.authenticationConfiguration); @@ -533,11 +556,15 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.testType = new TestType(entityClient); this.dataHubPolicyType = new DataHubPolicyType(entityClient); this.dataHubRoleType = new DataHubRoleType(entityClient); - this.schemaFieldType = new SchemaFieldType(); + this.schemaFieldType = new SchemaFieldType(entityClient, featureFlags); this.dataHubViewType = new DataHubViewType(entityClient); this.queryType = new QueryType(entityClient); this.dataProductType = new DataProductType(entityClient); this.ownershipType = new OwnershipType(entityClient); + this.structuredPropertyType = new StructuredPropertyType(entityClient); + this.dataTypeType = new DataTypeType(entityClient); + this.entityTypeType = new EntityTypeType(entityClient); + this.formType = new FormType(entityClient); // Init Lists this.entityTypes = @@ -573,11 +600,16 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { dataHubViewType, queryType, dataProductType, - ownershipType); + ownershipType, + structuredPropertyType, + dataTypeType, + entityTypeType, + formType); this.loadableTypes = new ArrayList<>(entityTypes); // Extend loadable types with types from the plugins // This allows us to offer search and browse capabilities out of the box for those types for (GmsGraphQLPlugin plugin : this.graphQLPlugins) { + this.entityTypes.addAll(plugin.getEntityTypes()); Collection> pluginLoadableTypes = plugin.getLoadableTypes(); if (pluginLoadableTypes != null) { this.loadableTypes.addAll(pluginLoadableTypes); @@ -654,18 +686,23 @@ public void configureRuntimeWiring(final RuntimeWiring.Builder builder) { configureRoleResolvers(builder); configureSchemaFieldResolvers(builder); configureEntityPathResolvers(builder); + configureResolvedAuditStampResolvers(builder); configureViewResolvers(builder); configureQueryEntityResolvers(builder); configureOwnershipTypeResolver(builder); configurePluginResolvers(builder); + configureStructuredPropertyResolvers(builder); + configureFormResolvers(builder); } private void configureOrganisationRoleResolvers(RuntimeWiring.Builder builder) { builder.type( "Role", typeWiring -> - typeWiring.dataFetcher( - "relationships", new EntityRelationshipsResultResolver(graphClient))); + typeWiring + .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); builder.type( "RoleAssociation", typeWiring -> @@ -703,7 +740,9 @@ public GraphQLEngine.Builder builder() { .addSchema(fileBasedSchema(TIMELINE_SCHEMA_FILE)) .addSchema(fileBasedSchema(TESTS_SCHEMA_FILE)) .addSchema(fileBasedSchema(STEPS_SCHEMA_FILE)) - .addSchema(fileBasedSchema(LINEAGE_SCHEMA_FILE)); + .addSchema(fileBasedSchema(LINEAGE_SCHEMA_FILE)) + .addSchema(fileBasedSchema(PROPERTIES_SCHEMA_FILE)) + .addSchema(fileBasedSchema(FORMS_SCHEMA_FILE)); for (GmsGraphQLPlugin plugin : this.graphQLPlugins) { List pluginSchemaFiles = plugin.getSchemaFiles(); @@ -767,6 +806,8 @@ private void configureContainerResolvers(final RuntimeWiring.Builder builder) { typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("entities", new ContainerEntitiesResolver(entityClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService)) .dataFetcher( "platform", @@ -841,7 +882,8 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { "scrollAcrossLineage", new ScrollAcrossLineageResolver(this.entityClient)) .dataFetcher( "aggregateAcrossEntities", - new AggregateAcrossEntitiesResolver(this.entityClient, this.viewService)) + new AggregateAcrossEntitiesResolver( + this.entityClient, this.viewService, this.formService)) .dataFetcher("autoComplete", new AutoCompleteResolver(searchableTypes)) .dataFetcher( "autoCompleteForMultiple", @@ -928,7 +970,8 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "listOwnershipTypes", new ListOwnershipTypesResolver(this.entityClient)) .dataFetcher( - "browseV2", new BrowseV2Resolver(this.entityClient, this.viewService))); + "browseV2", + new BrowseV2Resolver(this.entityClient, this.viewService, this.formService))); } private DataFetcher getEntitiesResolver() { @@ -1139,7 +1182,14 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { new UpdateOwnershipTypeResolver(this.ownershipTypeService)) .dataFetcher( "deleteOwnershipType", - new DeleteOwnershipTypeResolver(this.ownershipTypeService))); + new DeleteOwnershipTypeResolver(this.ownershipTypeService)) + .dataFetcher("submitFormPrompt", new SubmitFormPromptResolver(this.formService)) + .dataFetcher("batchAssignForm", new BatchAssignFormResolver(this.formService)) + .dataFetcher( + "createDynamicFormAssignment", + new CreateDynamicFormAssignmentResolver(this.formService)) + .dataFetcher( + "verifyForm", new VerifyFormResolver(this.formService, this.groupService))); } private void configureGenericEntityResolvers(final RuntimeWiring.Builder builder) { @@ -1342,7 +1392,25 @@ private void configureGenericEntityResolvers(final RuntimeWiring.Builder builder typeWiring.dataFetcher( "ownershipType", new EntityTypeResolver( - entityTypes, (env) -> ((Owner) env.getSource()).getOwnershipType()))); + entityTypes, (env) -> ((Owner) env.getSource()).getOwnershipType()))) + .type( + "StructuredPropertiesEntry", + typeWiring -> + typeWiring + .dataFetcher( + "structuredProperty", + new LoadableTypeResolver<>( + structuredPropertyType, + (env) -> + ((StructuredPropertiesEntry) env.getSource()) + .getStructuredProperty() + .getUrn())) + .dataFetcher( + "valueEntities", + new BatchGetEntitiesResolver( + entityTypes, + (env) -> + ((StructuredPropertiesEntry) env.getSource()).getValueEntities()))); } /** @@ -1422,6 +1490,14 @@ private void configureDatasetResolvers(final RuntimeWiring.Builder builder) { "owner", new OwnerTypeResolver<>( ownerTypes, (env) -> ((Owner) env.getSource()).getOwner()))) + .type( + "SchemaField", + typeWiring -> + typeWiring.dataFetcher( + "schemaFieldEntity", + new LoadableTypeResolver<>( + schemaFieldType, + (env) -> ((SchemaField) env.getSource()).getSchemaFieldEntity().getUrn()))) .type( "UserUsageCounts", typeWiring -> @@ -1518,6 +1594,8 @@ private void configureGlossaryTermResolvers(final RuntimeWiring.Builder builder) .dataFetcher("schemaMetadata", new AspectResolver()) .dataFetcher("parentNodes", new ParentNodesResolver(entityClient)) .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService))); } @@ -1528,7 +1606,9 @@ private void configureGlossaryNodeResolvers(final RuntimeWiring.Builder builder) typeWiring .dataFetcher("parentNodes", new ParentNodesResolver(entityClient)) .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) - .dataFetcher("exists", new EntityExistsResolver(entityService))); + .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); } private void configureSchemaFieldResolvers(final RuntimeWiring.Builder builder) { @@ -1551,6 +1631,16 @@ private void configureEntityPathResolvers(final RuntimeWiring.Builder builder) { entityTypes, (env) -> ((EntityPath) env.getSource()).getPath()))); } + private void configureResolvedAuditStampResolvers(final RuntimeWiring.Builder builder) { + builder.type( + "ResolvedAuditStamp", + typeWiring -> + typeWiring.dataFetcher( + "actor", + new LoadableTypeResolver<>( + corpUserType, (env) -> ((CorpUser) env.getSource()).getUrn()))); + } + /** * Configures resolvers responsible for resolving the {@link * com.linkedin.datahub.graphql.generated.CorpUser} type. @@ -1559,8 +1649,10 @@ private void configureCorpUserResolvers(final RuntimeWiring.Builder builder) { builder.type( "CorpUser", typeWiring -> - typeWiring.dataFetcher( - "relationships", new EntityRelationshipsResultResolver(graphClient))); + typeWiring + .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); builder.type( "CorpUserInfo", typeWiring -> @@ -1581,6 +1673,8 @@ private void configureCorpGroupResolvers(final RuntimeWiring.Builder builder) { typeWiring -> typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService))); builder .type( @@ -1623,8 +1717,10 @@ private void configureTagAssociationResolver(final RuntimeWiring.Builder builder builder.type( "Tag", typeWiring -> - typeWiring.dataFetcher( - "relationships", new EntityRelationshipsResultResolver(graphClient))); + typeWiring + .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); builder.type( "TagAssociation", typeWiring -> @@ -1659,6 +1755,8 @@ private void configureNotebookResolvers(final RuntimeWiring.Builder builder) { typeWiring -> typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.notebookType)) .dataFetcher( "platform", @@ -1690,6 +1788,8 @@ private void configureDashboardResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.dashboardType)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher( "platform", new LoadableTypeResolver<>( @@ -1758,6 +1858,42 @@ private void configureDashboardResolvers(final RuntimeWiring.Builder builder) { }))); } + private void configureStructuredPropertyResolvers(final RuntimeWiring.Builder builder) { + builder.type( + "StructuredPropertyDefinition", + typeWiring -> + typeWiring + .dataFetcher( + "valueType", + new LoadableTypeResolver<>( + dataTypeType, + (env) -> + ((StructuredPropertyDefinition) env.getSource()) + .getValueType() + .getUrn())) + .dataFetcher( + "entityTypes", + new LoadableTypeBatchResolver<>( + entityTypeType, + (env) -> + ((StructuredPropertyDefinition) env.getSource()) + .getEntityTypes().stream() + .map(entityTypeType.getKeyProvider()) + .collect(Collectors.toList())))); + builder.type( + "TypeQualifier", + typeWiring -> + typeWiring.dataFetcher( + "allowedTypes", + new LoadableTypeBatchResolver<>( + entityTypeType, + (env) -> + ((TypeQualifier) env.getSource()) + .getAllowedTypes().stream() + .map(entityTypeType.getKeyProvider()) + .collect(Collectors.toList())))); + } + /** * Configures resolvers responsible for resolving the {@link * com.linkedin.datahub.graphql.generated.Chart} type. @@ -1769,6 +1905,8 @@ private void configureChartResolvers(final RuntimeWiring.Builder builder) { typeWiring .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.chartType)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) .dataFetcher( "platform", @@ -1858,6 +1996,7 @@ private void configureTypeResolvers(final RuntimeWiring.Builder builder) { .type( "HyperParameterValueType", typeWiring -> typeWiring.typeResolver(new HyperParameterValueTypeResolver())) + .type("PropertyValue", typeWiring -> typeWiring.typeResolver(new PropertyValueResolver())) .type("Aspect", typeWiring -> typeWiring.typeResolver(new AspectInterfaceTypeResolver())) .type( "TimeSeriesAspect", @@ -1884,6 +2023,8 @@ private void configureDataJobResolvers(final RuntimeWiring.Builder builder) { "relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.dataJobType)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher( "dataFlow", new LoadableTypeResolver<>( @@ -1947,6 +2088,8 @@ private void configureDataFlowResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.dataFlowType)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher( "platform", new LoadableTypeResolver<>( @@ -1979,6 +2122,8 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde "relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher( "browsePaths", new EntityBrowsePathsResolver(this.mlFeatureTableType)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) .dataFetcher("exists", new EntityExistsResolver(entityService)) .dataFetcher( @@ -2064,6 +2209,8 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde .dataFetcher("browsePaths", new EntityBrowsePathsResolver(this.mlModelType)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher( "platform", new LoadableTypeResolver<>( @@ -2103,6 +2250,8 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde "relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher( "browsePaths", new EntityBrowsePathsResolver(this.mlModelGroupType)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) .dataFetcher( "platform", @@ -2127,6 +2276,8 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde .dataFetcher( "relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService)) .dataFetcher( "dataPlatformInstance", @@ -2145,6 +2296,8 @@ private void configureMLFeatureTableResolvers(final RuntimeWiring.Builder builde .dataFetcher( "relationships", new EntityRelationshipsResultResolver(graphClient)) .dataFetcher("lineage", new EntityLineageResultResolver(siblingGraphService)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("exists", new EntityExistsResolver(entityService)) .dataFetcher( "dataPlatformInstance", @@ -2179,6 +2332,8 @@ private void configureDomainResolvers(final RuntimeWiring.Builder builder) { typeWiring .dataFetcher("entities", new DomainEntitiesResolver(this.entityClient)) .dataFetcher("parentDomains", new ParentDomainsResolver(this.entityClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient))); builder.type( "DomainAssociation", @@ -2193,12 +2348,64 @@ private void configureDomainResolvers(final RuntimeWiring.Builder builder) { .getUrn()))); } + private void configureFormResolvers(final RuntimeWiring.Builder builder) { + builder.type( + "FormAssociation", + typeWiring -> + typeWiring.dataFetcher( + "form", + new LoadableTypeResolver<>( + formType, + (env) -> + ((com.linkedin.datahub.graphql.generated.FormAssociation) env.getSource()) + .getForm() + .getUrn()))); + builder.type( + "StructuredPropertyParams", + typeWiring -> + typeWiring.dataFetcher( + "structuredProperty", + new LoadableTypeResolver<>( + structuredPropertyType, + (env) -> + ((StructuredPropertyParams) env.getSource()) + .getStructuredProperty() + .getUrn()))); + builder.type( + "FormActorAssignment", + typeWiring -> + typeWiring + .dataFetcher( + "users", + new LoadableTypeBatchResolver<>( + corpUserType, + (env) -> { + final FormActorAssignment actors = env.getSource(); + return actors.getUsers().stream() + .map(CorpUser::getUrn) + .collect(Collectors.toList()); + })) + .dataFetcher( + "groups", + new LoadableTypeBatchResolver<>( + corpGroupType, + (env) -> { + final FormActorAssignment actors = env.getSource(); + return actors.getGroups().stream() + .map(CorpGroup::getUrn) + .collect(Collectors.toList()); + })) + .dataFetcher("isAssignedToMe", new IsFormAssignedToMeResolver(groupService))); + } + private void configureDataProductResolvers(final RuntimeWiring.Builder builder) { builder.type( "DataProduct", typeWiring -> typeWiring .dataFetcher("entities", new ListDataProductAssetsResolver(this.entityClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry)) .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient))); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java index 4829194a8ce4d9..5b780cc8cb40b4 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java @@ -25,6 +25,7 @@ import com.linkedin.metadata.recommendation.RecommendationsService; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; import com.linkedin.metadata.service.OwnershipTypeService; import com.linkedin.metadata.service.QueryService; @@ -73,6 +74,7 @@ public class GmsGraphQLEngineArgs { QueryService queryService; FeatureFlags featureFlags; DataProductService dataProductService; + FormService formService; // any fork specific args should go below this line } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLPlugin.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLPlugin.java index 472d9465aeee12..a544bd46527c46 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLPlugin.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLPlugin.java @@ -1,5 +1,6 @@ package com.linkedin.datahub.graphql; +import com.linkedin.datahub.graphql.types.EntityType; import com.linkedin.datahub.graphql.types.LoadableType; import graphql.schema.idl.RuntimeWiring; import java.util.Collection; @@ -34,6 +35,9 @@ public interface GmsGraphQLPlugin { */ Collection> getLoadableTypes(); + /** Return a list of Entity Types that the plugin services */ + Collection> getEntityTypes(); + /** * Optional callback that a plugin can implement to configure any Query, Mutation or Type specific * resolvers. diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/SubTypesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/SubTypesResolver.java new file mode 100644 index 00000000000000..ae8ac4330e7fb8 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/SubTypesResolver.java @@ -0,0 +1,55 @@ +package com.linkedin.datahub.graphql; + +import com.linkedin.common.SubTypes; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.r2.RemoteInvocationException; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@AllArgsConstructor +public class SubTypesResolver implements DataFetcher> { + + EntityClient _entityClient; + String _entityType; + String _aspectName; + + @Override + @Nullable + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + return CompletableFuture.supplyAsync( + () -> { + final QueryContext context = environment.getContext(); + SubTypes subType = null; + final String urnStr = ((Entity) environment.getSource()).getUrn(); + try { + final Urn urn = Urn.createFromString(urnStr); + EntityResponse entityResponse = + _entityClient + .batchGetV2( + urn.getEntityType(), + Collections.singleton(urn), + Collections.singleton(_aspectName), + context.getAuthentication()) + .get(urn); + if (entityResponse != null && entityResponse.getAspects().containsKey(_aspectName)) { + subType = + new SubTypes(entityResponse.getAspects().get(_aspectName).getValue().data()); + } + } catch (RemoteInvocationException | URISyntaxException e) { + throw new RuntimeException( + "Failed to fetch aspect " + _aspectName + " for urn " + urnStr + " ", e); + } + return subType; + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java index 22ee4d4d4845c9..d8665ae784bd1e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java @@ -10,7 +10,7 @@ import com.linkedin.datahub.graphql.generated.Entity; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.RawAspect; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.models.AspectSpec; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetMetadataAnalyticsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetMetadataAnalyticsResolver.java index 31a8359f8f0e3a..de389a358d9368 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetMetadataAnalyticsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/resolver/GetMetadataAnalyticsResolver.java @@ -12,8 +12,8 @@ import com.linkedin.datahub.graphql.generated.BarSegment; import com.linkedin.datahub.graphql.generated.MetadataAnalyticsInput; import com.linkedin.datahub.graphql.generated.NamedBar; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; import com.linkedin.metadata.query.filter.Filter; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/service/AnalyticsService.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/service/AnalyticsService.java index 03333bda05f61f..baea3ea4e62016 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/service/AnalyticsService.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/analytics/service/AnalyticsService.java @@ -10,7 +10,7 @@ import com.linkedin.datahub.graphql.generated.NamedLine; import com.linkedin.datahub.graphql.generated.NumericDataPoint; import com.linkedin.datahub.graphql.generated.Row; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; import java.util.List; import java.util.Map; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java index e74ed09849763c..667ccd368a7291 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java @@ -17,4 +17,5 @@ public class FeatureFlags { private boolean showAcrylInfo = false; private boolean showAccessManagement = false; private boolean nestedDomainsEnabled = false; + private boolean schemaFieldEntityFetchEnabled = false; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java index da4a3a76dd7e0e..d9ce2472c86345 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java @@ -12,13 +12,14 @@ import com.linkedin.datahub.graphql.generated.BrowseResultsV2; import com.linkedin.datahub.graphql.generated.BrowseV2Input; import com.linkedin.datahub.graphql.generated.EntityType; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.resolvers.search.SearchUtils; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.browse.BrowseResultV2; import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.ViewService; import com.linkedin.view.DataHubViewInfo; import graphql.schema.DataFetcher; @@ -37,6 +38,7 @@ public class BrowseV2Resolver implements DataFetcher get(DataFetchingEnvironment environmen ? BROWSE_PATH_V2_DELIMITER + String.join(BROWSE_PATH_V2_DELIMITER, input.getPath()) : ""; - final Filter filter = ResolverUtils.buildFilter(null, input.getOrFilters()); + final Filter inputFilter = ResolverUtils.buildFilter(null, input.getOrFilters()); BrowseResultV2 browseResults = _entityClient.browseV2( @@ -76,8 +78,8 @@ public CompletableFuture get(DataFetchingEnvironment environmen pathStr, maybeResolvedView != null ? SearchUtils.combineFilters( - filter, maybeResolvedView.getDefinition().getFilter()) - : filter, + inputFilter, maybeResolvedView.getDefinition().getFilter()) + : inputFilter, sanitizedQuery, start, count, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java index 81b52991cde90c..f127e6a49abfff 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/AppConfigResolver.java @@ -126,9 +126,15 @@ public CompletableFuture get(final DataFetchingEnvironment environmen appConfig.setAuthConfig(authConfig); final VisualConfig visualConfig = new VisualConfig(); - if (_visualConfiguration != null && _visualConfiguration.getAssets() != null) { - visualConfig.setLogoUrl(_visualConfiguration.getAssets().getLogoUrl()); - visualConfig.setFaviconUrl(_visualConfiguration.getAssets().getFaviconUrl()); + if (_visualConfiguration != null) { + if (_visualConfiguration.getAssets() != null) { + visualConfig.setLogoUrl(_visualConfiguration.getAssets().getLogoUrl()); + visualConfig.setFaviconUrl(_visualConfiguration.getAssets().getFaviconUrl()); + } + if (_visualConfiguration.getAppTitle() != null) { + visualConfig.setAppTitle(_visualConfiguration.getAppTitle()); + } + visualConfig.setHideGlossary(_visualConfiguration.isHideGlossary()); } if (_visualConfiguration != null && _visualConfiguration.getQueriesTab() != null) { QueriesTabConfig queriesTabConfig = new QueriesTabConfig(); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/ListDataProductAssetsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/ListDataProductAssetsResolver.java index a0f1698bf99e82..72912087190c05 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/ListDataProductAssetsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/dataproduct/ListDataProductAssetsResolver.java @@ -12,9 +12,9 @@ import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.SearchAcrossEntitiesInput; import com.linkedin.datahub.graphql.generated.SearchResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.dataproduct.DataProductAssociation; import com.linkedin.dataproduct.DataProductProperties; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolver.java index 8f6d109e71b2c5..6229e38954163d 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolver.java @@ -7,7 +7,7 @@ import com.linkedin.datahub.graphql.generated.Domain; import com.linkedin.datahub.graphql.generated.DomainEntitiesInput; import com.linkedin.datahub.graphql.generated.SearchResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.filter.Condition; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/BatchAssignFormResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/BatchAssignFormResolver.java new file mode 100644 index 00000000000000..39c9210c289e1d --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/BatchAssignFormResolver.java @@ -0,0 +1,52 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.BatchAssignFormInput; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class BatchAssignFormResolver implements DataFetcher> { + + private final FormService _formService; + + public BatchAssignFormResolver(@Nonnull final FormService formService) { + _formService = Objects.requireNonNull(formService, "formService must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + + final BatchAssignFormInput input = + bindArgument(environment.getArgument("input"), BatchAssignFormInput.class); + final Urn formUrn = UrnUtils.getUrn(input.getFormUrn()); + final List entityUrns = input.getEntityUrns(); + final Authentication authentication = context.getAuthentication(); + + return CompletableFuture.supplyAsync( + () -> { + try { + _formService.batchAssignFormToEntities( + entityUrns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()), + formUrn, + authentication); + return true; + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateDynamicFormAssignmentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateDynamicFormAssignmentResolver.java new file mode 100644 index 00000000000000..5b5f058dbdeac2 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateDynamicFormAssignmentResolver.java @@ -0,0 +1,50 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CreateDynamicFormAssignmentInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.FormUtils; +import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +public class CreateDynamicFormAssignmentResolver + implements DataFetcher> { + + private final FormService _formService; + + public CreateDynamicFormAssignmentResolver(@Nonnull final FormService formService) { + _formService = Objects.requireNonNull(formService, "formService must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + + final CreateDynamicFormAssignmentInput input = + bindArgument(environment.getArgument("input"), CreateDynamicFormAssignmentInput.class); + final Urn formUrn = UrnUtils.getUrn(input.getFormUrn()); + final DynamicFormAssignment formAssignment = FormUtils.mapDynamicFormAssignment(input); + final Authentication authentication = context.getAuthentication(); + + return CompletableFuture.supplyAsync( + () -> { + try { + _formService.createDynamicFormAssignment(formAssignment, formUrn, authentication); + return true; + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolver.java new file mode 100644 index 00000000000000..e7bf87ae7644ea --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolver.java @@ -0,0 +1,80 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import com.datahub.authentication.group.GroupService; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.FormActorAssignment; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class IsFormAssignedToMeResolver implements DataFetcher> { + + private final GroupService _groupService; + + public IsFormAssignedToMeResolver(@Nonnull final GroupService groupService) { + _groupService = Objects.requireNonNull(groupService, "groupService must not be null"); + } + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) { + final QueryContext context = environment.getContext(); + final FormActorAssignment parent = environment.getSource(); + + return CompletableFuture.supplyAsync( + () -> { + try { + + // Assign urn and group urns + final Set assignedUserUrns = + parent.getUsers() != null + ? parent.getUsers().stream().map(CorpUser::getUrn).collect(Collectors.toSet()) + : Collections.emptySet(); + + final Set assignedGroupUrns = + parent.getGroups() != null + ? parent.getGroups().stream().map(CorpGroup::getUrn).collect(Collectors.toSet()) + : Collections.emptySet(); + + final Urn userUrn = Urn.createFromString(context.getActorUrn()); + + // First check whether user is directly assigned. + if (assignedUserUrns.size() > 0) { + boolean isUserAssigned = assignedUserUrns.contains(userUrn.toString()); + if (isUserAssigned) { + return true; + } + } + + // Next check whether the user is assigned indirectly, by group. + if (assignedGroupUrns.size() > 0) { + final List groupUrns = + _groupService.getGroupsForUser(userUrn, context.getAuthentication()); + boolean isUserGroupAssigned = + groupUrns.stream() + .anyMatch(groupUrn -> assignedGroupUrns.contains(groupUrn.toString())); + if (isUserGroupAssigned) { + return true; + } + } + } catch (Exception e) { + log.error( + "Failed to determine whether the form is assigned to the currently authenticated user! Returning false.", + e); + } + + // Else the user is not directly assigned. + return false; + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/SubmitFormPromptResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/SubmitFormPromptResolver.java new file mode 100644 index 00000000000000..5b40c353b3809c --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/SubmitFormPromptResolver.java @@ -0,0 +1,89 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.FormPromptType; +import com.linkedin.datahub.graphql.generated.SubmitFormPromptInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.FormUtils; +import com.linkedin.metadata.service.FormService; +import com.linkedin.structured.PrimitivePropertyValueArray; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +public class SubmitFormPromptResolver implements DataFetcher> { + + private final FormService _formService; + + public SubmitFormPromptResolver(@Nonnull final FormService formService) { + _formService = Objects.requireNonNull(formService, "formService must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + + final Urn entityUrn = UrnUtils.getUrn(environment.getArgument("urn")); + final SubmitFormPromptInput input = + bindArgument(environment.getArgument("input"), SubmitFormPromptInput.class); + final String promptId = input.getPromptId(); + final Urn formUrn = UrnUtils.getUrn(input.getFormUrn()); + final String fieldPath = input.getFieldPath(); + + return CompletableFuture.supplyAsync( + () -> { + try { + if (input.getType().equals(FormPromptType.STRUCTURED_PROPERTY)) { + if (input.getStructuredPropertyParams() == null) { + throw new IllegalArgumentException( + "Failed to provide structured property params for prompt type STRUCTURED_PROPERTY"); + } + final Urn structuredPropertyUrn = + UrnUtils.getUrn(input.getStructuredPropertyParams().getStructuredPropertyUrn()); + final PrimitivePropertyValueArray values = + FormUtils.getStructuredPropertyValuesFromInput(input); + + return _formService.submitStructuredPropertyPromptResponse( + entityUrn, + structuredPropertyUrn, + values, + formUrn, + promptId, + context.getAuthentication()); + } else if (input.getType().equals(FormPromptType.FIELDS_STRUCTURED_PROPERTY)) { + if (input.getStructuredPropertyParams() == null) { + throw new IllegalArgumentException( + "Failed to provide structured property params for prompt type FIELDS_STRUCTURED_PROPERTY"); + } + if (fieldPath == null) { + throw new IllegalArgumentException( + "Failed to provide fieldPath for prompt type FIELDS_STRUCTURED_PROPERTY"); + } + final Urn structuredPropertyUrn = + UrnUtils.getUrn(input.getStructuredPropertyParams().getStructuredPropertyUrn()); + final PrimitivePropertyValueArray values = + FormUtils.getStructuredPropertyValuesFromInput(input); + + return _formService.submitFieldStructuredPropertyPromptResponse( + entityUrn, + structuredPropertyUrn, + values, + formUrn, + promptId, + fieldPath, + context.getAuthentication()); + } + return false; + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolver.java new file mode 100644 index 00000000000000..54e3562c97addf --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolver.java @@ -0,0 +1,63 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.group.GroupService; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.VerifyFormInput; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +public class VerifyFormResolver implements DataFetcher> { + + private final FormService _formService; + private final GroupService _groupService; + + public VerifyFormResolver( + @Nonnull final FormService formService, @Nonnull final GroupService groupService) { + _formService = Objects.requireNonNull(formService, "formService must not be null"); + _groupService = Objects.requireNonNull(groupService, "groupService must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + + final VerifyFormInput input = + bindArgument(environment.getArgument("input"), VerifyFormInput.class); + final Urn formUrn = UrnUtils.getUrn(input.getFormUrn()); + final Urn entityUrn = UrnUtils.getUrn(input.getEntityUrn()); + final Authentication authentication = context.getAuthentication(); + final Urn actorUrn = UrnUtils.getUrn(authentication.getActor().toUrnStr()); + + return CompletableFuture.supplyAsync( + () -> { + try { + final List groupsForUser = + _groupService.getGroupsForUser(actorUrn, authentication); + if (!_formService.isFormAssignedToUser( + formUrn, entityUrn, actorUrn, groupsForUser, authentication)) { + throw new AuthorizationException( + String.format( + "Failed to authorize form on entity as form with urn %s is not assigned to user", + formUrn)); + } + _formService.verifyFormForEntity(formUrn, entityUrn, authentication); + return true; + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java index 6a204286ba44e6..b52153d70fa7b1 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/glossary/CreateGlossaryNodeResolver.java @@ -37,7 +37,6 @@ public class CreateGlossaryNodeResolver implements DataFetcher get(DataFetchingEnvironment environment) throws Exception { - final QueryContext context = environment.getContext(); final CreateGlossaryEntityInput input = bindArgument(environment.getArgument("input"), CreateGlossaryEntityInput.class); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/EntityCountsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/EntityCountsResolver.java index 93582fb956bd8d..1f8c17ee728847 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/EntityCountsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/group/EntityCountsResolver.java @@ -6,7 +6,7 @@ import com.linkedin.datahub.graphql.generated.EntityCountInput; import com.linkedin.datahub.graphql.generated.EntityCountResult; import com.linkedin.datahub.graphql.generated.EntityCountResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolver.java index 0b909dee513747..3e9583824a5680 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolver.java @@ -44,7 +44,8 @@ public CompletableFuture rollbackIngestion( return CompletableFuture.supplyAsync( () -> { try { - _entityClient.rollbackIngestion(runId, context.getAuthentication()); + _entityClient.rollbackIngestion( + runId, context.getAuthorizer(), context.getAuthentication()); return true; } catch (Exception e) { throw new RuntimeException("Failed to rollback ingestion execution", e); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java new file mode 100644 index 00000000000000..25768da8195557 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java @@ -0,0 +1,105 @@ +package com.linkedin.datahub.graphql.resolvers.mutate.util; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.CreateDynamicFormAssignmentInput; +import com.linkedin.datahub.graphql.generated.SubmitFormPromptInput; +import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.form.FormInfo; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PrimitivePropertyValueArray; +import java.util.Objects; +import javax.annotation.Nonnull; + +public class FormUtils { + + private static final String COMPLETED_FORMS = "completedForms"; + private static final String INCOMPLETE_FORMS = "incompleteForms"; + private static final String VERIFIED_FORMS = "verifiedForms"; + private static final String OWNERS = "owners"; + private static final String COMPLETED_FORMS_COMPLETED_PROMPT_IDS = + "completedFormsCompletedPromptIds"; + private static final String INCOMPLETE_FORMS_COMPLETED_PROMPT_IDS = + "incompleteFormsCompletedPromptIds"; + + private FormUtils() {} + + public static PrimitivePropertyValueArray getStructuredPropertyValuesFromInput( + @Nonnull final SubmitFormPromptInput input) { + final PrimitivePropertyValueArray values = new PrimitivePropertyValueArray(); + + input + .getStructuredPropertyParams() + .getValues() + .forEach( + value -> { + if (value.getStringValue() != null) { + values.add(PrimitivePropertyValue.create(value.getStringValue())); + } else if (value.getNumberValue() != null) { + values.add(PrimitivePropertyValue.create(value.getNumberValue().doubleValue())); + } + }); + + return values; + } + + /** Map a GraphQL CreateDynamicFormAssignmentInput to the GMS DynamicFormAssignment aspect */ + @Nonnull + public static DynamicFormAssignment mapDynamicFormAssignment( + @Nonnull final CreateDynamicFormAssignmentInput input) { + Objects.requireNonNull(input, "input must not be null"); + + final DynamicFormAssignment result = new DynamicFormAssignment(); + final Filter filter = + new Filter() + .setOr(ResolverUtils.buildConjunctiveCriterionArrayWithOr(input.getOrFilters())); + result.setFilter(filter); + return result; + } + + /** + * Creates a Filter where the provided formUrn is either in completedForms or incompleteForms for + * an entity + */ + private static Filter generateCompleteOrIncompleteFilter(@Nonnull final String formUrn) + throws Exception { + final CriterionArray completedFormsAndArray = new CriterionArray(); + final CriterionArray incompleteFormsAndArray = new CriterionArray(); + completedFormsAndArray.add(buildFormCriterion(formUrn, COMPLETED_FORMS)); + incompleteFormsAndArray.add(buildFormCriterion(formUrn, INCOMPLETE_FORMS)); + // need this to be an OR not two ANDs + return new Filter() + .setOr( + new ConjunctiveCriterionArray( + new ConjunctiveCriterion().setAnd(completedFormsAndArray), + new ConjunctiveCriterion().setAnd(incompleteFormsAndArray))); + } + + private static Criterion buildFormCriterion( + @Nonnull final String formUrn, @Nonnull final String field) { + return buildFormCriterion(formUrn, field, false); + } + + private static Criterion buildFormCriterion( + @Nonnull final String formUrn, @Nonnull final String field, final boolean negated) { + return new Criterion() + .setField(field) + .setValue(formUrn) + .setCondition(Condition.EQUAL) + .setNegated(negated); + } + + private static boolean isActorExplicitlyAssigned( + @Nonnull final Urn actorUrn, @Nonnull final FormInfo formInfo) { + return (formInfo.getActors().getUsers() != null + && formInfo.getActors().getUsers().stream().anyMatch(user -> user.equals(actorUrn))) + || (formInfo.getActors().getGroups() != null + && formInfo.getActors().getGroups().stream().anyMatch(group -> group.equals(actorUrn))); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/policy/GetGrantedPrivilegesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/policy/GetGrantedPrivilegesResolver.java index 3328eff2bdf45b..7bfd166b18a205 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/policy/GetGrantedPrivilegesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/policy/GetGrantedPrivilegesResolver.java @@ -9,7 +9,7 @@ import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.GetGrantedPrivilegesInput; import com.linkedin.datahub.graphql.generated.Privileges; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.util.List; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/recommendation/ListRecommendationsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/recommendation/ListRecommendationsResolver.java index ca1e01b45989d2..e65666117b4fac 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/recommendation/ListRecommendationsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/recommendation/ListRecommendationsResolver.java @@ -15,8 +15,8 @@ import com.linkedin.datahub.graphql.generated.RecommendationRenderType; import com.linkedin.datahub.graphql.generated.RecommendationRequestContext; import com.linkedin.datahub.graphql.generated.SearchParams; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.recommendation.EntityRequestContext; import com.linkedin.metadata.recommendation.RecommendationsService; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java index 6d23456b76b4f4..b54987dc0e9b01 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolver.java @@ -15,6 +15,7 @@ import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.ViewService; import com.linkedin.view.DataHubViewInfo; import graphql.schema.DataFetcher; @@ -36,6 +37,7 @@ public class AggregateAcrossEntitiesResolver private final EntityClient _entityClient; private final ViewService _viewService; + private final FormService _formService; @Override public CompletableFuture get(DataFetchingEnvironment environment) { @@ -58,7 +60,7 @@ public CompletableFuture get(DataFetchingEnvironment environme context.getAuthentication()) : null; - final Filter baseFilter = ResolverUtils.buildFilter(null, input.getOrFilters()); + final Filter inputFilter = ResolverUtils.buildFilter(null, input.getOrFilters()); final SearchFlags searchFlags = mapInputFlags(input.getSearchFlags()); @@ -75,8 +77,8 @@ public CompletableFuture get(DataFetchingEnvironment environme sanitizedQuery, maybeResolvedView != null ? SearchUtils.combineFilters( - baseFilter, maybeResolvedView.getDefinition().getFilter()) - : baseFilter, + inputFilter, maybeResolvedView.getDefinition().getFilter()) + : inputFilter, 0, 0, // 0 entity count because we don't want resolved entities searchFlags, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolver.java index 6a01fa19867ad8..f300331ab4bc8b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolver.java @@ -10,9 +10,9 @@ import com.linkedin.datahub.graphql.generated.AutoCompleteMultipleInput; import com.linkedin.datahub.graphql.generated.AutoCompleteMultipleResults; import com.linkedin.datahub.graphql.generated.EntityType; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.SearchableEntityType; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.service.ViewService; import com.linkedin.view.DataHubViewInfo; import graphql.schema.DataFetcher; @@ -66,6 +66,12 @@ public CompletableFuture get(DataFetchingEnvironmen : null; List types = getEntityTypes(input.getTypes(), maybeResolvedView); + types = + types != null + ? types.stream() + .filter(AUTO_COMPLETE_ENTITY_TYPES::contains) + .collect(Collectors.toList()) + : null; if (types != null && types.size() > 0) { return AutocompleteUtils.batchGetAutocompleteResults( types.stream() diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolver.java index e54955e1857f09..1a380781385c34 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolver.java @@ -11,9 +11,9 @@ import com.linkedin.datahub.graphql.generated.GetQuickFiltersInput; import com.linkedin.datahub.graphql.generated.GetQuickFiltersResult; import com.linkedin.datahub.graphql.generated.QuickFilter; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.search.AggregationMetadata; import com.linkedin.metadata.search.AggregationMetadataArray; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossEntitiesResolver.java index 742d1d170de64b..658138ae6e3dc0 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossEntitiesResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossEntitiesResolver.java @@ -8,9 +8,9 @@ import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.ScrollAcrossEntitiesInput; import com.linkedin.datahub.graphql.generated.ScrollResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnScrollResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.SearchFlags; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossLineageResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossLineageResolver.java index adab62c22bb724..0af0a3827b1bb4 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossLineageResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/ScrollAcrossLineageResolver.java @@ -11,8 +11,8 @@ import com.linkedin.datahub.graphql.generated.LineageDirection; import com.linkedin.datahub.graphql.generated.ScrollAcrossLineageInput; import com.linkedin.datahub.graphql.generated.ScrollAcrossLineageResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnScrollAcrossLineageResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.SearchFlags; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java index 0f5d2d90ba0c29..2dc5032f2a4eb8 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java @@ -9,9 +9,9 @@ import com.linkedin.datahub.graphql.generated.LineageDirection; import com.linkedin.datahub.graphql.generated.SearchAcrossLineageInput; import com.linkedin.datahub.graphql.generated.SearchAcrossLineageResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchAcrossLineageResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.SearchFlags; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java index 68214238879237..bc177c600beeef 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java @@ -5,9 +5,9 @@ import com.linkedin.datahub.graphql.generated.SearchInput; import com.linkedin.datahub.graphql.generated.SearchResults; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.SearchFlags; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java index 6746c30a2edbcd..8c45df1b30b265 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchUtils.java @@ -21,8 +21,8 @@ import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.FacetFilterInput; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/type/PropertyValueResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/type/PropertyValueResolver.java new file mode 100644 index 00000000000000..cb0d24839056dc --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/type/PropertyValueResolver.java @@ -0,0 +1,25 @@ +package com.linkedin.datahub.graphql.resolvers.type; + +import com.linkedin.datahub.graphql.generated.NumberValue; +import com.linkedin.datahub.graphql.generated.StringValue; +import graphql.TypeResolutionEnvironment; +import graphql.schema.GraphQLObjectType; +import graphql.schema.TypeResolver; + +public class PropertyValueResolver implements TypeResolver { + + public static final String STRING_VALUE = "StringValue"; + public static final String NUMBER_VALUE = "NumberValue"; + + @Override + public GraphQLObjectType getType(TypeResolutionEnvironment env) { + if (env.getObject() instanceof StringValue) { + return env.getSchema().getObjectType(STRING_VALUE); + } else if (env.getObject() instanceof NumberValue) { + return env.getSchema().getObjectType(NUMBER_VALUE); + } else { + throw new RuntimeException( + "Unrecognized object type provided to type resolver, Type:" + env.getObject().toString()); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/view/ViewUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/view/ViewUtils.java index 9da5f915ff31d6..3a676f118c1ac8 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/view/ViewUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/view/ViewUtils.java @@ -11,8 +11,8 @@ import com.linkedin.datahub.graphql.generated.DataHubViewFilterInput; import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.LogicalOperator; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; import com.linkedin.metadata.query.filter.CriterionArray; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java index 4c452af1262012..18a082fee95f1a 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java @@ -32,6 +32,7 @@ import com.linkedin.datahub.graphql.generated.OwnershipTypeEntity; import com.linkedin.datahub.graphql.generated.Role; import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; import com.linkedin.datahub.graphql.generated.Tag; import com.linkedin.datahub.graphql.generated.Test; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; @@ -192,6 +193,11 @@ public Entity apply(Urn input) { ((OwnershipTypeEntity) partialEntity).setUrn(input.toString()); ((OwnershipTypeEntity) partialEntity).setType(EntityType.CUSTOM_OWNERSHIP_TYPE); } + if (input.getEntityType().equals(STRUCTURED_PROPERTY_ENTITY_NAME)) { + partialEntity = new StructuredPropertyEntity(); + ((StructuredPropertyEntity) partialEntity).setUrn(input.toString()); + ((StructuredPropertyEntity) partialEntity).setType(EntityType.STRUCTURED_PROPERTY); + } return partialEntity; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java index badb24810c82bf..fd31e1d394a927 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java @@ -88,6 +88,8 @@ public class DatasetType DATA_PRODUCTS_ASPECT_NAME, BROWSE_PATHS_V2_ASPECT_NAME, ACCESS_DATASET_ASPECT_NAME, + STRUCTURED_PROPERTIES_ASPECT_NAME, + FORMS_ASPECT_NAME, SUB_TYPES_ASPECT_NAME); private static final Set FACET_FIELDS = ImmutableSet.of("origin", "platform"); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetFilterMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetFilterMapper.java new file mode 100644 index 00000000000000..7e5372268170bb --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetFilterMapper.java @@ -0,0 +1,24 @@ +package com.linkedin.datahub.graphql.types.dataset.mappers; + +import com.linkedin.datahub.graphql.generated.DatasetFilter; +import com.linkedin.datahub.graphql.generated.DatasetFilterType; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import javax.annotation.Nonnull; + +public class DatasetFilterMapper + implements ModelMapper { + + public static final DatasetFilterMapper INSTANCE = new DatasetFilterMapper(); + + public static DatasetFilter map(@Nonnull final com.linkedin.dataset.DatasetFilter metadata) { + return INSTANCE.apply(metadata); + } + + @Override + public DatasetFilter apply(@Nonnull final com.linkedin.dataset.DatasetFilter input) { + final DatasetFilter result = new DatasetFilter(); + result.setType(DatasetFilterType.valueOf(input.getType().name())); + result.setSql(input.getSql()); + return result; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java index 7fa1decdf7f552..163e8b9288d87f 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/DatasetMapper.java @@ -7,6 +7,7 @@ import com.linkedin.common.DataPlatformInstance; import com.linkedin.common.Deprecation; import com.linkedin.common.Embed; +import com.linkedin.common.Forms; import com.linkedin.common.GlobalTags; import com.linkedin.common.GlossaryTerms; import com.linkedin.common.InstitutionalMemory; @@ -38,9 +39,11 @@ import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; import com.linkedin.datahub.graphql.types.common.mappers.util.SystemMetadataUtils; import com.linkedin.datahub.graphql.types.domain.DomainAssociationMapper; +import com.linkedin.datahub.graphql.types.form.FormsMapper; import com.linkedin.datahub.graphql.types.glossary.mappers.GlossaryTermsMapper; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; import com.linkedin.datahub.graphql.types.rolemetadata.mappers.AccessMapper; +import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertiesMapper; import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper; import com.linkedin.dataset.DatasetDeprecation; import com.linkedin.dataset.DatasetProperties; @@ -53,6 +56,7 @@ import com.linkedin.metadata.key.DatasetKey; import com.linkedin.schema.EditableSchemaMetadata; import com.linkedin.schema.SchemaMetadata; +import com.linkedin.structured.StructuredProperties; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -151,6 +155,15 @@ public Dataset apply(@Nonnull final EntityResponse entityResponse) { ACCESS_DATASET_ASPECT_NAME, ((dataset, dataMap) -> dataset.setAccess(AccessMapper.map(new Access(dataMap), entityUrn)))); + mappingHelper.mapToResult( + STRUCTURED_PROPERTIES_ASPECT_NAME, + ((dataset, dataMap) -> + dataset.setStructuredProperties( + StructuredPropertiesMapper.map(new StructuredProperties(dataMap))))); + mappingHelper.mapToResult( + FORMS_ASPECT_NAME, + ((dataset, dataMap) -> + dataset.setForms(FormsMapper.map(new Forms(dataMap), entityUrn.toString())))); mappingHelper.mapToResult( SUB_TYPES_ASPECT_NAME, (dashboard, dataMap) -> dashboard.setSubTypes(SubTypesMapper.map(new SubTypes(dataMap)))); diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaFieldMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaFieldMapper.java index edc9baf4ba9c59..e0a74d351125fd 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaFieldMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaFieldMapper.java @@ -1,10 +1,13 @@ package com.linkedin.datahub.graphql.types.dataset.mappers; import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.SchemaField; import com.linkedin.datahub.graphql.generated.SchemaFieldDataType; +import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; import com.linkedin.datahub.graphql.types.glossary.mappers.GlossaryTermsMapper; import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper; +import com.linkedin.metadata.utils.SchemaFieldUtils; import javax.annotation.Nonnull; public class SchemaFieldMapper { @@ -37,6 +40,7 @@ public SchemaField apply( result.setIsPartOfKey(input.isIsPartOfKey()); result.setIsPartitioningKey(input.isIsPartitioningKey()); result.setJsonProps(input.getJsonProps()); + result.setSchemaFieldEntity(this.createSchemaFieldEntity(input, entityUrn)); return result; } @@ -75,4 +79,14 @@ private SchemaFieldDataType mapSchemaFieldDataType( "Unrecognized SchemaFieldDataType provided %s", type.memberType().toString())); } } + + private SchemaFieldEntity createSchemaFieldEntity( + @Nonnull final com.linkedin.schema.SchemaField input, @Nonnull Urn entityUrn) { + SchemaFieldEntity schemaFieldEntity = new SchemaFieldEntity(); + schemaFieldEntity.setUrn( + SchemaFieldUtils.generateSchemaFieldUrn(entityUrn.toString(), input.getFieldPath()) + .toString()); + schemaFieldEntity.setType(EntityType.SCHEMA_FIELD); + return schemaFieldEntity; + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java index 31381073a16dd0..e550280a6c2dbc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java @@ -18,6 +18,11 @@ public static com.linkedin.datahub.graphql.generated.SchemaMetadata map( public com.linkedin.datahub.graphql.generated.SchemaMetadata apply( @Nonnull final EnvelopedAspect aspect, @Nonnull final Urn entityUrn) { final SchemaMetadata input = new SchemaMetadata(aspect.getValue().data()); + return apply(input, entityUrn, aspect.getVersion()); + } + + public com.linkedin.datahub.graphql.generated.SchemaMetadata apply( + @Nonnull final SchemaMetadata input, final Urn entityUrn, final long version) { final com.linkedin.datahub.graphql.generated.SchemaMetadata result = new com.linkedin.datahub.graphql.generated.SchemaMetadata(); @@ -35,7 +40,7 @@ public com.linkedin.datahub.graphql.generated.SchemaMetadata apply( .map(field -> SchemaFieldMapper.map(field, entityUrn)) .collect(Collectors.toList())); result.setPlatformSchema(PlatformSchemaMapper.map(input.getPlatformSchema())); - result.setAspectVersion(aspect.getVersion()); + result.setAspectVersion(version); if (input.hasForeignKeys()) { result.setForeignKeys( input.getForeignKeys().stream() diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeEntityMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeEntityMapper.java new file mode 100644 index 00000000000000..612644ae2dbb23 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeEntityMapper.java @@ -0,0 +1,51 @@ +package com.linkedin.datahub.graphql.types.datatype; + +import static com.linkedin.metadata.Constants.DATA_TYPE_INFO_ASPECT_NAME; + +import com.linkedin.data.DataMap; +import com.linkedin.datahub.graphql.generated.DataTypeEntity; +import com.linkedin.datahub.graphql.generated.DataTypeInfo; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import javax.annotation.Nonnull; + +public class DataTypeEntityMapper implements ModelMapper { + + public static final DataTypeEntityMapper INSTANCE = new DataTypeEntityMapper(); + + public static DataTypeEntity map(@Nonnull final EntityResponse entityResponse) { + return INSTANCE.apply(entityResponse); + } + + @Override + public DataTypeEntity apply(@Nonnull final EntityResponse entityResponse) { + final DataTypeEntity result = new DataTypeEntity(); + result.setUrn(entityResponse.getUrn().toString()); + result.setType(EntityType.DATA_TYPE); + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult(DATA_TYPE_INFO_ASPECT_NAME, this::mapDataTypeInfo); + + // Set the standard Type ENUM for the data type. + if (result.getInfo() != null) { + result.getInfo().setType(DataTypeUrnMapper.getType(entityResponse.getUrn().toString())); + } + return mappingHelper.getResult(); + } + + private void mapDataTypeInfo(@Nonnull DataTypeEntity dataType, @Nonnull DataMap dataMap) { + com.linkedin.datatype.DataTypeInfo gmsInfo = new com.linkedin.datatype.DataTypeInfo(dataMap); + DataTypeInfo info = new DataTypeInfo(); + info.setQualifiedName(gmsInfo.getQualifiedName()); + if (gmsInfo.getDisplayName() != null) { + info.setDisplayName(gmsInfo.getDisplayName()); + } + if (gmsInfo.getDescription() != null) { + info.setDescription(gmsInfo.getDescription()); + } + dataType.setInfo(info); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeType.java new file mode 100644 index 00000000000000..5ea1680546ce6d --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeType.java @@ -0,0 +1,78 @@ +package com.linkedin.datahub.graphql.types.datatype; + +import static com.linkedin.metadata.Constants.DATA_TYPE_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATA_TYPE_INFO_ASPECT_NAME; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DataTypeEntity; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import graphql.execution.DataFetcherResult; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DataTypeType + implements com.linkedin.datahub.graphql.types.EntityType { + + public static final Set ASPECTS_TO_FETCH = ImmutableSet.of(DATA_TYPE_INFO_ASPECT_NAME); + private final EntityClient _entityClient; + + @Override + public EntityType type() { + return EntityType.DATA_TYPE; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return DataTypeEntity.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List dataTypeUrns = urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + DATA_TYPE_ENTITY_NAME, + new HashSet<>(dataTypeUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + + final List gmsResults = new ArrayList<>(); + for (Urn urn : dataTypeUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(DataTypeEntityMapper.map(gmsResult)) + .build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load data type entities", e); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeUrnMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeUrnMapper.java new file mode 100644 index 00000000000000..ec71cd63a70d5e --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datatype/DataTypeUrnMapper.java @@ -0,0 +1,40 @@ +package com.linkedin.datahub.graphql.types.datatype; + +import com.google.common.collect.ImmutableMap; +import com.linkedin.datahub.graphql.generated.StdDataType; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class DataTypeUrnMapper { + + static final Map DATA_TYPE_ENUM_TO_URN = + ImmutableMap.builder() + .put(StdDataType.STRING, "urn:li:dataType:datahub.string") + .put(StdDataType.NUMBER, "urn:li:dataType:datahub.number") + .put(StdDataType.URN, "urn:li:dataType:datahub.urn") + .put(StdDataType.RICH_TEXT, "urn:li:dataType:datahub.rich_text") + .put(StdDataType.DATE, "urn:li:dataType:datahub.date") + .build(); + + private static final Map URN_TO_DATA_TYPE_ENUM = + DATA_TYPE_ENUM_TO_URN.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + + private DataTypeUrnMapper() {} + + public static StdDataType getType(String dataTypeUrn) { + if (!URN_TO_DATA_TYPE_ENUM.containsKey(dataTypeUrn)) { + return StdDataType.OTHER; + } + return URN_TO_DATA_TYPE_ENUM.get(dataTypeUrn); + } + + @Nonnull + public static String getUrn(StdDataType dataType) { + if (!DATA_TYPE_ENUM_TO_URN.containsKey(dataType)) { + throw new IllegalArgumentException("Unknown data type: " + dataType); + } + return DATA_TYPE_ENUM_TO_URN.get(dataType); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeEntityMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeEntityMapper.java new file mode 100644 index 00000000000000..b942ff2325bf72 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeEntityMapper.java @@ -0,0 +1,54 @@ +package com.linkedin.datahub.graphql.types.entitytype; + +import static com.linkedin.metadata.Constants.*; + +import com.linkedin.data.DataMap; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.EntityTypeEntity; +import com.linkedin.datahub.graphql.generated.EntityTypeInfo; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import javax.annotation.Nonnull; + +public class EntityTypeEntityMapper implements ModelMapper { + + public static final EntityTypeEntityMapper INSTANCE = new EntityTypeEntityMapper(); + + public static EntityTypeEntity map(@Nonnull final EntityResponse entityResponse) { + return INSTANCE.apply(entityResponse); + } + + @Override + public EntityTypeEntity apply(@Nonnull final EntityResponse entityResponse) { + final EntityTypeEntity result = new EntityTypeEntity(); + result.setUrn(entityResponse.getUrn().toString()); + result.setType(EntityType.ENTITY_TYPE); + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult(ENTITY_TYPE_INFO_ASPECT_NAME, this::mapEntityTypeInfo); + + // Set the standard Type ENUM for the entity type. + if (result.getInfo() != null) { + result + .getInfo() + .setType(EntityTypeUrnMapper.getEntityType(entityResponse.getUrn().toString())); + } + return mappingHelper.getResult(); + } + + private void mapEntityTypeInfo(@Nonnull EntityTypeEntity entityType, @Nonnull DataMap dataMap) { + com.linkedin.entitytype.EntityTypeInfo gmsInfo = + new com.linkedin.entitytype.EntityTypeInfo(dataMap); + EntityTypeInfo info = new EntityTypeInfo(); + info.setQualifiedName(gmsInfo.getQualifiedName()); + if (gmsInfo.getDisplayName() != null) { + info.setDisplayName(gmsInfo.getDisplayName()); + } + if (gmsInfo.getDescription() != null) { + info.setDescription(gmsInfo.getDescription()); + } + entityType.setInfo(info); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/EntityTypeMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java similarity index 91% rename from datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/EntityTypeMapper.java rename to datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java index aba781f9e1dc70..23e793782e8dc6 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/EntityTypeMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java @@ -1,4 +1,4 @@ -package com.linkedin.datahub.graphql.resolvers; +package com.linkedin.datahub.graphql.types.entitytype; import com.google.common.collect.ImmutableMap; import com.linkedin.datahub.graphql.generated.EntityType; @@ -17,7 +17,6 @@ public class EntityTypeMapper { ImmutableMap.builder() .put(EntityType.DATASET, "dataset") .put(EntityType.ROLE, "role") - .put(EntityType.ASSERTION, Constants.ASSERTION_ENTITY_NAME) .put(EntityType.CORP_USER, "corpuser") .put(EntityType.CORP_GROUP, "corpGroup") .put(EntityType.DATA_PLATFORM, "dataPlatform") @@ -41,6 +40,9 @@ public class EntityTypeMapper { .put(EntityType.TEST, "test") .put(EntityType.DATAHUB_VIEW, Constants.DATAHUB_VIEW_ENTITY_NAME) .put(EntityType.DATA_PRODUCT, Constants.DATA_PRODUCT_ENTITY_NAME) + .put(EntityType.SCHEMA_FIELD, "schemaField") + .put(EntityType.STRUCTURED_PROPERTY, Constants.STRUCTURED_PROPERTY_ENTITY_NAME) + .put(EntityType.ASSERTION, Constants.ASSERTION_ENTITY_NAME) .build(); private static final Map ENTITY_NAME_TO_TYPE = @@ -52,7 +54,7 @@ private EntityTypeMapper() {} public static EntityType getType(String name) { String lowercaseName = name.toLowerCase(); if (!ENTITY_NAME_TO_TYPE.containsKey(lowercaseName)) { - throw new IllegalArgumentException("Unknown entity name: " + name); + return EntityType.OTHER; } return ENTITY_NAME_TO_TYPE.get(lowercaseName); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeType.java new file mode 100644 index 00000000000000..aa5dfc13ea7578 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeType.java @@ -0,0 +1,78 @@ +package com.linkedin.datahub.graphql.types.entitytype; + +import static com.linkedin.metadata.Constants.*; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.EntityTypeEntity; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import graphql.execution.DataFetcherResult; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class EntityTypeType + implements com.linkedin.datahub.graphql.types.EntityType { + + public static final Set ASPECTS_TO_FETCH = ImmutableSet.of(ENTITY_TYPE_INFO_ASPECT_NAME); + private final EntityClient _entityClient; + + @Override + public EntityType type() { + return EntityType.ENTITY_TYPE; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return EntityTypeEntity.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List entityTypeUrns = + urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + ENTITY_TYPE_ENTITY_NAME, + new HashSet<>(entityTypeUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + + final List gmsResults = new ArrayList<>(); + for (Urn urn : entityTypeUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(EntityTypeEntityMapper.map(gmsResult)) + .build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load entity type entities", e); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java new file mode 100644 index 00000000000000..9e9bf86e5fe7fe --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java @@ -0,0 +1,85 @@ +package com.linkedin.datahub.graphql.types.entitytype; + +import com.google.common.collect.ImmutableMap; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.metadata.Constants; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +/** + * In this class we statically map "well-supported" entity types into a more usable Enum class + * served by our GraphQL API. + * + *

When we add new entity types / entity urns, we MAY NEED to update this. + * + *

Note that we currently do not support mapping entities that fall outside of this set. If you + * try to map an entity type without a corresponding enum symbol, the mapping WILL FAIL. + */ +public class EntityTypeUrnMapper { + + static final Map ENTITY_NAME_TO_ENTITY_TYPE_URN = + ImmutableMap.builder() + .put(Constants.DATASET_ENTITY_NAME, "urn:li:entityType:datahub.dataset") + .put(Constants.ROLE_ENTITY_NAME, "urn:li:entityType:datahub.role") + .put(Constants.CORP_USER_ENTITY_NAME, "urn:li:entityType:datahub.corpuser") + .put(Constants.CORP_GROUP_ENTITY_NAME, "urn:li:entityType:datahub.corpGroup") + .put(Constants.DATA_PLATFORM_ENTITY_NAME, "urn:li:entityType:datahub.dataPlatform") + .put(Constants.DASHBOARD_ENTITY_NAME, "urn:li:entityType:datahub.dashboard") + .put(Constants.CHART_ENTITY_NAME, "urn:li:entityType:datahub.chart") + .put(Constants.TAG_ENTITY_NAME, "urn:li:entityType:datahub.tag") + .put(Constants.DATA_FLOW_ENTITY_NAME, "urn:li:entityType:datahub.dataFlow") + .put(Constants.DATA_JOB_ENTITY_NAME, "urn:li:entityType:datahub.dataJob") + .put(Constants.GLOSSARY_TERM_ENTITY_NAME, "urn:li:entityType:datahub.glossaryTerm") + .put(Constants.GLOSSARY_NODE_ENTITY_NAME, "urn:li:entityType:datahub.glossaryNode") + .put(Constants.ML_MODEL_ENTITY_NAME, "urn:li:entityType:datahub.mlModel") + .put(Constants.ML_MODEL_GROUP_ENTITY_NAME, "urn:li:entityType:datahub.mlModelGroup") + .put(Constants.ML_FEATURE_TABLE_ENTITY_NAME, "urn:li:entityType:datahub.mlFeatureTable") + .put(Constants.ML_FEATURE_ENTITY_NAME, "urn:li:entityType:datahub.mlFeature") + .put(Constants.ML_PRIMARY_KEY_ENTITY_NAME, "urn:li:entityType:datahub.mlPrimaryKey") + .put(Constants.CONTAINER_ENTITY_NAME, "urn:li:entityType:datahub.container") + .put(Constants.DOMAIN_ENTITY_NAME, "urn:li:entityType:datahub.domain") + .put(Constants.NOTEBOOK_ENTITY_NAME, "urn:li:entityType:datahub.notebook") + .put( + Constants.DATA_PLATFORM_INSTANCE_ENTITY_NAME, + "urn:li:entityType:datahub.dataPlatformInstance") + .put(Constants.TEST_ENTITY_NAME, "urn:li:entityType:datahub.test") + .put(Constants.DATAHUB_VIEW_ENTITY_NAME, "urn:li:entityType:datahub.dataHubView") + .put(Constants.DATA_PRODUCT_ENTITY_NAME, "urn:li:entityType:datahub.dataProduct") + .put(Constants.ASSERTION_ENTITY_NAME, "urn:li:entityType:datahub.assertion") + .put(Constants.SCHEMA_FIELD_ENTITY_NAME, "urn:li:entityType:datahub.schemaField") + .build(); + + private static final Map ENTITY_TYPE_URN_TO_NAME = + ENTITY_NAME_TO_ENTITY_TYPE_URN.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + + private EntityTypeUrnMapper() {} + + public static String getName(String entityTypeUrn) { + if (!ENTITY_TYPE_URN_TO_NAME.containsKey(entityTypeUrn)) { + throw new IllegalArgumentException("Unknown entityTypeUrn: " + entityTypeUrn); + } + return ENTITY_TYPE_URN_TO_NAME.get(entityTypeUrn); + } + + /* + * Takes in a entityTypeUrn and returns a GraphQL EntityType by first mapping + * the urn to the entity name, and then mapping the entity name to EntityType. + */ + public static EntityType getEntityType(String entityTypeUrn) { + if (!ENTITY_TYPE_URN_TO_NAME.containsKey(entityTypeUrn)) { + throw new IllegalArgumentException("Unknown entityTypeUrn: " + entityTypeUrn); + } + final String entityName = ENTITY_TYPE_URN_TO_NAME.get(entityTypeUrn); + return EntityTypeMapper.getType(entityName); + } + + @Nonnull + public static String getEntityTypeUrn(String name) { + if (!ENTITY_NAME_TO_ENTITY_TYPE_URN.containsKey(name)) { + throw new IllegalArgumentException("Unknown entity name: " + name); + } + return ENTITY_NAME_TO_ENTITY_TYPE_URN.get(name); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormMapper.java new file mode 100644 index 00000000000000..a0ddd4a5883d25 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormMapper.java @@ -0,0 +1,129 @@ +package com.linkedin.datahub.graphql.types.form; + +import static com.linkedin.metadata.Constants.FORM_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.OWNERSHIP_ASPECT_NAME; + +import com.linkedin.common.Ownership; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.DataMap; +import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.FormActorAssignment; +import com.linkedin.datahub.graphql.generated.FormInfo; +import com.linkedin.datahub.graphql.generated.FormPrompt; +import com.linkedin.datahub.graphql.generated.FormPromptType; +import com.linkedin.datahub.graphql.generated.FormType; +import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.StructuredPropertyParams; +import com.linkedin.datahub.graphql.types.common.mappers.OwnershipMapper; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class FormMapper implements ModelMapper { + + public static final FormMapper INSTANCE = new FormMapper(); + + public static Form map(@Nonnull final EntityResponse form) { + return INSTANCE.apply(form); + } + + public Form apply(@Nonnull final EntityResponse entityResponse) { + Form result = new Form(); + Urn entityUrn = entityResponse.getUrn(); + result.setUrn(entityUrn.toString()); + result.setType(EntityType.FORM); + + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult(FORM_INFO_ASPECT_NAME, this::mapFormInfo); + mappingHelper.mapToResult( + OWNERSHIP_ASPECT_NAME, + (form, dataMap) -> + form.setOwnership(OwnershipMapper.map(new Ownership(dataMap), entityUrn))); + + return mappingHelper.getResult(); + } + + private void mapFormInfo(@Nonnull Form form, @Nonnull DataMap dataMap) { + com.linkedin.form.FormInfo gmsFormInfo = new com.linkedin.form.FormInfo(dataMap); + FormInfo formInfo = new FormInfo(); + formInfo.setName(gmsFormInfo.getName()); + formInfo.setType(FormType.valueOf(gmsFormInfo.getType().toString())); + if (gmsFormInfo.hasDescription()) { + formInfo.setDescription(gmsFormInfo.getDescription()); + } + formInfo.setPrompts(this.mapFormPrompts(gmsFormInfo, form.getUrn())); + formInfo.setActors(mapFormActors(gmsFormInfo.getActors())); + form.setInfo(formInfo); + } + + private List mapFormPrompts( + @Nonnull com.linkedin.form.FormInfo gmsFormInfo, @Nonnull String formUrn) { + List formPrompts = new ArrayList<>(); + if (gmsFormInfo.hasPrompts()) { + gmsFormInfo + .getPrompts() + .forEach(FormPrompt -> formPrompts.add(mapFormPrompt(FormPrompt, formUrn))); + } + return formPrompts; + } + + private FormPrompt mapFormPrompt( + @Nonnull com.linkedin.form.FormPrompt gmsFormPrompt, @Nonnull String formUrn) { + final FormPrompt formPrompt = new FormPrompt(); + formPrompt.setId(gmsFormPrompt.getId()); + formPrompt.setTitle(gmsFormPrompt.getTitle()); + formPrompt.setType(FormPromptType.valueOf(gmsFormPrompt.getType().toString())); + formPrompt.setRequired(gmsFormPrompt.isRequired()); + formPrompt.setFormUrn(formUrn); + if (gmsFormPrompt.hasDescription()) { + formPrompt.setDescription(gmsFormPrompt.getDescription()); + } + + if (gmsFormPrompt.hasStructuredPropertyParams()) { + final StructuredPropertyParams params = new StructuredPropertyParams(); + final Urn structuredPropUrn = gmsFormPrompt.getStructuredPropertyParams().getUrn(); + final StructuredPropertyEntity structuredProp = new StructuredPropertyEntity(); + structuredProp.setUrn(structuredPropUrn.toString()); + structuredProp.setType(EntityType.STRUCTURED_PROPERTY); + params.setStructuredProperty(structuredProp); + formPrompt.setStructuredPropertyParams(params); + } + + return formPrompt; + } + + private FormActorAssignment mapFormActors(com.linkedin.form.FormActorAssignment gmsFormActors) { + FormActorAssignment result = new FormActorAssignment(); + result.setOwners(gmsFormActors.isOwners()); + if (gmsFormActors.hasUsers()) { + result.setUsers( + gmsFormActors.getUsers().stream().map(this::mapUser).collect(Collectors.toList())); + } + if (gmsFormActors.hasGroups()) { + result.setGroups( + gmsFormActors.getGroups().stream().map(this::mapGroup).collect(Collectors.toList())); + } + return result; + } + + private CorpUser mapUser(Urn userUrn) { + CorpUser user = new CorpUser(); + user.setUrn(userUrn.toString()); + return user; + } + + private CorpGroup mapGroup(Urn groupUrn) { + CorpGroup group = new CorpGroup(); + group.setUrn(groupUrn.toString()); + return group; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormType.java new file mode 100644 index 00000000000000..8a09cee353cc97 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormType.java @@ -0,0 +1,76 @@ +package com.linkedin.datahub.graphql.types.form; + +import static com.linkedin.metadata.Constants.FORM_ENTITY_NAME; +import static com.linkedin.metadata.Constants.FORM_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.OWNERSHIP_ASPECT_NAME; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import graphql.execution.DataFetcherResult; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class FormType implements com.linkedin.datahub.graphql.types.EntityType { + public static final Set ASPECTS_TO_FETCH = + ImmutableSet.of(FORM_INFO_ASPECT_NAME, OWNERSHIP_ASPECT_NAME); + private final EntityClient _entityClient; + + @Override + public EntityType type() { + return EntityType.FORM; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return Form.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List formUrns = urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + FORM_ENTITY_NAME, + new HashSet<>(formUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + + final List gmsResults = new ArrayList<>(); + for (Urn urn : formUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult().data(FormMapper.map(gmsResult)).build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load Forms", e); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormsMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormsMapper.java new file mode 100644 index 00000000000000..43665b37b9ee87 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/form/FormsMapper.java @@ -0,0 +1,133 @@ +package com.linkedin.datahub.graphql.types.form; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.FieldFormPromptAssociationArray; +import com.linkedin.common.FormPromptAssociationArray; +import com.linkedin.common.Forms; +import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.FieldFormPromptAssociation; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.FormAssociation; +import com.linkedin.datahub.graphql.generated.FormPromptAssociation; +import com.linkedin.datahub.graphql.generated.FormPromptFieldAssociations; +import com.linkedin.datahub.graphql.generated.FormVerificationAssociation; +import com.linkedin.datahub.graphql.generated.ResolvedAuditStamp; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; + +public class FormsMapper { + + public static final FormsMapper INSTANCE = new FormsMapper(); + + public static com.linkedin.datahub.graphql.generated.Forms map( + @Nonnull final Forms forms, @Nonnull final String entityUrn) { + return INSTANCE.apply(forms, entityUrn); + } + + public com.linkedin.datahub.graphql.generated.Forms apply( + @Nonnull final Forms forms, @Nonnull final String entityUrn) { + final List incompleteForms = new ArrayList<>(); + forms + .getIncompleteForms() + .forEach( + formAssociation -> + incompleteForms.add(this.mapFormAssociation(formAssociation, entityUrn))); + final List completeForms = new ArrayList<>(); + forms + .getCompletedForms() + .forEach( + formAssociation -> + completeForms.add(this.mapFormAssociation(formAssociation, entityUrn))); + final List verifications = new ArrayList<>(); + forms + .getVerifications() + .forEach( + verificationAssociation -> + verifications.add(this.mapVerificationAssociation(verificationAssociation))); + + return new com.linkedin.datahub.graphql.generated.Forms( + incompleteForms, completeForms, verifications); + } + + private FormAssociation mapFormAssociation( + @Nonnull final com.linkedin.common.FormAssociation association, + @Nonnull final String entityUrn) { + FormAssociation result = new FormAssociation(); + result.setForm( + Form.builder().setType(EntityType.FORM).setUrn(association.getUrn().toString()).build()); + result.setAssociatedUrn(entityUrn); + result.setCompletedPrompts(this.mapPrompts(association.getCompletedPrompts())); + result.setIncompletePrompts(this.mapPrompts(association.getIncompletePrompts())); + return result; + } + + private FormVerificationAssociation mapVerificationAssociation( + @Nonnull final com.linkedin.common.FormVerificationAssociation verificationAssociation) { + FormVerificationAssociation result = new FormVerificationAssociation(); + result.setForm( + Form.builder() + .setType(EntityType.FORM) + .setUrn(verificationAssociation.getForm().toString()) + .build()); + if (verificationAssociation.hasLastModified()) { + result.setLastModified(createAuditStamp(verificationAssociation.getLastModified())); + } + return result; + } + + private List mapPrompts( + @Nonnull final FormPromptAssociationArray promptAssociations) { + List result = new ArrayList<>(); + promptAssociations.forEach( + promptAssociation -> { + FormPromptAssociation association = new FormPromptAssociation(); + association.setId(promptAssociation.getId()); + association.setLastModified(createAuditStamp(promptAssociation.getLastModified())); + if (promptAssociation.hasFieldAssociations()) { + association.setFieldAssociations( + mapFieldAssociations(promptAssociation.getFieldAssociations())); + } + result.add(association); + }); + return result; + } + + private List mapFieldPrompts( + @Nonnull final FieldFormPromptAssociationArray fieldPromptAssociations) { + List result = new ArrayList<>(); + fieldPromptAssociations.forEach( + fieldFormPromptAssociation -> { + FieldFormPromptAssociation association = new FieldFormPromptAssociation(); + association.setFieldPath(fieldFormPromptAssociation.getFieldPath()); + association.setLastModified( + createAuditStamp(fieldFormPromptAssociation.getLastModified())); + result.add(association); + }); + return result; + } + + private FormPromptFieldAssociations mapFieldAssociations( + com.linkedin.common.FormPromptFieldAssociations associationsObj) { + final FormPromptFieldAssociations fieldAssociations = new FormPromptFieldAssociations(); + if (associationsObj.hasCompletedFieldPrompts()) { + fieldAssociations.setCompletedFieldPrompts( + this.mapFieldPrompts(associationsObj.getCompletedFieldPrompts())); + } + if (associationsObj.hasIncompleteFieldPrompts()) { + fieldAssociations.setIncompleteFieldPrompts( + this.mapFieldPrompts(associationsObj.getIncompleteFieldPrompts())); + } + return fieldAssociations; + } + + private ResolvedAuditStamp createAuditStamp(AuditStamp auditStamp) { + final ResolvedAuditStamp resolvedAuditStamp = new ResolvedAuditStamp(); + final CorpUser emptyCreatedUser = new CorpUser(); + emptyCreatedUser.setUrn(auditStamp.getActor().toString()); + resolvedAuditStamp.setActor(emptyCreatedUser); + resolvedAuditStamp.setTime(auditStamp.getTime()); + return resolvedAuditStamp; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java index 901361eb0b2be6..31c8cec8cb5fa7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryNodeMapper.java @@ -8,6 +8,7 @@ import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.GlossaryNode; import com.linkedin.datahub.graphql.generated.GlossaryNodeProperties; +import com.linkedin.datahub.graphql.types.common.mappers.CustomPropertiesMapper; import com.linkedin.datahub.graphql.types.common.mappers.OwnershipMapper; import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; @@ -36,7 +37,8 @@ public GlossaryNode apply(@Nonnull final EntityResponse entityResponse) { MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); mappingHelper.mapToResult( GLOSSARY_NODE_INFO_ASPECT_NAME, - (glossaryNode, dataMap) -> glossaryNode.setProperties(mapGlossaryNodeProperties(dataMap))); + (glossaryNode, dataMap) -> + glossaryNode.setProperties(mapGlossaryNodeProperties(dataMap, entityUrn))); mappingHelper.mapToResult(GLOSSARY_NODE_KEY_ASPECT_NAME, this::mapGlossaryNodeKey); mappingHelper.mapToResult( OWNERSHIP_ASPECT_NAME, @@ -46,13 +48,18 @@ public GlossaryNode apply(@Nonnull final EntityResponse entityResponse) { return mappingHelper.getResult(); } - private GlossaryNodeProperties mapGlossaryNodeProperties(@Nonnull DataMap dataMap) { + private GlossaryNodeProperties mapGlossaryNodeProperties( + @Nonnull DataMap dataMap, @Nonnull final Urn entityUrn) { GlossaryNodeInfo glossaryNodeInfo = new GlossaryNodeInfo(dataMap); GlossaryNodeProperties result = new GlossaryNodeProperties(); result.setDescription(glossaryNodeInfo.getDefinition()); if (glossaryNodeInfo.hasName()) { result.setName(glossaryNodeInfo.getName()); } + if (glossaryNodeInfo.hasCustomProperties()) { + result.setCustomProperties( + CustomPropertiesMapper.map(glossaryNodeInfo.getCustomProperties(), entityUrn)); + } return result; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermsMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermsMapper.java index 8494eace222448..68475a2599158e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermsMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermsMapper.java @@ -2,6 +2,7 @@ import com.linkedin.common.GlossaryTermAssociation; import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.CorpUser; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.GlossaryTerm; import com.linkedin.datahub.graphql.generated.GlossaryTerms; @@ -46,7 +47,15 @@ private com.linkedin.datahub.graphql.generated.GlossaryTermAssociation mapGlossa resultGlossaryTerm.setName( GlossaryTermUtils.getGlossaryTermName(input.getUrn().getNameEntity())); result.setTerm(resultGlossaryTerm); - result.setAssociatedUrn(entityUrn.toString()); + if (input.hasActor()) { + CorpUser actor = new CorpUser(); + actor.setUrn(input.getActor().toString()); + actor.setType(EntityType.CORP_USER); + result.setActor(actor); + } + if (entityUrn != null) { + result.setAssociatedUrn(entityUrn.toString()); + } return result; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java index 7c7dab2e024720..b5733626468d67 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/MapperUtils.java @@ -9,8 +9,8 @@ import com.linkedin.datahub.graphql.generated.MatchedField; import com.linkedin.datahub.graphql.generated.SearchResult; import com.linkedin.datahub.graphql.generated.SearchSuggestion; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.search.SearchEntity; import com.linkedin.metadata.search.utils.SearchUtils; import java.net.URISyntaxException; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java new file mode 100644 index 00000000000000..254a1ed1767f17 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldMapper.java @@ -0,0 +1,54 @@ +package com.linkedin.datahub.graphql.types.schemafield; + +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; +import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertiesMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.structured.StructuredProperties; +import javax.annotation.Nonnull; + +public class SchemaFieldMapper implements ModelMapper { + + public static final SchemaFieldMapper INSTANCE = new SchemaFieldMapper(); + + public static SchemaFieldEntity map(@Nonnull final EntityResponse entityResponse) { + return INSTANCE.apply(entityResponse); + } + + @Override + public SchemaFieldEntity apply(@Nonnull final EntityResponse entityResponse) { + Urn entityUrn = entityResponse.getUrn(); + final SchemaFieldEntity result = this.mapSchemaFieldUrn(entityUrn); + + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult( + STRUCTURED_PROPERTIES_ASPECT_NAME, + ((schemaField, dataMap) -> + schemaField.setStructuredProperties( + StructuredPropertiesMapper.map(new StructuredProperties(dataMap))))); + + return result; + } + + private SchemaFieldEntity mapSchemaFieldUrn(Urn urn) { + try { + SchemaFieldEntity result = new SchemaFieldEntity(); + result.setUrn(urn.toString()); + result.setType(EntityType.SCHEMA_FIELD); + result.setFieldPath(urn.getEntityKey().get(1)); + Urn parentUrn = Urn.createFromString(urn.getEntityKey().get(0)); + result.setParent(UrnToEntityMapper.map(parentUrn)); + return result; + } catch (Exception e) { + throw new RuntimeException("Failed to load schemaField entity", e); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldType.java index b543a40cbac410..9f14bf52733ea9 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/schemafield/SchemaFieldType.java @@ -1,22 +1,40 @@ package com.linkedin.datahub.graphql.types.schemafield; +import static com.linkedin.metadata.Constants.SCHEMA_FIELD_ENTITY_NAME; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; + +import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.Entity; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; -import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; import graphql.execution.DataFetcherResult; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor public class SchemaFieldType implements com.linkedin.datahub.graphql.types.EntityType { - public SchemaFieldType() {} + public static final Set ASPECTS_TO_FETCH = + ImmutableSet.of(STRUCTURED_PROPERTIES_ASPECT_NAME); + + private final EntityClient _entityClient; + private final FeatureFlags _featureFlags; @Override public EntityType type() { @@ -40,29 +58,41 @@ public List> batchLoad( urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); try { - return schemaFieldUrns.stream() - .map(this::mapSchemaFieldUrn) + Map entities = new HashMap<>(); + if (_featureFlags.isSchemaFieldEntityFetchEnabled()) { + entities = + _entityClient.batchGetV2( + SCHEMA_FIELD_ENTITY_NAME, + new HashSet<>(schemaFieldUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + } + + final List gmsResults = new ArrayList<>(); + for (Urn urn : schemaFieldUrns) { + if (_featureFlags.isSchemaFieldEntityFetchEnabled()) { + gmsResults.add(entities.getOrDefault(urn, null)); + } else { + gmsResults.add( + new EntityResponse() + .setUrn(urn) + .setAspects(new EnvelopedAspectMap()) + .setEntityName(urn.getEntityType())); + } + } + + return gmsResults.stream() .map( - schemaFieldEntity -> - DataFetcherResult.newResult().data(schemaFieldEntity).build()) + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(SchemaFieldMapper.map(gmsResult)) + .build()) .collect(Collectors.toList()); } catch (Exception e) { throw new RuntimeException("Failed to load schemaField entity", e); } } - - private SchemaFieldEntity mapSchemaFieldUrn(Urn urn) { - try { - SchemaFieldEntity result = new SchemaFieldEntity(); - result.setUrn(urn.toString()); - result.setType(EntityType.SCHEMA_FIELD); - result.setFieldPath(urn.getEntityKey().get(1)); - Urn parentUrn = Urn.createFromString(urn.getEntityKey().get(0)); - result.setParent(UrnToEntityMapper.map(parentUrn)); - return result; - } catch (Exception e) { - throw new RuntimeException("Failed to load schemaField entity", e); - } - } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java new file mode 100644 index 00000000000000..ad48067599328f --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertiesMapper.java @@ -0,0 +1,80 @@ +package com.linkedin.datahub.graphql.types.structuredproperty; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.NumberValue; +import com.linkedin.datahub.graphql.generated.PropertyValue; +import com.linkedin.datahub.graphql.generated.StringValue; +import com.linkedin.datahub.graphql.generated.StructuredPropertiesEntry; +import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class StructuredPropertiesMapper { + + public static final StructuredPropertiesMapper INSTANCE = new StructuredPropertiesMapper(); + + public static com.linkedin.datahub.graphql.generated.StructuredProperties map( + @Nonnull final StructuredProperties structuredProperties) { + return INSTANCE.apply(structuredProperties); + } + + public com.linkedin.datahub.graphql.generated.StructuredProperties apply( + @Nonnull final StructuredProperties structuredProperties) { + com.linkedin.datahub.graphql.generated.StructuredProperties result = + new com.linkedin.datahub.graphql.generated.StructuredProperties(); + result.setProperties( + structuredProperties.getProperties().stream() + .map(this::mapStructuredProperty) + .collect(Collectors.toList())); + return result; + } + + private StructuredPropertiesEntry mapStructuredProperty( + StructuredPropertyValueAssignment valueAssignment) { + StructuredPropertiesEntry entry = new StructuredPropertiesEntry(); + entry.setStructuredProperty(createStructuredPropertyEntity(valueAssignment)); + final List values = new ArrayList<>(); + final List entities = new ArrayList<>(); + valueAssignment + .getValues() + .forEach( + value -> { + if (value.isString()) { + this.mapStringValue(value.getString(), values, entities); + } else if (value.isDouble()) { + values.add(new NumberValue(value.getDouble())); + } + }); + entry.setValues(values); + entry.setValueEntities(entities); + return entry; + } + + private StructuredPropertyEntity createStructuredPropertyEntity( + StructuredPropertyValueAssignment assignment) { + StructuredPropertyEntity entity = new StructuredPropertyEntity(); + entity.setUrn(assignment.getPropertyUrn().toString()); + entity.setType(EntityType.STRUCTURED_PROPERTY); + return entity; + } + + private void mapStringValue( + String stringValue, List values, List entities) { + try { + final Urn urnValue = Urn.createFromString(stringValue); + entities.add(UrnToEntityMapper.map(urnValue)); + } catch (Exception e) { + log.debug("String value is not an urn for this structured property entry"); + } + values.add(new StringValue(stringValue)); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java new file mode 100644 index 00000000000000..259020b83bee1a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyMapper.java @@ -0,0 +1,124 @@ +package com.linkedin.datahub.graphql.types.structuredproperty; + +import static com.linkedin.metadata.Constants.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.data.DataMap; +import com.linkedin.data.template.StringArrayMap; +import com.linkedin.datahub.graphql.generated.AllowedValue; +import com.linkedin.datahub.graphql.generated.DataTypeEntity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.EntityTypeEntity; +import com.linkedin.datahub.graphql.generated.NumberValue; +import com.linkedin.datahub.graphql.generated.PropertyCardinality; +import com.linkedin.datahub.graphql.generated.StringValue; +import com.linkedin.datahub.graphql.generated.StructuredPropertyDefinition; +import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.datahub.graphql.generated.TypeQualifier; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.structured.PropertyValueArray; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class StructuredPropertyMapper + implements ModelMapper { + + private static final String ALLOWED_TYPES = "allowedTypes"; + + public static final StructuredPropertyMapper INSTANCE = new StructuredPropertyMapper(); + + public static StructuredPropertyEntity map(@Nonnull final EntityResponse entityResponse) { + return INSTANCE.apply(entityResponse); + } + + @Override + public StructuredPropertyEntity apply(@Nonnull final EntityResponse entityResponse) { + final StructuredPropertyEntity result = new StructuredPropertyEntity(); + result.setUrn(entityResponse.getUrn().toString()); + result.setType(EntityType.STRUCTURED_PROPERTY); + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult( + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, (this::mapStructuredPropertyDefinition)); + return mappingHelper.getResult(); + } + + private void mapStructuredPropertyDefinition( + @Nonnull StructuredPropertyEntity extendedProperty, @Nonnull DataMap dataMap) { + com.linkedin.structured.StructuredPropertyDefinition gmsDefinition = + new com.linkedin.structured.StructuredPropertyDefinition(dataMap); + StructuredPropertyDefinition definition = new StructuredPropertyDefinition(); + definition.setQualifiedName(gmsDefinition.getQualifiedName()); + definition.setCardinality( + PropertyCardinality.valueOf(gmsDefinition.getCardinality().toString())); + definition.setValueType(createDataTypeEntity(gmsDefinition.getValueType())); + if (gmsDefinition.hasDisplayName()) { + definition.setDisplayName(gmsDefinition.getDisplayName()); + } + if (gmsDefinition.getDescription() != null) { + definition.setDescription(gmsDefinition.getDescription()); + } + if (gmsDefinition.hasAllowedValues()) { + definition.setAllowedValues(mapAllowedValues(gmsDefinition.getAllowedValues())); + } + if (gmsDefinition.hasTypeQualifier()) { + definition.setTypeQualifier(mapTypeQualifier(gmsDefinition.getTypeQualifier())); + } + definition.setEntityTypes( + gmsDefinition.getEntityTypes().stream() + .map(this::createEntityTypeEntity) + .collect(Collectors.toList())); + extendedProperty.setDefinition(definition); + } + + private List mapAllowedValues(@Nonnull PropertyValueArray gmsValues) { + List allowedValues = new ArrayList<>(); + gmsValues.forEach( + value -> { + final AllowedValue allowedValue = new AllowedValue(); + if (value.getValue().isString()) { + allowedValue.setValue(new StringValue(value.getValue().getString())); + } else if (value.getValue().isDouble()) { + allowedValue.setValue(new NumberValue(value.getValue().getDouble())); + } + if (value.getDescription() != null) { + allowedValue.setDescription(value.getDescription()); + } + allowedValues.add(allowedValue); + }); + return allowedValues; + } + + private DataTypeEntity createDataTypeEntity(final Urn dataTypeUrn) { + final DataTypeEntity dataType = new DataTypeEntity(); + dataType.setUrn(dataTypeUrn.toString()); + dataType.setType(EntityType.DATA_TYPE); + return dataType; + } + + private TypeQualifier mapTypeQualifier(final StringArrayMap gmsTypeQualifier) { + final TypeQualifier typeQualifier = new TypeQualifier(); + List allowedTypes = gmsTypeQualifier.get(ALLOWED_TYPES); + if (allowedTypes != null) { + typeQualifier.setAllowedTypes( + allowedTypes.stream().map(this::createEntityTypeEntity).collect(Collectors.toList())); + } + return typeQualifier; + } + + private EntityTypeEntity createEntityTypeEntity(final Urn entityTypeUrn) { + return createEntityTypeEntity(entityTypeUrn.toString()); + } + + private EntityTypeEntity createEntityTypeEntity(final String entityTypeUrnStr) { + final EntityTypeEntity entityType = new EntityTypeEntity(); + entityType.setUrn(entityTypeUrnStr); + entityType.setType(EntityType.ENTITY_TYPE); + return entityType; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java new file mode 100644 index 00000000000000..b028563b5253cc --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/structuredproperty/StructuredPropertyType.java @@ -0,0 +1,79 @@ +package com.linkedin.datahub.graphql.types.structuredproperty; + +import static com.linkedin.metadata.Constants.*; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import graphql.execution.DataFetcherResult; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class StructuredPropertyType + implements com.linkedin.datahub.graphql.types.EntityType { + + public static final Set ASPECTS_TO_FETCH = + ImmutableSet.of(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME); + private final EntityClient _entityClient; + + @Override + public EntityType type() { + return EntityType.STRUCTURED_PROPERTY; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return StructuredPropertyEntity.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List extendedPropertyUrns = + urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + STRUCTURED_PROPERTY_ENTITY_NAME, + new HashSet<>(extendedPropertyUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + + final List gmsResults = new ArrayList<>(); + for (Urn urn : extendedPropertyUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(StructuredPropertyMapper.map(gmsResult)) + .build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load Queries", e); + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/view/DataHubViewMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/view/DataHubViewMapper.java index 8ea06f46d51339..a4bbd685fd4a29 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/view/DataHubViewMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/view/DataHubViewMapper.java @@ -11,8 +11,8 @@ import com.linkedin.datahub.graphql.generated.FacetFilter; import com.linkedin.datahub.graphql.generated.FilterOperator; import com.linkedin.datahub.graphql.generated.LogicalOperator; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspectMap; diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index 52451e195ee841..7964f7e4fab238 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -212,6 +212,16 @@ type VisualConfig { """ faviconUrl: String + """ + Custom app title to show in the browser tab + """ + appTitle: String + + """ + Boolean flag disabling viewing the Business Glossary page for users without the 'Manage Glossaries' privilege + """ + hideGlossary: Boolean + """ Configuration for the queries tab """ diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index ebb13d32643ed9..2ad49825793803 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -700,6 +700,31 @@ type Mutation { deleteOwnershipType( "Urn of the Custom Ownership Type to remove." urn: String!, deleteReferences: Boolean): Boolean + + """ + Submit a response to a prompt from a form collecting metadata on different entities. + Provide the urn of the entity you're submitting a form response as well as the required input. + """ + submitFormPrompt(urn: String!, input: SubmitFormPromptInput!): Boolean + + """ + Assign a form to different entities. This will be a patch by adding this form to the list + of forms on an entity. + """ + batchAssignForm(input: BatchAssignFormInput!): Boolean + + """ + Creates a filter for a form to apply it to certain entities. Entities that match this filter will have + a given form applied to them. + This feature is ONLY supported in Acryl DataHub. + """ + createDynamicFormAssignment(input: CreateDynamicFormAssignmentInput!): Boolean + + """ + Verifies a form on an entity when all of the required questions on the form are complete and the form + is of type VERIFICATION. + """ + verifyForm(input: VerifyFormInput!): Boolean } """ @@ -910,6 +935,31 @@ enum EntityType { A Role from an organisation """ ROLE + + """" + An structured property on entities + """ + STRUCTURED_PROPERTY + + """" + A form entity on entities + """ + FORM + + """" + A data type registered to DataHub + """ + DATA_TYPE + + """" + A type of entity registered to DataHub + """ + ENTITY_TYPE + + """ + Another entity type - refer to a provided entity type urn. + """ + OTHER } """ @@ -1284,6 +1334,11 @@ type Dataset implements EntityWithRelationships & Entity & BrowsableEntity { """ domain: DomainAssociation + """ + The forms associated with the Dataset + """ + forms: Forms + """ The Roles and the properties to access the dataset """ @@ -1426,6 +1481,11 @@ type Dataset implements EntityWithRelationships & Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Structured properties about this Dataset + """ + structuredProperties: StructuredProperties } type RoleAssociation { @@ -1529,6 +1589,7 @@ type SiblingProperties { If this entity is the primary sibling among the sibling set """ isPrimary: Boolean + """ The sibling entities """ @@ -1910,6 +1971,12 @@ type GlossaryTerm implements Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -2047,6 +2114,12 @@ type GlossaryNode implements Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -2076,6 +2149,11 @@ type GlossaryNodeProperties { Description of the glossary term """ description: String + + """ + Custom properties of the Glossary Node + """ + customProperties: [CustomPropertiesEntry!] } """ @@ -2447,6 +2525,12 @@ type Container implements Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -2822,12 +2906,27 @@ type SchemaFieldEntity implements Entity { """ parent: Entity! + """ + Structured properties on this schema field + """ + structuredProperties: StructuredProperties + """ Granular API for querying edges extending from this entity """ relationships(input: RelationshipsInput!): EntityRelationshipsResult } +""" +Object containing structured properties for an entity +""" +type StructuredProperties { + """ + Structured properties on this entity + """ + properties: [StructuredPropertiesEntry!] +} + """ Information about an individual field in a Dataset schema """ @@ -2902,6 +3001,11 @@ type SchemaField { For schema fields that have other properties that are not modeled explicitly, represented as a JSON string. """ jsonProps: String + + """ + Schema field entity that exist in the database for this schema field + """ + schemaFieldEntity: SchemaFieldEntity } """ @@ -3444,6 +3548,12 @@ type CorpUser implements Entity { Settings that a user can customize through the datahub ui """ settings: CorpUserSettings + + """ + Experimental API. + For fetching extra aspects that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -3804,6 +3914,12 @@ type CorpGroup implements Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -4005,6 +4121,12 @@ type Tag implements Entity { Deprecated, use properties.description field instead """ description: String @deprecated + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -4099,6 +4221,11 @@ type GlossaryTermAssociation { """ term: GlossaryTerm! + """ + The actor who is responsible for the term being added" + """ + actor: CorpUser + """ Reference back to the associated urn for tracking purposes e.g. when sibling nodes are merged together """ @@ -4635,6 +4762,12 @@ type Notebook implements Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -4955,6 +5088,12 @@ type Dashboard implements EntityWithRelationships & Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -5265,6 +5404,12 @@ type Chart implements EntityWithRelationships & Entity & BrowsableEntity { Sub Types that this entity implements """ subTypes: SubTypes + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -5622,6 +5767,12 @@ type DataFlow implements EntityWithRelationships & Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -5822,6 +5973,12 @@ type DataJob implements EntityWithRelationships & Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -6558,10 +6715,10 @@ type PartitionSpec { """ The partition identifier """ - partition: String! + partition: String """ - The optional time window partition information + The optional time window partition information - required if type is TIMESTAMP_FIELD. """ timePartition: TimeWindow } @@ -6587,7 +6744,6 @@ type TimeWindow { durationMillis: Long! } - """ An assertion represents a programmatic validation, check, or test performed periodically against another Entity. """ @@ -7048,10 +7204,29 @@ type AssertionStdParameter { The type of an AssertionStdParameter """ enum AssertionStdParameterType { + """ + A string value + """ STRING + + """ + A numeric value + """ NUMBER + + """ + A list of values. When used, the value should be formatted as a serialized JSON array. + """ LIST + + """ + A set of values. When used, the value should be formatted as a serialized JSON array. + """ SET + + """ + A value of unknown type + """ UNKNOWN } @@ -8738,6 +8913,12 @@ type MLModel implements EntityWithRelationships & Entity & BrowsableEntity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -8849,6 +9030,12 @@ type MLModelGroup implements EntityWithRelationships & Entity & BrowsableEntity Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } type MLModelGroupProperties { @@ -8973,6 +9160,12 @@ type MLFeature implements EntityWithRelationships & Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } type MLHyperParam { @@ -9142,6 +9335,12 @@ type MLPrimaryKey implements EntityWithRelationships & Entity { Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } type MLPrimaryKeyProperties { @@ -9269,6 +9468,12 @@ type MLFeatureTable implements EntityWithRelationships & Entity & BrowsableEntit Whether or not this entity exists on DataHub """ exists: Boolean + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } type MLFeatureTableEditableProperties { @@ -9577,6 +9782,22 @@ enum CostType { ORG_COST_TYPE } + +""" +Audit stamp containing a resolved actor +""" +type ResolvedAuditStamp { + """ + When the audited action took place + """ + time: Long! + + """ + Who performed the audited action + """ + actor: CorpUser +} + type SubTypes { """ The sub-types that this entity implements. e.g. Datasets that are views will implement the "view" subtype @@ -9644,6 +9865,12 @@ type Domain implements Entity { Edges extending from this entity """ relationships(input: RelationshipsInput!): EntityRelationshipsResult + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -10139,6 +10366,12 @@ type DataHubRole implements Entity { The description of the Role """ description: String! + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -11015,6 +11248,12 @@ type DataProduct implements Entity { Tags used for searching Data Product """ tags: GlobalTags + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] } """ @@ -11270,3 +11509,94 @@ input UpdateOwnershipTypeInput { """ description: String } + +""" +Describes a generic filter on a dataset +""" +type DatasetFilter { + """ + Type of partition + """ + type: DatasetFilterType! + + """ + The raw query if using a SQL FilterType + """ + sql: String +} + +""" +Type of partition +""" +enum DatasetFilterType { + """ + Use a SQL string to apply the filter + """ + SQL +} + + +""" +Input required to create or update a DatasetFilter +""" +input DatasetFilterInput { + """ + Type of partition + """ + type: DatasetFilterType! + + """ + The raw query if using a SQL FilterType + """ + sql: String +} + +""" +An entity type registered in DataHub +""" +type EntityTypeEntity implements Entity { + """ + A primary key associated with the Query + """ + urn: String! + + """ + A standard Entity Type + """ + type: EntityType! + + """ + Info about this type including its name + """ + info: EntityTypeInfo! + + """ + Granular API for querying edges extending from this entity + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult +} + +""" +Properties about an individual entity type +""" +type EntityTypeInfo { + """ + The standard entity type + """ + type: EntityType! + + """ + The fully qualified name of the entity type. This includes its namespace + """ + qualifiedName: String! + + """ + The display name of this type + """ + displayName: String + + """ + The description of this type + """ + description: String +} diff --git a/datahub-graphql-core/src/main/resources/forms.graphql b/datahub-graphql-core/src/main/resources/forms.graphql new file mode 100644 index 00000000000000..0ff55cfa9f1733 --- /dev/null +++ b/datahub-graphql-core/src/main/resources/forms.graphql @@ -0,0 +1,407 @@ +""" +Requirements forms that are assigned to an entity. +""" +type Forms { + """ + Forms that are still incomplete. + """ + incompleteForms: [FormAssociation!]! + + """ + Forms that have been completed. + """ + completedForms: [FormAssociation!]! + + """ + Verifications that have been applied to the entity via completed forms. + """ + verifications: [FormVerificationAssociation!]! +} + +type FormAssociation { + """ + The form related to the associated urn + """ + form: Form! + + """ + Reference back to the urn with the form on it for tracking purposes e.g. when sibling nodes are merged together + """ + associatedUrn: String! + + """ + The prompt that still need to be completed for this form + """ + incompletePrompts: [FormPromptAssociation!] + + """ + The prompt that are already completed for this form + """ + completedPrompts: [FormPromptAssociation!] +} + +""" +Verification object that has been applied to the entity via a completed form. +""" +type FormVerificationAssociation { + """ + The form related to the associated urn + """ + form: Form! + + """ + When this verification was applied to this entity + """ + lastModified: ResolvedAuditStamp +} + +""" +A form that helps with filling out metadata on an entity +""" +type FormPromptAssociation { + """ + The unique id of the form prompt + """ + id: String! + + """ + When and by whom this form prompt has last been modified + """ + lastModified: ResolvedAuditStamp! + + """ + Optional information about the field-level prompt associations. + """ + fieldAssociations: FormPromptFieldAssociations +} + +""" +Information about the field-level prompt associations. +""" +type FormPromptFieldAssociations { + """ + If this form prompt is for fields, this will contain a list of completed associations per field + """ + completedFieldPrompts: [FieldFormPromptAssociation!] + + """ + If this form prompt is for fields, this will contain a list of incomlete associations per field + """ + incompleteFieldPrompts: [FieldFormPromptAssociation!] +} + +""" +An association for field-level form prompts +""" +type FieldFormPromptAssociation { + """ + The schema field path + """ + fieldPath: String! + + """ + When and by whom this form field-level prompt has last been modified + """ + lastModified: ResolvedAuditStamp! +} + +""" +A form that helps with filling out metadata on an entity +""" +type Form implements Entity { + """ + A primary key associated with the Form + """ + urn: String! + + """ + A standard Entity Type + """ + type: EntityType! + + """ + Information about this form + """ + info: FormInfo! + + """ + Ownership metadata of the form + """ + ownership: Ownership + + """ + Granular API for querying edges extending from this entity + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult +} + +""" +The type of a form. This is optional on a form entity +""" +enum FormType { + """ + This form is used for "verifying" entities as a state for governance and compliance + """ + VERIFICATION + + """ + This form is used to help with filling out metadata on entities + """ + COMPLETION +} + +""" +Properties about an individual Form +""" +type FormInfo { + """ + The name of this form + """ + name: String! + + """ + The description of this form + """ + description: String + + """ + The type of this form + """ + type: FormType! + + """ + The prompt for this form + """ + prompts: [FormPrompt!]! + + """ + The actors that are assigned to complete the forms for the associated entities. + """ + actors: FormActorAssignment! +} + +""" +A prompt shown to the user to collect metadata about an entity +""" +type FormPrompt { + """ + The ID of this prompt. This will be globally unique. + """ + id: String! + + """ + The title of this prompt + """ + title: String! + + """ + The urn of the parent form that this prompt is part of + """ + formUrn: String! + + """ + The description of this prompt + """ + description: String + + """ + The description of this prompt + """ + type: FormPromptType! + + """ + Whether the prompt is required for the form to be considered completed. + """ + required: Boolean! + + """ + The params for this prompt if type is STRUCTURED_PROPERTY + """ + structuredPropertyParams: StructuredPropertyParams +} + +""" +Enum of all form prompt types +""" +enum FormPromptType { + """ + A structured property form prompt type. + """ + STRUCTURED_PROPERTY + """ + A schema field-level structured property form prompt type. + """ + FIELDS_STRUCTURED_PROPERTY +} + +""" +A prompt shown to the user to collect metadata about an entity +""" +type StructuredPropertyParams { + """ + The structured property required for the prompt on this entity + """ + structuredProperty: StructuredPropertyEntity! +} + +""" +Input for responding to a singular prompt in a form +""" +input SubmitFormPromptInput { + """ + The unique ID of the prompt this input is responding to + """ + promptId: String! + + """ + The urn of the form that this prompt is a part of + """ + formUrn: String! + + """ + The type of prompt that this input is responding to + """ + type: FormPromptType! + + """ + The fieldPath on a schema field that this prompt submission is association with. + This should be provided when the prompt is type FIELDS_STRUCTURED_PROPERTY + """ + fieldPath: String + + """ + The structured property required for the prompt on this entity + """ + structuredPropertyParams: StructuredPropertyInputParams +} + +""" +Input for responding to a singular prompt in a form for a batch of entities +""" +input BatchSubmitFormPromptInput { + """ + The urns of the entities this prompt submission is for + """ + assetUrns: [String!]! + + """ + Input for responding to a specific prompt on a form + """ + input: SubmitFormPromptInput +} + +""" +Input for collecting structured property values to apply to entities +""" +input PropertyValueInput { + """ + The string value for this structured property + """ + stringValue: String + + """ + The number value for this structured property + """ + numberValue: Float +} + +""" +A prompt shown to the user to collect metadata about an entity +""" +input StructuredPropertyInputParams { + """ + The urn of the structured property being applied to an entity + """ + structuredPropertyUrn: String! + + """ + The list of values you want to apply on this structured property to an entity + """ + values: [PropertyValueInput!]! +} + +""" +Input for batch assigning a form to different entities +""" +input BatchAssignFormInput { + """ + The urn of the form being assigned to entities + """ + formUrn: String! + + """ + The entities that this form is being assigned to + """ + entityUrns: [String!]! +} + +""" +Input for batch assigning a form to different entities +""" +input CreateDynamicFormAssignmentInput { + """ + The urn of the form being assigned to entities that match some criteria + """ + formUrn: String! + + """ + A list of disjunctive criterion for the filter. (or operation to combine filters). + Entities that match this filter will have this form applied to them. + Currently, we only support a set of fields to filter on and they are: + (1) platform (2) subType (3) container (4) _entityType (5) domain + """ + orFilters: [AndFilterInput!]! +} + +type FormActorAssignment { + """ + Whether the form should be completed by owners of the assets which the form is applied to. + """ + owners: Boolean! + + """ + Urns of the users that the form is assigned to. If null, then no users are specifically targeted. + """ + users: [CorpUser!] + + """ + Groups that the form is assigned to. If null, then no groups are specifically targeted. + """ + groups: [CorpGroup!] + + """ + Whether or not the current actor is universally assigned to this form, either by user or by group. + Note that this does not take into account entity ownership based assignment. + """ + isAssignedToMe: Boolean! +} + +""" +Input for verifying forms on entities +""" +input VerifyFormInput { + """ + The urn of the form being verified on an entity + """ + formUrn: String! + + """ + The urn of the entity that is having a form verified on it + """ + entityUrn: String! +} + +""" +Input for verifying a batch of entities for a give form +""" +input BatchVerifyFormInput { + """ + The urns of the entities getting verified for this form + """ + assetUrns: [String!]! + + """ + The urn of the form being verified on the given entities + """ + formUrn: String! +} diff --git a/datahub-graphql-core/src/main/resources/properties.graphql b/datahub-graphql-core/src/main/resources/properties.graphql new file mode 100644 index 00000000000000..2bed0f1155ff1c --- /dev/null +++ b/datahub-graphql-core/src/main/resources/properties.graphql @@ -0,0 +1,243 @@ +""" +A structured property that can be shared between different entities +""" +type StructuredPropertyEntity implements Entity { + """ + A primary key associated with the structured property + """ + urn: String! + + """ + A standard Entity Type + """ + type: EntityType! + + """ + Definition of this structured property including its name + """ + definition: StructuredPropertyDefinition! + + """ + Granular API for querying edges extending from this entity + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult +} + +""" +Properties about an individual Query +""" +type StructuredPropertyDefinition { + """ + The fully qualified name of the property. This includes its namespace + """ + qualifiedName: String! + + """ + The display name of this structured property + """ + displayName: String + + """ + The description of this property + """ + description: String + + """ + The cardinality of a Structured Property determining whether one or multiple values + can be applied to the entity from this property. + """ + cardinality: PropertyCardinality + + """ + A list of allowed values that the property is allowed to take. + """ + allowedValues: [AllowedValue!] + + """ + The type of this structured property + """ + valueType: DataTypeEntity! + + """ + Allows for type specialization of the valueType to be more specific about which + entity types are allowed, for example. + """ + typeQualifier: TypeQualifier + + """ + Entity types that this structured property can be applied to + """ + entityTypes: [EntityTypeEntity!]! +} + +""" +An entry for an allowed value for a structured property +""" +type AllowedValue { + """ + The allowed value + """ + value: PropertyValue! + + """ + The description of this allowed value + """ + description: String +} + +""" +The cardinality of a Structured Property determining whether one or multiple values +can be applied to the entity from this property. +""" +enum PropertyCardinality { + """ + Only one value of this property can applied to an entity + """ + SINGLE + + """ + Multiple values of this property can applied to an entity + """ + MULTIPLE +} + +""" +Allows for type specialization of the valueType to be more specific about which +entity types are allowed, for example. +""" +type TypeQualifier { + """ + The list of allowed entity types + """ + allowedTypes: [EntityTypeEntity!] +} + +""" +String property value +""" +type StringValue { + """ + The value of a string type property + """ + stringValue: String! +} + +""" +Numeric property value +""" +type NumberValue { + """ + The value of a number type property + """ + numberValue: Float! +} + +""" +The value of a property +""" +union PropertyValue = StringValue | NumberValue + +""" +An entry in an structured properties list represented as a tuple +""" +type StructuredPropertiesEntry { + """ + The key of the map entry + """ + structuredProperty: StructuredPropertyEntity! + + """ + The values of the structured property for this entity + """ + values: [PropertyValue]! + + """ + The optional entities associated with the values if the values are entity urns + """ + valueEntities: [Entity] +} + +""" +A data type registered in DataHub +""" +type DataTypeEntity implements Entity { + """ + A primary key associated with the Query + """ + urn: String! + + """ + A standard Entity Type + """ + type: EntityType! + + """ + Info about this type including its name + """ + info: DataTypeInfo! + + """ + Granular API for querying edges extending from this entity + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult +} + +""" +A well-supported, standard DataHub Data Type. +""" +enum StdDataType { + """ + String data type + """ + STRING + + """ + Number data type + """ + NUMBER + + """ + Urn data type + """ + URN + + """ + Rich text data type. Right now this is markdown only. + """ + RICH_TEXT + + """ + Date data type in format YYYY-MM-DD + """ + DATE + + """ + Any other data type - refer to a provided data type urn. + """ + OTHER +} + +""" +Properties about an individual data type +""" +type DataTypeInfo { + """ + The standard data type + """ + type: StdDataType! + + """ + The fully qualified name of the type. This includes its namespace + """ + qualifiedName: String! + + """ + The display name of this type + """ + displayName: String + + """ + The description of this type + """ + description: String +} diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql index 8f2377edb546e0..8896dd02b5ad32 100644 --- a/datahub-graphql-core/src/main/resources/search.graphql +++ b/datahub-graphql-core/src/main/resources/search.graphql @@ -1139,7 +1139,7 @@ type QuickFilter { } """ -Freshness stats for a query result. +Freshness stats for a query result. Captures whether the query was served out of a cache, what the staleness was, etc. """ type FreshnessStats { @@ -1154,7 +1154,7 @@ type FreshnessStats { In case an index was consulted, this reflects the freshness of the index """ systemFreshness: [SystemFreshness] - + } type SystemFreshness { @@ -1303,4 +1303,4 @@ input SortCriterion { The order in which we will be sorting """ sortOrder: SortOrder! -} +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/resources/tests.graphql b/datahub-graphql-core/src/main/resources/tests.graphql index 9dce48ac60d834..579f4919bdc783 100644 --- a/datahub-graphql-core/src/main/resources/tests.graphql +++ b/datahub-graphql-core/src/main/resources/tests.graphql @@ -44,6 +44,7 @@ Definition of the test type TestDefinition { """ JSON-based def for the test + Deprecated! JSON representation is no longer supported. """ json: String } @@ -209,6 +210,7 @@ input UpdateTestInput { input TestDefinitionInput { """ The string representation of the Test + Deprecated! JSON representation is no longer supported. """ json: String } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/TestUtils.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/TestUtils.java index de507eda8cdef7..b75530773c352f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/TestUtils.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/TestUtils.java @@ -122,13 +122,7 @@ public static void verifyIngestProposal( int numberOfInvocations, List proposals) { AspectsBatchImpl batch = - AspectsBatchImpl.builder() - .mcps( - proposals, - mock(AuditStamp.class), - mockService.getEntityRegistry(), - mockService.getSystemEntityClient()) - .build(); + AspectsBatchImpl.builder().mcps(proposals, mock(AuditStamp.class), mockService).build(); Mockito.verify(mockService, Mockito.times(numberOfInvocations)) .ingestProposal(Mockito.eq(batch), Mockito.eq(false)); } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java index 433772d7e2cfe1..c565e771a04755 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java @@ -26,6 +26,7 @@ import com.linkedin.metadata.query.filter.Criterion; import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.ViewService; import com.linkedin.view.DataHubViewDefinition; import com.linkedin.view.DataHubViewInfo; @@ -44,6 +45,7 @@ public class BrowseV2ResolverTest { @Test public static void testBrowseV2Success() throws Exception { + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = Mockito.mock(ViewService.class); EntityClient mockClient = initMockEntityClient( @@ -70,7 +72,8 @@ public static void testBrowseV2Success() throws Exception { .setFrom(0) .setPageSize(10)); - final BrowseV2Resolver resolver = new BrowseV2Resolver(mockClient, mockService); + final BrowseV2Resolver resolver = + new BrowseV2Resolver(mockClient, mockService, mockFormService); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); @@ -87,6 +90,7 @@ public static void testBrowseV2Success() throws Exception { @Test public static void testBrowseV2SuccessWithQueryAndFilter() throws Exception { + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = Mockito.mock(ViewService.class); List orFilters = new ArrayList<>(); @@ -123,7 +127,8 @@ public static void testBrowseV2SuccessWithQueryAndFilter() throws Exception { .setFrom(0) .setPageSize(10)); - final BrowseV2Resolver resolver = new BrowseV2Resolver(mockClient, mockService); + final BrowseV2Resolver resolver = + new BrowseV2Resolver(mockClient, mockService, mockFormService); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); @@ -143,6 +148,7 @@ public static void testBrowseV2SuccessWithQueryAndFilter() throws Exception { @Test public static void testBrowseV2SuccessWithView() throws Exception { DataHubViewInfo viewInfo = createViewInfo(new StringArray()); + FormService mockFormService = Mockito.mock(FormService.class); ViewService viewService = initMockViewService(TEST_VIEW_URN, viewInfo); EntityClient mockClient = @@ -170,7 +176,8 @@ public static void testBrowseV2SuccessWithView() throws Exception { .setFrom(0) .setPageSize(10)); - final BrowseV2Resolver resolver = new BrowseV2Resolver(mockClient, viewService); + final BrowseV2Resolver resolver = + new BrowseV2Resolver(mockClient, viewService, mockFormService); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolverTest.java index 9596abf55d04fc..c6e6cdc7f018e2 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/DomainEntitiesResolverTest.java @@ -10,7 +10,7 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.Domain; import com.linkedin.datahub.graphql.generated.DomainEntitiesInput; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolverTest.java new file mode 100644 index 00000000000000..0fe57d0a28fffd --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/IsFormAssignedToMeResolverTest.java @@ -0,0 +1,167 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.*; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.group.GroupService; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.FormActorAssignment; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class IsFormAssignedToMeResolverTest { + + private static final Urn TEST_USER_1 = UrnUtils.getUrn("urn:li:corpuser:test-1"); + private static final Urn TEST_USER_2 = UrnUtils.getUrn("urn:li:corpuser:test-2"); + private static final Urn TEST_GROUP_1 = UrnUtils.getUrn("urn:li:corpGroup:test-1"); + private static final Urn TEST_GROUP_2 = UrnUtils.getUrn("urn:li:corpGroup:test-2"); + + @Test + public void testGetSuccessUserMatch() throws Exception { + GroupService groupService = mockGroupService(TEST_USER_1, Collections.emptyList()); + + CorpGroup assignedGroup = new CorpGroup(); + assignedGroup.setUrn(TEST_GROUP_1.toString()); + + CorpUser assignedUser = new CorpUser(); + assignedUser.setUrn(TEST_USER_1.toString()); + + FormActorAssignment actors = new FormActorAssignment(); + actors.setGroups(new ArrayList<>(ImmutableList.of(assignedGroup))); + actors.setUsers(new ArrayList<>(ImmutableList.of(assignedUser))); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertTrue(resolver.get(mockEnv).get()); + Mockito.verifyNoMoreInteractions(groupService); // Should not perform group lookup. + } + + @Test + public void testGetSuccessGroupMatch() throws Exception { + GroupService groupService = + mockGroupService(TEST_USER_1, ImmutableList.of(TEST_GROUP_1)); // is in group + + CorpGroup assignedGroup = new CorpGroup(); + assignedGroup.setUrn(TEST_GROUP_1.toString()); + + CorpUser assignedUser = new CorpUser(); + assignedUser.setUrn(TEST_USER_2.toString()); // does not match + + FormActorAssignment actors = new FormActorAssignment(); + actors.setGroups(new ArrayList<>(ImmutableList.of(assignedGroup))); + actors.setUsers(new ArrayList<>(ImmutableList.of(assignedUser))); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertTrue(resolver.get(mockEnv).get()); + } + + @Test + public void testGetSuccessBothMatch() throws Exception { + GroupService groupService = + mockGroupService(TEST_USER_1, ImmutableList.of(TEST_GROUP_1)); // is in group + + CorpGroup assignedGroup = new CorpGroup(); + assignedGroup.setUrn(TEST_GROUP_1.toString()); + + CorpUser assignedUser = new CorpUser(); + assignedUser.setUrn(TEST_USER_1.toString()); // is matching user + + FormActorAssignment actors = new FormActorAssignment(); + actors.setGroups(new ArrayList<>(ImmutableList.of(assignedGroup))); + actors.setUsers(new ArrayList<>(ImmutableList.of(assignedUser))); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertTrue(resolver.get(mockEnv).get()); + Mockito.verifyNoMoreInteractions(groupService); // Should not perform group lookup. + } + + @Test + public void testGetSuccessNoMatchNullAssignment() throws Exception { + GroupService groupService = + mockGroupService(TEST_USER_1, ImmutableList.of(TEST_GROUP_1, TEST_GROUP_2)); + + FormActorAssignment actors = new FormActorAssignment(); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertFalse(resolver.get(mockEnv).get()); + } + + @Test + public void testGetSuccessNoMatchEmptyAssignment() throws Exception { + GroupService groupService = + mockGroupService(TEST_USER_1, ImmutableList.of(TEST_GROUP_1, TEST_GROUP_2)); + + FormActorAssignment actors = new FormActorAssignment(); + actors.setUsers(Collections.emptyList()); + actors.setGroups(Collections.emptyList()); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertFalse(resolver.get(mockEnv).get()); + } + + @Test + public void testGetSuccessNoMatchNoAssignmentMatch() throws Exception { + GroupService groupService = mockGroupService(TEST_USER_1, ImmutableList.of(TEST_GROUP_1)); + + CorpGroup assignedGroup = new CorpGroup(); + assignedGroup.setUrn(TEST_GROUP_2.toString()); // Does not match. + + CorpUser assignedUser = new CorpUser(); + assignedUser.setUrn(TEST_USER_2.toString()); // does not match + + FormActorAssignment actors = new FormActorAssignment(); + actors.setGroups(new ArrayList<>(ImmutableList.of(assignedGroup))); + actors.setUsers(new ArrayList<>(ImmutableList.of(assignedUser))); + + QueryContext mockContext = getMockAllowContext(TEST_USER_1.toString()); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + Mockito.when(mockEnv.getSource()).thenReturn(actors); + + IsFormAssignedToMeResolver resolver = new IsFormAssignedToMeResolver(groupService); + assertFalse(resolver.get(mockEnv).get()); + } + + private GroupService mockGroupService(final Urn userUrn, final List groupUrns) + throws Exception { + GroupService mockService = Mockito.mock(GroupService.class); + Mockito.when( + mockService.getGroupsForUser(Mockito.eq(userUrn), Mockito.any(Authentication.class))) + .thenReturn(groupUrns); + return mockService; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolverTest.java new file mode 100644 index 00000000000000..192f4ff9aa7c7b --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/VerifyFormResolverTest.java @@ -0,0 +1,122 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.group.GroupService; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.VerifyFormInput; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class VerifyFormResolverTest { + + private static final String TEST_DATASET_URN = + "urn:li:dataset:(urn:li:dataPlatform:hive,name,PROD)"; + private static final String TEST_FORM_URN = "urn:li:form:1"; + + private static final VerifyFormInput TEST_INPUT = + new VerifyFormInput(TEST_FORM_URN, TEST_DATASET_URN); + + @Test + public void testGetSuccess() throws Exception { + FormService mockFormService = initMockFormService(true, true); + GroupService mockGroupService = initMockGroupService(); + VerifyFormResolver resolver = new VerifyFormResolver(mockFormService, mockGroupService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + boolean success = resolver.get(mockEnv).get(); + + assertTrue(success); + + // Validate that we called verify on the service + Mockito.verify(mockFormService, Mockito.times(1)) + .verifyFormForEntity( + Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN)), + Mockito.eq(UrnUtils.getUrn(TEST_DATASET_URN)), + Mockito.any(Authentication.class)); + } + + @Test + public void testGetUnauthorized() throws Exception { + FormService mockFormService = initMockFormService(false, true); + GroupService mockGroupService = initMockGroupService(); + VerifyFormResolver resolver = new VerifyFormResolver(mockFormService, mockGroupService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + // Validate that we do not call verify on the service + Mockito.verify(mockFormService, Mockito.times(0)) + .verifyFormForEntity(Mockito.any(), Mockito.any(), Mockito.any(Authentication.class)); + } + + @Test + public void testThrowErrorOnVerification() throws Exception { + FormService mockFormService = initMockFormService(true, false); + GroupService mockGroupService = initMockGroupService(); + VerifyFormResolver resolver = new VerifyFormResolver(mockFormService, mockGroupService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + // Validate that we do call verifyFormForEntity but an error is thrown + Mockito.verify(mockFormService, Mockito.times(1)) + .verifyFormForEntity(Mockito.any(), Mockito.any(), Mockito.any(Authentication.class)); + } + + private FormService initMockFormService( + final boolean isFormAssignedToUser, final boolean shouldVerify) throws Exception { + FormService service = Mockito.mock(FormService.class); + Mockito.when( + service.isFormAssignedToUser( + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(Authentication.class))) + .thenReturn(isFormAssignedToUser); + + if (shouldVerify) { + Mockito.when( + service.verifyFormForEntity( + Mockito.any(), Mockito.any(), Mockito.any(Authentication.class))) + .thenReturn(true); + } else { + Mockito.when( + service.verifyFormForEntity( + Mockito.any(), Mockito.any(), Mockito.any(Authentication.class))) + .thenThrow(new RuntimeException()); + } + + return service; + } + + private GroupService initMockGroupService() throws Exception { + GroupService service = Mockito.mock(GroupService.class); + Mockito.when(service.getGroupsForUser(Mockito.any(), Mockito.any(Authentication.class))) + .thenReturn(new ArrayList<>()); + + return service; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolverTest.java index bec141bddf2600..6ae2fa7dcbf644 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/RollbackIngestionResolverTest.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.ingest.execution; import static com.linkedin.datahub.graphql.resolvers.ingest.IngestTestUtils.*; +import static org.mockito.ArgumentMatchers.any; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -46,7 +47,7 @@ public void testGetUnauthorized() throws Exception { assertThrows(RuntimeException.class, () -> resolver.get(mockEnv).join()); Mockito.verify(mockClient, Mockito.times(0)) - .rollbackIngestion(Mockito.eq(RUN_ID), Mockito.any(Authentication.class)); + .rollbackIngestion(Mockito.eq(RUN_ID), any(), any(Authentication.class)); } @Test @@ -58,7 +59,7 @@ public void testRollbackIngestionMethod() throws Exception { resolver.rollbackIngestion(RUN_ID, mockContext).get(); Mockito.verify(mockClient, Mockito.times(1)) - .rollbackIngestion(Mockito.eq(RUN_ID), Mockito.any(Authentication.class)); + .rollbackIngestion(Mockito.eq(RUN_ID), any(), any(Authentication.class)); } @Test @@ -66,7 +67,7 @@ public void testGetEntityClientException() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); Mockito.doThrow(RuntimeException.class) .when(mockClient) - .rollbackIngestion(Mockito.any(), Mockito.any(Authentication.class)); + .rollbackIngestion(any(), any(), any(Authentication.class)); RollbackIngestionResolver resolver = new RollbackIngestionResolver(mockClient); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java index 8fc5ab6ebb8287..05387123f9c96f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java @@ -22,7 +22,6 @@ import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; -import com.linkedin.entity.client.RestliEntityClient; import com.linkedin.metadata.Constants; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetchingEnvironment; @@ -74,7 +73,7 @@ public class MutableTypeBatchResolverTest { @Test public void testGetSuccess() throws Exception { - EntityClient mockClient = Mockito.mock(RestliEntityClient.class); + EntityClient mockClient = Mockito.mock(EntityClient.class); BatchMutableType batchMutableType = new DatasetType(mockClient); @@ -167,7 +166,7 @@ public void testGetSuccess() throws Exception { @Test public void testGetFailureUnauthorized() throws Exception { - EntityClient mockClient = Mockito.mock(RestliEntityClient.class); + EntityClient mockClient = Mockito.mock(EntityClient.class); BatchMutableType batchMutableType = new DatasetType(mockClient); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolverTest.java index c7d397c5a4a73a..4d56cc3d52af8b 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AggregateAcrossEntitiesResolverTest.java @@ -15,7 +15,7 @@ import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.FilterOperator; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; import com.linkedin.metadata.query.filter.Condition; @@ -27,6 +27,7 @@ import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.search.SearchResultMetadata; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.ViewService; import com.linkedin.r2.RemoteInvocationException; import com.linkedin.view.DataHubViewDefinition; @@ -52,6 +53,7 @@ public static void testApplyViewNullBaseFilter() throws Exception { DataHubViewInfo info = getViewInfo(viewFilter); ViewService mockService = initMockViewService(TEST_VIEW_URN, info); + FormService mockFormService = Mockito.mock(FormService.class); List facets = ImmutableList.of("platform", "domains"); @@ -71,7 +73,7 @@ public static void testApplyViewNullBaseFilter() throws Exception { .setMetadata(new SearchResultMetadata())); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput( @@ -102,6 +104,7 @@ public static void testApplyViewBaseFilter() throws Exception { Filter viewFilter = createFilter("field", "test"); DataHubViewInfo info = getViewInfo(viewFilter); + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = initMockViewService(TEST_VIEW_URN, info); Filter baseFilter = createFilter("baseField.keyword", "baseTest"); @@ -122,7 +125,7 @@ public static void testApplyViewBaseFilter() throws Exception { .setMetadata(new SearchResultMetadata())); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput( @@ -166,6 +169,7 @@ public static void testApplyViewNullBaseEntityTypes() throws Exception { DataHubViewInfo info = getViewInfo(viewFilter); List facets = ImmutableList.of("platform"); + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = initMockViewService(TEST_VIEW_URN, info); EntityClient mockClient = @@ -184,7 +188,7 @@ public static void testApplyViewNullBaseEntityTypes() throws Exception { .setMetadata(new SearchResultMetadata())); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput(null, "", facets, null, TEST_VIEW_URN.toString(), null); @@ -217,6 +221,7 @@ public static void testApplyViewEmptyBaseEntityTypes() throws Exception { DataHubViewInfo info = getViewInfo(viewFilter); List facets = ImmutableList.of(); + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = initMockViewService(TEST_VIEW_URN, info); EntityClient mockClient = @@ -235,7 +240,7 @@ public static void testApplyViewEmptyBaseEntityTypes() throws Exception { .setMetadata(new SearchResultMetadata())); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput( @@ -267,6 +272,7 @@ public static void testApplyViewEmptyBaseEntityTypes() throws Exception { public static void testApplyViewViewDoesNotExist() throws Exception { // When a view does not exist, the endpoint should WARN and not apply the view. + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = initMockViewService(TEST_VIEW_URN, null); List searchEntityTypes = @@ -290,7 +296,7 @@ public static void testApplyViewViewDoesNotExist() throws Exception { .setMetadata(new SearchResultMetadata())); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput( Collections.emptyList(), "", null, null, TEST_VIEW_URN.toString(), null); @@ -306,6 +312,7 @@ public static void testApplyViewViewDoesNotExist() throws Exception { @Test public static void testErrorFetchingResults() throws Exception { + FormService mockFormService = Mockito.mock(FormService.class); ViewService mockService = initMockViewService(TEST_VIEW_URN, null); EntityClient mockClient = Mockito.mock(EntityClient.class); @@ -322,7 +329,7 @@ public static void testErrorFetchingResults() throws Exception { .thenThrow(new RemoteInvocationException()); final AggregateAcrossEntitiesResolver resolver = - new AggregateAcrossEntitiesResolver(mockClient, mockService); + new AggregateAcrossEntitiesResolver(mockClient, mockService, mockFormService); final AggregateAcrossEntitiesInput testInput = new AggregateAcrossEntitiesInput( Collections.emptyList(), "", null, null, TEST_VIEW_URN.toString(), null); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolverTest.java index 29a2b3081aefe3..f5accdfb02043a 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/GetQuickFiltersResolverTest.java @@ -9,8 +9,8 @@ import com.linkedin.datahub.graphql.generated.GetQuickFiltersInput; import com.linkedin.datahub.graphql.generated.GetQuickFiltersResult; import com.linkedin.datahub.graphql.generated.QuickFilter; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.AggregationMetadata; diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java index d0bbfd126b9b96..0b8c1f1aeb83fa 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolverTest.java @@ -15,7 +15,7 @@ import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.FilterOperator; import com.linkedin.datahub.graphql.generated.SearchAcrossEntitiesInput; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; import com.linkedin.metadata.query.filter.Condition; diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java index 909ceeb8f3bab4..ff8bd542fbdff8 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java @@ -1,5 +1,7 @@ package com.linkedin.datahub.upgrade; +import com.linkedin.gms.factory.auth.AuthorizerChainFactory; +import com.linkedin.gms.factory.auth.DataHubAuthorizerFactory; import com.linkedin.gms.factory.telemetry.ScheduledAnalyticsFactory; import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -19,7 +21,11 @@ excludeFilters = { @ComponentScan.Filter( type = FilterType.ASSIGNABLE_TYPE, - classes = ScheduledAnalyticsFactory.class) + classes = { + ScheduledAnalyticsFactory.class, + AuthorizerChainFactory.class, + DataHubAuthorizerFactory.class + }) }) public class UpgradeCliApplication { public static void main(String[] args) { diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSDisableWriteModeStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSDisableWriteModeStep.java index dd6c3fd1e44aa6..4be39ac3c4bfc1 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSDisableWriteModeStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSDisableWriteModeStep.java @@ -4,14 +4,16 @@ import com.linkedin.datahub.upgrade.UpgradeStep; import com.linkedin.datahub.upgrade.UpgradeStepResult; import com.linkedin.datahub.upgrade.impl.DefaultUpgradeStepResult; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import java.util.function.Function; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor public class GMSDisableWriteModeStep implements UpgradeStep { - private final SystemRestliEntityClient _entityClient; + private final SystemEntityClient entityClient; @Override public String id() { @@ -27,9 +29,9 @@ public int retryCount() { public Function executable() { return (context) -> { try { - _entityClient.setWritable(false); + entityClient.setWritable(false); } catch (Exception e) { - e.printStackTrace(); + log.error("Failed to turn write mode off in GMS", e); context.report().addLine("Failed to turn write mode off in GMS"); return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.FAILED); } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSEnableWriteModeStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSEnableWriteModeStep.java index 8a0d374d6ee3e6..09713dc78ee279 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSEnableWriteModeStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/common/steps/GMSEnableWriteModeStep.java @@ -4,13 +4,15 @@ import com.linkedin.datahub.upgrade.UpgradeStep; import com.linkedin.datahub.upgrade.UpgradeStepResult; import com.linkedin.datahub.upgrade.impl.DefaultUpgradeStepResult; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import java.util.function.Function; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor public class GMSEnableWriteModeStep implements UpgradeStep { - private final SystemRestliEntityClient _entityClient; + private final SystemEntityClient entityClient; @Override public String id() { @@ -26,9 +28,9 @@ public int retryCount() { public Function executable() { return (context) -> { try { - _entityClient.setWritable(true); + entityClient.setWritable(true); } catch (Exception e) { - e.printStackTrace(); + log.error("Failed to turn write mode back on in GMS", e); context.report().addLine("Failed to turn write mode back on in GMS"); return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.FAILED); } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java index abd144bf453ed8..406963c58fd71a 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java @@ -11,7 +11,7 @@ public class BackfillBrowsePathsV2Config { @Bean public BackfillBrowsePathsV2 backfillBrowsePathsV2( - EntityService entityService, SearchService searchService) { + EntityService entityService, SearchService searchService) { return new BackfillBrowsePathsV2(entityService, searchService); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BuildIndicesConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BuildIndicesConfig.java index 1e9298bc60612d..caa45988733df0 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BuildIndicesConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BuildIndicesConfig.java @@ -3,7 +3,9 @@ import com.linkedin.datahub.upgrade.system.elasticsearch.BuildIndices; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.gms.factory.search.BaseElasticSearchComponentsFactory; +import com.linkedin.metadata.entity.AspectDao; import com.linkedin.metadata.graph.GraphService; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.EntitySearchService; import com.linkedin.metadata.systemmetadata.SystemMetadataService; import com.linkedin.metadata.timeseries.TimeseriesAspectService; @@ -20,7 +22,9 @@ public BuildIndices buildIndices( final GraphService graphService, final BaseElasticSearchComponentsFactory.BaseElasticSearchComponents baseElasticSearchComponents, - final ConfigurationProvider configurationProvider) { + final ConfigurationProvider configurationProvider, + final AspectDao aspectDao, + final EntityRegistry entityRegistry) { return new BuildIndices( systemMetadataService, @@ -28,6 +32,8 @@ public BuildIndices buildIndices( entitySearchService, graphService, baseElasticSearchComponents, - configurationProvider); + configurationProvider, + aspectDao, + entityRegistry); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java index d968e8521867e8..741aeece1cf62f 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/NoCodeUpgradeConfig.java @@ -1,7 +1,7 @@ package com.linkedin.datahub.upgrade.config; import com.linkedin.datahub.upgrade.nocode.NoCodeUpgrade; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.models.registry.EntityRegistry; import io.ebean.Database; @@ -21,14 +21,13 @@ public class NoCodeUpgradeConfig { @Autowired ApplicationContext applicationContext; @Bean(name = "noCodeUpgrade") - @DependsOn({"ebeanServer", "entityService", "systemRestliEntityClient", "entityRegistry"}) + @DependsOn({"ebeanServer", "entityService", "systemEntityClient", "entityRegistry"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public NoCodeUpgrade createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); - final EntityService entityService = applicationContext.getBean(EntityService.class); - final SystemRestliEntityClient entityClient = - applicationContext.getBean(SystemRestliEntityClient.class); + final EntityService entityService = applicationContext.getBean(EntityService.class); + final SystemEntityClient entityClient = applicationContext.getBean(SystemEntityClient.class); final EntityRegistry entityRegistry = applicationContext.getBean(EntityRegistry.class); return new NoCodeUpgrade(ebeanServer, entityService, entityRegistry, entityClient); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RemoveUnknownAspectsConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RemoveUnknownAspectsConfig.java index 0b46133209382b..5bf1241e21305b 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RemoveUnknownAspectsConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RemoveUnknownAspectsConfig.java @@ -8,7 +8,7 @@ @Configuration public class RemoveUnknownAspectsConfig { @Bean(name = "removeUnknownAspects") - public RemoveUnknownAspects removeUnknownAspects(EntityService entityService) { + public RemoveUnknownAspects removeUnknownAspects(EntityService entityService) { return new RemoveUnknownAspects(entityService); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java index 116d62878f5c6e..ec6e5a4a8f04d1 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreBackupConfig.java @@ -1,7 +1,7 @@ package com.linkedin.datahub.upgrade.config; import com.linkedin.datahub.upgrade.restorebackup.RestoreBackup; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.models.registry.EntityRegistry; @@ -25,7 +25,7 @@ public class RestoreBackupConfig { @DependsOn({ "ebeanServer", "entityService", - "systemRestliEntityClient", + "systemEntityClient", "graphService", "searchService", "entityRegistry" @@ -34,9 +34,8 @@ public class RestoreBackupConfig { @Nonnull public RestoreBackup createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); - final EntityService entityService = applicationContext.getBean(EntityService.class); - final SystemRestliEntityClient entityClient = - applicationContext.getBean(SystemRestliEntityClient.class); + final EntityService entityService = applicationContext.getBean(EntityService.class); + final SystemEntityClient entityClient = applicationContext.getBean(SystemEntityClient.class); final GraphService graphClient = applicationContext.getBean(GraphService.class); final EntitySearchService searchClient = applicationContext.getBean(EntitySearchService.class); final EntityRegistry entityRegistry = applicationContext.getBean(EntityRegistry.class); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java index 9d229f315d709d..008bdf5cfac388 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/RestoreIndicesConfig.java @@ -3,7 +3,6 @@ import com.linkedin.datahub.upgrade.restoreindices.RestoreIndices; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphService; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.EntitySearchService; import io.ebean.Database; import javax.annotation.Nonnull; @@ -21,19 +20,17 @@ public class RestoreIndicesConfig { @Autowired ApplicationContext applicationContext; @Bean(name = "restoreIndices") - @DependsOn({"ebeanServer", "entityService", "searchService", "graphService", "entityRegistry"}) + @DependsOn({"ebeanServer", "entityService", "searchService", "graphService"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull public RestoreIndices createInstance() { final Database ebeanServer = applicationContext.getBean(Database.class); - final EntityService entityService = applicationContext.getBean(EntityService.class); + final EntityService entityService = applicationContext.getBean(EntityService.class); final EntitySearchService entitySearchService = applicationContext.getBean(EntitySearchService.class); final GraphService graphService = applicationContext.getBean(GraphService.class); - final EntityRegistry entityRegistry = applicationContext.getBean(EntityRegistry.class); - return new RestoreIndices( - ebeanServer, entityService, entityRegistry, entitySearchService, graphService); + return new RestoreIndices(ebeanServer, entityService, entitySearchService, graphService); } @Bean(name = "restoreIndices") @@ -41,6 +38,6 @@ public RestoreIndices createInstance() { @Nonnull public RestoreIndices createNotImplInstance() { log.warn("restoreIndices is not supported for cassandra!"); - return new RestoreIndices(null, null, null, null, null); + return new RestoreIndices(null, null, null, null); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeContext.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeContext.java index 6cc94fbed5bf31..57e16eb72d0250 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeContext.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeContext.java @@ -8,49 +8,33 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import lombok.Getter; +import lombok.experimental.Accessors; +@Getter +@Accessors(fluent = true) public class DefaultUpgradeContext implements UpgradeContext { - private final Upgrade _upgrade; - private final UpgradeReport _report; - private final List _previousStepResults; - private final List _args; - private final Map> _parsedArgs; + private final Upgrade upgrade; + private final UpgradeReport report; + private final List previousStepResults; + private final List args; + private final Map> parsedArgs; DefaultUpgradeContext( Upgrade upgrade, UpgradeReport report, List previousStepResults, List args) { - _upgrade = upgrade; - _report = report; - _previousStepResults = previousStepResults; - _args = args; - _parsedArgs = UpgradeUtils.parseArgs(args); - } - - @Override - public Upgrade upgrade() { - return _upgrade; + this.upgrade = upgrade; + this.report = report; + this.previousStepResults = previousStepResults; + this.args = args; + this.parsedArgs = UpgradeUtils.parseArgs(args); } @Override public List stepResults() { - return _previousStepResults; - } - - @Override - public UpgradeReport report() { - return _report; - } - - @Override - public List args() { - return _args; - } - - @Override - public Map> parsedArgs() { - return _parsedArgs; + return previousStepResults; } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeManager.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeManager.java index 623c8a71e861d8..bddf53a2749054 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeManager.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/impl/DefaultUpgradeManager.java @@ -16,7 +16,9 @@ import java.util.List; import java.util.Map; import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class DefaultUpgradeManager implements UpgradeManager { private final Map _upgrades = new HashMap<>(); @@ -137,6 +139,7 @@ private UpgradeStepResult executeStepInternal(UpgradeContext context, UpgradeSte break; } } catch (Exception e) { + log.error("Caught exception during attempt {} of Step with id {}", i, step.id(), e); context .report() .addLine( diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java index 674efb2b8ba78c..1524a015e414e4 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/NoCodeUpgrade.java @@ -6,7 +6,7 @@ import com.linkedin.datahub.upgrade.UpgradeStep; import com.linkedin.datahub.upgrade.common.steps.GMSEnableWriteModeStep; import com.linkedin.datahub.upgrade.common.steps.GMSQualificationStep; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.models.registry.EntityRegistry; import io.ebean.Database; @@ -28,9 +28,9 @@ public class NoCodeUpgrade implements Upgrade { // Upgrade requires the Database. public NoCodeUpgrade( @Nullable final Database server, - final EntityService entityService, + final EntityService entityService, final EntityRegistry entityRegistry, - final SystemRestliEntityClient entityClient) { + final SystemEntityClient entityClient) { if (server != null) { _steps = buildUpgradeSteps(server, entityService, entityRegistry, entityClient); _cleanupSteps = buildCleanupSteps(); @@ -61,9 +61,9 @@ private List buildCleanupSteps() { private List buildUpgradeSteps( final Database server, - final EntityService entityService, + final EntityService entityService, final EntityRegistry entityRegistry, - final SystemRestliEntityClient entityClient) { + final SystemEntityClient entityClient) { final List steps = new ArrayList<>(); steps.add(new RemoveAspectV2TableStep(server)); steps.add(new GMSQualificationStep(ImmutableMap.of("noCode", "true"))); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveClientIdAspectStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveClientIdAspectStep.java index 7e55dcddc639f4..74d97767d1c394 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveClientIdAspectStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveClientIdAspectStep.java @@ -17,7 +17,7 @@ public class RemoveClientIdAspectStep implements UpgradeStep { private static final String INVALID_CLIENT_ID_ASPECT = "clientId"; - private final EntityService _entityService; + private final EntityService _entityService; @Override public String id() { diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveUnknownAspects.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveUnknownAspects.java index dc95b7605ef88f..3ea449051b3558 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveUnknownAspects.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/removeunknownaspects/RemoveUnknownAspects.java @@ -12,7 +12,7 @@ public class RemoveUnknownAspects implements Upgrade { private final List _steps; - public RemoveUnknownAspects(final EntityService entityService) { + public RemoveUnknownAspects(final EntityService entityService) { _steps = buildSteps(entityService); } @@ -26,7 +26,7 @@ public List steps() { return _steps; } - private List buildSteps(final EntityService entityService) { + private List buildSteps(final EntityService entityService) { final List steps = new ArrayList<>(); steps.add(new RemoveClientIdAspectStep(entityService)); return steps; diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java index 4ac295b4fdfb75..bcaeaa34e8936d 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreBackup.java @@ -8,7 +8,7 @@ import com.linkedin.datahub.upgrade.common.steps.ClearSearchServiceStep; import com.linkedin.datahub.upgrade.common.steps.GMSDisableWriteModeStep; import com.linkedin.datahub.upgrade.common.steps.GMSEnableWriteModeStep; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.models.registry.EntityRegistry; @@ -24,9 +24,9 @@ public class RestoreBackup implements Upgrade { public RestoreBackup( @Nullable final Database server, - final EntityService entityService, + final EntityService entityService, final EntityRegistry entityRegistry, - final SystemRestliEntityClient entityClient, + final SystemEntityClient entityClient, final GraphService graphClient, final EntitySearchService searchClient) { if (server != null) { @@ -50,9 +50,9 @@ public List steps() { private List buildSteps( final Database server, - final EntityService entityService, + final EntityService entityService, final EntityRegistry entityRegistry, - final SystemRestliEntityClient entityClient, + final SystemEntityClient entityClient, final GraphService graphClient, final EntitySearchService searchClient) { final List steps = new ArrayList<>(); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreStorageStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreStorageStep.java index 5c4e8cdc47e345..c756407832a36e 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreStorageStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/RestoreStorageStep.java @@ -47,7 +47,7 @@ public class RestoreStorageStep implements UpgradeStep { private final ExecutorService _gmsThreadPool; public RestoreStorageStep( - final EntityService entityService, final EntityRegistry entityRegistry) { + final EntityService entityService, final EntityRegistry entityRegistry) { _entityService = entityService; _entityRegistry = entityRegistry; _backupReaders = ImmutableBiMap.of(LocalParquetReader.READER_NAME, LocalParquetReader.class); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/backupreader/BackupReader.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/backupreader/BackupReader.java index 212f0da9f592d0..c6839c0e63f055 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/backupreader/BackupReader.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restorebackup/backupreader/BackupReader.java @@ -9,6 +9,7 @@ * Strings */ public interface BackupReader { + String getName(); @Nonnull diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java index f46bb9b05624db..9bc42e23a99746 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/RestoreIndices.java @@ -8,7 +8,6 @@ import com.linkedin.datahub.upgrade.common.steps.ClearSearchServiceStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphService; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.EntitySearchService; import io.ebean.Database; import java.util.ArrayList; @@ -32,12 +31,11 @@ public class RestoreIndices implements Upgrade { public RestoreIndices( @Nullable final Database server, - final EntityService entityService, - final EntityRegistry entityRegistry, + final EntityService entityService, final EntitySearchService entitySearchService, final GraphService graphService) { if (server != null) { - _steps = buildSteps(server, entityService, entityRegistry, entitySearchService, graphService); + _steps = buildSteps(server, entityService, entitySearchService, graphService); } else { _steps = List.of(); } @@ -55,14 +53,13 @@ public List steps() { private List buildSteps( final Database server, - final EntityService entityService, - final EntityRegistry entityRegistry, + final EntityService entityService, final EntitySearchService entitySearchService, final GraphService graphService) { final List steps = new ArrayList<>(); steps.add(new ClearSearchServiceStep(entitySearchService, false)); steps.add(new ClearGraphServiceStep(graphService, false)); - steps.add(new SendMAEStep(server, entityService, entityRegistry)); + steps.add(new SendMAEStep(server, entityService)); return steps; } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java index bedf200a1c0553..aca27892d2e3a1 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/restoreindices/SendMAEStep.java @@ -10,7 +10,6 @@ import com.linkedin.metadata.entity.ebean.EbeanAspectV2; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesResult; -import com.linkedin.metadata.models.registry.EntityRegistry; import io.ebean.Database; import io.ebean.ExpressionList; import java.util.ArrayList; @@ -23,7 +22,9 @@ import java.util.concurrent.Future; import java.util.concurrent.ThreadPoolExecutor; import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class SendMAEStep implements UpgradeStep { private static final int DEFAULT_BATCH_SIZE = 1000; @@ -51,10 +52,7 @@ public RestoreIndicesResult call() { } } - public SendMAEStep( - final Database server, - final EntityService entityService, - final EntityRegistry entityRegistry) { + public SendMAEStep(final Database server, final EntityService entityService) { _server = server; _entityService = entityService; } @@ -77,7 +75,7 @@ private List iterateFutures(List iterateFutures(List indexedServices = Stream.of(graphService, entitySearchService, systemMetadataService, timeseriesAspectService) @@ -36,7 +40,13 @@ public BuildIndices( .map(service -> (ElasticSearchIndexed) service) .collect(Collectors.toList()); - _steps = buildSteps(indexedServices, baseElasticSearchComponents, configurationProvider); + _steps = + buildSteps( + indexedServices, + baseElasticSearchComponents, + configurationProvider, + aspectDao, + entityRegistry); } @Override @@ -53,13 +63,19 @@ private List buildSteps( final List indexedServices, final BaseElasticSearchComponentsFactory.BaseElasticSearchComponents baseElasticSearchComponents, - final ConfigurationProvider configurationProvider) { + final ConfigurationProvider configurationProvider, + final AspectDao aspectDao, + final EntityRegistry entityRegistry) { final List steps = new ArrayList<>(); // Disable ES write mode/change refresh rate and clone indices steps.add( new BuildIndicesPreStep( - baseElasticSearchComponents, indexedServices, configurationProvider)); + baseElasticSearchComponents, + indexedServices, + configurationProvider, + aspectDao, + entityRegistry)); // Configure graphService, entitySearchService, systemMetadataService, timeseriesAspectService steps.add(new BuildIndicesStep(indexedServices)); // Reset configuration (and delete clones? Or just do this regularly? Or delete clone in diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/steps/BuildIndicesPreStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/steps/BuildIndicesPreStep.java index c25888be07f899..894075417a3498 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/steps/BuildIndicesPreStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/steps/BuildIndicesPreStep.java @@ -2,6 +2,8 @@ import static com.linkedin.datahub.upgrade.system.elasticsearch.util.IndexUtils.INDEX_BLOCKS_WRITE_SETTING; import static com.linkedin.datahub.upgrade.system.elasticsearch.util.IndexUtils.getAllReindexConfigs; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_ENTITY_NAME; import com.google.common.collect.ImmutableMap; import com.linkedin.datahub.upgrade.UpgradeContext; @@ -11,8 +13,12 @@ import com.linkedin.datahub.upgrade.system.elasticsearch.util.IndexUtils; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.gms.factory.search.BaseElasticSearchComponentsFactory; +import com.linkedin.metadata.entity.AspectDao; +import com.linkedin.metadata.entity.EntityUtils; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; import com.linkedin.metadata.shared.ElasticSearchIndexed; +import com.linkedin.structured.StructuredPropertyDefinition; import java.io.IOException; import java.util.List; import java.util.Map; @@ -31,6 +37,8 @@ public class BuildIndicesPreStep implements UpgradeStep { private final BaseElasticSearchComponentsFactory.BaseElasticSearchComponents _esComponents; private final List _services; private final ConfigurationProvider _configurationProvider; + private final AspectDao _aspectDao; + private final EntityRegistry _entityRegistry; @Override public String id() { @@ -46,9 +54,28 @@ public int retryCount() { public Function executable() { return (context) -> { try { + List reindexConfigs = + _configurationProvider.getStructuredProperties().isSystemUpdateEnabled() + ? getAllReindexConfigs( + _services, + _aspectDao + .streamAspects( + STRUCTURED_PROPERTY_ENTITY_NAME, + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME) + .map( + entityAspect -> + EntityUtils.toAspectRecord( + STRUCTURED_PROPERTY_ENTITY_NAME, + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, + entityAspect.getMetadata(), + _entityRegistry)) + .map(recordTemplate -> (StructuredPropertyDefinition) recordTemplate) + .collect(Collectors.toSet())) + : getAllReindexConfigs(_services); + // Get indices to update List indexConfigs = - getAllReindexConfigs(_services).stream() + reindexConfigs.stream() .filter(ReindexConfig::requiresReindex) .collect(Collectors.toList()); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/util/IndexUtils.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/util/IndexUtils.java index b3de7c503fb3e5..52b34200991c35 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/util/IndexUtils.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/elasticsearch/util/IndexUtils.java @@ -2,8 +2,10 @@ import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; import com.linkedin.metadata.shared.ElasticSearchIndexed; +import com.linkedin.structured.StructuredPropertyDefinition; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Set; import lombok.extern.slf4j.Slf4j; @@ -39,6 +41,23 @@ public static List getAllReindexConfigs( return reindexConfigs; } + public static List getAllReindexConfigs( + List elasticSearchIndexedList, + Collection structuredProperties) + throws IOException { + // Avoid locking & reprocessing + List reindexConfigs = new ArrayList<>(_reindexConfigs); + if (reindexConfigs.isEmpty()) { + for (ElasticSearchIndexed elasticSearchIndexed : elasticSearchIndexedList) { + reindexConfigs.addAll( + elasticSearchIndexed.buildReindexConfigsWithAllStructProps(structuredProperties)); + } + _reindexConfigs = new ArrayList<>(reindexConfigs); + } + + return reindexConfigs; + } + public static boolean validateWriteBlock( RestHighLevelClient esClient, String indexName, boolean expectedState) throws IOException, InterruptedException { diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java index 03f0b0b7f2ec2f..4b9fc5bba0204c 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java @@ -11,7 +11,7 @@ public class BackfillBrowsePathsV2 implements Upgrade { private final List _steps; - public BackfillBrowsePathsV2(EntityService entityService, SearchService searchService) { + public BackfillBrowsePathsV2(EntityService entityService, SearchService searchService) { _steps = ImmutableList.of(new BackfillBrowsePathsV2Step(entityService, searchService)); } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java index 610d9069337a52..9a426369cfb026 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java @@ -54,10 +54,10 @@ public class BackfillBrowsePathsV2Step implements UpgradeStep { Constants.ML_FEATURE_ENTITY_NAME); private static final Integer BATCH_SIZE = 5000; - private final EntityService _entityService; + private final EntityService _entityService; private final SearchService _searchService; - public BackfillBrowsePathsV2Step(EntityService entityService, SearchService searchService) { + public BackfillBrowsePathsV2Step(EntityService entityService, SearchService searchService) { _searchService = searchService; _entityService = entityService; } diff --git a/datahub-upgrade/src/main/resources/application.properties b/datahub-upgrade/src/main/resources/application.properties new file mode 100644 index 00000000000000..b884c92f74bd48 --- /dev/null +++ b/datahub-upgrade/src/main/resources/application.properties @@ -0,0 +1,5 @@ +management.health.elasticsearch.enabled=false +management.health.neo4j.enabled=false +ingestion.enabled=false +spring.main.allow-bean-definition-overriding=true +entityClient.impl=restli diff --git a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java index 0e7bf5ddd5250c..be28b7f739cf58 100644 --- a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java +++ b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java @@ -20,7 +20,7 @@ public class UpgradeCliApplicationTestConfiguration { @MockBean private Database ebeanServer; - @MockBean private EntityService _entityService; + @MockBean private EntityService _entityService; @MockBean private SearchService searchService; diff --git a/datahub-web-react/build.gradle b/datahub-web-react/build.gradle index c0355b935137a4..05af6871715ced 100644 --- a/datahub-web-react/build.gradle +++ b/datahub-web-react/build.gradle @@ -117,7 +117,6 @@ task cleanExtraDirs { delete 'dist' delete 'tmp' delete 'just' - delete fileTree('../datahub-frontend/public') delete fileTree(dir: 'src', include: '*.generated.ts') } clean.finalizedBy(cleanExtraDirs) diff --git a/datahub-web-react/index.html b/datahub-web-react/index.html index 9490881246e122..bb86e2f350e1a3 100644 --- a/datahub-web-react/index.html +++ b/datahub-web-react/index.html @@ -2,7 +2,8 @@ - + + diff --git a/datahub-web-react/public/assets/favicon.ico b/datahub-web-react/public/assets/icons/favicon.ico similarity index 100% rename from datahub-web-react/public/assets/favicon.ico rename to datahub-web-react/public/assets/icons/favicon.ico diff --git a/datahub-web-react/public/assets/logo.png b/datahub-web-react/public/assets/logo.png deleted file mode 100644 index 5e34e6425d23fa1a19ca3c89dae7acdfb2902e86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53563 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

?*5y(jBpY@{v`pxiG=JUUwS>Js z?$i$u&=&WL{UTKA)RUa{A`ThGfk4kjdF+*~NWtVBlqz)7o>5D?Qd`yFYTcF|X75vcrcy5O zTaXFSCu^8bV8gMY1nI$K%l79VQ-U$u2X+2#JKE5rum`W8L4rpvJ^P z&~DixXN+WlQGYI$sl3>hNH&&9#WAxWb`%Y4z)bgt*Xb_JlX9B{ZreL>yp4e8xg{Of zUR7cJZ`k;ik6hVUw_Wg_RUH#kp;OfDJae`RVn1MuqpZ%nxV4JvY--~@&RzYTp!RA& z^Mn(_I5$6j$1mxm*nUL0Q}yg%#oe~T!|!lI8<~^#sWj+l%Xa3d2er-*YoI)A9s(U6 z-^oiTE`Gs{D7SsDDbIY;{mpEVm8?l6zBI$xpIsL@-yI;yf%h&5UDzewx53j z!s{rcQTT=aoCG(7x}k*Pq#7 z&aCv|VS@zvCQ4R41u{yJ7){2sA5Ui0x^cWLRR*G=r;cN**~YX__3>59ZIl0yL|6Uo zJTu`)%XKogN)Dzvg|Q+>u2O7=?dx4ZFa=lNzN;f|vMclxjq5KiHX-=qgn%#-^Sbg5 zR^hpdmy2;F8ROo2Z{NSAhtU_-%+p|~{qp*N@T4tVqK$VCWdHngPWIOse!=dvKmlv_ zt4q?G=5>;xYgEm{7Jnf;BUkoe79_zOdtHqqaRr0Yk)^NZy`r<3=d9-+o9CY;#<`;6 zc7#+rZtV$y6feXskM;$`!bDo&u+Vkot*|?4ZJbMy)~%fP0grO5oLZutM-6jVBWW3o z`NwP2Gp3%<7g{hvZE1lkgrWRt_Ms?4i>B&q3=dr;-nKR!@d$Ma+#&En8EEh41$GLb-O~> zD)ja`z){@DLk%10U~C@RB2t$+M~8n(cPA-<{G~;o)ZOWynIib(DEPF~8RJe@LTNr# z6c6G*Iz#wev{YJS8|RoCjZvl3E66i_;xDmV=5+EU(nNL5fw&w*(`XaQ@d%ye26)?y zxN*eX1)xmcnYB94%om8E!O zTc6B6RO?0c)71GBc$k)>Pm10b3S&F2Zk^B|Ux&|Z-q^^%+Rk*VVyK^EK#AQ#jV;i- zu%F-kWXZ)5*p{#Ru2k_>UV0ryb#!Z#p5ey!1EPOLFBGf|1a?@w8^t-P}{-kl3 zKc-px<2n!p&O`qLx_3=d@U=dF-2+yVDNzQd5f(epjSG|t$>yJ}(~A%vh$#j@hvBcC z49F{Cfc|)0>WpP#f}A$k3#Er;>0KC#kha|Mp;B*V{Qg(`&3@AE;CiWA4Cr-~4C1SH zea*s-B(J-VD`^C+Jo}#YHEUA>gd)B0o0&msVZXr7U?{WB%~<|BE*>bF!WiAht-RGp z31#!)o2D#j`kAt20AQ)Y^mRQa&moYy$(0*{8}<#h$Yg>{L~y_9L|ENnSu8z|dVcO1 zh{q5lyFyNRB7y@R6fMA>LoSg!36)+Mt7?_~Vaqu*4wc8jSby&n4ky#^2RW&YXyGGCF~Fi2-g=ci08|OYLgo&t4eNy9^3n(N^yUb)5Mf z?@mN(#5c&vMeCw^22SUUx4kiqW=X%7K~T~jwo5<_Z74BtScV5^24^+bHskZWPl_o` z+p3Lr&JZ6Lig)E``Uh-$d@r=J-yk;|c-dvR-JdLtVm zM*+;raHBRQSLEyR`S1TNASK38osEc~U#pIyjBQufDgx19tfSB2H0!j~P|jrT zaP>3DgU;W*n3G=akX5vyTtB&yF%>FO!nKg<8fndpKLhtEu@q&EyCJMD*UyKf?SMhW zOrIQ?gy2nT5#4E`aQ0c0#KVG>ccuKZp4^Kej{N%c zYc_AM7Kr=Kd<^sP0$&<&t<5wcllBPovgsZ6Y!*dA`%JA7{)d0oa_XnJvrCKBf9jKE zoam1BgkS0gbOm#!?qstcu7^UpFT-B-Jw2+jK5dmw+l-NRAeq0Hwz_lV?63km050mn z;CqrkY+TkbP@a3X1E9yC5no#iQp7~-XxcZu3rL!x(K+7FxqADRp^|hCl*$)8d9V@Oo-2i~IOMSvb!B z*DJloS;Cv25?_B+$6+)B0GX`7#h|PA`51&2U18a)^GJm?8aqZ$fu&po;oE3aJHzR= zK9Zjf)Li3PURiYYD%)u`X3>{)$2rWLFFv9!R1;fxV$GZZ#OL8%Q~LZ0-a=dESahXZ z#g5}RFAk%(tWB6d_FZsus)-6THS{co2IyZ+X`PUqyj|EXM z3$v!1PI~5D|563aIX+%dJp%hQr^xFUV|R40b0SQ4JYda=%_#7h)s_F-AN*(^tF}0O zU8?-9216F9i3Tfay5wk-4`k)x7zcCI+g4kUm))jcp0;~3+J6EYAsPCbE&!=NzZOpZ zp&U&bnn?ok;;4d~sXzPT_ro=dU&myz3v<{CkJ1sJs8%s5ZS~SV#a}j2);5nz(*YFv zmDj8OW3eXh1n69a3&7-xXJ4Sp++`i%U_8fo!-6Nxed=dDo^atgzzDXhUAQe%zeTC7 zb^K3FD|FIWt=_n8+9UYd4EuV5rLMfcCzxx4{2ke5v=>=Rk*ejZY(36ntAeCq;a(+( zIt3XA1d2`ui_Y;6Rz6De~_j3nC`>0GOvIw5uj4RdFJk!nG!-q`9m9H(G zC1NK8Lh-fqY5L&h7A7{3Y<-tvB+Ni(+5w4Ts@9toqT?YEq~)X0uzv@uiEEN>`6u}8 z)`fNxtq$BdD&1qAY zj3@ma(|%3;$5CA6p=*hIzLl+NA>T%QgJv#CUXv8M$`4vlXy_?I(#N>>L4*Wdae8YN zSIl!*zK0nOiPqjM{h=I)WcD%`qkd{=&#P{Cq&7wSr4Tm3#0S!|>Wt!*1 zF`rr(FjDFK!*fq&a#?K{EeqD`A;JYyydHZM%=|Z|t`FxnEhX%~s%a|oNFvks$}8in z>?QlFS1x8`5v)1g#yQtDSUmXC5tBEHW5||w%hZAI7U#7AhcpR*Gu}#q_ zq4I5%bkC*_J2UqxII==<>incd?kvo%4gO9rv{9Ll6Cp%{I5oCh=CjqpAd&fP;j>uZ@y%+JX6SlJ$Y^) zi0v0CxiJ;}5HqmDS%&rYYSSH2e1c^~MWXa8GKSG%y&1!sk74lNS#mqYe%(@r}tG=CbMdxc!p{+Msp}xs+g`fq&D2gJL z8NAD(=&;_M2#1BvYA~^#QuC9NGk2y~AoK`q+~tEv*uW=dc!qbtX%l7EvE%cjX`{}7 zGiE69R49#onpU#tyEpet$ZH?m368PlLoP9HnanI9Vz8Y#F2Xs@@}R;3p^kL}2c67Y z0ejIPGgTIwyBPy^ELJ$n;&x5HZfUsh&4;`igX6T7*NI`3+0QoZS8zf($qx0G6TyI%W;h$=O_!580AJe;$)_s!NV6-cWRVK`i zHF+>7-2LMmGF;m*6aD>xT`&9JmKl78T#gL=W@^3=93z7ViuP0 zbkXS_c2e#1^j8@)Jldp&&a8P_&5ArHO*2fkT!x^qp^#%rxa_xp~} z*ImC4!?00ucnyijd}*ZpJ-mIYtNw?!uQe7|Zx}zcJM|(Q9KflbVEfnD@b!o##)jQe zeEEbaANlh;)9T_T5m4bpr~zex8q07GecG6K@>3s)u->46Ir|63cW zklw!5yDw}ZedoXp>e4}?p>o@8aWF9PlPcJKIn13jCcOzxf|<83#i71d&g30gptttA z$Z`_|6+Ggsl9H<3cu`Rvo~rT;d@rRJHjDCCE2l1x5?EN%m)7f}nVll`&|CE~2lZh? zZ7H_PeNkCe$fVq``GJ%{oxZ~vsH_fGitSOT1&wpgw#6Yjp=z{tQSmD!5>O1SlbfcTInRvKI~K;~o(<jM;@e#rwb0>E?P5lrQWb3A!8M8C4{;c$r=#Zzbqv;W+i25n z@}tpa!E#SPi~6#4oQJ-}W(3=~T*@E(^=Zx}Sb&>1#g=Y>?_45@Wg?6yS5`U6NKzZq z;{qj5#*nQ*2X=$&vfI~^yH96Y4APEb*Xn~KtRqQ2ppw^g=gHxxYPgeN({Gm5(oLeu=w z+(Grhke(!|4PtP3##^9g1m13w`%L^TZ{G7Gh!k@6guY2O(3~a6<~D9qQ{!LjbMM4+ z&KiI2Shz^cK1b=JZ7%SJ&oZKe7w5)q(+3KETkZiZjdhCgI!k)%VpI5TSDZtU`kjoU z(dUCYUCr;`!OnvA&FZ>oB^d0rkTc;Zq{QfP05i38M(rddoEZc)My)Xt6JDl9bMOX=;)OA&y(QpA4W_e1&cGE{V+xTkc0kf-sZNhRJhrXE*SlJF}L9T7Y9+j zViS0Kj2c$DT%$bI^aqok;&OcbKxX}paJL1xQ=pa(IE)GTZZrAtTQ*-^d@b25u#if; z8BUFVn+%~!Xcptd^0B2eo2IzJ>6v$Y^kDu+Xs6z%N25lZu(Kje!rx?W?D(hotMJ^7 z&F+J#Ue57=;eBaeb{HGpN_s7 z$>XZH-s!wWOKH=Tbz_d`IOg+j_S~{DDySv>@qOpv=Bum8W37#FT)(y!XU6aJ&do%t z&2!+llEW#TZQ6{PoTcHJJ-Kd%eY7uwjm7P9aPcMMSyeT;TcziK&$-76DxX3vQK(NI z05!x+sI?0doCP~CP;8ArNZuqX;J4(HM>s26b1v$fZ{E#h{ZYQ4viYfV_vw;n-d1Z< zLXk+J_O?zr|Nry|UNgI2Cls7aY1a!xa8 zXmHA~_LpqgVquzlwHx_d_-S0#2xz_s6jy=;2kn^JBY}~%fsrwzM0WXO>TQpnwLgt| zPUna>x*Mf$zm?ORG>E_25A18PNymcH79`sNpW)dX|iIn|GKC#N|gs z6&p|v``ou{tMANM>dTvi=%Y-ov2|ByoG6QJE1yD5zJsY+wuhl`Y>MX~v7(Z;+@9Zr ztBcZ%sXR-m$qNR`_sNFRAhh^;zwTq35H1L}GXS051)5qb>Gye5j=V!%f_Fx0BOst{ zj~RAWQ!%DpIyNkuciDA{lqu=K)E%|43wMdhvj=)!)A`dBLRn9a!sJ%8hju7^OeCKV znK3xa^k(2`=MoxKdkVX^n(V>H>DkzMg~@`zep9D2l&SsVcp%&9O}4p23{{PHKdY0s z_PQA5EbDnTVb2T0a`@m}_LYvs;C%5aZ^ZcivCVUadFUc7_OXt+cgYo%3#<-JlR}+0 zxo@pG$J4sFVBdFDfF!oUrY zdsw*IuttLXWk_KjSW|r+FN>W|STQ>zWEg^o=egzF$lTl+7y;FthkLGXCG`%(Nn=d5 z1|C3#NSMUFA$kAV6eoeN9V|I9$na4sQfX|ImDSKU{Y^F|5$esYwZ;wFKo-M#bXU$E z)$ZVgw0YZusRt(J_8RT!kMNdBJrH$GnyJqV4BzJ{jF0#u2M&Ui6cC?=yx)@exdRqz z<&)z(A^r2mXu)i?jd_>34Ya>@6Cd;|9K)Zo3_Lg0Y8;5uz4s8d9RkAQb^P-WcOqCF zek^U7ZRL-rJDC+i%U6GQy%ybAA{Sn0O?L5BqYYbE{B)7_mjfn>ZOcbJu#aTTJ0ZVc z?}~EsYXOW{5dXPT}M7US)DuJI>ycZd!}lEng216vFhrU)Y%uj zrw{noC~>p{nP?XsM}I3jGWv;(X$C(g2Qa@*v1;Jh{3V_95Y)1ISPmo}_tk0Qd2?*J6c=LJ+U_Nu>&JL#X!cm&F)hZfk46B(Q>WT2$@ zbYSmxsNR#{!x!61FdF$@cUxF2CF$-%!6{C_q^yh=WL2g?)o<3fYdX;M42uerAY9M6 z+FYLqtK@ai3tFv6KFqAyUwbi2Kx^kV>yE)mxPwx;@3o!b{PF9m_5r@SJf*HcE? zGZb20wB)MuWALp!ReC#gv8Z6#wkHmh8;^Y;O4AzjyQg0jAM-C8Oj%{#T3-yl8FH{) zjA0u3wSQ>DrT%mp3PrZs^p`v_em9x$++sjs3ur{9igUuC5V@4N@U1J{ED-Q;PxI zSMS2|+w&Xr*QN3kE4+k)BPM%c*Y3W$OUB5FeQvj}ml+Ruo%Ry)C~uS}6mA>tk+4@% z8i7!sf3gS`XV`=b_eTgJL5~TR@ zaD+o<3y}ml+g+FHbwznBNnD{&AoQBD<_~u6h05Rn!=8`5EVup3C5~Rv6FbC{fobX& zYU>xVxf%cu3{Q0Amt%bSUKP}H%3YYSRW*IZ-~Sc}JUOy_*Iz48H?fP|xGYeQ zS{tZ#YA)L3pX7}q`)3Bn?98yq|FW&GZ=Mc|H7f~`1+60T0$;v{x{*oH$(!qk{g3#) z-or;SQb41B?Ughkyx!jsWuW93Q>>-)1uQTv_NCVtYS)6FHKj)B5l9ua#67nZ<;wCb zO~Wz6Wn|$GXtWz_*yJB{rNl&aLY&B_us8|nD;j3l8^7Al;RF6i@`i6Q7VP)}{T2H{ zx1FQ%=I70$AL`el_n%cL;4xufzWXq{Pu2-}X*SCf%FFK`6-cCZ5zgUv4GZ0d<%+^` z_3O&=B%z|?usrthdgOvkB#PyH6Ry&GMFeA()TRs3vkD- zerx9BMfu%pI^01!J9DLYz{}DxDs>f341J`{O1GJ#vc|mSaO3#>i&p***dBrX>K|{; zWq9MK-_e=K0YjN%SGN)-X)6yxQh)MPM+v}_{!Hn{D^|?i%=M!pkI=gTk}0|_#=U!E z6&SvLUFcihi|M?1_zajB-NF3|+2_NGNl5k1pVAxf-VR*e6o}pGyRvY>Dr*uM2Tjdi zVf*c=46+;)%$*>4t zaOG>9^nGscob@xjmsU)HW!n$?tq})WC5T<~Ek@sUMSVM(f3jM;hcU#B@5}wb&0;lq zi$y+~h_!O>x#M<_Mh7HayvP6EzrcDOImfFr1jd&u2vKfYS~=XIO3*F*Rk zgw{qoH)8gY`SBo-ddkFkUQFd=p~{KmBQ-$BGD^oe4BIkkZ@FaH2Ml#byD*aKw5IP+ zTCoAHrn9dkqIFkDhf{2r!mG`#z8K1hMS%$4rycX7V~+{8dJ|w$4qTz0WGh~YKOHuo zE}2qPeA9i8G*qmROJ{pw1&Vc?RhdeHuwY=FbmgCyk=8P`8wHw&^n5r87AK+N`k6&G z>ftR<|N5do-@Shv@4}eC_rNP+(O>D;HU&QU_Fw5N@yA@SvbuVmY4T0fQ`z%Z*4U`4 z*=r=N*?^f02>ue^QNx_s{s73XzzL_FvMV?uPpn=x2PxAC)LlBrt#wc`m|pbf+}SMn z&f}?o&a&P;BgQF>hCNT4`eV$n#~ocPC-*4Ec>bT7zB(Z4r+N73qeJNi5x#_=q;v>^ zN-23L2dD^0N$1g^QUa1n2?$bmv~-DdOSg149LE9g;`4j?ll$!M?CjLePMAtB$+N3t zJyv%4!D&`S4W`zCmeqta&h@1X_9y@26Y8T;`;+i66xU0>sdt{tdVae9fsvC-9L%zy zkKjim$>>2(=x876S;n2+@v@sPNmVi=jsI4S7A{g`?~-$nQUPryka43`AzHdPYP`#! zqh0UwnfJDFa=uff+7^kV08BSV< z=v}DVl`GOzbSfEay8^s?t^9L~vRMKTxW^4n^=i2tN30xbjDNmpXjIuR!m8Wp`BUfA zZ3y^!J2{(i=n<#CbQ!caC0+?2=FmF#97&At_J4fPe7c<9h>^SrKc=ss@uyDhRHF0h zWoJo->VKPO=;g-8d#{aYI{EWec%D$(-UpGK3%@2o8y*NZ)3rm4oQN)goYbIYl|hIL zeA}wQ=5rs8=e!)W%Pw3Wh5^~em*3oQMZSsLSC`j{U1|++(8L#5aE|y$6NnSMDMr~) z$;zGKKSDGYMiV&l5WsN5pSrwLsWN|br)8ufm>+zaz=L{%wg zKUzv(OB*Pc|H>o-g2SMc#PVuEy(A|f1im06qjv7OX?ij*;+fnl|I;&pIUL%|VGF18 z`k>MQK-D*3_wkFf4>kjunX+df8~7<<@jwO}SczoM3;4WgU%N%wP@Hg2K|OXU@8I6v zrG^TsV&QzED^Jm=-0_z{y_+vT8aH2L@(W_RBYX~R7+QY79eEEweH5r^A~bkIZU9{I zrk(Ev#pOECy!xKBL8t596>RFUazUU^JljIG~ z#T;BScXeWpE`O8N^b!ZDk{fBi1`wAHDZTX?1r4S%RqCoFQ>rZ_ho~MN($}*AXY*zZ4OeWy(&_PetVdEJYkk-5C=J>e8iMb$QvNmK-4grzB8_*)YcH$iwNxKAp?8_zVwp|NyAazL}9xuGF0Ej5Wd%9X~G_dqr0;E_3|7nPPxQQT>=?JT zkc~a$qCPh5U$&(fuRQkLI80QvkEJ2q0`Wsvb#Yf^<6rY_>be}J8@hr@3|mkC+L-|L zb#7m!R~xn`;1XtByLf?7xv8>m=yuu`veT`lj(@ zI=`*lrTv!Soa4-IruNOSi_Jr#!I?FUALU|*OxHCwX0Yj*UhHunzX#N8hMmvw?3wI7c6DSQ*60i`5VOg$8pu_{!FlKop^F zJC0aKN8$HEnPbxI{s8f2D?zduAGVvN$y-gcC4GTUe*V$yY<<6~LKA8v5mGu8k2j?m z{Z-k#dJp;2od>-b68bf!gnUJXu_O~3vpeHn#-;I`^hTG?<>`h&p6`Q00sRUU)69Pz zAcf9G7DXE}eryEu>~SS86f#qFHqp7CP~0EalTR5^n?VnQ_@4M&xy zpfj|!=|xUyqS*s_f8qwZd_idUn;+W=4I1=5AzLEsPVB0T0K}8~3LAD64=&Pka$6}| zD3mk950cGZP@>^T+oCSgwLtK^ycxgy`1}UAnJBP$Ogz&0M%;8DvXVSsfXWF%`J_#j zLI&jpf$ z$ydvAzd!Ct`R!g@*3f zUJi#;b^x=`6ZGzGLF&l$@?UWkV^Urp+%vWxNgW6|zW*p}(SZoXI&ke1eS3ENv-fb? zn$68O`Ref6-8qt}=n(0adtCHnwwFqf)n~Ux=~Ey{6Hu-*uVA_k;$>e~%9<;Ai6%by zxc{iO2}J6!u53r8zA!Iv<}^x5=W%Y%Q1w(b@=7~LtxV*GdAg2Co;5ZObwT%lbO0@- zLvU4H0Z|O^w+0X-Kn+B2jDTZ@4)FtH^Sg6Ma-;=Gl}hmCv=&}1>Y42F^0<6Y;UV&X zs<6zQ|JU`qvX-gmUUMuhl$RdH;OEQc^Ax4c3|IzqHq#KCQ~K+ zkc@uN6Em!%i2I6IaK=dV#M0pw;g09M$hz1pN_;q{bZ^-7>49<_c>I4TDIn6|JenZ8lKCAi0r? z8l)br=+Ew05o4y8UekT^qjupjatDlf_FQCn8=|-x;gl^Q%J&n_kTxpQyH`9i%u(4H3xOw+&nYmEvJFYR7%BqlNfhmK&~l#zvX(tF3eOL9 z+BLG3DGm8)12&=l^2|wM3y~rVcOu|UxrHX_HJ9J){`f*NsjlbIhEw@r zC?2UPq|f%I8}>t+KiUR0pFYSviT=DNKm#=C7C5|jWFD021)N5H3J=dc#giJ55Jf#O zq7Ph;pp{n4vup}1YdW0#6@8E!2vub?ZR`=36L`ihBv zm;VI$WS;WQJBe?yC5v~%b6bQh^h_YPu^;`FPBS%#)R~;%0l;9>!LBHaq-W{X*bLDV zF5y8+I#vP8h;|)`03+AE3(`eWXu90pI&iGQsiW_&rYJl9$^$>1W$8?GsNvTrK_z*MY zrPtG)L`w=$4#OZB&sgf;lEeEeODqG&0?gqp`a+~6iR-U#c2BdS`oISGi;WgokLwzwmgih2z`*j zp44(ZFtzX|F+|Mr=x|;4by@zoK0!jIyjNQ0jeKZRgHe81ikF8OWt+L_an6W<4ylSr zdd_!Q&8eU;b}Mp1$i`jFSvkgd^PGU7w$wJBnk387CZDYrP)+5als zTax#2CR${6nJec#`{XkQ^7);z(zPYu8s`wQn*cIn^e?fy@>4T-G zJOH?iZ*jRs{&m#05`Je)qs`O6*}X7aQkD_K|c;RfzoKRd&> zzH5xL$Ce$=P1Mzs3$;UnNbzUN3JZkTjSA%fo_ zlml;@9mme|h*7#~&Im$CUxeHDs#qnC{sIuNatw6o`;Odi!>6_7)3$2w;3&?$UNDqCLNImsD)IF@wpA_u@)h>g;k%K> za_g<uqlkb9u3|sf5U3OC#SSMh_)-}HKWb|cG&(kH#fRHT_pFc@&%`j^7LJuMwyuAK z!st0)%Jc}dOoQj`_DHwylKr;inYaR5{HXHH*S>Z4_r4{X^3nKjpLj7&e1|qnx&V{e zI`gj@P#i_F__Z&!^$Ri1^X4E2H-j9)DmIx+=Y9V_eD6h!o0V_2p*@x_|I+6DUV-NM zS)s6l9Nz<|L6BE}fH&xcM9Ynn!L24Ue02XfPv>=mr&(hCL{$0lewK&?NlFPHC>EkG zxnO9bb>Q}}K;|ioEl|!G@m#lTRY8dX;?lH9%idiVaWn9Rgt}>@B++JoExDNG4iy&M z(EO_wbpHIxIF|rAZ?i=>K^)6*pB3>*h_?O>@0}lKhj1o???DIdyywQQq7bUwnW1=e z=wG-*@6O9icY7Ft(=ci^1?=>IpRz%I=s=zJfU1Z5f$?@49)UT8>c?Q8i~BkkIS`V$ zix2TcpRVUrt0u(-*`6eZ1?3YrPJRk~kxsVD%YM3Rm`e;!JMW<9aS%a^?3HySzCTp( z`F=?rqDqA=ik*C?;vkL_BC6mP7qnzQYpAIY&gVWbdcA+<7{t+Ql*GTs@ME1bILIKl z`rs9@I9)*HyDH8mSoKu;Xta*5Bkb~#o;rFYbXXQ$!;Ae!+)CL8vpqC0>1z|+-K!Hn zID4hw4Z5k~o0W^!#~Dt#KR!Ht7#$2hnyHw+&yG-7KactmJYOK>eMSr{k6BLgFq8`N4f+8^_<2ar=qGvR(9XMB-Y%4)#X4L~}A24>HjN z08=bj1J_NUuyQPAJJQ}%m8{15^05P(LT{fabDt0JYx!%S4S~(7L_^at4(75SFe7?5LiGI2>@Bn z_`FN8bai;$(Yl}2T>q$%`@zlz8H$o+fCt)`k|1weOxpOqZ7-b_;%@6 zBfe)}QSEUTY_Z7qSNp#7|XH@AQOJ%0Gs=@S7QKBYw9x%YO` zZT7$lEjIY2u4A%y(_|#izRyoJ8)ux?dL%vb4B}WexT_@@r^G=@1afHXqS?vi^Ic3b zxbF~}U6>TvGO}S)PQYWK^wtrRvTIDR8F$mq@VgckEAT{ z7NlMhGh>b6+6#ZIzpsqy{^;-!Gi2wBwVG$cTlvZ0&+6(GQmPMj@)X9iZ{;zE$8|x4 zeJ--vD4nfuE*#1{(Idj46X*A7hPmI?kR1Wn=Pk6bM7wH1-Hc1NMNUcSz#N{xlG{wJ zhpcCJ)&f4wCf7-s=!*QTHtP#;1>Fk+I$Dd%xlU8DIgW!NnG}aH^Q_$MEJ-lSj=A(a%`?s*=@Z5B*SukrZ_4~rXU`zj3epL60Ls&IjXTTG3BBc@g z#%+=SAloUH2+q&WA?X>D8qHo+sqpnmnS(4S<;kU#cc&wrmnNy9r_iEU`gv6mKUrS{ z!X38rI-P2(baTplhyJr8n}5YQik1dooH-v8xCzWIo8Xz>CS0j_D!0gjXkU=j_n9z% zk(ncIF-zXt{0atk|NntGyx(f;=iaelC-*ANPerDdR8Kyg)WsyIuQp0O5e_z|3^sR+ zOU^VL+Bn-0EAYBP{56lCHI~Lo^WxWT*~;`>1jhE^p^%TsFk5Vt2)mlx3u=1<@5kBB zW@tJQxunmv2YKls)Hz(MMJadTlyGQj+MFTu0ors99FGn5D(+B3Zo_Dr`$O@eY4Ud)% zAHhabul}O2@4Q?yrJuwLrN&lCq#1e%A@V3reR6qXwF-b_!8fZu-q$8_1O$~YIK38QC>DNcK!&xx;{dEwDAbHt2ubZVnEq$B3N#YRBF3R5hwB6BO{47$nMwuBQ7l+b( z5XQJ(y^vN7N2Q2COp4-f6S83gCF+XC!_i;}NGgG$uHev)c4?E|$wft#!HIBib3rJ58d=KRB)gnpEW(y%kn3gC z;v^9PQjB|r1{F5)?|H_^_Vuj~)2Mv2emFh$|ns z3}wRk#p93kUc>uOX}9v{=u~zym1oGBt{=7?BS3;iQ76LSMTz$hrU;bFq4Y67k3L@c; z*lY$;As_-NjIkXgSso{Ey^+kREow||bKNKnZyrY;ek^&0cRCe^{N=%BTeN&wV^83@ zv)2D+eRl7BGMspLb8i#NGKi#Qq&KmXA*&Bdap8s>>QFZ@M}cI~4atfAzGL>j7yre2 zRcpn+i@jQvW$|`pOZvfLqG{Uc%4RduRZwlOI(%oQu|I}iB+y0ZA2ZvYZHoZNX#5~6 znOr`k*HfjKOBj(p&)RTg`;@wJ_&b)A(^$Bmq0uW_x2pUvV%f0C@< z1#;Ccp)(#O=*jx+#q&?gel`lPlCb+ZbKBExdmB@RY|`h{mZ#@sMU@b_?O`aBxvOaVL|ey9{MAD*5O7ee=kYR&#u+AX-Bm0c4PV5 zqcZFLxSxo;A+69>9yGTCxBX6ln05CF;dSw@TQlT}dv zkm|Z8Odnd**@_e^rjJmMsip6RVUPcscj+d*NiH4*L6JR^Hba1K$?ZO4Dz3 zPO%m9&r8!bEEhi$>%5z?JhLb9p@EMTkxIJk%yQ}Wbp^y|c$TX$Lh(1pjj7_leSFVafV3or1*^U9=iqdo$d7OC!pFErtV#>;t## zczBI@hSD5U1O1xQf_*RuTu^CCoSD<5N?cH@l>>_rXve8Z8 z+!N^NzRH(VAVjfF#Drr88q$9^Q0sBn5~XBmLI-FqBWmXZtAlziOm{I8NAGeL z=*zYu{qCVV(gIdek+_}=zn-G6SG>6U)SM=H@Z`uI(SSY)VLmXSzr6T+_u`&I4)A*x z{*qB!|Ag&CxIGzW{>$q-$6ox5+A-qqyj1+L!SgSVBh(bWd~Y7J6#8QSy2IJ}G=7g1 z<^*)0!e$#djg9+iD`W)r<#OQZX-{4dO^Dek^kcVs;}>vpsAKDZ9k-DzQ4d?<)i~IH zrC{>kfw2S8y;?1>8z^7Q3=r+4mOZ`#h zx`!}=DpARj!S2l=yL@5EHWzuTye=bMdMAR;cIRXzpvRS;BI+5MB}<=$qA|T9Mi4? zcqlzS%A}R>(d(-NbakofYDrL?{YyNMm_)1I{g0-udnhF9s40v#)K`1el zLTo_<_ye&+x;c|3DKY1WhL3&^+Z@W}>cuD1lneV!qMPrF1n$e1IPwB5k^v$ z4ofh6&PpKh2fJ_O?FAAx@3clP0n55Kx0>G7y9_WfOV%DSAE!}PIxthM-FC+i=#h)V zU$Y)>2YUHE-~VZYY<0u@2qcvk-l7@E=ji<_{!6cQy}8D5D0Aim;zT7FN_^zUyb>B&E9=_F%qlZf(&=-B?x~e&%U(?ARFfJBOd#)cVBJc zhK@vYxJtr3%=*zNmgQve@xA)Ox*9C=Z3mo1s=m8k)J+mc zy@wnLwZ*@M1Rl7xhHz||Zc}>vf8wfW9*2@JUxr+12L()r_m(ADz!Kz33a@4DxL3TH^5j;wX9U zHw_IDj(X|hfNqdX*6f(V;hnkCT%BZ7aC08gkGl}u^7-0dCF8xhEln%`Pf4hQuq$t1 zjX#&fSQ{){)eRCvt;pY%D7@GnXTItWdRXK@fGYQ-HT$7mdbMfE=ktyw>&Aj%7RHp| z*rQzTqUAZt9x!DtIMu~Et}WerFGQ+hPpT84=`U|GQBFLM>pz#a@o{x1`t)Isw8q>X zBH<%EajHWd;9+lcb#<~M)cIixhUaL5ALi9vbtf7ki@OJ*2wlU0Q19$43baA3)cjFX z_LvVF#6R<2;91?pO?+r}rs)v-FyltDt}hZ{q_0jHbUWDvD?{ z)($-ioB69`^+AljFjtw%4RIiaZ~ozvob=?=+UB=6zZmkuCxZ^FCs%!EEXP4+6{(lS z8_snszr)=@6SgdSJA*P$xkizz0>(*pNiPW*(LxvK3ccsv*#p{F_`&@9hfHm2n5FKe zL={kiH1-lz7Z(w0J3E%ecc*)Ak`wu+k?~BDSgxdi;NHZ2+oTg0hgOUldL&OuLj9d* zV$`8|$wa3Q-+R8ifU)!`58Oz@A!bvvH zI4;bSBQrd!BzC(+FyEmdtm|YWEe*b`JqX7<9*Es(=uEm833RDD+P73dTE5A0s72}I zi#MukwML+94U#@K@&DZX)&KTpnIM*?FOC2{;XTw=L@6338TPiyW(>qZvrS1ty3^#b zwH41EhUTAt88PIgyn&+={W><)rRD+&W+l+$A(x!3=oWx1DLK?})H!ee6ZB7gY$`umgQW?;@E_FTwIjYZ6 z7wHP_@80@qNx-5T9(r5;G^m_Ra^hbSCt3HD% zG52*l(7Eq7to-Tl5Ow({evn4vj$X)xbh(C8oK6mNoz~r2vUs?>73K7-Q1sNVr@rwn z4Kz>C1bDgQCZd>ma@)NFK;2l1)jLR=F(fo2b(IFJ1AospmC!GMOE9hJYn1+_9t1GLN3dxeN0GAl4P zZ!HX_`?rsRE#GBL2`1$c`gO{)y3sAilCrKlp`K>xQ+}*1!VF2EIS$@_!Jo$7{N__3 z8h?EoSsCVJ-;NaJ@TDxJI(icQn&!=$+|Bgq8h%Ezr26m?Lt4^idG~YaV7Q1P(ag6M zL(j(@GJfV!V2TYfJwH!w>kp7Zr?E_?9y6EGV7g8VhEWCOc(iAv#pepCOP7NjaCxdvkhXqJxOBl0Vp7yJh{` z4DUb2x$U_Y@2kJ9s%z!xGuE+s`><)W2ziYlw(o467HtR{3CMq+yHQvx4-eCi>p+a-5&2)%80V%-h0uQCK zo#DHmeqnhe7&`(Hx{pZ^YkV|WTkWplCs`OSa@rk{t)9KO++T0q{Bf*cVnV%*JLiF% z=##u5a?{*OX#KcJQP57e2xZ}#&k3-w^VY7%UruyNMBhI0E5o3d%p30BqFMjh#v9e@ z#G2n+9ERZJyY?7;lhSS2LT>!o(0yU3T3?zpf{Ae)-W2^Xp?Xh2tNT<3DNl~Ceq z^3kg*?Ar9TvU9mw;aHz~ZQy$4R79l+tt_5!jd%l(1lVT{Skw1nPJ#1iw+=@yF#Wa^ zS5R7B%1rP#%2`}i@xZLciq=vNJOG=A`A$s^2?$dukv%A@FR zNK3$&242%=z8LxG(y4hu;`zP!aoBOi2P)ea^>3*WI3^=b=gIJ8_PTV6l~T$#Wi`-v zxaG?ScgmGgy2sc?%)~4ZF^0jkcqP48DK&K)+l5 zwuDp?)f2R8_E$ni0x-_zCCBY+dS27P6=r&tzp;6nbG*i8EXGeO*?0Msy;&8^ApkUL z3x+=sWL?$%?`m7zCe=m+25NG}2*v%;M(c+rs4=^Zs*^?mKQ@J-yOrAy^T zUM|q1kZSh6^(0_Tm-ohIgSzBuE-QZht*EW`nJ%rASCR{3x5pM9vUym)$<_pC>BjE! zqFV)5Wq*vdGGSBajE8~fWOTd;dB{1wCsELXOmpOV^|@F@JjoCK!20quT4|hqq2N(T zKD04bXChwT9xD+1A`Uu=UwyP)dU*rnvI9Y?r&G;}J50kNP0%l$}5bqLcMHU`(6CJP-7k#=qG8ca|tV8}CH?WUb zv~6c5kM2gq%%7|JU;JxbCsbF)6{0Bw?yYW&fgmdVb)g-Muq2B!{*nj6$iAllK#8k~|*YJGjmxN2%aYgxqqGu9EQ zf&ZLL72`X*Si(T_9O5T$jR}dK*q4RCxwZs<6{o{?9y;`n}sGkPuCO?>cnj>_$yWOd;7fQ`+<`VKFmfYeoT!7)1E#FruG>58^Fzuj7h z=^~sd;93w}_h8%&xM}Oe7AV530vR`ZS-W^AFt;}a-tBR6yr#N1N6Qewhlh$k(>Kk> z79VVUUI>9N4jioEi86;!2~P3JyM{%gOTqYqMjE>_R z)2~W6oEXb`Ga^K0rjSbB^bzRT`5oT$dg>DeEG&3>l0>BcZ+43yyQ7ANoQ?Q10-_k%xdnS1- zX50lB!>l!zmcOP66rueFanWLM^c(Hsl!I|f%Ge4`w4TG0PYKF@7~&WWV%a^RC!0Fe zL&x{kCYalzjnO%i;g!J)Xk;(JreM$k&f4cLqG@(zXr*y=#q%&v_z8z%ffutsZO**a zGf-VP=ejOH?^R5{2&_Wg%{*<8%g5;AzhKi)6`ByRlW*;`2`Y}jQ);V>^ZqHOY8jBY zp-)@M995uQ5htbYENU?dF9X_;w@FFY7>W2!#;uT#eaSO!iF{X-@SLT|fWdKVjJUo0DkOysY zgN|4faBR)u91<0`GB+vf)BE0B39LW->z2kr=Kp(}tl=X*;MV%vE50~`H2ydbiJhAf z5-g4M_~wv5_Pg_MRUwteK5y^tlD-FBOQc{)q;AxmPvoL_p?DciIYCIS=3&eI!sWIt z=EDa6pTtf6UBDXJyrt3id~lX37{{?ZEAZbp+r>On>{y$V-j1L29A3C08Bf8n^7Rl5 z(@d}jia;I}2%`CEWbH=#V7JfekPchq{`R2Gay+SX^X!OA3ja1Wn5zKxzqxvgU(23b z9(^0!g*t-z>pI~g^n!0c_-v4zMu9zvR}jHffd=g@Up;cQHj5_OP1U6n)O(&x;7Cue^LlV&mIP70c2E=3dKzP7n57; z=8#I-0tw~8hYOo{`_cQ`8T7--WpPtM*<~hb7#C=G8vT6V^h+>@?Y$#XoPmey@I?1m zUButRI6gL8;%12gdm$KGz%1BN@&SaUyT!CQbu;mP_ zyI?{x%YoULRPiysnc_|5$FJNzW0DPDIhH@fE$1k|FaP5eO90pldh8y#K0LoPVD^ST zj;==gnI$$gp`*EmEcaoG9Ij4K&?L(#kH@6oY{m1?y~nf?BA_HLhjb>>mc{D)3(OsF zu8Wsaa!>Ucnqu72R*ZN6rM)ZSA+0m*X<$r!h+^{p4N7QR0h7|0;Zr~A2ML~(V%`ZX zb!EKgtES3+RrpJxxL2->`3UR3e41^if=rxAoCL3=kJ_MW5denY)W7oZ?Mc* zRXKxW0u(4-wI(}awq*@tNOLe{peky4j$G7wcT?JKtnsZgBaBTSOvnkr^HLIL;301H z&&!?@+6_7Rof|JdKn?H|{k9C+oXG#i-voeYs!RSGC)iw3fFyGaBXXV(O|9I#gq7Y; z6b4{z0g^n5vJnAnh-WjRzUCT7Wm{`s+(sM?Ap*2tpTiWv4HkF=7GS?==_P){YvvaI zRI$n1cP-pa0pJvnKM1q9wBqm|Xypp}Z} z$d3V6XzQJdM=hvqzs^-pIjCs;j@?Bosn_)==H*>sPQ&j_vKO3l!9uyDUI5N%7DhEFX zT{HEErs>u)l;g+vkEA0$y^+LOcyvMqWM$RlCQd%6#!e{2Dy*#{KEA+iW2P{1<3b~k z;)_*cuvCs;=Nx_MY4CYGC5m2pTVjjKmMKt}s0i{0lz7_Xgm%j>)k)k+C)9Mnw@5ot zAtkYkJZ&BGnpzCeswEf2_m02S>2YFWcL;F>LJS|6m-hk~#4`4q-rmR@wr?eFzMZ-8 zSXD$34-^HK4CXvTR;Uh@S|@RSe@zqh>C)4am$8Dy!;Z^kviNGu62#CPc3pKtY7g7wpL{G(KdIj{=2J3f`}MI~Obg(fvcslcP9Fmb-Q2BU47{t2Ki?@5MGEQN6|buC3^ zlCi~+8U}QK4cWIx!x~a`JIcNs+q#d#%4KjLC_@N!l&!*j*2~qUv&W^AeN*#qW|5MZ zkI~2X@6a4Q zt&ExinIAJN$4^xDmK%tvKvkBen4x!<0Xwc@4>whgKQ@nl8+i1iCO~}sOEm3^0>@3n zJq&|an<~^z1`u@mXc?4OCW_mH69Vd&`+p2RGeepbSo*s?X$foeBW@4PqH-zT6!0-)2NJDG;Zuo{*$q z5Pj5JIv(|tq^beh=>EK^g7SaEPew&M1sV{mm-qvq3O49uzPMaJKv!Ka1pTRbm(Qfa zr1+*nFhyypApTB);^s5|WAj9JzwOhP8QYEu%DFQsFZ#vs;j57dLkK@;AZMk>@cHUa z7w5nLoV}ovWY*zqc;{K~JDnyvSk@_VdS&W+kE`FvQ+7V~LoMz3au5w+e~f%X9Vm>q zgCh-UISpihGMYIYWL6PWg)DDtW<)}d-ZYPWxxPNmF*;HX5CIVBfF!R1JtHFTx)-o; zj&EaF15?BDuV?TA+%rG}Y_3RM0)wncR?!##q5mtar=cp-TWZG~)b;tOc6m7DmD z#`Qvc?w-%Ac|~6dLqNSPy!Q{JI)zAE(!4bNVbJET8`9gO+s99o+s5R>%plkOVf{Ox zE&ed#=B|?OQ?3qjfx`Ycfc+jkq9XjG1~rHhpDAWm*(ZQQK#w-O_g^{4@B*)pSY zO9(1BBf0z>3H0mNL#7 zXcpZBxqqMLXZ-(88?pKB8#kl^P%&9jz*&=)GRmoN-EC^@B}xtgvS)EV0lQ{g{U6jj z!(Kh7xEUaM6C!_OC0K5djo9bajsd{z)RS1*RCfL=+bPEanue)|Q77VbR{aTL^F@te--w5OZcA6#~qW3bRm)idXv=kXPkv5$r zr8SS%3u%Uc4KUk7-I9AxQ!dP1CdS?I{aFDrKGSJM_bC66%jLD-*V_Y5{QiI+uC9O3 zN}@mEE7u@t&3FuGBzMBqH)WIO2-nOEK=S}-yS#s`G;5+Q)nw&$ndRRvLGX{ zss9>pXVOEqz^N)C88Rqir~~7j&r`kw=PAwDUmOlofHW@wu<}j*RyNO^OdhbG(Xr2| z>JYF4o99O6j>2gFPtWhpd4{YmgvAtN1$hhFM08cmR+Q_Lx7DXx^eohz>9Kaat#j zqLzJ?Zt`{+$5t`_UBZIY7wZ6u7yW6`-!E}-6&1iqbKJ{(v}MU> znZR&ixZ8ImjYTJJ+<`EFuJRBUf1QZtJWF>NS#4?8ow8bp8K|cX834tJON7g{hnSb$ z^5R5lACD9K6I=diz*A>HYjN6YIAY<_zQVduZZr~q0b}NdpoGC}WDlfG<@tim1Mgah z_xeQ~G#?F9xq##_NEEQk{R2pw$MaJU?9pdInQ(fW#1L4xvg(+H+wC=6Ak#sadI|m> z1{3{{GeY>U-JgrwmlzS4a4w}3PcpoDlNAz9^iLW@r2lvoG%pk+e#VGn(VfY zVaNEZA^zil+5KTav*bH%X)U;P>8o2_8ciFFm}sCebIJsUqxn{7S;)YD0K*yjA&*M4 zbu>>DLIwoy#ud^OKm>^4SI^LA8>bb#xDe3W01qO~!z%1hX*5gLzGH9Iyrp8YVv{!7 zJ9qA|FxDpWSbz(n2=oEy&pGhDI{Ib)z(=7F8PE&_!VfIoD?CuPc6;S{dng3_X{c&F JC{{KL{C{3veEk3b diff --git a/datahub-web-react/public/assets/logo.png b/datahub-web-react/public/assets/logo.png new file mode 120000 index 00000000000000..c570fd37bed974 --- /dev/null +++ b/datahub-web-react/public/assets/logo.png @@ -0,0 +1 @@ +logos/datahub-logo.png \ No newline at end of file diff --git a/datahub-web-react/public/assets/logos/datahub-logo.png b/datahub-web-react/public/assets/logos/datahub-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5e34e6425d23fa1a19ca3c89dae7acdfb2902e86 GIT binary patch literal 53563 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

?*5y(jBpY@{v`pxiG=JUUwS>Js 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 JC{{KL{C{3veEk3b literal 0 HcmV?d00001 diff --git a/datahub-web-react/public/browserconfig.xml b/datahub-web-react/public/browserconfig.xml new file mode 100644 index 00000000000000..0f5fd50ca7ce4a --- /dev/null +++ b/datahub-web-react/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #020d10 + + + diff --git a/datahub-web-react/public/manifest.json b/datahub-web-react/public/manifest.json index 35dad30b4bb57d..1ff1cb2a1f2693 100644 --- a/datahub-web-react/public/manifest.json +++ b/datahub-web-react/public/manifest.json @@ -3,7 +3,7 @@ "name": "DataHub", "icons": [ { - "src": "/assets/favicon.ico", + "src": "/assets/icons/favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } diff --git a/docker/build.gradle b/docker/build.gradle index 8b71ff1f6f06bc..cc95e12f26f767 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -61,6 +61,7 @@ dockerCompose { composeAdditionalArgs = ['--profile', 'quickstart-consumers'] environment.put 'DATAHUB_VERSION', "v${version}" + environment.put 'DATAHUB_TELEMETRY_ENABLED', 'false' // disabled when built locally useComposeFiles = ['profiles/docker-compose.yml'] projectName = 'datahub' @@ -78,6 +79,7 @@ dockerCompose { composeAdditionalArgs = ['--profile', 'quickstart-postgres'] environment.put 'DATAHUB_VERSION', "v${version}" + environment.put 'DATAHUB_TELEMETRY_ENABLED', 'false' // disabled when built locally useComposeFiles = ['profiles/docker-compose.yml'] projectName = 'datahub' @@ -97,6 +99,7 @@ dockerCompose { environment.put "ACTIONS_VERSION", "v${version}-slim" environment.put "ACTIONS_EXTRA_PACKAGES", 'acryl-datahub-actions[executor] acryl-datahub-actions' environment.put "ACTIONS_CONFIG", 'https://raw.githubusercontent.com/acryldata/datahub-actions/main/docker/config/executor.yaml' + environment.put 'DATAHUB_TELEMETRY_ENABLED', 'false' // disabled when built locally useComposeFiles = ['profiles/docker-compose.yml'] projectName = 'datahub' @@ -113,6 +116,8 @@ dockerCompose { isRequiredBy(tasks.named('quickstartDebug')) composeAdditionalArgs = ['--profile', 'debug'] + environment.put 'DATAHUB_TELEMETRY_ENABLED', 'false' // disabled when built locally + useComposeFiles = ['profiles/docker-compose.yml'] projectName = 'datahub' projectNamePrefix = '' diff --git a/docker/datahub-ingestion-base/smoke.Dockerfile b/docker/datahub-ingestion-base/smoke.Dockerfile index 15dc46ae5b882a..5c6738720e05ec 100644 --- a/docker/datahub-ingestion-base/smoke.Dockerfile +++ b/docker/datahub-ingestion-base/smoke.Dockerfile @@ -15,12 +15,12 @@ RUN apt-get update && apt-get install -y \ xauth \ xvfb -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y openjdk-11-jdk +RUN DEBIAN_FRONTEND=noninteractive apt-get install -y openjdk-17-jdk COPY . /datahub-src ARG RELEASE_VERSION RUN cd /datahub-src/metadata-ingestion && \ - sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" src/datahub/__init__.py && \ + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/datahub/__init__.py && \ cat src/datahub/__init__.py && \ cd ../ && \ ./gradlew :metadata-ingestion:installAll diff --git a/docker/datahub-ingestion/Dockerfile b/docker/datahub-ingestion/Dockerfile index 2898a363a0a185..4f0e66251b1542 100644 --- a/docker/datahub-ingestion/Dockerfile +++ b/docker/datahub-ingestion/Dockerfile @@ -13,8 +13,8 @@ COPY ./metadata-ingestion-modules/airflow-plugin /datahub-ingestion/airflow-plug ARG RELEASE_VERSION WORKDIR /datahub-ingestion -RUN sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" src/datahub/__init__.py && \ - sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" airflow-plugin/src/datahub_airflow_plugin/__init__.py && \ +RUN sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/datahub/__init__.py && \ + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" airflow-plugin/src/datahub_airflow_plugin/__init__.py && \ cat src/datahub/__init__.py && \ chown -R datahub /datahub-ingestion diff --git a/docker/datahub-ingestion/Dockerfile-slim-only b/docker/datahub-ingestion/Dockerfile-slim-only index 4112f470c25bee..24412958a2d08c 100644 --- a/docker/datahub-ingestion/Dockerfile-slim-only +++ b/docker/datahub-ingestion/Dockerfile-slim-only @@ -10,7 +10,7 @@ COPY ./metadata-ingestion /datahub-ingestion ARG RELEASE_VERSION WORKDIR /datahub-ingestion -RUN sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" src/datahub/__init__.py && \ +RUN sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/datahub/__init__.py && \ cat src/datahub/__init__.py && \ chown -R datahub /datahub-ingestion diff --git a/docker/elasticsearch-setup/Dockerfile b/docker/elasticsearch-setup/Dockerfile index ea64f94f887276..fdaf9ddbaf813b 100644 --- a/docker/elasticsearch-setup/Dockerfile +++ b/docker/elasticsearch-setup/Dockerfile @@ -44,9 +44,9 @@ FROM base AS dev-install # See this excellent thread https://github.com/docker/cli/issues/1134 FROM ${APP_ENV}-install AS final + CMD if [ "$ELASTICSEARCH_USE_SSL" == "true" ]; then ELASTICSEARCH_PROTOCOL=https; else ELASTICSEARCH_PROTOCOL=http; fi \ && if [[ -n "$ELASTICSEARCH_USERNAME" ]]; then ELASTICSEARCH_HTTP_HEADERS="Authorization: Basic $(echo -ne "$ELASTICSEARCH_USERNAME:$ELASTICSEARCH_PASSWORD" | base64)"; else ELASTICSEARCH_HTTP_HEADERS="Accept: */*"; fi \ && if [[ "$SKIP_ELASTICSEARCH_CHECK" != "true" ]]; then \ dockerize -wait $ELASTICSEARCH_PROTOCOL://$ELASTICSEARCH_HOST:$ELASTICSEARCH_PORT -wait-http-header "${ELASTICSEARCH_HTTP_HEADERS}" -timeout 120s /create-indices.sh; \ else /create-indices.sh; fi - diff --git a/docker/profiles/docker-compose.gms.yml b/docker/profiles/docker-compose.gms.yml index f863dff7a59c5a..769bce3105a7f1 100644 --- a/docker/profiles/docker-compose.gms.yml +++ b/docker/profiles/docker-compose.gms.yml @@ -64,6 +64,8 @@ x-datahub-system-update-service: &datahub-system-update-service SCHEMA_REGISTRY_SYSTEM_UPDATE: ${SCHEMA_REGISTRY_SYSTEM_UPDATE:-true} SPRING_KAFKA_PROPERTIES_AUTO_REGISTER_SCHEMAS: ${SPRING_KAFKA_PROPERTIES_AUTO_REGISTER_SCHEMAS:-true} SPRING_KAFKA_PROPERTIES_USE_LATEST_VERSION: ${SPRING_KAFKA_PROPERTIES_USE_LATEST_VERSION:-true} + volumes: + - ${HOME}/.datahub/plugins:/etc/datahub/plugins x-datahub-system-update-service-dev: &datahub-system-update-service-dev <<: *datahub-system-update-service @@ -99,6 +101,8 @@ x-datahub-gms-service: &datahub-gms-service timeout: 5s volumes: - ${HOME}/.datahub/plugins:/etc/datahub/plugins + labels: + io.datahubproject.datahub.component: "gms" x-datahub-gms-service-dev: &datahub-gms-service-dev <<: *datahub-gms-service diff --git a/docs-website/graphql/generateGraphQLSchema.sh b/docs-website/graphql/generateGraphQLSchema.sh index 4e41c5dfbfacd3..c6d7ec528b6137 100755 --- a/docs-website/graphql/generateGraphQLSchema.sh +++ b/docs-website/graphql/generateGraphQLSchema.sh @@ -16,3 +16,5 @@ cat ../../datahub-graphql-core/src/main/resources/tests.graphql >> combined.grap cat ../../datahub-graphql-core/src/main/resources/timeline.graphql >> combined.graphql cat ../../datahub-graphql-core/src/main/resources/step.graphql >> combined.graphql cat ../../datahub-graphql-core/src/main/resources/lineage.graphql >> combined.graphql +cat ../../datahub-graphql-core/src/main/resources/properties.graphql >> combined.graphql +cat ../../datahub-graphql-core/src/main/resources/forms.graphql >> combined.graphql \ No newline at end of file diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index 2b8873c6787783..1e6d8bec018138 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -561,9 +561,18 @@ module.exports = { ], }, { - type: "doc", - label: "OpenAPI", - id: "docs/api/openapi/openapi-usage-guide", + OpenAPI: [ + { + type: "doc", + label: "OpenAPI", + id: "docs/api/openapi/openapi-usage-guide", + }, + { + type: "doc", + label: "Structured Properties", + id: "docs/api/openapi/openapi-structured-properties", + }, + ], }, "docs/dev-guides/timeline", { @@ -768,6 +777,7 @@ module.exports = { // "docs/how/add-user-data", // "docs/_feature-guide-template" // - "metadata-service/services/README" + // "metadata-ingestion/examples/structured_properties/README" // ], ], }; diff --git a/docs-website/src/pages/_components/TownhallButton/townhallbutton.module.scss b/docs-website/src/pages/_components/TownhallButton/townhallbutton.module.scss index 3d30c65f89539a..862fb04c8370b1 100644 --- a/docs-website/src/pages/_components/TownhallButton/townhallbutton.module.scss +++ b/docs-website/src/pages/_components/TownhallButton/townhallbutton.module.scss @@ -26,4 +26,4 @@ background-image: linear-gradient(to right, #1890ff 0%, #48DBFB 100%); background-origin: border-box; } - } \ No newline at end of file + } diff --git a/docs/api/openapi/openapi-structured-properties.md b/docs/api/openapi/openapi-structured-properties.md new file mode 100644 index 00000000000000..521ce8789db0d4 --- /dev/null +++ b/docs/api/openapi/openapi-structured-properties.md @@ -0,0 +1,284 @@ +# Structured Properties - DataHub OpenAPI v2 Guide + +This guides walks through the process of creating and using a Structured Property using the `v2` version +of the DataHub OpenAPI implementation. Note that this refers to DataHub's OpenAPI version and not the version of OpenAPI itself. + +Requirements: +* curl +* jq + +## Structured Property Definition + +Before a structured property can be added to an entity it must first be defined. Here is an example +structured property being created against a local quickstart instance. + +### Create Property Definition + +Example Request: + +```shell +curl -X 'POST' -v \ + 'http://localhost:8080/openapi/v2/entity/structuredProperty/urn%3Ali%3AstructuredProperty%3Amy.test.MyProperty01/propertyDefinition' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "qualifiedName": "my.test.MyProperty01", + "displayName": "MyProperty01", + "valueType": "urn:li:dataType:datahub.string", + "allowedValues": [ + { + "value": {"string": "foo"}, + "description": "test foo value" + }, + { + "value": {"string": "bar"}, + "description": "test bar value" + } + ], + "cardinality": "SINGLE", + "entityTypes": [ + "urn:li:entityType:datahub.dataset" + ], + "description": "test description" +}' | jq +``` + +### Read Property Definition + +Example Request: + +```shell +curl -X 'GET' -v \ + 'http://localhost:8080/openapi/v2/entity/structuredProperty/urn%3Ali%3AstructuredProperty%3Amy.test.MyProperty01/propertyDefinition' \ + -H 'accept: application/json' | jq +``` + +Example Response: + +```json +{ + "value": { + "allowedValues": [ + { + "value": { + "string": "foo" + }, + "description": "test foo value" + }, + { + "value": { + "string": "bar" + }, + "description": "test bar value" + } + ], + "qualifiedName": "my.test.MyProperty01", + "displayName": "MyProperty01", + "valueType": "urn:li:dataType:datahub.string", + "description": "test description", + "entityTypes": [ + "urn:li:entityType:datahub.dataset" + ], + "cardinality": "SINGLE" + } +} +``` + +### Delete Property Definition + +⚠ **Not Implemented** ⚠ + +## Applying Structured Properties + +Structured Properties can now be added to entities which have the `structuredProperties` as aspect. In the following +example we'll attach and remove properties to an example dataset entity with urn `urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)`. + +### Set Structured Property Values + +This will set/replace all structured properties on the entity. See `PATCH` operations to add/remove a single property. + +```shell +curl -X 'POST' -v \ + 'http://localhost:8080/openapi/v2/entity/dataset/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2CSampleHiveDataset%2CPROD%29/structuredProperties' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "properties": [ + { + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty01", + "values": [ + {"string": "foo"} + ] + } + ] +}' | jq +``` + +### Patch Structured Property Value + +For this example, we'll extend create a second structured property and apply both properties to the same +dataset used previously. After this your system should include both `my.test.MyProperty01` and `my.test.MyProperty02`. + +```shell +curl -X 'POST' -v \ + 'http://localhost:8080/openapi/v2/entity/structuredProperty/urn%3Ali%3AstructuredProperty%3Amy.test.MyProperty02/propertyDefinition' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "qualifiedName": "my.test.MyProperty02", + "displayName": "MyProperty02", + "valueType": "urn:li:dataType:datahub.string", + "allowedValues": [ + { + "value": {"string": "foo2"}, + "description": "test foo2 value" + }, + { + "value": {"string": "bar2"}, + "description": "test bar2 value" + } + ], + "cardinality": "SINGLE", + "entityTypes": [ + "urn:li:entityType:datahub.dataset" + ] +}' | jq +``` + +This command will attach one of each of the two properties to our test dataset `urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)`. + +```shell +curl -X 'POST' -v \ + 'http://localhost:8080/openapi/v2/entity/dataset/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2CSampleHiveDataset%2CPROD%29/structuredProperties' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "properties": [ + { + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty01", + "values": [ + {"string": "foo"} + ] + }, + { + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty02", + "values": [ + {"string": "bar2"} + ] + } + ] +}' | jq +``` + +#### Remove Structured Property Value + +The expected state of our test dataset include 2 structured properties. We'd like to remove the first one and preserve +the second property. + +```shell +curl -X 'PATCH' -v \ + 'http://localhost:8080/openapi/v2/entity/dataset/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2CSampleHiveDataset%2CPROD%29/structuredProperties' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json-patch+json' \ + -d '{ + "patch": [ + { + "op": "remove", + "path": "/properties/urn:li:structuredProperty:my.test.MyProperty01" + } + ], + "arrayPrimaryKeys": { + "properties": [ + "propertyUrn" + ] + } + }' | jq +``` + +The response will show that the expected property has been removed. + +```json +{ + "urn": "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)", + "aspects": { + "structuredProperties": { + "value": { + "properties": [ + { + "values": [ + { + "string": "bar2" + } + ], + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty02" + } + ] + } + } + } +} +``` + +#### Add Structured Property Value + +In this example, we'll add the property back with a different value, preserving the existing property. + +```shell +curl -X 'PATCH' -v \ + 'http://localhost:8080/openapi/v2/entity/dataset/urn%3Ali%3Adataset%3A%28urn%3Ali%3AdataPlatform%3Ahive%2CSampleHiveDataset%2CPROD%29/structuredProperties' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json-patch+json' \ + -d '{ + "patch": [ + { + "op": "add", + "path": "/properties/urn:li:structuredProperty:my.test.MyProperty01", + "value": { + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty01", + "values": [ + { + "string": "bar" + } + ] + } + } + ], + "arrayPrimaryKeys": { + "properties": [ + "propertyUrn" + ] + } + }' | jq +``` + +The response shows that the property was re-added with the new value `bar` instead of the previous value `foo`. + +```json +{ + "urn": "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)", + "aspects": { + "structuredProperties": { + "value": { + "properties": [ + { + "values": [ + { + "string": "bar2" + } + ], + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty02" + }, + { + "values": [ + { + "string": "bar" + } + ], + "propertyUrn": "urn:li:structuredProperty:my.test.MyProperty01" + } + ] + } + } + } +} +``` diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java index 83e40b22a5e447..453eddd3ae56c2 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java @@ -1,7 +1,6 @@ package com.linkedin.metadata.aspect.batch; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.mxe.SystemMetadata; import com.linkedin.util.Pair; import java.util.HashSet; @@ -33,14 +32,12 @@ default List getMCPItems() { } Pair>, List> toUpsertBatchItems( - Map> latestAspects, - EntityRegistry entityRegistry, - AspectRetriever aspectRetriever); + Map> latestAspects, AspectRetriever aspectRetriever); default Stream applyMCPSideEffects( - List items, EntityRegistry entityRegistry, AspectRetriever aspectRetriever) { - return entityRegistry.getAllMCPSideEffects().stream() - .flatMap(mcpSideEffect -> mcpSideEffect.apply(items, entityRegistry, aspectRetriever)); + List items, AspectRetriever aspectRetriever) { + return aspectRetriever.getEntityRegistry().getAllMCPSideEffects().stream() + .flatMap(mcpSideEffect -> mcpSideEffect.apply(items, aspectRetriever)); } default boolean containsDuplicateAspects() { diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCPBatchItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCPBatchItem.java index bb5e0ac53934af..dd0d0ec68dac6c 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCPBatchItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCPBatchItem.java @@ -1,8 +1,8 @@ package com.linkedin.metadata.aspect.batch; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.models.AspectSpec; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.mxe.MetadataChangeProposal; import javax.annotation.Nullable; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/PatchItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/PatchItem.java index f790c12ee53354..e9e30f7f2bd96f 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/PatchItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/PatchItem.java @@ -3,7 +3,6 @@ import com.github.fge.jsonpatch.Patch; import com.linkedin.data.template.RecordTemplate; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; -import com.linkedin.metadata.models.registry.EntityRegistry; /** * A change proposal represented as a patch to an exiting stored object in the primary data store. @@ -13,14 +12,11 @@ public abstract class PatchItem extends MCPBatchItem { /** * Convert a Patch to an Upsert * - * @param entityRegistry the entity registry * @param recordTemplate the current value record template * @return the upsert */ public abstract UpsertItem applyPatch( - EntityRegistry entityRegistry, - RecordTemplate recordTemplate, - AspectRetriever aspectRetriever); + RecordTemplate recordTemplate, AspectRetriever aspectRetriever); public abstract Patch getPatch(); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java index 4e4d2a38799dcc..c337e4f848e5c7 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java @@ -3,7 +3,6 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; -import com.linkedin.metadata.models.registry.EntityRegistry; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -17,8 +16,6 @@ public abstract class UpsertItem extends MCPBatchItem { public abstract SystemAspect toLatestEntityAspect(); public abstract void validatePreCommit( - @Nullable RecordTemplate previous, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) + @Nullable RecordTemplate previous, @Nonnull AspectRetriever aspectRetriever) throws AspectValidationException; } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java new file mode 100644 index 00000000000000..c73ccbb2d93e33 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java @@ -0,0 +1,34 @@ +package com.linkedin.metadata.aspect.patch; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonpatch.JsonPatch; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GenericJsonPatch { + @Nullable private Map> arrayPrimaryKeys; + + @Nonnull private JsonNode patch; + + @Nonnull + public Map> getArrayPrimaryKeys() { + return arrayPrimaryKeys == null ? Map.of() : arrayPrimaryKeys; + } + + @JsonIgnore + public JsonPatch getJsonPatch() throws IOException { + return JsonPatch.fromJson(patch); + } +} diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/PatchOperationType.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/PatchOperationType.java similarity index 81% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/PatchOperationType.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/PatchOperationType.java index ac93fd24fee02e..6eaa6069267ba0 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/PatchOperationType.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/PatchOperationType.java @@ -1,4 +1,4 @@ -package datahub.client.patch; +package com.linkedin.metadata.aspect.patch; import lombok.Getter; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/AbstractMultiFieldPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/AbstractMultiFieldPatchBuilder.java similarity index 95% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/AbstractMultiFieldPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/AbstractMultiFieldPatchBuilder.java index 943aaefec469ba..165a4d26c339cc 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/AbstractMultiFieldPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/AbstractMultiFieldPatchBuilder.java @@ -1,6 +1,6 @@ -package datahub.client.patch; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -13,7 +13,6 @@ import java.util.ArrayList; import java.util.List; import org.apache.commons.lang3.tuple.ImmutableTriple; -import org.apache.http.entity.ContentType; public abstract class AbstractMultiFieldPatchBuilder> { @@ -87,7 +86,7 @@ protected GenericAspect buildPatch() { .set(VALUE_KEY, triple.right))); GenericAspect genericAspect = new GenericAspect(); - genericAspect.setContentType(ContentType.APPLICATION_JSON.getMimeType()); + genericAspect.setContentType("application/json"); genericAspect.setValue(ByteString.copyString(patches.toString(), StandardCharsets.UTF_8)); return genericAspect; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/ChartInfoPatchBuilder.java similarity index 75% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/ChartInfoPatchBuilder.java index 0655d2b3eb8eb2..09f9dad134a0b0 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/chart/ChartInfoPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/ChartInfoPatchBuilder.java @@ -1,12 +1,12 @@ -package datahub.client.patch.chart; +package com.linkedin.metadata.aspect.patch.builder; -import static com.linkedin.metadata.Constants.*; -import static datahub.client.patch.common.PatchUtil.*; +import static com.linkedin.metadata.Constants.CHART_ENTITY_NAME; +import static com.linkedin.metadata.Constants.CHART_INFO_ASPECT_NAME; +import static com.linkedin.metadata.aspect.patch.builder.PatchUtil.createEdgeValue; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.urn.Urn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/CustomPropertiesPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/CustomPropertiesPatchBuilder.java similarity index 90% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/CustomPropertiesPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/CustomPropertiesPatchBuilder.java index e621aaf57ff97d..e4143851afbe51 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/CustomPropertiesPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/CustomPropertiesPatchBuilder.java @@ -1,12 +1,11 @@ -package datahub.client.patch.common; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; -import datahub.client.patch.subtypesupport.IntermediatePatchBuilder; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.builder.subtypesupport.IntermediatePatchBuilder; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DashboardInfoPatchBuilder.java similarity index 86% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DashboardInfoPatchBuilder.java index cadde582f1c64a..9156b304a394e9 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dashboard/DashboardInfoPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DashboardInfoPatchBuilder.java @@ -1,15 +1,17 @@ -package datahub.client.patch.dashboard; +package com.linkedin.metadata.aspect.patch.builder; -import static com.linkedin.metadata.Constants.*; -import static datahub.client.patch.common.PatchUtil.*; +import static com.linkedin.metadata.Constants.CHART_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DASHBOARD_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DASHBOARD_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; +import static com.linkedin.metadata.aspect.patch.builder.PatchUtil.createEdgeValue; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.Edge; import com.linkedin.common.urn.ChartUrn; import com.linkedin.common.urn.DatasetUrn; import com.linkedin.common.urn.Urn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataflow/DataFlowInfoPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataFlowInfoPatchBuilder.java similarity index 92% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataflow/DataFlowInfoPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataFlowInfoPatchBuilder.java index 9e55ab4fc6db44..6a114d90875fe3 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataflow/DataFlowInfoPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataFlowInfoPatchBuilder.java @@ -1,15 +1,14 @@ -package datahub.client.patch.dataflow; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATA_FLOW_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATA_FLOW_INFO_ASPECT_NAME; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.TimeStamp; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; -import datahub.client.patch.common.CustomPropertiesPatchBuilder; -import datahub.client.patch.subtypesupport.CustomPropertiesPatchBuilderSupport; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.builder.subtypesupport.CustomPropertiesPatchBuilderSupport; import java.util.List; import java.util.Map; import javax.annotation.Nonnull; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInfoPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInfoPatchBuilder.java similarity index 93% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInfoPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInfoPatchBuilder.java index 581616f54e9b91..99c0ac6c15eb1a 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInfoPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInfoPatchBuilder.java @@ -1,16 +1,15 @@ -package datahub.client.patch.datajob; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATA_JOB_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATA_JOB_INFO_ASPECT_NAME; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.TimeStamp; import com.linkedin.common.urn.DataFlowUrn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; -import datahub.client.patch.common.CustomPropertiesPatchBuilder; -import datahub.client.patch.subtypesupport.CustomPropertiesPatchBuilderSupport; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.builder.subtypesupport.CustomPropertiesPatchBuilderSupport; import java.util.List; import java.util.Map; import javax.annotation.Nonnull; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInputOutputPatchBuilder.java similarity index 93% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInputOutputPatchBuilder.java index fc250daffe916d..8e2168e5b6a338 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/datajob/DataJobInputOutputPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DataJobInputOutputPatchBuilder.java @@ -1,8 +1,10 @@ -package datahub.client.patch.datajob; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; -import static datahub.client.patch.common.PatchUtil.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATA_JOB_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATA_JOB_INPUT_OUTPUT_ASPECT_NAME; +import static com.linkedin.metadata.aspect.patch.builder.PatchUtil.createEdgeValue; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; @@ -10,9 +12,8 @@ import com.linkedin.common.urn.DataJobUrn; import com.linkedin.common.urn.DatasetUrn; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import com.linkedin.metadata.graph.LineageDirection; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; import javax.annotation.Nonnull; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/DatasetPropertiesPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DatasetPropertiesPatchBuilder.java similarity index 91% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/DatasetPropertiesPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DatasetPropertiesPatchBuilder.java index f4329c84f33ffa..31e181fc244fba 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/DatasetPropertiesPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/DatasetPropertiesPatchBuilder.java @@ -1,13 +1,12 @@ -package datahub.client.patch.dataset; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DATASET_PROPERTIES_ASPECT_NAME; import com.fasterxml.jackson.databind.JsonNode; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; -import datahub.client.patch.common.CustomPropertiesPatchBuilder; -import datahub.client.patch.subtypesupport.CustomPropertiesPatchBuilderSupport; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.builder.subtypesupport.CustomPropertiesPatchBuilderSupport; import java.util.List; import java.util.Map; import javax.annotation.Nonnull; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/EditableSchemaMetadataPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/EditableSchemaMetadataPatchBuilder.java similarity index 90% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/EditableSchemaMetadataPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/EditableSchemaMetadataPatchBuilder.java index 6478b31d27ef03..5e9e1911925faa 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/EditableSchemaMetadataPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/EditableSchemaMetadataPatchBuilder.java @@ -1,15 +1,15 @@ -package datahub.client.patch.dataset; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; +import static com.linkedin.metadata.Constants.EDITABLE_SCHEMA_METADATA_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.GlossaryTermAssociation; import com.linkedin.common.TagAssociation; import com.linkedin.common.urn.GlossaryTermUrn; import com.linkedin.common.urn.TagUrn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlobalTagsPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java similarity index 88% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlobalTagsPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java index 84db0ba307cf27..ff34b187f6151e 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlobalTagsPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java @@ -1,12 +1,11 @@ -package datahub.client.patch.common; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.GLOBAL_TAGS_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.TagUrn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlossaryTermsPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlossaryTermsPatchBuilder.java similarity index 89% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlossaryTermsPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlossaryTermsPatchBuilder.java index 6f31025406b1b0..16d9beded30662 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/GlossaryTermsPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlossaryTermsPatchBuilder.java @@ -1,12 +1,11 @@ -package datahub.client.patch.common; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.GLOSSARY_TERMS_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.urn.GlossaryTermUrn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/OwnershipPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/OwnershipPatchBuilder.java similarity index 91% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/OwnershipPatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/OwnershipPatchBuilder.java index 20e0c930a8c958..35a647424a88aa 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/OwnershipPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/OwnershipPatchBuilder.java @@ -1,13 +1,12 @@ -package datahub.client.patch.common; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.OWNERSHIP_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.OwnershipType; import com.linkedin.common.urn.Urn; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/PatchUtil.java similarity index 96% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/PatchUtil.java index 69db36c6e038cf..7556a8b1d94186 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/common/PatchUtil.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/PatchUtil.java @@ -1,7 +1,7 @@ -package datahub.client.patch.common; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.UNKNOWN_ACTOR; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.Edge; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/StructuredPropertiesPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/StructuredPropertiesPatchBuilder.java new file mode 100644 index 00000000000000..fab81e0af5bf51 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/StructuredPropertiesPatchBuilder.java @@ -0,0 +1,110 @@ +package com.linkedin.metadata.aspect.patch.builder; + +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.ValueNode; +import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.apache.commons.lang3.tuple.ImmutableTriple; + +public class StructuredPropertiesPatchBuilder + extends AbstractMultiFieldPatchBuilder { + + private static final String BASE_PATH = "/properties"; + private static final String URN_KEY = "urn"; + private static final String CONTEXT_KEY = "context"; + + /** + * Remove a property from a structured properties aspect. If the property doesn't exist, this is a + * no-op. + * + * @param propertyUrn + * @return + */ + public StructuredPropertiesPatchBuilder removeProperty(Urn propertyUrn) { + pathValues.add( + ImmutableTriple.of( + PatchOperationType.REMOVE.getValue(), BASE_PATH + "/" + propertyUrn, null)); + return this; + } + + public StructuredPropertiesPatchBuilder setProperty( + @Nonnull Urn propertyUrn, @Nullable List propertyValues) { + propertyValues.stream() + .map( + propertyValue -> + propertyValue instanceof Integer + ? this.setProperty(propertyUrn, (Integer) propertyValue) + : this.setProperty(propertyUrn, String.valueOf(propertyValue))) + .collect(Collectors.toList()); + return this; + } + + public StructuredPropertiesPatchBuilder setProperty( + @Nonnull Urn propertyUrn, @Nullable Integer propertyValue) { + ValueNode propertyValueNode = instance.numberNode((Integer) propertyValue); + ObjectNode value = instance.objectNode(); + value.put(URN_KEY, propertyUrn.toString()); + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), BASE_PATH + "/" + propertyUrn, propertyValueNode)); + return this; + } + + public StructuredPropertiesPatchBuilder setProperty( + @Nonnull Urn propertyUrn, @Nullable String propertyValue) { + ValueNode propertyValueNode = instance.textNode(String.valueOf(propertyValue)); + ObjectNode value = instance.objectNode(); + value.put(URN_KEY, propertyUrn.toString()); + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), BASE_PATH + "/" + propertyUrn, propertyValueNode)); + return this; + } + + public StructuredPropertiesPatchBuilder addProperty( + @Nonnull Urn propertyUrn, @Nullable Integer propertyValue) { + ValueNode propertyValueNode = instance.numberNode((Integer) propertyValue); + ObjectNode value = instance.objectNode(); + value.put(URN_KEY, propertyUrn.toString()); + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), + BASE_PATH + "/" + propertyUrn + "/" + String.valueOf(propertyValue), + propertyValueNode)); + return this; + } + + public StructuredPropertiesPatchBuilder addProperty( + @Nonnull Urn propertyUrn, @Nullable String propertyValue) { + ValueNode propertyValueNode = instance.textNode(String.valueOf(propertyValue)); + ObjectNode value = instance.objectNode(); + value.put(URN_KEY, propertyUrn.toString()); + pathValues.add( + ImmutableTriple.of( + PatchOperationType.ADD.getValue(), + BASE_PATH + "/" + propertyUrn + "/" + String.valueOf(propertyValue), + propertyValueNode)); + return this; + } + + @Override + protected String getAspectName() { + return STRUCTURED_PROPERTIES_ASPECT_NAME; + } + + @Override + protected String getEntityType() { + if (this.targetEntityUrn == null) { + throw new IllegalStateException( + "Target Entity Urn must be set to determine entity type before building Patch."); + } + return this.targetEntityUrn.getEntityType(); + } +} diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/UpstreamLineagePatchBuilder.java similarity index 96% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/UpstreamLineagePatchBuilder.java index 9db2ebc522e093..bfb46d8fc5773b 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/dataset/UpstreamLineagePatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/UpstreamLineagePatchBuilder.java @@ -1,7 +1,9 @@ -package datahub.client.patch.dataset; +package com.linkedin.metadata.aspect.patch.builder; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; +import static com.linkedin.metadata.Constants.UNKNOWN_ACTOR; +import static com.linkedin.metadata.Constants.UPSTREAM_LINEAGE_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.urn.DatasetUrn; @@ -9,8 +11,7 @@ import com.linkedin.dataset.DatasetLineageType; import com.linkedin.dataset.FineGrainedLineageDownstreamType; import com.linkedin.dataset.FineGrainedLineageUpstreamType; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; -import datahub.client.patch.PatchOperationType; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.ToString; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/CustomPropertiesPatchBuilderSupport.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/CustomPropertiesPatchBuilderSupport.java similarity index 81% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/CustomPropertiesPatchBuilderSupport.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/CustomPropertiesPatchBuilderSupport.java index 9f221bac15be40..5e1cd094b204e0 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/CustomPropertiesPatchBuilderSupport.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/CustomPropertiesPatchBuilderSupport.java @@ -1,6 +1,6 @@ -package datahub.client.patch.subtypesupport; +package com.linkedin.metadata.aspect.patch.builder.subtypesupport; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.AbstractMultiFieldPatchBuilder; import java.util.Map; import javax.annotation.Nonnull; diff --git a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/IntermediatePatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/IntermediatePatchBuilder.java similarity index 83% rename from metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/IntermediatePatchBuilder.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/IntermediatePatchBuilder.java index e3b14c0838ad64..d891a6b9673da0 100644 --- a/metadata-integration/java/datahub-client/src/main/java/datahub/client/patch/subtypesupport/IntermediatePatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/subtypesupport/IntermediatePatchBuilder.java @@ -1,7 +1,7 @@ -package datahub.client.patch.subtypesupport; +package com.linkedin.metadata.aspect.patch.builder.subtypesupport; import com.fasterxml.jackson.databind.JsonNode; -import datahub.client.patch.AbstractMultiFieldPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.AbstractMultiFieldPatchBuilder; import java.util.List; import org.apache.commons.lang3.tuple.ImmutableTriple; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/ArrayMergingTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/ArrayMergingTemplate.java similarity index 98% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/ArrayMergingTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/ArrayMergingTemplate.java index 9cd8e74d952d6d..ff721e97c0e1d7 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/ArrayMergingTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/ArrayMergingTemplate.java @@ -1,6 +1,6 @@ -package com.linkedin.metadata.models.registry.template; +package com.linkedin.metadata.aspect.patch.template; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java similarity index 71% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java index 029eb688c5291a..e9d09085e7eb5e 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/AspectTemplateEngine.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java @@ -1,6 +1,18 @@ -package com.linkedin.metadata.models.registry.template; +package com.linkedin.metadata.aspect.patch.template; -import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.CHART_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DASHBOARD_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATASET_PROPERTIES_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATA_FLOW_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATA_JOB_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATA_JOB_INPUT_OUTPUT_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATA_PRODUCT_PROPERTIES_ASPECT_NAME; +import static com.linkedin.metadata.Constants.EDITABLE_SCHEMA_METADATA_ASPECT_NAME; +import static com.linkedin.metadata.Constants.GLOBAL_TAGS_ASPECT_NAME; +import static com.linkedin.metadata.Constants.GLOSSARY_TERMS_ASPECT_NAME; +import static com.linkedin.metadata.Constants.OWNERSHIP_ASPECT_NAME; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; +import static com.linkedin.metadata.Constants.UPSTREAM_LINEAGE_ASPECT_NAME; import com.fasterxml.jackson.core.JsonProcessingException; import com.github.fge.jsonpatch.JsonPatchException; @@ -34,7 +46,8 @@ public class AspectTemplateEngine { DATA_PRODUCT_PROPERTIES_ASPECT_NAME, DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, CHART_INFO_ASPECT_NAME, - DASHBOARD_INFO_ASPECT_NAME) + DASHBOARD_INFO_ASPECT_NAME, + STRUCTURED_PROPERTIES_ASPECT_NAME) .collect(Collectors.toSet()); private final Map> _aspectTemplateMap; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/CompoundKeyTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/CompoundKeyTemplate.java new file mode 100644 index 00000000000000..78cf14c47a0bf1 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/CompoundKeyTemplate.java @@ -0,0 +1,23 @@ +package com.linkedin.metadata.aspect.patch.template; + +import static com.linkedin.metadata.aspect.patch.template.TemplateUtil.populateTopLevelKeys; + +import com.datahub.util.RecordUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonpatch.JsonPatchException; +import com.github.fge.jsonpatch.Patch; +import com.linkedin.data.template.RecordTemplate; + +public abstract class CompoundKeyTemplate + implements ArrayMergingTemplate { + + @Override + public T applyPatch(RecordTemplate recordTemplate, Patch jsonPatch) + throws JsonProcessingException, JsonPatchException { + JsonNode transformed = populateTopLevelKeys(preprocessTemplate(recordTemplate), jsonPatch); + JsonNode patched = jsonPatch.apply(transformed); + JsonNode postProcessed = rebaseFields(patched); + return RecordUtils.toRecordTemplate(getTemplateType(), postProcessed.toString()); + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/Template.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/Template.java similarity index 69% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/Template.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/Template.java index 0793cacce780f0..bd8cd544fb59be 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/Template.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/Template.java @@ -1,6 +1,7 @@ -package com.linkedin.metadata.models.registry.template; +package com.linkedin.metadata.aspect.patch.template; -import static com.linkedin.metadata.models.registry.template.util.TemplateUtil.*; +import static com.linkedin.metadata.aspect.patch.template.TemplateUtil.OBJECT_MAPPER; +import static com.linkedin.metadata.aspect.patch.template.TemplateUtil.populateTopLevelKeys; import com.datahub.util.RecordUtils; import com.fasterxml.jackson.core.JsonProcessingException; @@ -19,7 +20,12 @@ public interface Template { * @return specific type for this template * @throws {@link ClassCastException} when recordTemplate is not the correct type for the template */ - T getSubtype(RecordTemplate recordTemplate) throws ClassCastException; + default T getSubtype(RecordTemplate recordTemplate) throws ClassCastException { + if (getTemplateType().isInstance(recordTemplate)) { + return getTemplateType().cast(recordTemplate); + } + throw new ClassCastException("Unable to cast RecordTemplate to " + getTemplateType().getName()); + } /** Get the template clas type */ Class getTemplateType(); @@ -43,10 +49,20 @@ public interface Template { */ default T applyPatch(RecordTemplate recordTemplate, Patch jsonPatch) throws JsonProcessingException, JsonPatchException { - JsonNode transformed = preprocessTemplate(recordTemplate); - JsonNode patched = jsonPatch.apply(transformed); - JsonNode postProcessed = rebaseFields(patched); - return RecordUtils.toRecordTemplate(getTemplateType(), postProcessed.toString()); + + TemplateUtil.validatePatch(jsonPatch); + JsonNode transformed = populateTopLevelKeys(preprocessTemplate(recordTemplate), jsonPatch); + try { + JsonNode patched = jsonPatch.apply(transformed); + JsonNode postProcessed = rebaseFields(patched); + return RecordUtils.toRecordTemplate(getTemplateType(), postProcessed.toString()); + } catch (JsonPatchException e) { + throw new RuntimeException( + String.format( + "Error performing JSON PATCH on aspect %s. Patch: %s Target: %s", + recordTemplate.schema().getName(), jsonPatch, transformed.toString()), + e); + } } /** diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/TemplateUtil.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/TemplateUtil.java new file mode 100644 index 00000000000000..d998692f2c3889 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/TemplateUtil.java @@ -0,0 +1,97 @@ +package com.linkedin.metadata.aspect.patch.template; + +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.INGESTION_MAX_SERIALIZED_STRING_LENGTH; +import static com.linkedin.metadata.Constants.MAX_JACKSON_STRING_SIZE; + +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jsonpatch.Patch; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import com.linkedin.util.Pair; +import java.util.ArrayList; +import java.util.List; + +public class TemplateUtil { + + private TemplateUtil() {} + + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + static { + int maxSize = + Integer.parseInt( + System.getenv() + .getOrDefault(INGESTION_MAX_SERIALIZED_STRING_LENGTH, MAX_JACKSON_STRING_SIZE)); + OBJECT_MAPPER + .getFactory() + .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build()); + } + + public static List> getPaths(Patch jsonPatch) { + JsonNode patchNode = OBJECT_MAPPER.valueToTree(jsonPatch); + List> paths = new ArrayList<>(); + patchNode + .elements() + .forEachRemaining( + node -> + paths.add( + Pair.of( + PatchOperationType.valueOf(node.get("op").asText().toUpperCase()), + node.get("path").asText()))); + return paths; + } + + public static void validatePatch(Patch jsonPatch) { + // ensure supported patch operations + JsonNode patchNode = OBJECT_MAPPER.valueToTree(jsonPatch); + patchNode + .elements() + .forEachRemaining( + node -> { + try { + PatchOperationType.valueOf(node.get("op").asText().toUpperCase()); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Unsupported PATCH operation: `%s` Operation `%s`", + node.get("op").asText(), node), + e); + } + }); + } + + /** + * Necessary step for templates with compound keys due to JsonPatch not allowing non-existent + * paths to be specified + * + * @param transformedNode transformed node to have keys populated + * @return transformed node that has top level keys populated + */ + public static JsonNode populateTopLevelKeys(JsonNode transformedNode, Patch jsonPatch) { + JsonNode transformedNodeClone = transformedNode.deepCopy(); + List> paths = getPaths(jsonPatch); + for (Pair operationPath : paths) { + String[] keys = operationPath.getSecond().split("/"); + JsonNode parent = transformedNodeClone; + + // if not remove, skip last key as we only need to populate top level + int endIdx = + PatchOperationType.REMOVE.equals(operationPath.getFirst()) + ? keys.length + : keys.length - 1; + + // Skip first as it will always be blank due to path starting with / + for (int i = 1; i < endIdx; i++) { + if (parent.get(keys[i]) == null) { + ((ObjectNode) parent).set(keys[i], instance.objectNode()); + } + parent = parent.get(keys[i]); + } + } + + return transformedNodeClone; + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/chart/ChartInfoTemplate.java similarity index 92% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/chart/ChartInfoTemplate.java index 654f923e7322d0..aabc5b54cfa5c5 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/chart/ChartInfoTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/chart/ChartInfoTemplate.java @@ -1,6 +1,6 @@ -package com.linkedin.metadata.models.registry.template.chart; +package com.linkedin.metadata.aspect.patch.template.chart; -import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.chart.ChartDataSourceTypeArray; @@ -10,7 +10,7 @@ import com.linkedin.common.EdgeArray; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GenericPatchTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GenericPatchTemplate.java new file mode 100644 index 00000000000000..3a3e3c99f25a38 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GenericPatchTemplate.java @@ -0,0 +1,59 @@ +package com.linkedin.metadata.aspect.patch.template.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonpatch.JsonPatchException; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.metadata.aspect.patch.GenericJsonPatch; +import com.linkedin.metadata.aspect.patch.template.CompoundKeyTemplate; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import lombok.Builder; + +@Builder +public class GenericPatchTemplate extends CompoundKeyTemplate { + + @Nonnull private final GenericJsonPatch genericJsonPatch; + @Nonnull private final Class templateType; + @Nonnull private final T templateDefault; + + @Nonnull + @Override + public Class getTemplateType() { + return templateType; + } + + @Nonnull + @Override + public T getDefault() { + return templateDefault; + } + + @Nonnull + @Override + public JsonNode transformFields(final JsonNode baseNode) { + JsonNode transformedNode = baseNode; + for (Map.Entry> composite : + genericJsonPatch.getArrayPrimaryKeys().entrySet()) { + transformedNode = arrayFieldToMap(transformedNode, composite.getKey(), composite.getValue()); + } + return transformedNode; + } + + @Nonnull + @Override + public JsonNode rebaseFields(JsonNode patched) { + JsonNode transformedNode = patched; + for (Map.Entry> composite : + genericJsonPatch.getArrayPrimaryKeys().entrySet()) { + transformedNode = + transformedMapToArray(transformedNode, composite.getKey(), composite.getValue()); + } + return transformedNode; + } + + public T applyPatch(RecordTemplate recordTemplate) throws IOException, JsonPatchException { + return super.applyPatch(recordTemplate, genericJsonPatch.getJsonPatch()); + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlobalTagsTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlobalTagsTemplate.java similarity index 90% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlobalTagsTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlobalTagsTemplate.java index a98e60c7397492..dac5e89edc88e4 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlobalTagsTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlobalTagsTemplate.java @@ -1,10 +1,10 @@ -package com.linkedin.metadata.models.registry.template.common; +package com.linkedin.metadata.aspect.patch.template.common; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.common.GlobalTags; import com.linkedin.common.TagAssociationArray; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlossaryTermsTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlossaryTermsTemplate.java similarity index 92% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlossaryTermsTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlossaryTermsTemplate.java index 7ce59916f20737..e6dd1fd523006b 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/GlossaryTermsTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/GlossaryTermsTemplate.java @@ -1,7 +1,7 @@ -package com.linkedin.metadata.models.registry.template.common; +package com.linkedin.metadata.aspect.patch.template.common; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -10,7 +10,7 @@ import com.linkedin.common.GlossaryTerms; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/OwnershipTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/OwnershipTemplate.java similarity index 89% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/OwnershipTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/OwnershipTemplate.java index b850ae830b98c3..0eaed27ec4cb7d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/common/OwnershipTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/OwnershipTemplate.java @@ -1,6 +1,6 @@ -package com.linkedin.metadata.models.registry.template.common; +package com.linkedin.metadata.aspect.patch.template.common; -import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.common.AuditStamp; @@ -8,7 +8,7 @@ import com.linkedin.common.Ownership; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.CompoundKeyTemplate; +import com.linkedin.metadata.aspect.patch.template.CompoundKeyTemplate; import java.util.Arrays; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/StructuredPropertiesTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/StructuredPropertiesTemplate.java new file mode 100644 index 00000000000000..df3d682632bca9 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/StructuredPropertiesTemplate.java @@ -0,0 +1,56 @@ +package com.linkedin.metadata.aspect.patch.template.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; +import java.util.Collections; +import javax.annotation.Nonnull; + +public class StructuredPropertiesTemplate implements ArrayMergingTemplate { + + private static final String PROPERTIES_FIELD_NAME = "properties"; + private static final String URN_FIELD_NAME = "propertyUrn"; + + // private static final String AUDIT_STAMP_FIELD = "auditStamp"; + // private static final String TIME_FIELD = "time"; + // private static final String ACTOR_FIELD = "actor"; + + @Override + public StructuredProperties getSubtype(RecordTemplate recordTemplate) throws ClassCastException { + if (recordTemplate instanceof StructuredProperties) { + return (StructuredProperties) recordTemplate; + } + throw new ClassCastException("Unable to cast RecordTemplate to StructuredProperties"); + } + + @Override + public Class getTemplateType() { + return StructuredProperties.class; + } + + @Nonnull + @Override + public StructuredProperties getDefault() { + StructuredProperties structuredProperties = new StructuredProperties(); + structuredProperties.setProperties(new StructuredPropertyValueAssignmentArray()); + // .setAuditStamp(new + // AuditStamp().setActor(UrnUtils.getUrn(SYSTEM_ACTOR)).setTime(System.currentTimeMillis())); + return structuredProperties; + } + + @Nonnull + @Override + public JsonNode transformFields(JsonNode baseNode) { + return arrayFieldToMap( + baseNode, PROPERTIES_FIELD_NAME, Collections.singletonList(URN_FIELD_NAME)); + } + + @Nonnull + @Override + public JsonNode rebaseFields(JsonNode patched) { + return transformedMapToArray( + patched, PROPERTIES_FIELD_NAME, Collections.singletonList(URN_FIELD_NAME)); + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dashboard/DashboardInfoTemplate.java similarity index 94% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dashboard/DashboardInfoTemplate.java index eae04b5285adf4..85ce06b01c1d78 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dashboard/DashboardInfoTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dashboard/DashboardInfoTemplate.java @@ -1,6 +1,6 @@ -package com.linkedin.metadata.models.registry.template.dashboard; +package com.linkedin.metadata.aspect.patch.template.dashboard; -import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.common.AuditStamp; @@ -11,7 +11,7 @@ import com.linkedin.common.urn.UrnUtils; import com.linkedin.dashboard.DashboardInfo; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataflow/DataFlowInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataflow/DataFlowInfoTemplate.java similarity index 89% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataflow/DataFlowInfoTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataflow/DataFlowInfoTemplate.java index 73e837f368f0bd..28ee7695219957 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataflow/DataFlowInfoTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataflow/DataFlowInfoTemplate.java @@ -1,10 +1,10 @@ -package com.linkedin.metadata.models.registry.template.dataflow; +package com.linkedin.metadata.aspect.patch.template.dataflow; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringMap; import com.linkedin.datajob.DataFlowInfo; -import com.linkedin.metadata.models.registry.template.Template; +import com.linkedin.metadata.aspect.patch.template.Template; import javax.annotation.Nonnull; public class DataFlowInfoTemplate implements Template { diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInfoTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInfoTemplate.java similarity index 89% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInfoTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInfoTemplate.java index bdb306c2d32e41..7cb986da0cba6e 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInfoTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInfoTemplate.java @@ -1,10 +1,10 @@ -package com.linkedin.metadata.models.registry.template.datajob; +package com.linkedin.metadata.aspect.patch.template.datajob; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringMap; import com.linkedin.datajob.DataJobInfo; -import com.linkedin.metadata.models.registry.template.Template; +import com.linkedin.metadata.aspect.patch.template.Template; import javax.annotation.Nonnull; public class DataJobInfoTemplate implements Template { diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInputOutputTemplate.java similarity index 96% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInputOutputTemplate.java index 6761892b1b31b4..3d398d97b50c38 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/datajob/DataJobInputOutputTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/datajob/DataJobInputOutputTemplate.java @@ -1,4 +1,4 @@ -package com.linkedin.metadata.models.registry.template.datajob; +package com.linkedin.metadata.aspect.patch.template.datajob; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.common.DataJobUrnArray; @@ -8,7 +8,7 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.datajob.DataJobInputOutput; import com.linkedin.dataset.FineGrainedLineageArray; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataproduct/DataProductPropertiesTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataproduct/DataProductPropertiesTemplate.java similarity index 91% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataproduct/DataProductPropertiesTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataproduct/DataProductPropertiesTemplate.java index 899c51a7c3d7ec..9b117114395b12 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataproduct/DataProductPropertiesTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataproduct/DataProductPropertiesTemplate.java @@ -1,10 +1,10 @@ -package com.linkedin.metadata.models.registry.template.dataproduct; +package com.linkedin.metadata.aspect.patch.template.dataproduct; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.data.template.RecordTemplate; import com.linkedin.dataproduct.DataProductAssociationArray; import com.linkedin.dataproduct.DataProductProperties; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/DatasetPropertiesTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/DatasetPropertiesTemplate.java similarity index 91% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/DatasetPropertiesTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/DatasetPropertiesTemplate.java index 991f7f3d4053a3..cf76bed2fd3f7e 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/DatasetPropertiesTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/DatasetPropertiesTemplate.java @@ -1,11 +1,11 @@ -package com.linkedin.metadata.models.registry.template.dataset; +package com.linkedin.metadata.aspect.patch.template.dataset; import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringArray; import com.linkedin.data.template.StringMap; import com.linkedin.dataset.DatasetProperties; -import com.linkedin.metadata.models.registry.template.ArrayMergingTemplate; +import com.linkedin.metadata.aspect.patch.template.ArrayMergingTemplate; import java.util.Collections; import javax.annotation.Nonnull; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/EditableSchemaMetadataTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/EditableSchemaMetadataTemplate.java similarity index 92% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/EditableSchemaMetadataTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/EditableSchemaMetadataTemplate.java index 9712a9081d33a1..0b3605708e610d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/EditableSchemaMetadataTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/EditableSchemaMetadataTemplate.java @@ -1,15 +1,15 @@ -package com.linkedin.metadata.models.registry.template.dataset; +package com.linkedin.metadata.aspect.patch.template.dataset; -import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.metadata.models.registry.template.CompoundKeyTemplate; -import com.linkedin.metadata.models.registry.template.common.GlobalTagsTemplate; -import com.linkedin.metadata.models.registry.template.common.GlossaryTermsTemplate; +import com.linkedin.metadata.aspect.patch.template.CompoundKeyTemplate; +import com.linkedin.metadata.aspect.patch.template.common.GlobalTagsTemplate; +import com.linkedin.metadata.aspect.patch.template.common.GlossaryTermsTemplate; import com.linkedin.schema.EditableSchemaFieldInfoArray; import com.linkedin.schema.EditableSchemaMetadata; import java.util.Collections; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/UpstreamLineageTemplate.java similarity index 96% rename from entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java rename to entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/UpstreamLineageTemplate.java index 81a4065dedb1a2..6907181b3f7ffb 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/dataset/UpstreamLineageTemplate.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/dataset/UpstreamLineageTemplate.java @@ -1,7 +1,10 @@ -package com.linkedin.metadata.models.registry.template.dataset; +package com.linkedin.metadata.aspect.patch.template.dataset; -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.Constants.*; +import static com.fasterxml.jackson.databind.node.JsonNodeFactory.instance; +import static com.linkedin.metadata.Constants.FINE_GRAINED_LINEAGE_DATASET_TYPE; +import static com.linkedin.metadata.Constants.FINE_GRAINED_LINEAGE_FIELD_SET_TYPE; +import static com.linkedin.metadata.Constants.FINE_GRAINED_LINEAGE_FIELD_TYPE; +import static com.linkedin.metadata.Constants.SCHEMA_FIELD_ENTITY_NAME; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -13,7 +16,7 @@ import com.linkedin.dataset.FineGrainedLineageArray; import com.linkedin.dataset.UpstreamArray; import com.linkedin.dataset.UpstreamLineage; -import com.linkedin.metadata.models.registry.template.CompoundKeyTemplate; +import com.linkedin.metadata.aspect.patch.template.CompoundKeyTemplate; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java index dd9bbcda8f4af7..aec0a4cfa07065 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java @@ -27,6 +27,13 @@ @Slf4j public class PluginFactory { + private static final String[] VALIDATOR_PACKAGES = { + "com.linkedin.metadata.aspect.plugins.validation", "com.linkedin.metadata.aspect.validation" + }; + private static final String[] HOOK_PACKAGES = { + "com.linkedin.metadata.aspect.plugins.hooks", "com.linkedin.metadata.aspect.hooks" + }; + public static PluginFactory withCustomClasspath( @Nullable PluginConfiguration pluginConfiguration, @Nonnull List classLoaders) { return new PluginFactory(pluginConfiguration, classLoaders); @@ -178,17 +185,14 @@ private List buildAspectPayloadValidators( build( AspectPayloadValidator.class, pluginConfiguration.getAspectPayloadValidators(), - "com.linkedin.metadata.aspect.plugins.validation")); + VALIDATOR_PACKAGES)); } private List buildMutationHooks(@Nullable PluginConfiguration pluginConfiguration) { return pluginConfiguration == null ? List.of() : applyDisable( - build( - MutationHook.class, - pluginConfiguration.getMutationHooks(), - "com.linkedin.metadata.aspect.plugins.hooks")); + build(MutationHook.class, pluginConfiguration.getMutationHooks(), HOOK_PACKAGES)); } private List buildMCLSideEffects( @@ -196,10 +200,7 @@ private List buildMCLSideEffects( return pluginConfiguration == null ? List.of() : applyDisable( - build( - MCLSideEffect.class, - pluginConfiguration.getMclSideEffects(), - "com.linkedin.metadata.aspect.plugins.hooks")); + build(MCLSideEffect.class, pluginConfiguration.getMclSideEffects(), HOOK_PACKAGES)); } private List buildMCPSideEffects( @@ -207,10 +208,7 @@ private List buildMCPSideEffects( return pluginConfiguration == null ? List.of() : applyDisable( - build( - MCPSideEffect.class, - pluginConfiguration.getMcpSideEffects(), - "com.linkedin.metadata.aspect.plugins.hooks")); + build(MCPSideEffect.class, pluginConfiguration.getMcpSideEffects(), HOOK_PACKAGES)); } private List build( @@ -226,6 +224,11 @@ private List build( config -> { try { ClassInfo classInfo = classMap.get(config.getClassName()); + if (classInfo == null) { + throw new IllegalStateException( + String.format( + "The following class cannot be loaded: %s", config.getClassName())); + } MethodInfo constructorMethod = classInfo.getConstructorInfo().get(0); return Stream.of( (T) constructorMethod.loadClassAndGetConstructor().newInstance(config)); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginSpec.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginSpec.java index 03a0473677fb81..d88b05ede84548 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginSpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginSpec.java @@ -38,9 +38,11 @@ && isChangeTypeSupported(changeType) protected boolean isEntityAspectSupported( @Nonnull String entityName, @Nonnull String aspectName) { - return (ENTITY_WILDCARD.equals(entityName) - || getConfig().getSupportedEntityAspectNames().stream() - .anyMatch(supported -> supported.getEntityName().equals(entityName))) + return (getConfig().getSupportedEntityAspectNames().stream() + .anyMatch( + supported -> + ENTITY_WILDCARD.equals(supported.getEntityName()) + || supported.getEntityName().equals(entityName))) && isAspectSupported(aspectName); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffect.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffect.java index ef9786f8d711ed..a21f3cd2436de7 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffect.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffect.java @@ -4,7 +4,6 @@ import com.linkedin.metadata.aspect.plugins.PluginSpec; import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; -import com.linkedin.metadata.models.registry.EntityRegistry; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -23,16 +22,12 @@ public MCLSideEffect(AspectPluginConfig aspectPluginConfig) { * @return additional upserts */ public final Stream apply( - @Nonnull List input, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) { + @Nonnull List input, @Nonnull AspectRetriever aspectRetriever) { return input.stream() .filter(item -> shouldApply(item.getChangeType(), item.getUrn(), item.getAspectSpec())) - .flatMap(i -> applyMCLSideEffect(i, entityRegistry, aspectRetriever)); + .flatMap(i -> applyMCLSideEffect(i, aspectRetriever)); } protected abstract Stream applyMCLSideEffect( - @Nonnull MCLBatchItem input, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever); + @Nonnull MCLBatchItem input, @Nonnull AspectRetriever aspectRetriever); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffect.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffect.java index fc1d1587d10fb0..80cb405201c876 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffect.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffect.java @@ -4,7 +4,6 @@ import com.linkedin.metadata.aspect.plugins.PluginSpec; import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; -import com.linkedin.metadata.models.registry.EntityRegistry; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -23,14 +22,12 @@ public MCPSideEffect(AspectPluginConfig aspectPluginConfig) { * @return additional upserts */ public final Stream apply( - List input, - EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) { + List input, @Nonnull AspectRetriever aspectRetriever) { return input.stream() .filter(item -> shouldApply(item.getChangeType(), item.getUrn(), item.getAspectSpec())) - .flatMap(i -> applyMCPSideEffect(i, entityRegistry, aspectRetriever)); + .flatMap(i -> applyMCPSideEffect(i, aspectRetriever)); } protected abstract Stream applyMCPSideEffect( - UpsertItem input, EntityRegistry entityRegistry, @Nonnull AspectRetriever aspectRetriever); + UpsertItem input, @Nonnull AspectRetriever aspectRetriever); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java index 78aa4689472f5d..00a20b3131c2a2 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java @@ -2,12 +2,35 @@ import com.linkedin.common.urn.Urn; import com.linkedin.entity.Aspect; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.r2.RemoteInvocationException; import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; import javax.annotation.Nonnull; +import javax.annotation.Nullable; public interface AspectRetriever { - Aspect getLatestAspectObject(@Nonnull final Urn urn, @Nonnull final String aspectName) + @Nullable + default Aspect getLatestAspectObject(@Nonnull final Urn urn, @Nonnull final String aspectName) + throws RemoteInvocationException, URISyntaxException { + return getLatestAspectObjects(Set.of(urn), Set.of(aspectName)) + .getOrDefault(urn, Map.of()) + .get(aspectName); + } + + /** + * Returns for each URN, the map of aspectName to Aspect + * + * @param urns urns to fetch + * @param aspectNames aspect names + * @return urn to aspect name and values + */ + @Nonnull + Map> getLatestAspectObjects(Set urns, Set aspectNames) throws RemoteInvocationException, URISyntaxException; + + @Nonnull + EntityRegistry getEntityRegistry(); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/PropertyDefinitionValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/PropertyDefinitionValidator.java new file mode 100644 index 00000000000000..5a4635da433ae4 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/PropertyDefinitionValidator.java @@ -0,0 +1,91 @@ +package com.linkedin.metadata.aspect.validation; + +import static com.linkedin.structured.PropertyCardinality.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; +import com.linkedin.metadata.aspect.plugins.validation.AspectPayloadValidator; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; +import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PropertyValue; +import com.linkedin.structured.StructuredPropertyDefinition; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class PropertyDefinitionValidator extends AspectPayloadValidator { + + public PropertyDefinitionValidator(AspectPluginConfig aspectPluginConfig) { + super(aspectPluginConfig); + } + + @Override + protected void validateProposedAspect( + @Nonnull ChangeType changeType, + @Nonnull Urn entityUrn, + @Nonnull AspectSpec aspectSpec, + @Nonnull RecordTemplate aspectPayload, + @Nonnull AspectRetriever aspectRetriever) + throws AspectValidationException { + // No-op + } + + @Override + protected void validatePreCommitAspect( + @Nonnull ChangeType changeType, + @Nonnull Urn entityUrn, + @Nonnull AspectSpec aspectSpec, + @Nullable RecordTemplate previousAspect, + @Nonnull RecordTemplate proposedAspect, + AspectRetriever aspectRetriever) + throws AspectValidationException { + validate(previousAspect, proposedAspect); + } + + public static boolean validate( + @Nullable RecordTemplate previousAspect, @Nonnull RecordTemplate proposedAspect) + throws AspectValidationException { + if (previousAspect != null) { + StructuredPropertyDefinition previousDefinition = + (StructuredPropertyDefinition) previousAspect; + StructuredPropertyDefinition newDefinition = (StructuredPropertyDefinition) proposedAspect; + if (!newDefinition.getValueType().equals(previousDefinition.getValueType())) { + throw new AspectValidationException( + "Value type cannot be changed as this is a backwards incompatible change"); + } + if (newDefinition.getCardinality().equals(SINGLE) + && previousDefinition.getCardinality().equals(MULTIPLE)) { + throw new AspectValidationException( + "Property definition cardinality cannot be changed from MULTI to SINGLE"); + } + if (!newDefinition.getQualifiedName().equals(previousDefinition.getQualifiedName())) { + throw new AspectValidationException( + "Cannot change the fully qualified name of a Structured Property"); + } + // Assure new definition has only added allowed values, not removed them + if (newDefinition.getAllowedValues() != null) { + if (!previousDefinition.hasAllowedValues() + || previousDefinition.getAllowedValues() == null) { + throw new AspectValidationException( + "Cannot restrict values that were previously allowed"); + } + Set newAllowedValues = + newDefinition.getAllowedValues().stream() + .map(PropertyValue::getValue) + .collect(Collectors.toSet()); + for (PropertyValue value : previousDefinition.getAllowedValues()) { + if (!newAllowedValues.contains(value.getValue())) { + throw new AspectValidationException( + "Cannot restrict values that were previously allowed"); + } + } + } + } + return true; + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/StructuredPropertiesValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/StructuredPropertiesValidator.java new file mode 100644 index 00000000000000..efd95e0c2e3f12 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/StructuredPropertiesValidator.java @@ -0,0 +1,264 @@ +package com.linkedin.metadata.aspect.validation; + +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.data.template.StringArray; +import com.linkedin.data.template.StringArrayMap; +import com.linkedin.entity.Aspect; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; +import com.linkedin.metadata.aspect.plugins.validation.AspectPayloadValidator; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; +import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.models.LogicalValueType; +import com.linkedin.metadata.models.StructuredPropertyUtils; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.PropertyCardinality; +import com.linkedin.structured.PropertyValue; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyDefinition; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** A Validator for StructuredProperties Aspect that is attached to entities like Datasets, etc. */ +@Slf4j +public class StructuredPropertiesValidator extends AspectPayloadValidator { + + private static final Set VALID_VALUE_STORED_AS_STRING = + new HashSet<>( + Arrays.asList( + LogicalValueType.STRING, + LogicalValueType.RICH_TEXT, + LogicalValueType.DATE, + LogicalValueType.URN)); + + public StructuredPropertiesValidator(AspectPluginConfig aspectPluginConfig) { + super(aspectPluginConfig); + } + + public static LogicalValueType getLogicalValueType(Urn valueType) { + String valueTypeId = getValueTypeId(valueType); + if (valueTypeId.equals("string")) { + return LogicalValueType.STRING; + } else if (valueTypeId.equals("date")) { + return LogicalValueType.DATE; + } else if (valueTypeId.equals("number")) { + return LogicalValueType.NUMBER; + } else if (valueTypeId.equals("urn")) { + return LogicalValueType.URN; + } else if (valueTypeId.equals("rich_text")) { + return LogicalValueType.RICH_TEXT; + } + + return LogicalValueType.UNKNOWN; + } + + @Override + protected void validateProposedAspect( + @Nonnull ChangeType changeType, + @Nonnull Urn entityUrn, + @Nonnull AspectSpec aspectSpec, + @Nonnull RecordTemplate aspectPayload, + @Nonnull AspectRetriever aspectRetriever) + throws AspectValidationException { + validate(aspectPayload, aspectRetriever); + } + + public static boolean validate( + @Nonnull RecordTemplate aspectPayload, @Nonnull AspectRetriever aspectRetriever) + throws AspectValidationException { + StructuredProperties structuredProperties = (StructuredProperties) aspectPayload; + log.warn("Validator called with {}", structuredProperties); + Map> structuredPropertiesMap = + structuredProperties.getProperties().stream() + .collect( + Collectors.groupingBy( + x -> x.getPropertyUrn(), + HashMap::new, + Collectors.toCollection(ArrayList::new))); + for (Map.Entry> entry : + structuredPropertiesMap.entrySet()) { + // There should only be one entry per structured property + List values = entry.getValue(); + if (values.size() > 1) { + throw new AspectValidationException( + "Property: " + entry.getKey() + " has multiple entries: " + values); + } + } + + for (StructuredPropertyValueAssignment structuredPropertyValueAssignment : + structuredProperties.getProperties()) { + Urn propertyUrn = structuredPropertyValueAssignment.getPropertyUrn(); + String property = propertyUrn.toString(); + if (!propertyUrn.getEntityType().equals("structuredProperty")) { + throw new IllegalStateException( + "Unexpected entity type. Expected: structuredProperty Found: " + + propertyUrn.getEntityType()); + } + Aspect structuredPropertyDefinitionAspect = null; + try { + structuredPropertyDefinitionAspect = + aspectRetriever.getLatestAspectObject(propertyUrn, "propertyDefinition"); + + if (structuredPropertyDefinitionAspect == null) { + throw new AspectValidationException("Unexpected null value found."); + } + } catch (Exception e) { + log.error("Could not fetch latest aspect. PropertyUrn: {}", propertyUrn, e); + throw new AspectValidationException("Could not fetch latest aspect: " + e.getMessage(), e); + } + + StructuredPropertyDefinition structuredPropertyDefinition = + new StructuredPropertyDefinition(structuredPropertyDefinitionAspect.data()); + log.warn( + "Retrieved property definition for {}. {}", propertyUrn, structuredPropertyDefinition); + if (structuredPropertyDefinition != null) { + PrimitivePropertyValueArray values = structuredPropertyValueAssignment.getValues(); + // Check cardinality + if (structuredPropertyDefinition.getCardinality() == PropertyCardinality.SINGLE) { + if (values.size() > 1) { + throw new AspectValidationException( + "Property: " + + property + + " has cardinality 1, but multiple values were assigned: " + + values); + } + } + // Check values + for (PrimitivePropertyValue value : values) { + validateType(propertyUrn, structuredPropertyDefinition, value); + validateAllowedValues(propertyUrn, structuredPropertyDefinition, value); + } + } + } + + return true; + } + + private static void validateAllowedValues( + Urn propertyUrn, StructuredPropertyDefinition definition, PrimitivePropertyValue value) + throws AspectValidationException { + if (definition.getAllowedValues() != null) { + Set definedValues = + definition.getAllowedValues().stream() + .map(PropertyValue::getValue) + .collect(Collectors.toSet()); + if (definedValues.stream().noneMatch(definedPrimitive -> definedPrimitive.equals(value))) { + throw new AspectValidationException( + String.format( + "Property: %s, value: %s should be one of %s", propertyUrn, value, definedValues)); + } + } + } + + private static void validateType( + Urn propertyUrn, StructuredPropertyDefinition definition, PrimitivePropertyValue value) + throws AspectValidationException { + Urn valueType = definition.getValueType(); + LogicalValueType typeDefinition = getLogicalValueType(valueType); + + // Primitive Type Validation + if (VALID_VALUE_STORED_AS_STRING.contains(typeDefinition)) { + log.debug( + "Property definition demands a string value. {}, {}", value.isString(), value.isDouble()); + if (value.getString() == null) { + throw new AspectValidationException( + "Property: " + propertyUrn.toString() + ", value: " + value + " should be a string"); + } else if (typeDefinition.equals(LogicalValueType.DATE)) { + if (!StructuredPropertyUtils.isValidDate(value)) { + throw new AspectValidationException( + "Property: " + + propertyUrn.toString() + + ", value: " + + value + + " should be a date with format YYYY-MM-DD"); + } + } else if (typeDefinition.equals(LogicalValueType.URN)) { + StringArrayMap valueTypeQualifier = definition.getTypeQualifier(); + Urn typeValue; + try { + typeValue = Urn.createFromString(value.getString()); + } catch (URISyntaxException e) { + throw new AspectValidationException( + "Property: " + propertyUrn.toString() + ", value: " + value + " should be an urn", e); + } + if (valueTypeQualifier != null) { + if (valueTypeQualifier.containsKey("allowedTypes")) { + // Let's get the allowed types and validate that the value is one of those types + StringArray allowedTypes = valueTypeQualifier.get("allowedTypes"); + boolean matchedAny = false; + for (String type : allowedTypes) { + Urn typeUrn = null; + try { + typeUrn = Urn.createFromString(type); + } catch (URISyntaxException e) { + + // we don't expect to have types that we allowed to be written that aren't + // urns + throw new RuntimeException(e); + } + String allowedEntityName = getValueTypeId(typeUrn); + if (typeValue.getEntityType().equals(allowedEntityName)) { + matchedAny = true; + } + } + if (!matchedAny) { + throw new AspectValidationException( + "Property: " + + propertyUrn.toString() + + ", value: " + + value + + " is not of any supported urn types:" + + allowedTypes); + } + } + } + } + } else if (typeDefinition.equals(LogicalValueType.NUMBER)) { + log.debug("Property definition demands a numeric value. {}, {}", value.isString(), value); + try { + Double doubleValue = + value.getDouble() != null ? value.getDouble() : Double.parseDouble(value.getString()); + } catch (NumberFormatException | NullPointerException e) { + throw new AspectValidationException( + "Property: " + propertyUrn.toString() + ", value: " + value + " should be a number"); + } + } else { + throw new AspectValidationException( + "Validation support for type " + definition.getValueType() + " is not yet implemented."); + } + } + + private static String getValueTypeId(@Nonnull final Urn valueType) { + String valueTypeId = valueType.getId(); + if (valueTypeId.startsWith("datahub.")) { + valueTypeId = valueTypeId.split("\\.")[1]; + } + return valueTypeId; + } + + @Override + protected void validatePreCommitAspect( + @Nonnull ChangeType changeType, + @Nonnull Urn entityUrn, + @Nonnull AspectSpec aspectSpec, + @Nullable RecordTemplate previousAspect, + @Nonnull RecordTemplate proposedAspect, + AspectRetriever aspectRetriever) + throws AspectValidationException { + // No-op + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/LogicalValueType.java b/entity-registry/src/main/java/com/linkedin/metadata/models/LogicalValueType.java new file mode 100644 index 00000000000000..1643ce900f748e --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/LogicalValueType.java @@ -0,0 +1,10 @@ +package com.linkedin.metadata.models; + +public enum LogicalValueType { + STRING, + RICH_TEXT, + NUMBER, + DATE, + URN, + UNKNOWN +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java new file mode 100644 index 00000000000000..a8711429421f3b --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java @@ -0,0 +1,45 @@ +package com.linkedin.metadata.models; + +import com.linkedin.structured.PrimitivePropertyValue; +import java.sql.Date; +import java.time.format.DateTimeParseException; + +public class StructuredPropertyUtils { + + private StructuredPropertyUtils() {} + + static final Date MIN_DATE = Date.valueOf("1000-01-01"); + static final Date MAX_DATE = Date.valueOf("9999-12-31"); + + /** + * Sanitizes fully qualified name for use in an ElasticSearch field name Replaces . and " " + * characters + * + * @param fullyQualifiedName The original fully qualified name of the property + * @return The sanitized version that can be used as a field name + */ + public static String sanitizeStructuredPropertyFQN(String fullyQualifiedName) { + String sanitizedName = fullyQualifiedName.replace('.', '_').replace(' ', '_'); + return sanitizedName; + } + + public static Date toDate(PrimitivePropertyValue value) throws DateTimeParseException { + return Date.valueOf(value.getString()); + } + + public static boolean isValidDate(PrimitivePropertyValue value) { + if (value.getString() == null) { + return false; + } + if (value.getString().length() != 10) { + return false; + } + Date date; + try { + date = toDate(value); + } catch (DateTimeParseException e) { + return false; + } + return date.compareTo(MIN_DATE) >= 0 && date.compareTo(MAX_DATE) <= 0; + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java index ce8718c536fbe1..bd9a6b6c9e5898 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.linkedin.data.schema.DataSchema; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.plugins.PluginFactory; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.DataSchemaFactory; @@ -18,7 +19,6 @@ import com.linkedin.metadata.models.registry.config.Entities; import com.linkedin.metadata.models.registry.config.Entity; import com.linkedin.metadata.models.registry.config.Event; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.util.Pair; import java.io.FileInputStream; import java.io.FileNotFoundException; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/EntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/EntityRegistry.java index fbc3285579cc08..c2aa1fab6c2c0f 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/EntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/EntityRegistry.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.models.registry; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.plugins.PluginFactory; import com.linkedin.metadata.aspect.plugins.hooks.MCLSideEffect; import com.linkedin.metadata.aspect.plugins.hooks.MCPSideEffect; @@ -10,7 +11,6 @@ import com.linkedin.metadata.models.DefaultEntitySpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.EventSpec; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -39,11 +39,10 @@ default String getIdentifier() { EntitySpec getEntitySpec(@Nonnull final String entityName); /** - * Given an event name, returns an instance of {@link DefaultEventSpec}. + * Given an event name, returns an instance of {@link EventSpec}. * * @param eventName the name of the event to be retrieved - * @return an {@link DefaultEventSpec} corresponding to the entity name provided, null if none - * exists. + * @return an {@link EventSpec} corresponding to the entity name provided, null if none exists. */ @Nullable EventSpec getEventSpec(@Nonnull final String eventName); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java index 285b96b93d1d60..650a1cd41066e9 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java @@ -3,13 +3,13 @@ import com.linkedin.data.schema.compatibility.CompatibilityChecker; import com.linkedin.data.schema.compatibility.CompatibilityOptions; import com.linkedin.data.schema.compatibility.CompatibilityResult; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.plugins.PluginFactory; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.ConfigEntitySpec; import com.linkedin.metadata.models.DefaultEntitySpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.EventSpec; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import java.util.ArrayList; import java.util.HashMap; import java.util.List; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java index c605cfa188fc83..35bfe935423f04 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.linkedin.data.schema.DataSchema; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.plugins.PluginFactory; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.DataSchemaFactory; @@ -17,7 +18,6 @@ import com.linkedin.metadata.models.registry.config.Entities; import com.linkedin.metadata.models.registry.config.Entity; import com.linkedin.metadata.models.registry.config.Event; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.util.Pair; import java.io.FileInputStream; import java.io.FileNotFoundException; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java index bb0113abc9ed6c..8fefa2fe00ae86 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java @@ -5,25 +5,26 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.UnionTemplate; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; +import com.linkedin.metadata.aspect.patch.template.Template; +import com.linkedin.metadata.aspect.patch.template.chart.ChartInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.common.GlobalTagsTemplate; +import com.linkedin.metadata.aspect.patch.template.common.GlossaryTermsTemplate; +import com.linkedin.metadata.aspect.patch.template.common.OwnershipTemplate; +import com.linkedin.metadata.aspect.patch.template.common.StructuredPropertiesTemplate; +import com.linkedin.metadata.aspect.patch.template.dashboard.DashboardInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.dataflow.DataFlowInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.datajob.DataJobInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.datajob.DataJobInputOutputTemplate; +import com.linkedin.metadata.aspect.patch.template.dataproduct.DataProductPropertiesTemplate; +import com.linkedin.metadata.aspect.patch.template.dataset.DatasetPropertiesTemplate; +import com.linkedin.metadata.aspect.patch.template.dataset.EditableSchemaMetadataTemplate; +import com.linkedin.metadata.aspect.patch.template.dataset.UpstreamLineageTemplate; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.DefaultEntitySpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.EntitySpecBuilder; import com.linkedin.metadata.models.EventSpec; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; -import com.linkedin.metadata.models.registry.template.Template; -import com.linkedin.metadata.models.registry.template.chart.ChartInfoTemplate; -import com.linkedin.metadata.models.registry.template.common.GlobalTagsTemplate; -import com.linkedin.metadata.models.registry.template.common.GlossaryTermsTemplate; -import com.linkedin.metadata.models.registry.template.common.OwnershipTemplate; -import com.linkedin.metadata.models.registry.template.dashboard.DashboardInfoTemplate; -import com.linkedin.metadata.models.registry.template.dataflow.DataFlowInfoTemplate; -import com.linkedin.metadata.models.registry.template.datajob.DataJobInfoTemplate; -import com.linkedin.metadata.models.registry.template.datajob.DataJobInputOutputTemplate; -import com.linkedin.metadata.models.registry.template.dataproduct.DataProductPropertiesTemplate; -import com.linkedin.metadata.models.registry.template.dataset.DatasetPropertiesTemplate; -import com.linkedin.metadata.models.registry.template.dataset.EditableSchemaMetadataTemplate; -import com.linkedin.metadata.models.registry.template.dataset.UpstreamLineageTemplate; import com.linkedin.metadata.snapshot.Snapshot; import java.util.ArrayList; import java.util.HashMap; @@ -84,6 +85,8 @@ private AspectTemplateEngine populateTemplateEngine(Map aspe aspectSpecTemplateMap.put(CHART_INFO_ASPECT_NAME, new ChartInfoTemplate()); aspectSpecTemplateMap.put(DASHBOARD_INFO_ASPECT_NAME, new DashboardInfoTemplate()); aspectSpecTemplateMap.put(DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, new DataJobInputOutputTemplate()); + aspectSpecTemplateMap.put( + STRUCTURED_PROPERTIES_ASPECT_NAME, new StructuredPropertiesTemplate()); return new AspectTemplateEngine(aspectSpecTemplateMap); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/CompoundKeyTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/CompoundKeyTemplate.java deleted file mode 100644 index 44090b3a6d05b6..00000000000000 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/CompoundKeyTemplate.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.linkedin.metadata.models.registry.template; - -import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; -import static com.linkedin.metadata.models.registry.template.util.TemplateUtil.*; - -import com.datahub.util.RecordUtils; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.github.fge.jsonpatch.JsonPatchException; -import com.github.fge.jsonpatch.Patch; -import com.linkedin.data.template.RecordTemplate; -import java.util.List; - -public abstract class CompoundKeyTemplate - implements ArrayMergingTemplate { - - /** - * Necessary step for templates with compound keys due to JsonPatch not allowing non-existent - * paths to be specified - * - * @param transformedNode transformed node to have keys populated - * @return transformed node that has top level keys populated - */ - public JsonNode populateTopLevelKeys(JsonNode transformedNode, Patch jsonPatch) { - JsonNode transformedNodeClone = transformedNode.deepCopy(); - List paths = getPaths(jsonPatch); - for (String path : paths) { - String[] keys = path.split("/"); - // Skip first as it will always be blank due to path starting with /, skip last key as we only - // need to populate top level - JsonNode parent = transformedNodeClone; - for (int i = 1; i < keys.length - 1; i++) { - if (parent.get(keys[i]) == null) { - ((ObjectNode) parent).set(keys[i], instance.objectNode()); - } - parent = parent.get(keys[i]); - } - } - - return transformedNodeClone; - } - - @Override - public T applyPatch(RecordTemplate recordTemplate, Patch jsonPatch) - throws JsonProcessingException, JsonPatchException { - JsonNode transformed = populateTopLevelKeys(preprocessTemplate(recordTemplate), jsonPatch); - JsonNode patched = jsonPatch.apply(transformed); - JsonNode postProcessed = rebaseFields(patched); - return RecordUtils.toRecordTemplate(getTemplateType(), postProcessed.toString()); - } -} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/util/TemplateUtil.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/util/TemplateUtil.java deleted file mode 100644 index 18d070ec3da455..00000000000000 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/template/util/TemplateUtil.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.linkedin.metadata.models.registry.template.util; - -import static com.linkedin.metadata.Constants.*; - -import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.fge.jsonpatch.Patch; -import java.util.ArrayList; -import java.util.List; - -public class TemplateUtil { - - private TemplateUtil() {} - - public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - static { - int maxSize = - Integer.parseInt( - System.getenv() - .getOrDefault(INGESTION_MAX_SERIALIZED_STRING_LENGTH, MAX_JACKSON_STRING_SIZE)); - OBJECT_MAPPER - .getFactory() - .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build()); - } - - public static List getPaths(Patch jsonPatch) { - JsonNode patchNode = OBJECT_MAPPER.valueToTree(jsonPatch); - List paths = new ArrayList<>(); - patchNode - .elements() - .forEachRemaining( - node -> { - paths.add(node.get("path").asText()); - }); - return paths; - } -} diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/ChartInfoTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/ChartInfoTemplateTest.java similarity index 92% rename from entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/ChartInfoTemplateTest.java rename to entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/ChartInfoTemplateTest.java index 108936bde2ed5b..b2911100519fc5 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/ChartInfoTemplateTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/ChartInfoTemplateTest.java @@ -1,4 +1,4 @@ -package com.linkedin.metadata.models.registry; +package com.linkedin.metadata.aspect.patch.template; import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; @@ -9,7 +9,7 @@ import com.github.fge.jsonpatch.JsonPatchOperation; import com.linkedin.chart.ChartInfo; import com.linkedin.common.urn.UrnUtils; -import com.linkedin.metadata.models.registry.template.chart.ChartInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.chart.ChartInfoTemplate; import java.util.ArrayList; import java.util.List; import org.testng.Assert; diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/DashboardInfoTemplateTest.java similarity index 91% rename from entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java rename to entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/DashboardInfoTemplateTest.java index 962ff1d40d873c..be15d6976aee6f 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/DashboardInfoTemplateTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/DashboardInfoTemplateTest.java @@ -1,4 +1,4 @@ -package com.linkedin.metadata.models.registry.patch; +package com.linkedin.metadata.aspect.patch.template; import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; @@ -9,7 +9,7 @@ import com.github.fge.jsonpatch.JsonPatchOperation; import com.linkedin.common.urn.UrnUtils; import com.linkedin.dashboard.DashboardInfo; -import com.linkedin.metadata.models.registry.template.dashboard.DashboardInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.dashboard.DashboardInfoTemplate; import java.util.ArrayList; import java.util.List; import org.testng.Assert; diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/UpstreamLineageTemplateTest.java similarity index 99% rename from entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java rename to entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/UpstreamLineageTemplateTest.java index 8f410ae8da0857..7d59664513d571 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/patch/UpstreamLineageTemplateTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/patch/template/UpstreamLineageTemplateTest.java @@ -1,4 +1,4 @@ -package com.linkedin.metadata.models.registry.patch; +package com.linkedin.metadata.aspect.patch.template; import static com.fasterxml.jackson.databind.node.JsonNodeFactory.*; @@ -16,7 +16,7 @@ import com.linkedin.dataset.FineGrainedLineageDownstreamType; import com.linkedin.dataset.FineGrainedLineageUpstreamType; import com.linkedin.dataset.UpstreamLineage; -import com.linkedin.metadata.models.registry.template.dataset.UpstreamLineageTemplate; +import com.linkedin.metadata.aspect.patch.template.dataset.UpstreamLineageTemplate; import java.util.ArrayList; import java.util.List; import org.testng.Assert; diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/PluginsTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/PluginsTest.java index 8c3f71fcc8019b..f801ce7bf1ffe6 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/PluginsTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/PluginsTest.java @@ -61,17 +61,16 @@ public void testConfigEntityRegistry() throws FileNotFoundException { assertNotNull(eventSpec.getPegasusSchema()); assertEquals( - configEntityRegistry.getAspectPayloadValidators(ChangeType.UPSERT, "*", "status").size(), + configEntityRegistry + .getAspectPayloadValidators(ChangeType.UPSERT, "chart", "status") + .size(), 2); assertEquals( - configEntityRegistry.getAspectPayloadValidators(ChangeType.DELETE, "*", "status").size(), + configEntityRegistry + .getAspectPayloadValidators(ChangeType.DELETE, "chart", "status") + .size(), 0); - assertEquals( - configEntityRegistry.getMCLSideEffects(ChangeType.UPSERT, "chart", "chartInfo").size(), 1); - assertEquals( - configEntityRegistry.getMCLSideEffects(ChangeType.DELETE, "chart", "chartInfo").size(), 0); - assertEquals( configEntityRegistry.getMCPSideEffects(ChangeType.UPSERT, "dataset", "datasetKey").size(), 1); @@ -124,17 +123,16 @@ public void testMergedEntityRegistry() throws EntityRegistryException { assertNotNull(eventSpec.getPegasusSchema()); assertEquals( - mergedEntityRegistry.getAspectPayloadValidators(ChangeType.UPSERT, "*", "status").size(), - 3); + mergedEntityRegistry + .getAspectPayloadValidators(ChangeType.UPSERT, "chart", "status") + .size(), + 2); assertEquals( - mergedEntityRegistry.getAspectPayloadValidators(ChangeType.DELETE, "*", "status").size(), + mergedEntityRegistry + .getAspectPayloadValidators(ChangeType.DELETE, "chart", "status") + .size(), 1); - assertEquals( - mergedEntityRegistry.getMCLSideEffects(ChangeType.UPSERT, "chart", "chartInfo").size(), 2); - assertEquals( - mergedEntityRegistry.getMCLSideEffects(ChangeType.DELETE, "chart", "chartInfo").size(), 1); - assertEquals( mergedEntityRegistry.getMCPSideEffects(ChangeType.UPSERT, "dataset", "datasetKey").size(), 2); diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffectTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffectTest.java index ce904142fecfeb..8ee5ff4f998206 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffectTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCLSideEffectTest.java @@ -9,7 +9,6 @@ import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.models.registry.ConfigEntityRegistry; -import com.linkedin.metadata.models.registry.EntityRegistry; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -60,9 +59,7 @@ public TestMCLSideEffect(AspectPluginConfig aspectPluginConfig) { @Override protected Stream applyMCLSideEffect( - @Nonnull MCLBatchItem input, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) { + @Nonnull MCLBatchItem input, @Nonnull AspectRetriever aspectRetriever) { return Stream.of(input); } } diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffectTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffectTest.java index ee8f947e0e994a..8522e8facf3e08 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffectTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/hooks/MCPSideEffectTest.java @@ -9,7 +9,6 @@ import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.models.registry.ConfigEntityRegistry; -import com.linkedin.metadata.models.registry.EntityRegistry; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -60,7 +59,7 @@ public TestMCPSideEffect(AspectPluginConfig aspectPluginConfig) { @Override protected Stream applyMCPSideEffect( - UpsertItem input, EntityRegistry entityRegistry, @Nonnull AspectRetriever aspectRetriever) { + UpsertItem input, @Nonnull AspectRetriever aspectRetriever) { return Stream.of(input); } } diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/validation/ValidatorPluginTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/validation/ValidatorPluginTest.java index 07c99ee8546be0..eb132836be4656 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/validation/ValidatorPluginTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/plugins/validation/ValidatorPluginTest.java @@ -33,7 +33,7 @@ public void testCustomValidator() { TestEntityProfile.class.getClassLoader().getResourceAsStream(REGISTRY_FILE)); List validators = - configEntityRegistry.getAspectPayloadValidators(ChangeType.UPSERT, "*", "status"); + configEntityRegistry.getAspectPayloadValidators(ChangeType.UPSERT, "chart", "status"); assertEquals( validators, List.of( diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/PropertyDefinitionValidatorTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/PropertyDefinitionValidatorTest.java new file mode 100644 index 00000000000000..96e9fceb4a05d8 --- /dev/null +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/PropertyDefinitionValidatorTest.java @@ -0,0 +1,212 @@ +package com.linkedin.metadata.aspect.validators; + +import static org.testng.Assert.*; + +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; +import com.linkedin.metadata.aspect.validation.PropertyDefinitionValidator; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PropertyCardinality; +import com.linkedin.structured.PropertyValue; +import com.linkedin.structured.PropertyValueArray; +import com.linkedin.structured.StructuredPropertyDefinition; +import java.net.URISyntaxException; +import org.testng.annotations.Test; + +public class PropertyDefinitionValidatorTest { + @Test + public void testValidatePreCommitNoPrevious() + throws URISyntaxException, AspectValidationException { + StructuredPropertyDefinition newProperty = new StructuredPropertyDefinition(); + newProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + newProperty.setDisplayName("newProp"); + newProperty.setQualifiedName("prop3"); + newProperty.setCardinality(PropertyCardinality.MULTIPLE); + newProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + assertTrue(PropertyDefinitionValidator.validate(null, newProperty)); + } + + @Test + public void testCanChangeSingleToMultiple() + throws URISyntaxException, CloneNotSupportedException, AspectValidationException { + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.SINGLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + newProperty.setCardinality(PropertyCardinality.MULTIPLE); + assertTrue(PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCannotChangeMultipleToSingle() + throws URISyntaxException, CloneNotSupportedException { + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + newProperty.setCardinality(PropertyCardinality.SINGLE); + assertThrows( + AspectValidationException.class, + () -> PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCannotChangeValueType() throws URISyntaxException, CloneNotSupportedException { + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + newProperty.setValueType(Urn.createFromString("urn:li:logicalType:NUMBER")); + assertThrows( + AspectValidationException.class, + () -> PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCanChangeDisplayName() + throws URISyntaxException, CloneNotSupportedException, AspectValidationException { + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + newProperty.setDisplayName("newProp"); + assertTrue(PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCannotChangeFullyQualifiedName() + throws URISyntaxException, CloneNotSupportedException { + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + newProperty.setQualifiedName("newProp"); + assertThrows( + AspectValidationException.class, + () -> PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCannotChangeRestrictAllowedValues() + throws URISyntaxException, CloneNotSupportedException { + // No constraint -> constraint case + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + PropertyValue allowedValue = + new PropertyValue().setValue(PrimitivePropertyValue.create(1.0)).setDescription("hello"); + newProperty.setAllowedValues(new PropertyValueArray(allowedValue)); + assertThrows( + AspectValidationException.class, + () -> PropertyDefinitionValidator.validate(oldProperty, newProperty)); + + // Remove allowed values from constraint case + PropertyValue oldAllowedValue = + new PropertyValue().setValue(PrimitivePropertyValue.create(3.0)).setDescription("hello"); + oldProperty.setAllowedValues((new PropertyValueArray(allowedValue, oldAllowedValue))); + assertThrows( + AspectValidationException.class, + () -> PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCanExpandAllowedValues() + throws URISyntaxException, CloneNotSupportedException, AspectValidationException { + // Constraint -> no constraint case + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + PropertyValue allowedValue = + new PropertyValue().setValue(PrimitivePropertyValue.create(1.0)).setDescription("hello"); + oldProperty.setAllowedValues(new PropertyValueArray(allowedValue)); + assertTrue(PropertyDefinitionValidator.validate(oldProperty, newProperty)); + + // Add allowed values to constraint case + PropertyValue newAllowedValue = + new PropertyValue().setValue(PrimitivePropertyValue.create(3.0)).setDescription("hello"); + newProperty.setAllowedValues((new PropertyValueArray(allowedValue, newAllowedValue))); + assertTrue(PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } + + @Test + public void testCanChangeAllowedValueDescriptions() + throws URISyntaxException, CloneNotSupportedException, AspectValidationException { + // Constraint -> no constraint case + StructuredPropertyDefinition oldProperty = new StructuredPropertyDefinition(); + oldProperty.setEntityTypes( + new UrnArray( + Urn.createFromString("urn:li:logicalEntity:dataset"), + Urn.createFromString("urn:li:logicalEntity:chart"), + Urn.createFromString("urn:li:logicalEntity:glossaryTerm"))); + oldProperty.setDisplayName("oldProp"); + oldProperty.setQualifiedName("prop3"); + oldProperty.setCardinality(PropertyCardinality.MULTIPLE); + oldProperty.setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + StructuredPropertyDefinition newProperty = oldProperty.copy(); + PropertyValue allowedValue = + new PropertyValue().setValue(PrimitivePropertyValue.create(1.0)).setDescription("hello"); + oldProperty.setAllowedValues(new PropertyValueArray(allowedValue)); + PropertyValue newAllowedValue = + new PropertyValue() + .setValue(PrimitivePropertyValue.create(1.0)) + .setDescription("hello there"); + newProperty.setAllowedValues(new PropertyValueArray(newAllowedValue)); + assertTrue(PropertyDefinitionValidator.validate(oldProperty, newProperty)); + } +} diff --git a/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/StructuredPropertiesValidatorTest.java b/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/StructuredPropertiesValidatorTest.java new file mode 100644 index 00000000000000..450b299b48b34f --- /dev/null +++ b/entity-registry/src/test/java/com/linkedin/metadata/aspect/validators/StructuredPropertiesValidatorTest.java @@ -0,0 +1,246 @@ +package com.linkedin.metadata.aspect.validators; + +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.Aspect; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; +import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; +import com.linkedin.metadata.aspect.validation.StructuredPropertiesValidator; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.r2.RemoteInvocationException; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.PropertyValue; +import com.linkedin.structured.PropertyValueArray; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyDefinition; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nonnull; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class StructuredPropertiesValidatorTest { + + static class MockAspectRetriever implements AspectRetriever { + StructuredPropertyDefinition _propertyDefinition; + + MockAspectRetriever(StructuredPropertyDefinition defToReturn) { + this._propertyDefinition = defToReturn; + } + + @Nonnull + @Override + public Map> getLatestAspectObjects( + Set urns, Set aspectNames) + throws RemoteInvocationException, URISyntaxException { + return Map.of( + urns.stream().findFirst().get(), + Map.of(aspectNames.stream().findFirst().get(), new Aspect(_propertyDefinition.data()))); + } + + @Nonnull + @Override + public EntityRegistry getEntityRegistry() { + return null; + } + } + + @Test + public void testValidateAspectNumberUpsert() throws URISyntaxException { + StructuredPropertyDefinition numberPropertyDef = + new StructuredPropertyDefinition() + .setValueType(Urn.createFromString("urn:li:type:datahub.number")) + .setAllowedValues( + new PropertyValueArray( + List.of( + new PropertyValue().setValue(PrimitivePropertyValue.create(30.0)), + new PropertyValue().setValue(PrimitivePropertyValue.create(60.0)), + new PropertyValue().setValue(PrimitivePropertyValue.create(90.0))))); + + try { + StructuredPropertyValueAssignment assignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create(30.0))); + StructuredProperties numberPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(assignment)); + + boolean isValid = + StructuredPropertiesValidator.validate( + numberPayload, new MockAspectRetriever(numberPropertyDef)); + Assert.assertTrue(isValid); + } catch (AspectValidationException e) { + throw new RuntimeException(e); + } + + try { + StructuredPropertyValueAssignment assignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create(0.0))); + StructuredProperties numberPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(assignment)); + + StructuredPropertiesValidator.validate( + numberPayload, new MockAspectRetriever(numberPropertyDef)); + Assert.fail("Should have raised exception for disallowed value 0.0"); + } catch (AspectValidationException e) { + Assert.assertTrue(e.getMessage().contains("{double=0.0} should be one of [{")); + } + + // Assign string value to number property + StructuredPropertyValueAssignment stringAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create("hello"))); + StructuredProperties stringPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(stringAssignment)); + try { + StructuredPropertiesValidator.validate( + stringPayload, new MockAspectRetriever(numberPropertyDef)); + Assert.fail("Should have raised exception for mis-matched types"); + } catch (AspectValidationException e) { + Assert.assertTrue(e.getMessage().contains("should be a number")); + } + } + + @Test + public void testValidateAspectDateUpsert() throws URISyntaxException { + // Assign string value + StructuredPropertyValueAssignment stringAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create("hello"))); + StructuredProperties stringPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(stringAssignment)); + + // Assign invalid date + StructuredPropertyDefinition datePropertyDef = + new StructuredPropertyDefinition() + .setValueType(Urn.createFromString("urn:li:type:datahub.date")); + try { + StructuredPropertiesValidator.validate( + stringPayload, new MockAspectRetriever(datePropertyDef)); + Assert.fail("Should have raised exception for mis-matched types"); + } catch (AspectValidationException e) { + Assert.assertTrue(e.getMessage().contains("should be a date with format")); + } + + // Assign valid date + StructuredPropertyValueAssignment dateAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues( + new PrimitivePropertyValueArray(PrimitivePropertyValue.create("2023-10-24"))); + StructuredProperties datePayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(dateAssignment)); + try { + boolean isValid = + StructuredPropertiesValidator.validate( + datePayload, new MockAspectRetriever(datePropertyDef)); + Assert.assertTrue(isValid); + } catch (AspectValidationException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testValidateAspectStringUpsert() throws URISyntaxException { + // Assign string value + StructuredPropertyValueAssignment stringAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create("hello"))); + StructuredProperties stringPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(stringAssignment)); + + // Assign date + StructuredPropertyValueAssignment dateAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues( + new PrimitivePropertyValueArray(PrimitivePropertyValue.create("2023-10-24"))); + StructuredProperties datePayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(dateAssignment)); + + // Assign number + StructuredPropertyValueAssignment assignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues(new PrimitivePropertyValueArray(PrimitivePropertyValue.create(30.0))); + StructuredProperties numberPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(assignment)); + + StructuredPropertyDefinition stringPropertyDef = + new StructuredPropertyDefinition() + .setValueType(Urn.createFromString("urn:li:type:datahub.string")) + .setAllowedValues( + new PropertyValueArray( + List.of( + new PropertyValue().setValue(PrimitivePropertyValue.create("hello")), + new PropertyValue() + .setValue(PrimitivePropertyValue.create("2023-10-24"))))); + + // Valid strings (both the date value and "hello" are valid) + try { + boolean isValid = + StructuredPropertiesValidator.validate( + stringPayload, new MockAspectRetriever(stringPropertyDef)); + Assert.assertTrue(isValid); + isValid = + StructuredPropertiesValidator.validate( + datePayload, new MockAspectRetriever(stringPropertyDef)); + Assert.assertTrue(isValid); + } catch (AspectValidationException e) { + throw new RuntimeException(e); + } + + // Invalid: assign a number to the string property + try { + StructuredPropertiesValidator.validate( + numberPayload, new MockAspectRetriever(stringPropertyDef)); + Assert.fail("Should have raised exception for mis-matched types"); + } catch (AspectValidationException e) { + Assert.assertTrue(e.getMessage().contains("should be a string")); + } + + // Invalid allowedValue + try { + assignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn( + Urn.createFromString("urn:li:structuredProperty:io.acryl.privacy.retentionTime")) + .setValues( + new PrimitivePropertyValueArray(PrimitivePropertyValue.create("not hello"))); + stringPayload = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(assignment)); + + StructuredPropertiesValidator.validate( + stringPayload, new MockAspectRetriever(stringPropertyDef)); + Assert.fail("Should have raised exception for disallowed value `not hello`"); + } catch (AspectValidationException e) { + Assert.assertTrue(e.getMessage().contains("{string=not hello} should be one of [{")); + } + } +} diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java index 2cb48c1b20da90..d9cf8fd2603a8f 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java @@ -198,7 +198,7 @@ private void validateTestEntityInfo(final AspectSpec testEntityInfo) { .getSearchableAnnotation() .getFieldName()); assertEquals( - SearchableAnnotation.FieldType.KEYWORD, + SearchableAnnotation.FieldType.TEXT, testEntityInfo .getSearchableFieldSpecMap() .get(new PathSpec("customProperties").toString()) diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoaderTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoaderTest.java index b3eb2af72708cc..1a64359008dd84 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoaderTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoaderTest.java @@ -6,6 +6,7 @@ import com.linkedin.data.schema.ArrayDataSchema; import com.linkedin.data.schema.DataSchema; import com.linkedin.data.schema.RecordDataSchema; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.DataSchemaFactory; import com.linkedin.metadata.models.DefaultEntitySpec; @@ -17,7 +18,6 @@ import com.linkedin.metadata.models.annotation.EventAnnotation; import com.linkedin.metadata.models.registry.config.EntityRegistryLoadResult; import com.linkedin.metadata.models.registry.config.LoadStatus; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.util.Pair; import java.io.FileNotFoundException; import java.util.ArrayList; diff --git a/ingestion-scheduler/src/test/java/com/datahub/metadata/ingestion/IngestionSchedulerTest.java b/ingestion-scheduler/src/test/java/com/datahub/metadata/ingestion/IngestionSchedulerTest.java index f9d22b142cbb93..8174afc20765f0 100644 --- a/ingestion-scheduler/src/test/java/com/datahub/metadata/ingestion/IngestionSchedulerTest.java +++ b/ingestion-scheduler/src/test/java/com/datahub/metadata/ingestion/IngestionSchedulerTest.java @@ -11,11 +11,11 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; import com.linkedin.ingestion.DataHubIngestionSourceConfig; import com.linkedin.ingestion.DataHubIngestionSourceInfo; import com.linkedin.ingestion.DataHubIngestionSourceSchedule; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.client.JavaEntityClient; import com.linkedin.metadata.config.IngestionConfiguration; import com.linkedin.metadata.query.ListResult; import java.util.Collections; @@ -88,7 +88,7 @@ public void setupTest() throws Exception { .thenReturn(Constants.INGESTION_SOURCE_ENTITY_NAME); Mockito.when(entityResponse2.getAspects()).thenReturn(map2); - JavaEntityClient mockClient = Mockito.mock(JavaEntityClient.class); + EntityClient mockClient = Mockito.mock(EntityClient.class); // Set up mocks for ingestion source batch fetching Mockito.when( diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index 3d9b533dc8f720..39a17612aa4b3a 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -13,6 +13,9 @@ public class Constants { public static final String UNKNOWN_ACTOR = "urn:li:corpuser:UNKNOWN"; // Unknown principal. public static final Long ASPECT_LATEST_VERSION = 0L; public static final String UNKNOWN_DATA_PLATFORM = "urn:li:dataPlatform:unknown"; + public static final String ENTITY_TYPE_URN_PREFIX = "urn:li:entityType:"; + public static final String DATA_TYPE_URN_PREFIX = "urn:li:dataType:"; + public static final String STRUCTURED_PROPERTY_MAPPING_FIELD = "structuredProperties"; // !!!!!!! IMPORTANT !!!!!!! // This effectively sets the max aspect size to 16 MB. Used in deserialization of messages. @@ -73,6 +76,10 @@ public class Constants { public static final String QUERY_ENTITY_NAME = "query"; public static final String DATA_PRODUCT_ENTITY_NAME = "dataProduct"; public static final String OWNERSHIP_TYPE_ENTITY_NAME = "ownershipType"; + public static final String STRUCTURED_PROPERTY_ENTITY_NAME = "structuredProperty"; + public static final String DATA_TYPE_ENTITY_NAME = "dataType"; + public static final String ENTITY_TYPE_ENTITY_NAME = "entityType"; + public static final String FORM_ENTITY_NAME = "form"; /** Aspects */ // Common @@ -125,6 +132,8 @@ public class Constants { public static final String VIEW_PROPERTIES_ASPECT_NAME = "viewProperties"; public static final String DATASET_PROFILE_ASPECT_NAME = "datasetProfile"; + public static final String STRUCTURED_PROPERTIES_ASPECT_NAME = "structuredProperties"; + public static final String FORMS_ASPECT_NAME = "forms"; // Aspect support public static final String FINE_GRAINED_LINEAGE_DATASET_TYPE = "DATASET"; public static final String FINE_GRAINED_LINEAGE_FIELD_SET_TYPE = "FIELD_SET"; @@ -306,6 +315,20 @@ public class Constants { public static final String OWNERSHIP_TYPE_KEY_ASPECT_NAME = "ownershipTypeKey"; public static final String OWNERSHIP_TYPE_INFO_ASPECT_NAME = "ownershipTypeInfo"; + // Structured Property + public static final String STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME = "propertyDefinition"; + + // Form + public static final String FORM_INFO_ASPECT_NAME = "formInfo"; + public static final String FORM_KEY_ASPECT_NAME = "formKey"; + public static final String DYNAMIC_FORM_ASSIGNMENT_ASPECT_NAME = "dynamicFormAssignment"; + + // Data Type + public static final String DATA_TYPE_INFO_ASPECT_NAME = "dataTypeInfo"; + + // Entity Type + public static final String ENTITY_TYPE_INFO_ASPECT_NAME = "entityTypeInfo"; + // Settings public static final String GLOBAL_SETTINGS_ENTITY_NAME = "globalSettings"; public static final String GLOBAL_SETTINGS_INFO_ASPECT_NAME = "globalSettingsInfo"; diff --git a/metadata-ingestion-modules/airflow-plugin/scripts/release.sh b/metadata-ingestion-modules/airflow-plugin/scripts/release.sh index 87157479f37d63..5667e761ea5581 100755 --- a/metadata-ingestion-modules/airflow-plugin/scripts/release.sh +++ b/metadata-ingestion-modules/airflow-plugin/scripts/release.sh @@ -13,7 +13,7 @@ MODULE=datahub_airflow_plugin python -c 'import setuptools; where="./src"; assert setuptools.find_packages(where) == setuptools.find_namespace_packages(where), "you seem to be missing or have extra __init__.py files"' if [[ ${RELEASE_VERSION:-} ]]; then # Replace version with RELEASE_VERSION env variable - sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" src/${MODULE}/__init__.py + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/${MODULE}/__init__.py else vim src/${MODULE}/__init__.py fi diff --git a/metadata-ingestion/examples/bootstrap_data/business_glossary.yml b/metadata-ingestion/examples/bootstrap_data/business_glossary.yml index de6ba8731c8785..327246863b0ab0 100644 --- a/metadata-ingestion/examples/bootstrap_data/business_glossary.yml +++ b/metadata-ingestion/examples/bootstrap_data/business_glossary.yml @@ -10,6 +10,8 @@ nodes: knowledge_links: - label: Wiki link for classification url: "https://en.wikipedia.org/wiki/Classification" + custom_properties: + is_confidential: true terms: - name: Sensitive description: Sensitive Data diff --git a/metadata-ingestion/examples/forms/forms.yaml b/metadata-ingestion/examples/forms/forms.yaml new file mode 100644 index 00000000000000..80bb7cee08ec33 --- /dev/null +++ b/metadata-ingestion/examples/forms/forms.yaml @@ -0,0 +1,54 @@ +- id: 123456 + # urn: "urn:li:form:123456" # optional if id is provided + type: VERIFICATION + name: "Metadata Initiative 2023" + description: "How we want to ensure the most important data assets in our organization have all of the most important and expected pieces of metadata filled out" + prompts: + - id: "123" + title: "Retention Time" + description: "Apply Retention Time structured property to form" + type: STRUCTURED_PROPERTY + structured_property_id: io.acryl.privacy.retentionTime + required: True # optional, will default to True + - id: "92847" + title: "Replication SLA" + description: "Apply Replication SLA structured property to form" + type: STRUCTURED_PROPERTY + structured_property_urn: urn:li:structuredProperty:io.acryl.dataManagement.replicationSLA + required: True + - id: "76543" + title: "Replication SLA" + description: "Apply Replication SLA structured property to form" + type: FIELDS_STRUCTURED_PROPERTY + structured_property_urn: urn:li:structuredProperty:io.acryl.dataManagement.replicationSLA + required: False + entities: # Either pass a list of urns or a group of filters + # urns: + # - urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD) + # - urn:li:dataset:(urn:li:dataPlatform:snowflake,user.clicks,PROD) + filters: + types: + - dataset + platforms: + - snowflake + - dbt + domains: + - urn:li:domain:b41fbb69-1549-4f30-a463-d75d1bed31c1 + containers: + - urn:li:container:21d4204e13d5b984c58acad468ecdbdd +- urn: "urn:li:form:917364" + # id: 917364 # optional if urn is provided + type: VERIFICATION + name: "Governance Initiative" + prompts: + - id: "123" + title: "Retention Time" + description: "Apply Retention Time structured property to form" + type: STRUCTURED_PROPERTY + structured_property_id: io.acryl.privacy.retentionTime + required: False + - id: "certifier" + title: "Certifier" + type: STRUCTURED_PROPERTY + structured_property_id: io.acryl.dataManagement.certifier + required: True diff --git a/metadata-ingestion/examples/mce_files/test_structured_properties.json b/metadata-ingestion/examples/mce_files/test_structured_properties.json new file mode 100644 index 00000000000000..7771883152d381 --- /dev/null +++ b/metadata-ingestion/examples/mce_files/test_structured_properties.json @@ -0,0 +1,218 @@ +[ + { + "auditHeader": null, + "entityType": "entityType", + "entityUrn": "urn:li:entityType:datahub.dataset", + "changeType": "UPSERT", + "aspectName": "entityTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.dataset\", \"displayName\": \"Dataset\", \"description\": \"An entity type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "entityType", + "entityUrn": "urn:li:entityType:datahub.corpuser", + "changeType": "UPSERT", + "aspectName": "entityTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.corpuser\", \"displayName\": \"User\", \"description\": \"An entity type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "entityType", + "entityUrn": "urn:li:entityType:datahub.corpGroup", + "changeType": "UPSERT", + "aspectName": "entityTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.corpGroup\", \"displayName\": \"Group\", \"description\": \"An entity type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "dataType", + "entityUrn": "urn:li:dataType:datahub.string", + "changeType": "UPSERT", + "aspectName": "dataTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.string\", \"displayName\": \"String\", \"description\": \"A string type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "dataType", + "entityUrn": "urn:li:dataType:datahub.float", + "changeType": "UPSERT", + "aspectName": "dataTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.float\", \"displayName\": \"Number\", \"description\": \"A number type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "dataType", + "entityUrn": "urn:li:dataType:datahub.urn", + "changeType": "UPSERT", + "aspectName": "dataTypeInfo", + "aspect": { + "value": "{\"qualifiedName\": \"datahub.urn\", \"displayName\": \"Urn\", \"description\": \"A entity type.\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property1", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property1\", \"displayName\": \"String Property\", \"valueType\": \"urn:li:dataType:datahub.string\", \"cardinality\": \"SINGLE\", \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property2", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property2\", \"displayName\": \"String Property With Allowed Values\", \"valueType\": \"urn:li:dataType:datahub.string\", \"cardinality\": \"MULTIPLE\", \"allowedValues\": [ { \"value\": { \"string\": \"Test 1\" } }, { \"value\": { \"string\": \"Test 2\" } } ], \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property3", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property3\", \"displayName\": \"Numeric Property\", \"valueType\": \"urn:li:dataType:datahub.float\", \"cardinality\": \"MULTIPLE\", \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property4", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property4\", \"displayName\": \"Numeric Property with Allowed Values\", \"valueType\": \"urn:li:dataType:datahub.float\", \"cardinality\": \"MULTIPLE\", \"allowedValues\": [ { \"value\": { \"double\": 0.12 } }, { \"value\": { \"double\": 1 } } ], \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property5", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property5\", \"displayName\": \"Urn property no type qualifier\", \"valueType\": \"urn:li:dataType:datahub.urn\", \"cardinality\": \"MULTIPLE\", \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property6", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property6\", \"displayName\": \"Urn property with 1 type qualifier (user)\", \"valueType\": \"urn:li:dataType:datahub.urn\", \"typeQualifier\": { \"allowedTypes\": [\"urn:li:entityType:datahub.corpuser\"] }, \"cardinality\": \"MULTIPLE\", \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "structuredProperty", + "entityUrn": "urn:li:structuredProperty:test.Property7", + "changeType": "UPSERT", + "aspectName": "propertyDefinition", + "aspect": { + "value": "{\"qualifiedName\": \"test.Property7\", \"displayName\": \"Urn property with 2 type qualifier (user)\", \"valueType\": \"urn:li:dataType:datahub.urn\", \"typeQualifier\": { \"allowedTypes\": [\"urn:li:entityType:datahub.corpuser\", \"urn:li:entityType:datahub.corpGroup\"] }, \"cardinality\": \"MULTIPLE\", \"entityTypes\": [\"urn:li:entityType:datahub.dataset\"], \"description\": \"My description\"}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "form", + "entityUrn": "urn:li:form:my-test-form-verification-default-3", + "changeType": "UPSERT", + "aspectName": "formInfo", + "aspect": { + "value": "{\"name\": \"My test form\", \"description\": \"My test description\", \"type\": \"VERIFICATION\", \"prompts\": [{\"id\": \"prompt-1\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" }}, {\"id\": \"prompt-2\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" }}]}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "form", + "entityUrn": "urn:li:form:my-test-no-verification-3", + "changeType": "UPSERT", + "aspectName": "formInfo", + "aspect": { + "value": "{\"name\": \"My test form without verification\", \"description\": \"My test description\", \"prompts\": [{\"id\": \"prompt-1\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" }}, {\"id\": \"prompt-2\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" }}]}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "form", + "entityUrn": "urn:li:form:my-test-no-verification-custom-5", + "changeType": "UPSERT", + "aspectName": "formInfo", + "aspect": { + "value": "{\"name\": \"My test form with custom verification\", \"description\": \"My test description\", \"type\": \"VERIFICATION\", \"verification\": { \"type\": \"urn:li:verificationType:my-test\"}, \"prompts\": [{\"id\": \"prompt-1\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"required\": true, \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" } }, {\"id\": \"prompt-2\", \"title\": \"Select your thing\", \"description\": \"Which will you select?\", \"type\": \"STRUCTURED_PROPERTY\", \"required\": true, \"structuredPropertyParams\": { \"urn\": \"urn:li:structuredProperty:test.Property7\" }}]}", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "form", + "entityUrn": "urn:li:form:my-test-no-verification-custom-5", + "changeType": "UPSERT", + "aspectName": "dynamicFormAssignment", + "aspect": { + "value": "{\"filter\": { \"or\": [ { \"and\": [ { \"field\": \"platform\", \"condition\": \"EQUAL\", \"values\": [\"urn:li:dataPlatform:snowflake\"], \"value\": \"\" } ] } ] } }", + "contentType": "application/json" + }, + "systemMetadata": null + }, + { + "auditHeader": null, + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)", + "changeType": "UPSERT", + "aspectName": "forms", + "aspect": { + "value": "{ \"incompleteForms\":[\n {\n \"incompletePrompts\":[\n \n ],\n \"urn\":\"urn:li:form:my-test-no-verification-custom-4\",\n \"completedPrompts\":[\n {\n \"lastModified\":{\n \"actor\":\"urn:li:corpuser:__datahub_system\",\n \"time\":1697585983115\n },\n \"id\":\"prompt-2\"\n },\n {\n \"id\":\"prompt-1\",\n \"lastModified\":{\n \"actor\":\"urn:li:corpuser:__datahub_system\",\n \"time\":1697585983252\n }\n }\n ]\n },\n {\n \"incompletePrompts\":[\n \n ],\n \"urn\":\"urn:li:form:my-test-no-verification-custom-5\",\n \"completedPrompts\":[\n {\n \"lastModified\":{\n \"actor\":\"urn:li:corpuser:__datahub_system\",\n \"time\":1697645753521\n },\n \"id\":\"prompt-2\"\n },\n {\n \"id\":\"prompt-1\",\n \"lastModified\":{\n \"actor\":\"urn:li:corpuser:__datahub_system\",\n \"time\":1697645754180\n }\n }\n ]\n }\n ],\n \"completedForms\":[\n \n ]}", + "contentType": "application/json" + }, + "systemMetadata": null + } +] \ No newline at end of file diff --git a/metadata-ingestion/examples/structured_properties/README.md b/metadata-ingestion/examples/structured_properties/README.md new file mode 100644 index 00000000000000..0429310be74243 --- /dev/null +++ b/metadata-ingestion/examples/structured_properties/README.md @@ -0,0 +1,51 @@ +# Extended Properties + +## Expected Capabilities + +### structured_properties command + +```yaml +- id: io.acryl.privacy.retentionTime + # urn: urn:li:structuredProperty:<> + # fullyQualifiedName: io.acryl.privacy.retentionTime + type: STRING + cardinality: MULTIPLE + entityTypes: + - dataset # or urn:li:logicalEntity:metamodel.datahub.dataset + - dataflow + description: "Retention Time is used to figure out how long to retain records in a dataset" + allowedValues: + - value: 30 days + description: 30 days, usually reserved for datasets that are ephemeral and contain pii + - value: 3 months + description: Use this for datasets that drive monthly reporting but contain pii + - value: 2 yrs + description: Use this for non-sensitive data that can be retained for longer +- id: io.acryl.dataManagement.replicationSLA + type: NUMBER + description: "SLA for how long data can be delayed before replicating to the destination cluster" + entityTypes: + - dataset +- id: io.acryl.dataManagement.deprecationDate + type: DATE + entityTypes: + - dataset + - dataFlow + - dataJob +``` + +``` +datahub properties create -f structured_properties.yaml +``` + +``` +datahub properties create --name io.acryl.privacy.retentionTime --type STRING --cardinality MULTIPLE --entity_type DATASET --entity_type DATAFLOW +``` + +### dataset command + +``` +datahub dataset create -f dataset.yaml +``` + +See example in `dataproduct`. diff --git a/metadata-ingestion/examples/structured_properties/click_event.avsc b/metadata-ingestion/examples/structured_properties/click_event.avsc new file mode 100644 index 00000000000000..b277674f8b62fb --- /dev/null +++ b/metadata-ingestion/examples/structured_properties/click_event.avsc @@ -0,0 +1,14 @@ +{ + "namespace": "org.acryl", + "type": "record", + "name": "ClickEvent", + "fields": [ + { "name": "ip", "type": "string" }, + { "name": "url", "type": "string" }, + { "name": "time", "type": "long" }, + { "name": "referer", "type": ["string", "null"] }, + { "name": "user_agent", "type": ["string", "null"] }, + { "name": "user_id", "type": ["string", "null"] }, + { "name": "session_id", "type": ["string", "null"] } + ] +} diff --git a/metadata-ingestion/examples/structured_properties/dataset.yaml b/metadata-ingestion/examples/structured_properties/dataset.yaml new file mode 100644 index 00000000000000..557bf0167a51bc --- /dev/null +++ b/metadata-ingestion/examples/structured_properties/dataset.yaml @@ -0,0 +1,45 @@ +## This file is used to define a dataset and provide metadata for it +- id: user.clicks + platform: hive + # - urn: urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD) # use urn instead of id and platform + subtype: Table + schema: + file: examples/structured_properties/click_event.avsc + fields: + - id: ip + - urn: urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD),ip) + structured_properties: # structured properties for schema fields/columns go here + io.acryl.dataManagement.deprecationDate: "2023-01-01" + io.acryl.dataManagement.certifier: urn:li:corpuser:john.doe@example.com + io.acryl.dataManagement.replicationSLA: 90 + structured_properties: # dataset level structured properties go here + io.acryl.privacy.retentionTime: 365 + projectNames: + - Tracking + - DataHub +- id: ClickEvent + platform: events + subtype: Topic + description: | + This is a sample event that is generated when a user clicks on a link. + Do not use this event for any purpose other than testing. + properties: + project_name: Tracking + namespace: org.acryl.tracking + version: 1.0.0 + retention: 30 + structured_properties: + io.acryl.dataManagement.certifier: urn:li:corpuser:john.doe@example.com + schema: + file: examples/structured_properties/click_event.avsc + downstreams: + - urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD) +- id: user.clicks + platform: snowflake + schema: + fields: + - id: user_id + structured_properties: + io.acryl.dataManagement.deprecationDate: "2023-01-01" + structured_properties: + io.acryl.dataManagement.replicationSLA: 90 diff --git a/metadata-ingestion/examples/structured_properties/structured_properties.yaml b/metadata-ingestion/examples/structured_properties/structured_properties.yaml new file mode 100644 index 00000000000000..5c7ce47ba3b8a5 --- /dev/null +++ b/metadata-ingestion/examples/structured_properties/structured_properties.yaml @@ -0,0 +1,68 @@ +- id: io.acryl.privacy.retentionTime + # - urn: urn:li:structuredProperty:io.acryl.privacy.retentionTime # optional if id is provided + qualified_name: io.acryl.privacy.retentionTime # required if urn is provided + type: number + cardinality: MULTIPLE + display_name: Retention Time + entity_types: + - dataset # or urn:li:entityType:datahub.dataset + - dataFlow + description: "Retention Time is used to figure out how long to retain records in a dataset" + allowed_values: + - value: 30 + description: 30 days, usually reserved for datasets that are ephemeral and contain pii + - value: 90 + description: Use this for datasets that drive monthly reporting but contain pii + - value: 365 + description: Use this for non-sensitive data that can be retained for longer +- id: io.acryl.dataManagement.replicationSLA + type: number + display_name: Replication SLA + description: "SLA for how long data can be delayed before replicating to the destination cluster" + entity_types: + - dataset +- id: io.acryl.dataManagement.deprecationDate + type: date + display_name: Deprecation Date + entity_types: + - dataset + - dataFlow + - dataJob +- id: io.acryl.dataManagement.steward + type: urn + type_qualifier: + allowed_types: # only user and group urns are allowed + - corpuser + - corpGroup + display_name: Steward + entity_types: + - dataset + - dataFlow + - dataJob +- id: io.acryl.dataManagement.certifier + type: urn + display_name: Person Certifying the asset + entity_types: + - dataset + - schemaField +- id: io.acryl.dataManagement.team + type: string + display_name: Management team + entity_types: + - dataset +- id: projectNames + type: string + cardinality: MULTIPLE + display_name: Project names + entity_types: + - dataset + allowed_values: + - value: Tracking + description: test value 1 for project + - value: DataHub + description: test value 2 for project +- id: namespace + type: string + display_name: Namespace + entity_types: + - dataset diff --git a/metadata-ingestion/scripts/docgen.sh b/metadata-ingestion/scripts/docgen.sh index affb87f2e70a95..09fa2be912f614 100755 --- a/metadata-ingestion/scripts/docgen.sh +++ b/metadata-ingestion/scripts/docgen.sh @@ -7,4 +7,4 @@ DOCS_OUT_DIR=$DATAHUB_ROOT/docs/generated/ingestion EXTRA_DOCS_DIR=$DATAHUB_ROOT/metadata-ingestion/docs/sources rm -r $DOCS_OUT_DIR || true -python scripts/docgen.py --out-dir ${DOCS_OUT_DIR} --extra-docs ${EXTRA_DOCS_DIR} $@ +SPARK_VERSION=3.3 python scripts/docgen.py --out-dir ${DOCS_OUT_DIR} --extra-docs ${EXTRA_DOCS_DIR} $@ diff --git a/metadata-ingestion/scripts/modeldocgen.py b/metadata-ingestion/scripts/modeldocgen.py index 81b26145e620c9..610c6d3107916e 100644 --- a/metadata-ingestion/scripts/modeldocgen.py +++ b/metadata-ingestion/scripts/modeldocgen.py @@ -493,10 +493,32 @@ def strip_types(field_path: str) -> str: ], ) +@dataclass +class EntityAspectName: + entityName: str + aspectName: str + + +@dataclass +class AspectPluginConfig: + className: str + enabled: bool + supportedOperations: List[str] + supportedEntityAspectNames: List[EntityAspectName] + + +@dataclass +class PluginConfiguration: + aspectPayloadValidators: Optional[List[AspectPluginConfig]] = None + mutationHooks: Optional[List[AspectPluginConfig]] = None + mclSideEffects: Optional[List[AspectPluginConfig]] = None + mcpSideEffects: Optional[List[AspectPluginConfig]] = None + class EntityRegistry(ConfigModel): entities: List[EntityDefinition] events: Optional[List[EventDefinition]] + plugins: Optional[PluginConfiguration] = None def load_registry_file(registry_file: str) -> Dict[str, EntityDefinition]: diff --git a/metadata-ingestion/scripts/release.sh b/metadata-ingestion/scripts/release.sh index eacaf1d920a8d2..955eb562089f79 100755 --- a/metadata-ingestion/scripts/release.sh +++ b/metadata-ingestion/scripts/release.sh @@ -11,7 +11,7 @@ fi python -c 'import setuptools; where="./src"; assert setuptools.find_packages(where) == setuptools.find_namespace_packages(where), "you seem to be missing or have extra __init__.py files"' if [[ ${RELEASE_VERSION:-} ]]; then # Replace version with RELEASE_VERSION env variable - sed -i.bak "s/__version__ = \"1!0.0.0.dev0\"/__version__ = \"$RELEASE_VERSION\"/" src/datahub/__init__.py + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/datahub/__init__.py else vim src/datahub/__init__.py fi diff --git a/metadata-ingestion/src/datahub/api/entities/dataset/__init__.py b/metadata-ingestion/src/datahub/api/entities/dataset/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py b/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py new file mode 100644 index 00000000000000..3b4a5fbfbb0614 --- /dev/null +++ b/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py @@ -0,0 +1,466 @@ +import json +import logging +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple, Union + +from pydantic import BaseModel, Field, validator +from ruamel.yaml import YAML + +from datahub.api.entities.structuredproperties.structuredproperties import ( + AllowedTypes, + StructuredProperties, +) +from datahub.configuration.common import ConfigModel +from datahub.emitter.mce_builder import ( + make_data_platform_urn, + make_dataset_urn, + make_schema_field_urn, +) +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.extractor.schema_util import avro_schema_to_mce_fields +from datahub.ingestion.graph.client import DataHubGraph, get_default_graph +from datahub.metadata.schema_classes import ( + DatasetPropertiesClass, + MetadataChangeProposalClass, + OtherSchemaClass, + SchemaFieldClass, + SchemaMetadataClass, + StructuredPropertiesClass, + StructuredPropertyValueAssignmentClass, + SubTypesClass, + UpstreamClass, +) +from datahub.specific.dataset import DatasetPatchBuilder +from datahub.utilities.urns.dataset_urn import DatasetUrn +from datahub.utilities.urns.urn import Urn + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class SchemaFieldSpecification(BaseModel): + id: Optional[str] + urn: Optional[str] + structured_properties: Optional[ + Dict[str, Union[str, float, List[Union[str, float]]]] + ] = None + type: Optional[str] + nativeDataType: Optional[str] = None + jsonPath: Union[None, str] = None + nullable: Optional[bool] = None + description: Union[None, str] = None + label: Optional[str] = None + created: Optional[dict] = None + lastModified: Optional[dict] = None + recursive: Optional[bool] = None + globalTags: Optional[dict] = None + glossaryTerms: Optional[dict] = None + isPartOfKey: Optional[bool] = None + isPartitioningKey: Optional[bool] = None + jsonProps: Optional[dict] = None + + def with_structured_properties( + self, + structured_properties: Optional[Dict[str, List[Union[str, float]]]], + ) -> "SchemaFieldSpecification": + self.structured_properties = ( + {k: v for k, v in structured_properties.items()} + if structured_properties + else None + ) + return self + + @classmethod + def from_schema_field( + cls, schema_field: SchemaFieldClass, parent_urn: str + ) -> "SchemaFieldSpecification": + return SchemaFieldSpecification( + id=Dataset._simplify_field_path(schema_field.fieldPath), + urn=make_schema_field_urn( + parent_urn, Dataset._simplify_field_path(schema_field.fieldPath) + ), + type=str(schema_field.type), + nativeDataType=schema_field.nativeDataType, + nullable=schema_field.nullable, + description=schema_field.description, + label=schema_field.label, + created=schema_field.created.__dict__ if schema_field.created else None, + lastModified=schema_field.lastModified.__dict__ + if schema_field.lastModified + else None, + recursive=schema_field.recursive, + globalTags=schema_field.globalTags.__dict__ + if schema_field.globalTags + else None, + glossaryTerms=schema_field.glossaryTerms.__dict__ + if schema_field.glossaryTerms + else None, + isPartitioningKey=schema_field.isPartitioningKey, + jsonProps=json.loads(schema_field.jsonProps) + if schema_field.jsonProps + else None, + ) + + @validator("urn", pre=True, always=True) + def either_id_or_urn_must_be_filled_out(cls, v, values): + if not v and not values.get("id"): + raise ValueError("Either id or urn must be present") + return v + + +class SchemaSpecification(BaseModel): + file: Optional[str] + fields: Optional[List[SchemaFieldSpecification]] + + @validator("file") + def file_must_be_avsc(cls, v): + if v and not v.endswith(".avsc"): + raise ValueError("file must be a .avsc file") + return v + + +class StructuredPropertyValue(ConfigModel): + value: Union[str, float, List[str], List[float]] + created: Optional[str] + lastModified: Optional[str] + + +class Dataset(BaseModel): + id: Optional[str] + platform: Optional[str] + env: str = "PROD" + urn: Optional[str] + description: Optional[str] + name: Optional[str] + schema_metadata: Optional[SchemaSpecification] = Field(alias="schema") + downstreams: Optional[List[str]] + properties: Optional[Dict[str, str]] + subtype: Optional[str] + subtypes: Optional[List[str]] + structured_properties: Optional[ + Dict[str, Union[str, float, List[Union[str, float]]]] + ] = None + + @property + def platform_urn(self) -> str: + if self.platform: + return make_data_platform_urn(self.platform) + else: + assert self.urn is not None # validator should have filled this in + dataset_urn = DatasetUrn.create_from_string(self.urn) + return str(dataset_urn.get_data_platform_urn()) + + @validator("urn", pre=True, always=True) + def urn_must_be_present(cls, v, values): + if not v: + assert "id" in values, "id must be present if urn is not" + assert "platform" in values, "platform must be present if urn is not" + assert "env" in values, "env must be present if urn is not" + return make_dataset_urn(values["platform"], values["id"], values["env"]) + return v + + @validator("name", pre=True, always=True) + def name_filled_with_id_if_not_present(cls, v, values): + if not v: + assert "id" in values, "id must be present if name is not" + return values["id"] + return v + + @validator("platform") + def platform_must_not_be_urn(cls, v): + if v.startswith("urn:li:dataPlatform:"): + return v[len("urn:li:dataPlatform:") :] + return v + + @classmethod + def from_yaml(cls, file: str) -> Iterable["Dataset"]: + with open(file) as fp: + yaml = YAML(typ="rt") # default, if not specfied, is 'rt' (round-trip) + datasets: Union[dict, List[dict]] = yaml.load(fp) + if isinstance(datasets, dict): + datasets = [datasets] + for dataset_raw in datasets: + dataset = Dataset.parse_obj(dataset_raw) + yield dataset + + def generate_mcp( + self, + ) -> Iterable[Union[MetadataChangeProposalClass, MetadataChangeProposalWrapper]]: + mcp = MetadataChangeProposalWrapper( + entityUrn=self.urn, + aspect=DatasetPropertiesClass( + description=self.description, + name=self.name, + customProperties=self.properties, + ), + ) + yield mcp + + if self.schema_metadata: + if self.schema_metadata.file: + with open(self.schema_metadata.file, "r") as schema_fp: + schema_string = schema_fp.read() + schema_metadata = SchemaMetadataClass( + schemaName=self.name or self.id or self.urn or "", + platform=self.platform_urn, + version=0, + hash="", + platformSchema=OtherSchemaClass(rawSchema=schema_string), + fields=avro_schema_to_mce_fields(schema_string), + ) + mcp = MetadataChangeProposalWrapper( + entityUrn=self.urn, aspect=schema_metadata + ) + yield mcp + + if self.schema_metadata.fields: + for field in self.schema_metadata.fields: + field_urn = field.urn or make_schema_field_urn( + self.urn, field.id # type: ignore[arg-type] + ) + assert field_urn.startswith("urn:li:schemaField:") + if field.structured_properties: + # field_properties_flattened = ( + # Dataset.extract_structured_properties( + # field.structured_properties + # ) + # ) + mcp = MetadataChangeProposalWrapper( + entityUrn=field_urn, + aspect=StructuredPropertiesClass( + properties=[ + StructuredPropertyValueAssignmentClass( + propertyUrn=f"urn:li:structuredProperty:{prop_key}", + values=prop_value + if isinstance(prop_value, list) + else [prop_value], + ) + for prop_key, prop_value in field.structured_properties.items() + ] + ), + ) + yield mcp + + if self.subtype or self.subtypes: + mcp = MetadataChangeProposalWrapper( + entityUrn=self.urn, + aspect=SubTypesClass( + typeNames=[ + s + for s in [self.subtype] + (self.subtypes or []) + if s + ] + ), + ) + yield mcp + + if self.structured_properties: + # structured_properties_flattened = ( + # Dataset.extract_structured_properties( + # self.structured_properties + # ) + # ) + mcp = MetadataChangeProposalWrapper( + entityUrn=self.urn, + aspect=StructuredPropertiesClass( + properties=[ + StructuredPropertyValueAssignmentClass( + propertyUrn=f"urn:li:structuredProperty:{prop_key}", + values=prop_value + if isinstance(prop_value, list) + else [prop_value], + ) + for prop_key, prop_value in self.structured_properties.items() + ] + ), + ) + yield mcp + + if self.downstreams: + for downstream in self.downstreams: + patch_builder = DatasetPatchBuilder(downstream) + assert ( + self.urn is not None + ) # validator should have filled this in + patch_builder.add_upstream_lineage( + UpstreamClass( + dataset=self.urn, + type="COPY", + ) + ) + for patch_event in patch_builder.build(): + yield patch_event + + logger.info(f"Created dataset {self.urn}") + + @staticmethod + def extract_structured_properties( + structured_properties: Dict[str, Union[str, float, List[str], List[float]]] + ) -> List[Tuple[str, Union[str, float]]]: + structured_properties_flattened: List[Tuple[str, Union[str, float]]] = [] + for key, value in structured_properties.items(): + validated_structured_property = Dataset.validate_structured_property( + key, value + ) + if validated_structured_property: + structured_properties_flattened.append(validated_structured_property) + structured_properties_flattened = sorted( + structured_properties_flattened, key=lambda x: x[0] + ) + return structured_properties_flattened + + @staticmethod + def validate_structured_property( + sp_name: str, sp_value: Union[str, float, List[str], List[float]] + ) -> Union[Tuple[str, Union[str, float]], None]: + """ + Validate based on: + 1. Structured property exists/has been created + 2. Structured property value is of the expected type + """ + urn = Urn.make_structured_property_urn(sp_name) + with get_default_graph() as graph: + if graph.exists(urn): + validated_structured_property = StructuredProperties.from_datahub( + graph, urn + ) + allowed_type = Urn.get_data_type_from_urn( + validated_structured_property.type + ) + try: + if not isinstance(sp_value, list): + return Dataset.validate_type(sp_name, sp_value, allowed_type) + else: + for v in sp_value: + return Dataset.validate_type(sp_name, v, allowed_type) + except ValueError: + logger.warning( + f"Property: {sp_name}, value: {sp_value} should be a {allowed_type}." + ) + else: + logger.error( + f"Property {sp_name} does not exist and therefore will not be added to dataset. Please create property before trying again." + ) + return None + + @staticmethod + def validate_type( + sp_name: str, sp_value: Union[str, float], allowed_type: str + ) -> Tuple[str, Union[str, float]]: + if allowed_type == AllowedTypes.NUMBER.value: + return (sp_name, float(sp_value)) + else: + return (sp_name, sp_value) + + @staticmethod + def _simplify_field_path(field_path: str) -> str: + if field_path.startswith("[version=2.0]"): + # v2 field path + field_components = [] + current_field = "" + for c in field_path: + if c == "[": + if current_field: + field_components.append(current_field) + current_field = "" + omit_next = True + elif c == "]": + omit_next = False + elif c == ".": + pass + elif not omit_next: + current_field += c + if current_field: + field_components.append(current_field) + return ".".join(field_components) + else: + return field_path + + @staticmethod + def _schema_from_schema_metadata( + graph: DataHubGraph, urn: str + ) -> Optional[SchemaSpecification]: + schema_metadata: Optional[SchemaMetadataClass] = graph.get_aspect( + urn, SchemaMetadataClass + ) + + if schema_metadata: + schema_specification = SchemaSpecification( + fields=[ + SchemaFieldSpecification.from_schema_field( + field, urn + ).with_structured_properties( + { + sp.propertyUrn: sp.values + for sp in structured_props.properties + } + if structured_props + else None + ) + for field, structured_props in [ + ( + field, + graph.get_aspect( + make_schema_field_urn(urn, field.fieldPath), + StructuredPropertiesClass, + ) + or graph.get_aspect( + make_schema_field_urn( + urn, Dataset._simplify_field_path(field.fieldPath) + ), + StructuredPropertiesClass, + ), + ) + for field in schema_metadata.fields + ] + ] + ) + return schema_specification + else: + return None + + @classmethod + def from_datahub(cls, graph: DataHubGraph, urn: str) -> "Dataset": + dataset_properties: Optional[DatasetPropertiesClass] = graph.get_aspect( + urn, DatasetPropertiesClass + ) + subtypes: Optional[SubTypesClass] = graph.get_aspect(urn, SubTypesClass) + structured_properties: Optional[StructuredPropertiesClass] = graph.get_aspect( + urn, StructuredPropertiesClass + ) + if structured_properties: + structured_properties_map: Dict[str, List[Union[str, float]]] = {} + for sp in structured_properties.properties: + if sp.propertyUrn in structured_properties_map: + assert isinstance(structured_properties_map[sp.propertyUrn], list) + structured_properties_map[sp.propertyUrn].extend(sp.values) # type: ignore[arg-type,union-attr] + else: + structured_properties_map[sp.propertyUrn] = sp.values + + return Dataset( # type: ignore[call-arg] + urn=urn, + description=dataset_properties.description + if dataset_properties and dataset_properties.description + else None, + name=dataset_properties.name + if dataset_properties and dataset_properties.name + else None, + schema=Dataset._schema_from_schema_metadata(graph, urn), + properties=dataset_properties.customProperties + if dataset_properties + else None, + subtypes=[subtype for subtype in subtypes.typeNames] if subtypes else None, + structured_properties=structured_properties_map + if structured_properties + else None, + ) + + def to_yaml( + self, + file: Path, + ) -> None: + with open(file, "w") as fp: + yaml = YAML(typ="rt") # default, if not specfied, is 'rt' (round-trip) + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.default_flow_style = False + yaml.dump(self.dict(exclude_none=True, exclude_unset=True), fp) diff --git a/metadata-ingestion/src/datahub/api/entities/forms/__init__.py b/metadata-ingestion/src/datahub/api/entities/forms/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/metadata-ingestion/src/datahub/api/entities/forms/forms.py b/metadata-ingestion/src/datahub/api/entities/forms/forms.py new file mode 100644 index 00000000000000..cc43779bda4097 --- /dev/null +++ b/metadata-ingestion/src/datahub/api/entities/forms/forms.py @@ -0,0 +1,353 @@ +import logging +import uuid +from enum import Enum +from pathlib import Path +from typing import List, Optional, Union + +import yaml +from pydantic import validator +from ruamel.yaml import YAML +from typing_extensions import Literal + +from datahub.api.entities.forms.forms_graphql_constants import ( + CREATE_DYNAMIC_FORM_ASSIGNMENT, + FIELD_FILTER_TEMPLATE, + UPLOAD_ENTITIES_FOR_FORMS, +) +from datahub.configuration.common import ConfigModel +from datahub.emitter.mce_builder import ( + make_data_platform_urn, + make_group_urn, + make_user_urn, +) +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.graph.client import DataHubGraph, get_default_graph +from datahub.metadata.schema_classes import ( + FormInfoClass, + FormPromptClass, + OwnerClass, + OwnershipClass, + OwnershipTypeClass, + StructuredPropertyParamsClass, +) +from datahub.utilities.urns.urn import Urn + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class PromptType(Enum): + STRUCTURED_PROPERTY = "STRUCTURED_PROPERTY" + FIELDS_STRUCTURED_PROPERTY = "FIELDS_STRUCTURED_PROPERTY" + + @classmethod + def has_value(cls, value): + return value in cls._value2member_map_ + + +class Prompt(ConfigModel): + id: Optional[str] + title: str + description: Optional[str] + type: str + structured_property_id: Optional[str] + structured_property_urn: Optional[str] + required: Optional[bool] + + @validator("structured_property_urn", pre=True, always=True) + def structured_property_urn_must_be_present(cls, v, values): + if not v and values.get("structured_property_id"): + return Urn.make_structured_property_urn(values["structured_property_id"]) + return v + + +class FormType(Enum): + VERIFICATION = "VERIFICATION" + DOCUMENTATION = "COMPLETION" + + @classmethod + def has_value(cls, value): + return value in cls._value2member_map_ + + +class Filters(ConfigModel): + types: Optional[List[str]] + platforms: Optional[List[str]] + domains: Optional[List[str]] + containers: Optional[List[str]] + + +class Entities(ConfigModel): + urns: Optional[List[str]] + filters: Optional[Filters] + + +class Forms(ConfigModel): + id: Optional[str] + urn: Optional[str] + name: str + description: Optional[str] + prompts: List[Prompt] = [] + type: Optional[str] + version: Optional[Literal[1]] + entities: Optional[Entities] + owners: Optional[List[str]] # can be user IDs or urns + group_owners: Optional[List[str]] # can be group IDs or urns + + @validator("urn", pre=True, always=True) + def urn_must_be_present(cls, v, values): + if not v: + assert values.get("id") is not None, "Form id must be present if urn is not" + return f"urn:li:form:{values['id']}" + return v + + @staticmethod + def create(file: str) -> None: + emitter: DataHubGraph + + with get_default_graph() as emitter: + with open(file, "r") as fp: + forms: List[dict] = yaml.safe_load(fp) + for form_raw in forms: + form = Forms.parse_obj(form_raw) + + try: + if not FormType.has_value(form.type): + logger.error( + f"Form type {form.type} does not exist. Please try again with a valid type." + ) + + mcp = MetadataChangeProposalWrapper( + entityUrn=form.urn, + aspect=FormInfoClass( + name=form.name, + description=form.description, + prompts=form.validate_prompts(emitter), + type=form.type, + ), + ) + emitter.emit_mcp(mcp) + + logger.info(f"Created form {form.urn}") + + if form.owners or form.group_owners: + form.add_owners(emitter) + + if form.entities: + if form.entities.urns: + # Associate specific entities with a form + form.upload_entities_for_form(emitter) + + if form.entities.filters: + # Associate groups of entities with a form based on filters + form.create_form_filters(emitter) + + except Exception as e: + logger.error(e) + return + + def validate_prompts(self, emitter: DataHubGraph) -> List[FormPromptClass]: + prompts = [] + if self.prompts: + for prompt in self.prompts: + if not prompt.id: + prompt.id = str(uuid.uuid4()) + logger.warning( + f"Prompt id not provided. Setting prompt id to {prompt.id}" + ) + if prompt.structured_property_urn: + structured_property_urn = prompt.structured_property_urn + if emitter.exists(structured_property_urn): + prompt.structured_property_urn = structured_property_urn + else: + raise Exception( + f"Structured property {structured_property_urn} does not exist. Unable to create form." + ) + elif ( + prompt.type + in ( + PromptType.STRUCTURED_PROPERTY.value, + PromptType.FIELDS_STRUCTURED_PROPERTY.value, + ) + and not prompt.structured_property_urn + ): + raise Exception( + f"Prompt type is {prompt.type} but no structured properties exist. Unable to create form." + ) + + prompts.append( + FormPromptClass( + id=prompt.id, + title=prompt.title, + description=prompt.description, + type=prompt.type, + structuredPropertyParams=StructuredPropertyParamsClass( + urn=prompt.structured_property_urn + ) + if prompt.structured_property_urn + else None, + required=prompt.required, + ) + ) + else: + logger.warning(f"No prompts exist on form {self.urn}. Is that intended?") + + return prompts + + def upload_entities_for_form(self, emitter: DataHubGraph) -> Union[None, Exception]: + if self.entities and self.entities.urns: + formatted_entity_urns = ", ".join( + ['"{}"'.format(value) for value in self.entities.urns] + ) + query = UPLOAD_ENTITIES_FOR_FORMS.format( + form_urn=self.urn, entity_urns=formatted_entity_urns + ) + result = emitter.execute_graphql(query=query) + if not result: + return Exception(f"Could not bulk upload entities for form {self.urn}.") + + return None + + def create_form_filters(self, emitter: DataHubGraph) -> Union[None, Exception]: + filters_raw = [] + # Loop through each entity and assign a filter for it + if self.entities and self.entities.filters: + filters = self.entities.filters + if filters.types: + filters_raw.append( + Forms.format_form_filter("_entityType", filters.types) + ) + if filters.platforms: + urns = [ + make_data_platform_urn(platform) for platform in filters.platforms + ] + filters_raw.append(Forms.format_form_filter("platform", urns)) + if filters.domains: + urns = [] + for domain in filters.domains: + domain_urn = Forms.validate_domain_urn(domain) + if domain_urn: + urns.append(domain_urn) + filters_raw.append(Forms.format_form_filter("domains", urns)) + if filters.containers: + urns = [] + for container in filters.containers: + container_urn = Forms.validate_container_urn(container) + if container_urn: + urns.append(container_urn) + filters_raw.append(Forms.format_form_filter("container", urns)) + + filters_str = ", ".join(item for item in filters_raw) + result = emitter.execute_graphql( + query=CREATE_DYNAMIC_FORM_ASSIGNMENT.format( + form_urn=self.urn, filters=filters_str + ) + ) + if not result: + return Exception( + f"Could not bulk upload urns or filters for form {self.urn}." + ) + + return None + + def add_owners(self, emitter: DataHubGraph) -> Union[None, Exception]: + owner_urns: List[str] = [] + if self.owners: + owner_urns += Forms.format_owners(self.owners) + if self.group_owners: + owner_urns += Forms.format_group_owners(self.group_owners) + + ownership = OwnershipClass( + owners=[ + OwnerClass(owner=urn, type=OwnershipTypeClass.TECHNICAL_OWNER) + for urn in (owner_urns or []) + ], + ) + + try: + mcp = MetadataChangeProposalWrapper(entityUrn=self.urn, aspect=ownership) + emitter.emit_mcp(mcp) + except Exception as e: + logger.error(e) + + return None + + @staticmethod + def format_form_filter(field: str, urns: List[str]) -> str: + formatted_urns = ", ".join(['"{}"'.format(urn) for urn in urns]) + return FIELD_FILTER_TEMPLATE.format(field=field, values=formatted_urns) + + @staticmethod + def validate_domain_urn(domain: str) -> Union[str, None]: + if domain.startswith("urn:li:domain:"): + return domain + + logger.warning(f"{domain} is not an urn. Unable to create domain filter.") + return None + + @staticmethod + def validate_container_urn(container: str) -> Union[str, None]: + if container.startswith("urn:li:container:"): + return container + + logger.warning(f"{container} is not an urn. Unable to create container filter.") + return None + + @staticmethod + def from_datahub(graph: DataHubGraph, urn: str) -> "Forms": + form: Optional[FormInfoClass] = graph.get_aspect(urn, FormInfoClass) + assert form is not None + prompts = [] + for prompt_raw in form.prompts: + prompts.append( + Prompt( + id=prompt_raw.id, + title=prompt_raw.title, + description=prompt_raw.description, + type=prompt_raw.type, + structured_property_urn=prompt_raw.structuredPropertyParams.urn + if prompt_raw.structuredPropertyParams + else None, + ) + ) + return Forms( + urn=urn, + name=form.name, + description=form.description, + prompts=prompts, + type=form.type, + ) + + @staticmethod + def format_owners(owners: List[str]) -> List[str]: + formatted_owners: List[str] = [] + + for owner in owners: + if owner.startswith("urn:li:"): + formatted_owners.append(owner) + else: + formatted_owners.append(make_user_urn(owner)) + + return formatted_owners + + @staticmethod + def format_group_owners(owners: List[str]) -> List[str]: + formatted_owners: List[str] = [] + + for owner in owners: + if owner.startswith("urn:li:"): + formatted_owners.append(owner) + else: + formatted_owners.append(make_group_urn(owner)) + + return formatted_owners + + def to_yaml( + self, + file: Path, + ) -> None: + with open(file, "w") as fp: + yaml = YAML(typ="rt") # default, if not specfied, is 'rt' (round-trip) + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.default_flow_style = False + yaml.dump(self.dict(), fp) diff --git a/metadata-ingestion/src/datahub/api/entities/forms/forms_graphql_constants.py b/metadata-ingestion/src/datahub/api/entities/forms/forms_graphql_constants.py new file mode 100644 index 00000000000000..c227d8fc05366e --- /dev/null +++ b/metadata-ingestion/src/datahub/api/entities/forms/forms_graphql_constants.py @@ -0,0 +1,27 @@ +UPLOAD_ENTITIES_FOR_FORMS = """ +mutation batchAssignForm {{ + batchAssignForm( + input: {{ + formUrn: "{form_urn}", + entityUrns: [{entity_urns}] + }} + ) +}} +""" + +FIELD_FILTER_TEMPLATE = ( + """{{ field: "{field}", values: [{values}], condition: EQUAL, negated: false }}""" +) + +CREATE_DYNAMIC_FORM_ASSIGNMENT = """ +mutation createDynamicFormAssignment {{ + createDynamicFormAssignment( + input: {{ + formUrn: "{form_urn}" + orFilters: [{{ + and: [{filters}] + }}] + }} + ) +}} +""" diff --git a/metadata-ingestion/src/datahub/api/entities/structuredproperties/__init__.py b/metadata-ingestion/src/datahub/api/entities/structuredproperties/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py new file mode 100644 index 00000000000000..af9bf3dccac5c4 --- /dev/null +++ b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py @@ -0,0 +1,185 @@ +import logging +from enum import Enum +from pathlib import Path +from typing import List, Optional + +import yaml +from pydantic import validator +from ruamel.yaml import YAML + +from datahub.configuration.common import ConfigModel +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.graph.client import DataHubGraph, get_default_graph +from datahub.metadata.schema_classes import ( + PropertyValueClass, + StructuredPropertyDefinitionClass, +) +from datahub.utilities.urns.urn import Urn + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class AllowedTypes(Enum): + STRING = "string" + RICH_TEXT = "rich_text" + NUMBER = "number" + DATE = "date" + URN = "urn" + + @staticmethod + def check_allowed_type(value: str) -> bool: + return value in [allowed_type.value for allowed_type in AllowedTypes] + + @staticmethod + def values(): + return ", ".join([allowed_type.value for allowed_type in AllowedTypes]) + + +class AllowedValue(ConfigModel): + value: str + description: Optional[str] + + +class TypeQualifierAllowedTypes(ConfigModel): + allowed_types: List[str] + + @validator("allowed_types") + def validate_allowed_types(cls, v): + validated_entity_type_urns = [] + if v: + with get_default_graph() as graph: + for et in v: + validated_urn = Urn.make_entity_type_urn(et) + if graph.exists(validated_urn): + validated_entity_type_urns.append(validated_urn) + else: + logger.warn( + f"Input {et} is not a valid entity type urn. Skipping." + ) + v = validated_entity_type_urns + if not v: + logger.warn("No allowed_types given within type_qualifier.") + return v + + +class StructuredProperties(ConfigModel): + id: Optional[str] + urn: Optional[str] + qualified_name: Optional[str] + type: str + value_entity_types: Optional[List[str]] + description: Optional[str] + display_name: Optional[str] + entity_types: Optional[List[str]] + cardinality: Optional[str] + allowed_values: Optional[List[AllowedValue]] + type_qualifier: Optional[TypeQualifierAllowedTypes] + + @property + def fqn(self) -> str: + assert self.urn is not None + return ( + self.qualified_name + or self.id + or Urn.create_from_string(self.urn).get_entity_id()[0] + ) + + @validator("urn", pre=True, always=True) + def urn_must_be_present(cls, v, values): + if not v: + assert "id" in values, "id must be present if urn is not" + return f"urn:li:structuredProperty:{values['id']}" + return v + + @staticmethod + def create(file: str) -> None: + emitter: DataHubGraph + + with get_default_graph() as emitter: + with open(file, "r") as fp: + structuredproperties: List[dict] = yaml.safe_load(fp) + for structuredproperty_raw in structuredproperties: + structuredproperty = StructuredProperties.parse_obj( + structuredproperty_raw + ) + if not structuredproperty.type.islower(): + structuredproperty.type = structuredproperty.type.lower() + logger.warn( + f"Structured property type should be lowercase. Updated to {structuredproperty.type}" + ) + if not AllowedTypes.check_allowed_type(structuredproperty.type): + raise ValueError( + f"Type {structuredproperty.type} is not allowed. Allowed types are {AllowedTypes.values()}" + ) + mcp = MetadataChangeProposalWrapper( + entityUrn=structuredproperty.urn, + aspect=StructuredPropertyDefinitionClass( + qualifiedName=structuredproperty.fqn, + valueType=Urn.make_data_type_urn(structuredproperty.type), + displayName=structuredproperty.display_name, + description=structuredproperty.description, + entityTypes=[ + Urn.make_entity_type_urn(entity_type) + for entity_type in structuredproperty.entity_types or [] + ], + cardinality=structuredproperty.cardinality, + allowedValues=[ + PropertyValueClass( + value=v.value, description=v.description + ) + for v in structuredproperty.allowed_values + ] + if structuredproperty.allowed_values + else None, + typeQualifier={ + "allowedTypes": structuredproperty.type_qualifier.allowed_types + } + if structuredproperty.type_qualifier + else None, + ), + ) + emitter.emit_mcp(mcp) + + logger.info(f"Created structured property {structuredproperty.urn}") + + @classmethod + def from_datahub(cls, graph: DataHubGraph, urn: str) -> "StructuredProperties": + + structured_property: Optional[ + StructuredPropertyDefinitionClass + ] = graph.get_aspect(urn, StructuredPropertyDefinitionClass) + assert structured_property is not None + return StructuredProperties( + urn=urn, + qualified_name=structured_property.qualifiedName, + display_name=structured_property.displayName, + type=structured_property.valueType, + description=structured_property.description, + entity_types=structured_property.entityTypes, + cardinality=structured_property.cardinality, + allowed_values=[ + AllowedValue( + value=av.value, + description=av.description, + ) + for av in structured_property.allowedValues or [] + ] + if structured_property.allowedValues is not None + else None, + type_qualifier={ + "allowed_types": structured_property.typeQualifier.get("allowedTypes") + } + if structured_property.typeQualifier + else None, + ) + + def to_yaml( + self, + file: Path, + ) -> None: + with open(file, "w") as fp: + yaml = YAML(typ="rt") # default, if not specfied, is 'rt' (round-trip) + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.default_flow_style = False + yaml.dump(self.dict(), fp) diff --git a/metadata-ingestion/src/datahub/cli/docker_check.py b/metadata-ingestion/src/datahub/cli/docker_check.py index 97b88cbc8b8ebd..47b89af6dfd040 100644 --- a/metadata-ingestion/src/datahub/cli/docker_check.py +++ b/metadata-ingestion/src/datahub/cli/docker_check.py @@ -193,6 +193,11 @@ def check_docker_quickstart() -> QuickstartStatus: .labels.get("com.docker.compose.project.config_files") .split(",") ) + + # If using profiles, alternative check + if config_files and "/profiles/" in config_files[0]: + return check_docker_quickstart_profiles(client) + all_containers = set() for config_file in config_files: with open(config_file, "r") as config_file: @@ -234,3 +239,35 @@ def check_docker_quickstart() -> QuickstartStatus: ) return QuickstartStatus(container_statuses) + + +def check_docker_quickstart_profiles(client: docker.DockerClient) -> QuickstartStatus: + container_statuses: List[DockerContainerStatus] = [] + containers = client.containers.list( + all=True, + filters={"label": "io.datahubproject.datahub.component=gms"}, + # We can get race conditions between docker running up / recreating + # containers and our status checks. + ignore_removed=True, + ) + if len(containers) == 0: + return QuickstartStatus([]) + + existing_containers = set() + # Check that the containers are running and healthy. + container: docker.models.containers.Container + for container in containers: + name = container.labels.get("com.docker.compose.service", container.name) + existing_containers.add(name) + status = ContainerStatus.OK + if container.status != "running": + status = ContainerStatus.DIED + elif "Health" in container.attrs["State"]: + if container.attrs["State"]["Health"]["Status"] == "starting": + status = ContainerStatus.STARTING + elif container.attrs["State"]["Health"]["Status"] != "healthy": + status = ContainerStatus.UNHEALTHY + + container_statuses.append(DockerContainerStatus(name, status)) + + return QuickstartStatus(container_statuses) diff --git a/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py b/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py index 5d6c65512354a2..a52a9dddff1276 100644 --- a/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py +++ b/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py @@ -56,7 +56,6 @@ def _abort_if_non_existent_urn(graph: DataHubGraph, urn: str, operation: str) -> def _print_diff(orig_file, new_file): - with open(orig_file) as fp: orig_lines = fp.readlines() with open(new_file) as fp: @@ -388,7 +387,7 @@ def add_asset(urn: str, asset: str, validate_assets: bool) -> None: graph.emit(mcp) -@dataproduct.command(name="remove_asset", help="Add an asset to a Data Product") +@dataproduct.command(name="remove_asset", help="Remove an asset from a Data Product") @click.option("--urn", required=True, type=str) @click.option("--asset", required=True, type=str) @click.option( diff --git a/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py b/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py new file mode 100644 index 00000000000000..c702d0ec28961c --- /dev/null +++ b/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py @@ -0,0 +1,67 @@ +import json +import logging +from pathlib import Path + +import click +from click_default_group import DefaultGroup + +from datahub.api.entities.dataset.dataset import Dataset +from datahub.ingestion.graph.client import get_default_graph +from datahub.telemetry import telemetry +from datahub.upgrade import upgrade + +logger = logging.getLogger(__name__) + + +@click.group(cls=DefaultGroup, default="upsert") +def dataset() -> None: + """A group of commands to interact with the Dataset entity in DataHub.""" + pass + + +@dataset.command( + name="upsert", +) +@click.option("-f", "--file", required=True, type=click.Path(exists=True)) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def upsert(file: Path) -> None: + """Upsert attributes to a Dataset in DataHub.""" + + with get_default_graph() as graph: + for dataset in Dataset.from_yaml(str(file)): + try: + for mcp in dataset.generate_mcp(): + graph.emit(mcp) + click.secho(f"Update succeeded for urn {dataset.urn}.", fg="green") + except Exception as e: + click.secho( + f"Update failed for id {id}. due to {e}", + fg="red", + ) + + +@dataset.command( + name="get", +) +@click.option("--urn", required=True, type=str) +@click.option("--to-file", required=False, type=str) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def get(urn: str, to_file: str) -> None: + """Get a Dataset from DataHub""" + + if not urn.startswith("urn:li:dataset:"): + urn = f"urn:li:dataset:{urn}" + + with get_default_graph() as graph: + if graph.exists(urn): + dataset: Dataset = Dataset.from_datahub(graph=graph, urn=urn) + click.secho( + f"{json.dumps(dataset.dict(exclude_unset=True, exclude_none=True), indent=2)}" + ) + if to_file: + dataset.to_yaml(Path(to_file)) + click.secho(f"Dataset yaml written to {to_file}", fg="green") + else: + click.secho(f"Dataset {urn} does not exist") diff --git a/metadata-ingestion/src/datahub/cli/specific/forms_cli.py b/metadata-ingestion/src/datahub/cli/specific/forms_cli.py new file mode 100644 index 00000000000000..a494396909b32d --- /dev/null +++ b/metadata-ingestion/src/datahub/cli/specific/forms_cli.py @@ -0,0 +1,53 @@ +import json +import logging +from pathlib import Path + +import click +from click_default_group import DefaultGroup + +from datahub.api.entities.forms.forms import Forms +from datahub.ingestion.graph.client import get_default_graph +from datahub.telemetry import telemetry +from datahub.upgrade import upgrade + +logger = logging.getLogger(__name__) + + +@click.group(cls=DefaultGroup, default="upsert") +def forms() -> None: + """A group of commands to interact with forms in DataHub.""" + pass + + +@forms.command( + name="upsert", +) +@click.option("-f", "--file", required=True, type=click.Path(exists=True)) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def upsert(file: Path) -> None: + """Upsert forms in DataHub.""" + + Forms.create(str(file)) + + +@forms.command( + name="get", +) +@click.option("--urn", required=True, type=str) +@click.option("--to-file", required=False, type=str) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def get(urn: str, to_file: str) -> None: + """Get form from DataHub""" + with get_default_graph() as graph: + if graph.exists(urn): + form: Forms = Forms.from_datahub(graph=graph, urn=urn) + click.secho( + f"{json.dumps(form.dict(exclude_unset=True, exclude_none=True), indent=2)}" + ) + if to_file: + form.to_yaml(Path(to_file)) + click.secho(f"Form yaml written to {to_file}", fg="green") + else: + click.secho(f"Form {urn} does not exist") diff --git a/metadata-ingestion/src/datahub/cli/specific/structuredproperties_cli.py b/metadata-ingestion/src/datahub/cli/specific/structuredproperties_cli.py new file mode 100644 index 00000000000000..4162d44b9b0ea8 --- /dev/null +++ b/metadata-ingestion/src/datahub/cli/specific/structuredproperties_cli.py @@ -0,0 +1,62 @@ +import json +import logging +from pathlib import Path + +import click +from click_default_group import DefaultGroup + +from datahub.api.entities.structuredproperties.structuredproperties import ( + StructuredProperties, +) +from datahub.ingestion.graph.client import get_default_graph +from datahub.telemetry import telemetry +from datahub.upgrade import upgrade +from datahub.utilities.urns.urn import Urn + +logger = logging.getLogger(__name__) + + +@click.group(cls=DefaultGroup, default="upsert") +def properties() -> None: + """A group of commands to interact with structured properties in DataHub.""" + pass + + +@properties.command( + name="upsert", +) +@click.option("-f", "--file", required=True, type=click.Path(exists=True)) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def upsert(file: Path) -> None: + """Upsert structured properties in DataHub.""" + + StructuredProperties.create(str(file)) + + +@properties.command( + name="get", +) +@click.option("--urn", required=True, type=str) +@click.option("--to-file", required=False, type=str) +@upgrade.check_upgrade +@telemetry.with_telemetry() +def get(urn: str, to_file: str) -> None: + """Get structured properties from DataHub""" + urn = Urn.make_structured_property_urn(urn) + + with get_default_graph() as graph: + if graph.exists(urn): + structuredproperties: StructuredProperties = ( + StructuredProperties.from_datahub(graph=graph, urn=urn) + ) + click.secho( + f"{json.dumps(structuredproperties.dict(exclude_unset=True, exclude_none=True), indent=2)}" + ) + if to_file: + structuredproperties.to_yaml(Path(to_file)) + click.secho( + f"Structured property yaml written to {to_file}", fg="green" + ) + else: + click.secho(f"Structured property {urn} does not exist") diff --git a/metadata-ingestion/src/datahub/entrypoints.py b/metadata-ingestion/src/datahub/entrypoints.py index 0cd37cc9398549..4989f984badcc8 100644 --- a/metadata-ingestion/src/datahub/entrypoints.py +++ b/metadata-ingestion/src/datahub/entrypoints.py @@ -23,7 +23,10 @@ from datahub.cli.put_cli import put from datahub.cli.specific.datacontract_cli import datacontract from datahub.cli.specific.dataproduct_cli import dataproduct +from datahub.cli.specific.dataset_cli import dataset +from datahub.cli.specific.forms_cli import forms from datahub.cli.specific.group_cli import group +from datahub.cli.specific.structuredproperties_cli import properties from datahub.cli.specific.user_cli import user from datahub.cli.state_cli import state from datahub.cli.telemetry import telemetry as telemetry_cli @@ -144,6 +147,9 @@ def init() -> None: datahub.add_command(user) datahub.add_command(group) datahub.add_command(dataproduct) +datahub.add_command(dataset) +datahub.add_command(properties) +datahub.add_command(forms) datahub.add_command(datacontract) try: diff --git a/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py b/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py index 6baa70aa581d62..675c87b13313d2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py +++ b/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py @@ -71,6 +71,7 @@ class GlossaryNodeConfig(ConfigModel): terms: Optional[List["GlossaryTermConfig"]] nodes: Optional[List["GlossaryNodeConfig"]] knowledge_links: Optional[List[KnowledgeCard]] + custom_properties: Optional[Dict[str, str]] # Private fields. _urn: str @@ -252,6 +253,7 @@ def get_mces_from_node( definition=glossaryNode.description, parentNode=parentNode, name=glossaryNode.name, + customProperties=glossaryNode.custom_properties, ) node_owners = parentOwners if glossaryNode.owners is not None: diff --git a/metadata-ingestion/src/datahub/specific/dataset.py b/metadata-ingestion/src/datahub/specific/dataset.py index 62ee4fc57b61b6..d3c3de36198e3f 100644 --- a/metadata-ingestion/src/datahub/specific/dataset.py +++ b/metadata-ingestion/src/datahub/specific/dataset.py @@ -23,6 +23,7 @@ ) from datahub.specific.custom_properties import CustomPropertiesPatchHelper from datahub.specific.ownership import OwnershipPatchHelper +from datahub.specific.structured_properties import StructuredPropertiesPatchHelper from datahub.utilities.urns.tag_urn import TagUrn from datahub.utilities.urns.urn import Urn @@ -103,6 +104,7 @@ def __init__( self, DatasetProperties.ASPECT_NAME ) self.ownership_patch_helper = OwnershipPatchHelper(self) + self.structured_properties_patch_helper = StructuredPropertiesPatchHelper(self) def add_owner(self, owner: Owner) -> "DatasetPatchBuilder": self.ownership_patch_helper.add_owner(owner) @@ -331,3 +333,33 @@ def set_display_name(self, display_name: str) -> "DatasetPatchBuilder": value=display_name, ) return self + + def set_structured_property( + self, property_name: str, value: Union[str, float, List[Union[str, float]]] + ) -> "DatasetPatchBuilder": + """ + This is a helper method to set a structured property. + @param property_name: the name of the property (either bare or urn form) + @param value: the value of the property (for multi-valued properties, this can be a list) + """ + self.structured_properties_patch_helper.set_property(property_name, value) + return self + + def add_structured_property( + self, property_name: str, value: Union[str, float] + ) -> "DatasetPatchBuilder": + """ + This is a helper method to add a structured property. + @param property_name: the name of the property (either bare or urn form) + @param value: the value of the property (for multi-valued properties, this value will be appended to the list) + """ + self.structured_properties_patch_helper.add_property(property_name, value) + return self + + def remove_structured_property(self, property_name: str) -> "DatasetPatchBuilder": + """ + This is a helper method to remove a structured property. + @param property_name: the name of the property (either bare or urn form) + """ + self.structured_properties_patch_helper.remove_property(property_name) + return self diff --git a/metadata-ingestion/src/datahub/specific/structured_properties.py b/metadata-ingestion/src/datahub/specific/structured_properties.py new file mode 100644 index 00000000000000..6b2592bf1cbba2 --- /dev/null +++ b/metadata-ingestion/src/datahub/specific/structured_properties.py @@ -0,0 +1,53 @@ +from typing import Generic, List, TypeVar, Union + +from datahub.emitter.mcp_patch_builder import MetadataPatchProposal +from datahub.metadata.schema_classes import StructuredPropertyValueAssignmentClass +from datahub.utilities.urns.structured_properties_urn import ( + make_structured_property_urn, +) + +T = TypeVar("T", bound=MetadataPatchProposal) + + +class StructuredPropertiesPatchHelper(Generic[T]): + def __init__( + self, + parent: T, + aspect_name: str = "structuredProperties", + ) -> None: + self.aspect_name = aspect_name + self._parent = parent + self.aspect_field = "properties" + + def parent(self) -> T: + return self._parent + + def set_property( + self, key: str, value: Union[str, float, List[Union[str, float]]] + ) -> "StructuredPropertiesPatchHelper": + self.remove_property(key) + self.add_property(key, value) + return self + + def remove_property(self, key: str) -> "StructuredPropertiesPatchHelper": + self._parent._add_patch( + self.aspect_name, + "remove", + path=f"/{self.aspect_field}/{make_structured_property_urn(key)}", + value={}, + ) + return self + + def add_property( + self, key: str, value: Union[str, float, List[Union[str, float]]] + ) -> "StructuredPropertiesPatchHelper": + self._parent._add_patch( + self.aspect_name, + "add", + path=f"/{self.aspect_field}/{make_structured_property_urn(key)}", + value=StructuredPropertyValueAssignmentClass( + propertyUrn=make_structured_property_urn(key), + values=value if isinstance(value, list) else [value], + ), + ) + return self diff --git a/metadata-ingestion/src/datahub/utilities/urn_encoder.py b/metadata-ingestion/src/datahub/utilities/urn_encoder.py index 093c9ade8c1528..b39dd043706825 100644 --- a/metadata-ingestion/src/datahub/utilities/urn_encoder.py +++ b/metadata-ingestion/src/datahub/utilities/urn_encoder.py @@ -3,6 +3,7 @@ # NOTE: Frontend relies on encoding these three characters. Specifically, we decode and encode schema fields for column level lineage. # If this changes, make appropriate changes to datahub-web-react/src/app/lineage/utils/columnLineageUtils.ts +# We also rely on encoding these exact three characters when generating schemaField urns in our graphQL layer. Update SchemaFieldUtils if this changes. RESERVED_CHARS = {",", "(", ")"} RESERVED_CHARS_EXTENDED = RESERVED_CHARS.union({"%"}) diff --git a/metadata-ingestion/src/datahub/utilities/urns/_urn_base.py b/metadata-ingestion/src/datahub/utilities/urns/_urn_base.py index fbde0d6e6d69a5..1b50d4b2fe810c 100644 --- a/metadata-ingestion/src/datahub/utilities/urns/_urn_base.py +++ b/metadata-ingestion/src/datahub/utilities/urns/_urn_base.py @@ -207,6 +207,46 @@ def url_encode(urn: str) -> str: # safe='' encodes '/' as '%2F' return urllib.parse.quote(urn, safe="") + @staticmethod + def make_data_type_urn(type: str) -> str: + if type.startswith("urn:li:dataType:"): + return type + else: + if not type.startswith("datahub."): + # we want all data types to be fully qualified within the datahub namespace + type = f"datahub.{type}" + return f"urn:li:dataType:{type}" + + @staticmethod + def get_data_type_from_urn(urn: str) -> str: + if urn.startswith("urn:li:dataType:"): + # urn is formatted like urn:li:dataType:datahub:{dataType}, so extract dataType by + # parsing by . and getting the last element + return urn.split(".")[-1] + return urn + + @staticmethod + def make_entity_type_urn(entity_type: str) -> str: + if entity_type.startswith("urn:li:entityType:"): + return entity_type + else: + if not entity_type.startswith("datahub."): + # we want all entity types to be fully qualified within the datahub namespace + entity_type = f"datahub.{entity_type}" + return f"urn:li:entityType:{entity_type}" + + @staticmethod + def make_structured_property_urn(structured_property: str) -> str: + if not structured_property.startswith("urn:li:structuredProperty:"): + return f"urn:li:structuredProperty:{structured_property}" + return structured_property + + @staticmethod + def make_form_urn(form: str) -> str: + if not form.startswith("urn:li:form:"): + return f"urn:li:form:{form}" + return form + class _SpecificUrn(Urn): ENTITY_TYPE: str = "" diff --git a/metadata-ingestion/src/datahub/utilities/urns/structured_properties_urn.py b/metadata-ingestion/src/datahub/utilities/urns/structured_properties_urn.py new file mode 100644 index 00000000000000..5bd36a0656d99e --- /dev/null +++ b/metadata-ingestion/src/datahub/utilities/urns/structured_properties_urn.py @@ -0,0 +1,5 @@ +from datahub.metadata.urns import StructuredPropertyUrn # noqa: F401 + + +def make_structured_property_urn(structured_property_id: str) -> str: + return str(StructuredPropertyUrn.create_from_string(structured_property_id)) diff --git a/metadata-ingestion/tests/integration/business-glossary/business_glossary.yml b/metadata-ingestion/tests/integration/business-glossary/business_glossary.yml index da238701e718d4..c919dde18b1870 100644 --- a/metadata-ingestion/tests/integration/business-glossary/business_glossary.yml +++ b/metadata-ingestion/tests/integration/business-glossary/business_glossary.yml @@ -10,6 +10,8 @@ nodes: knowledge_links: - label: Wiki link for classification url: "https://en.wikipedia.org/wiki/Classification" + custom_properties: + is_confidential: true terms: - name: Sensitive description: Sensitive Data diff --git a/metadata-ingestion/tests/integration/business-glossary/glossary_events_auto_id_golden.json b/metadata-ingestion/tests/integration/business-glossary/glossary_events_auto_id_golden.json index b8cc922f0c1c35..1dce940b44390f 100644 --- a/metadata-ingestion/tests/integration/business-glossary/glossary_events_auto_id_golden.json +++ b/metadata-ingestion/tests/integration/business-glossary/glossary_events_auto_id_golden.json @@ -6,6 +6,9 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": { + "is_confidential": "True" + }, "definition": "A set of terms related to Data Classification", "name": "Classification" } @@ -29,7 +32,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -54,7 +58,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -94,7 +99,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -119,7 +125,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -159,7 +166,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -176,7 +184,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -216,7 +225,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -226,6 +236,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "All terms related to personal information", "name": "Personal Information" } @@ -249,7 +260,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -294,7 +306,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -332,7 +345,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -377,7 +391,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -387,6 +402,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Provides basic concepts such as account, account holder, account provider, relationship manager that are commonly used by financial services providers to describe customers and to determine counterparty identities", "name": "Clients And Accounts" } @@ -410,7 +426,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +475,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -496,7 +514,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -506,6 +525,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Common Business KPIs", "name": "KPIs" } @@ -529,7 +549,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -567,7 +588,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -582,7 +604,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -597,7 +620,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -612,7 +636,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -627,7 +652,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -642,7 +668,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -657,7 +684,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -672,7 +700,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -687,7 +716,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -702,7 +732,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -717,7 +748,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -732,7 +764,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -747,7 +780,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -762,7 +796,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/business-glossary/glossary_events_golden.json b/metadata-ingestion/tests/integration/business-glossary/glossary_events_golden.json index e2b525658e36e2..af85f6e2a35183 100644 --- a/metadata-ingestion/tests/integration/business-glossary/glossary_events_golden.json +++ b/metadata-ingestion/tests/integration/business-glossary/glossary_events_golden.json @@ -6,6 +6,9 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": { + "is_confidential": "True" + }, "definition": "A set of terms related to Data Classification", "name": "Classification" } @@ -29,7 +32,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -54,7 +58,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -94,7 +99,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -119,7 +125,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -159,7 +166,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -176,7 +184,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -216,7 +225,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -226,6 +236,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "All terms related to personal information", "name": "Personal Information" } @@ -249,7 +260,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -294,7 +306,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -332,7 +345,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -377,7 +391,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -387,6 +402,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Provides basic concepts such as account, account holder, account provider, relationship manager that are commonly used by financial services providers to describe customers and to determine counterparty identities", "name": "Clients And Accounts" } @@ -410,7 +426,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +475,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -496,7 +514,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -506,6 +525,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Common Business KPIs", "name": "KPIs" } @@ -529,7 +549,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -567,7 +588,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -582,7 +604,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -597,7 +620,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -612,7 +636,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -627,7 +652,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -642,7 +668,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -657,7 +684,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -672,7 +700,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -687,7 +716,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -702,7 +732,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -717,7 +748,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -732,7 +764,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -747,7 +780,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } }, { @@ -762,7 +796,8 @@ }, "systemMetadata": { "lastObserved": 1586847600000, - "runId": "datahub-business-glossary-2020_04_14-07_00_00" + "runId": "datahub-business-glossary-2020_04_14-07_00_00", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/remote/content/business_glossary.yml b/metadata-ingestion/tests/integration/remote/content/business_glossary.yml index 59bea251a24e16..e0bee3eb4468ff 100644 --- a/metadata-ingestion/tests/integration/remote/content/business_glossary.yml +++ b/metadata-ingestion/tests/integration/remote/content/business_glossary.yml @@ -10,6 +10,8 @@ nodes: knowledge_links: - label: Wiki link for classification url: "https://en.wikipedia.org/wiki/Classification" + custom_properties: + is_confidential: true terms: - name: Sensitive description: Sensitive Data diff --git a/metadata-ingestion/tests/integration/remote/golden/remote_glossary_golden.json b/metadata-ingestion/tests/integration/remote/golden/remote_glossary_golden.json index 1e1932822aee89..a3adcb7639712b 100644 --- a/metadata-ingestion/tests/integration/remote/golden/remote_glossary_golden.json +++ b/metadata-ingestion/tests/integration/remote/golden/remote_glossary_golden.json @@ -6,6 +6,9 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": { + "is_confidential": "True" + }, "definition": "A set of terms related to Data Classification", "name": "Classification" } @@ -29,7 +32,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -54,7 +58,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -94,7 +99,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -119,7 +125,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -159,7 +166,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -176,7 +184,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -216,7 +225,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -226,6 +236,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "All terms related to personal information", "name": "Personal Information" } @@ -249,7 +260,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -294,7 +306,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -332,7 +345,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -377,7 +391,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -387,6 +402,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Provides basic concepts such as account, account holder, account provider, relationship manager that are commonly used by financial services providers to describe customers and to determine counterparty identities", "name": "Clients And Accounts" } @@ -410,7 +426,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +475,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -496,7 +514,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -506,6 +525,7 @@ "aspects": [ { "com.linkedin.pegasus2avro.glossary.GlossaryNodeInfo": { + "customProperties": {}, "definition": "Common Business KPIs", "name": "KPIs" } @@ -529,7 +549,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -567,7 +588,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -582,7 +604,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -597,7 +620,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -612,7 +636,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -627,7 +652,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -642,7 +668,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -657,7 +684,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -672,7 +700,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -687,7 +716,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -702,7 +732,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -717,7 +748,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -732,7 +764,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -747,7 +780,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } }, { @@ -762,7 +796,8 @@ }, "systemMetadata": { "lastObserved": 1629795600000, - "runId": "remote-4" + "runId": "remote-4", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-integration/java/datahub-client/build.gradle b/metadata-integration/java/datahub-client/build.gradle index b14953d7ce0218..8e05b7ef8f5d68 100644 --- a/metadata-integration/java/datahub-client/build.gradle +++ b/metadata-integration/java/datahub-client/build.gradle @@ -15,8 +15,7 @@ import org.apache.tools.ant.filters.ReplaceTokens jar.enabled = false // Since we only want to build shadow jars, disabling the regular jar creation dependencies { - implementation project(':metadata-models') - implementation project(path: ':metadata-models', configuration: "dataTemplate") + implementation project(':entity-registry') implementation(externalDependency.kafkaAvroSerializer) { exclude group: "org.apache.avro" } @@ -81,13 +80,13 @@ shadowJar { // preventing java multi-release JAR leakage // https://github.com/johnrengelman/shadow/issues/729 exclude('module-info.class', 'META-INF/versions/**', - '**/LICENSE', '**/LICENSE.txt', '**/NOTICE', '**/NOTICE.txt') + '**/LICENSE', '**/LICENSE*.txt', '**/NOTICE', '**/NOTICE.txt', 'licenses/**', 'log4j2.xml', 'log4j.xml') mergeServiceFiles() // we relocate namespaces manually, because we want to know exactly which libs we are exposing and why // we can move to automatic relocation using ConfigureShadowRelocation after we get to a good place on these first relocate 'org.springframework', 'datahub.shaded.org.springframework' relocate 'com.fasterxml.jackson', 'datahub.shaded.jackson' - relocate 'org.yaml', 'io.acryl.shaded.org.yaml' // Required for shading snakeyaml + relocate 'org.yaml', 'datahub.shaded.org.yaml' // Required for shading snakeyaml relocate 'net.jcip.annotations', 'datahub.shaded.annotations' relocate 'javassist', 'datahub.shaded.javassist' relocate 'edu.umd.cs.findbugs', 'datahub.shaded.findbugs' @@ -95,6 +94,7 @@ shadowJar { relocate 'antlr', 'datahub.shaded.antlr' relocate 'com.google.common', 'datahub.shaded.com.google.common' relocate 'org.apache.commons', 'datahub.shaded.org.apache.commons' + relocate 'org.apache.maven', 'datahub.shaded.org.apache.maven' relocate 'org.reflections', 'datahub.shaded.org.reflections' relocate 'st4hidden', 'datahub.shaded.st4hidden' relocate 'org.stringtemplate', 'datahub.shaded.org.stringtemplate' @@ -104,7 +104,6 @@ shadowJar { relocate 'com.github.benmanes.caffeine', 'datahub.shaded.com.github.benmanes.caffeine' relocate 'org.checkerframework', 'datahub.shaded.org.checkerframework' relocate 'com.google.errorprone', 'datahub.shaded.com.google.errorprone' - relocate 'com.sun.jna', 'datahub.shaded.com.sun.jna' // Below jars added for kafka emitter only relocate 'org.apache.avro', 'datahub.shaded.org.apache.avro' relocate 'com.thoughtworks.paranamer', 'datahub.shaded.com.thoughtworks.paranamer' @@ -121,6 +120,9 @@ shadowJar { relocate 'common.message', 'datahub.shaded.common.message' relocate 'org.glassfish', 'datahub.shaded.org.glassfish' relocate 'ch.randelshofer', 'datahub.shaded.ch.randelshofer' + relocate 'io.github.classgraph', 'datahub.shaded.io.github.classgraph' + relocate 'nonapi.io.github.classgraph', 'datahub.shaded.nonapi.io.github.classgraph' + relocate 'com.github.fge', 'datahub.shaded.com.github.fge' finalizedBy checkShadowJar } diff --git a/metadata-integration/java/datahub-client/scripts/check_jar.sh b/metadata-integration/java/datahub-client/scripts/check_jar.sh index 02a1d06b73acfa..e2c9ec16d49f88 100755 --- a/metadata-integration/java/datahub-client/scripts/check_jar.sh +++ b/metadata-integration/java/datahub-client/scripts/check_jar.sh @@ -35,7 +35,8 @@ jar -tvf $jarFile |\ grep -v "linux/" |\ grep -v "darwin" |\ grep -v "MetadataChangeProposal.avsc" |\ - grep -v "aix" + grep -v "aix" |\ + grep -v "com/sun/" if [ $? -ne 0 ]; then echo "✅ No unexpected class paths found in ${jarFile}" diff --git a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java index 5bd10245899e41..1107f552012dbc 100644 --- a/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java +++ b/metadata-integration/java/datahub-client/src/test/java/datahub/client/patch/PatchTest.java @@ -18,20 +18,20 @@ import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.dataset.DatasetLineageType; +import com.linkedin.metadata.aspect.patch.builder.ChartInfoPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.DashboardInfoPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.DataFlowInfoPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.DataJobInfoPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.DataJobInputOutputPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.DatasetPropertiesPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.EditableSchemaMetadataPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.OwnershipPatchBuilder; +import com.linkedin.metadata.aspect.patch.builder.UpstreamLineagePatchBuilder; import com.linkedin.metadata.graph.LineageDirection; import com.linkedin.mxe.MetadataChangeProposal; import datahub.client.MetadataWriteResponse; import datahub.client.file.FileEmitter; import datahub.client.file.FileEmitterConfig; -import datahub.client.patch.chart.ChartInfoPatchBuilder; -import datahub.client.patch.common.OwnershipPatchBuilder; -import datahub.client.patch.dashboard.DashboardInfoPatchBuilder; -import datahub.client.patch.dataflow.DataFlowInfoPatchBuilder; -import datahub.client.patch.datajob.DataJobInfoPatchBuilder; -import datahub.client.patch.datajob.DataJobInputOutputPatchBuilder; -import datahub.client.patch.dataset.DatasetPropertiesPatchBuilder; -import datahub.client.patch.dataset.EditableSchemaMetadataPatchBuilder; -import datahub.client.patch.dataset.UpstreamLineagePatchBuilder; import datahub.client.rest.RestEmitter; import datahub.client.rest.RestEmitterConfig; import java.io.IOException; diff --git a/metadata-integration/java/datahub-protobuf/scripts/check_jar.sh b/metadata-integration/java/datahub-protobuf/scripts/check_jar.sh index 930e3ab7be9e19..e3aa181c588019 100755 --- a/metadata-integration/java/datahub-protobuf/scripts/check_jar.sh +++ b/metadata-integration/java/datahub-protobuf/scripts/check_jar.sh @@ -38,7 +38,8 @@ jar -tvf $jarFile |\ grep -v "linux/" |\ grep -v "darwin" |\ grep -v "MetadataChangeProposal.avsc" |\ - grep -v "aix" + grep -v "aix" |\ + grep -v "com/sun/" if [ $? -ne 0 ]; then echo "✅ No unexpected class paths found in ${jarFile}" diff --git a/metadata-integration/java/examples/build.gradle b/metadata-integration/java/examples/build.gradle index ddf574e8c8905d..62c80562c7c3bd 100644 --- a/metadata-integration/java/examples/build.gradle +++ b/metadata-integration/java/examples/build.gradle @@ -4,7 +4,6 @@ plugins { } dependencies { - implementation externalDependency.slf4jApi compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok @@ -12,8 +11,6 @@ dependencies { implementation externalDependency.typesafeConfig implementation externalDependency.opentracingJdbc - implementation project(path: ':li-utils') - implementation project(path: ':metadata-models') implementation project(path: ':metadata-integration:java:datahub-client', configuration: 'shadow') implementation externalDependency.httpAsyncClient diff --git a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DataJobLineageAdd.java b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DataJobLineageAdd.java index 4cff55afc92de1..e84511083b6d9a 100644 --- a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DataJobLineageAdd.java +++ b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DataJobLineageAdd.java @@ -3,9 +3,9 @@ import com.linkedin.common.urn.DataJobUrn; import com.linkedin.common.urn.DatasetUrn; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.aspect.patch.builder.DataJobInputOutputPatchBuilder; import com.linkedin.mxe.MetadataChangeProposal; import datahub.client.MetadataWriteResponse; -import datahub.client.patch.datajob.DataJobInputOutputPatchBuilder; import datahub.client.rest.RestEmitter; import java.io.IOException; import java.util.concurrent.ExecutionException; diff --git a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAdd.java b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAdd.java index b30cb5166df702..03f0673cd85a4f 100644 --- a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAdd.java +++ b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAdd.java @@ -1,9 +1,9 @@ package io.datahubproject.examples; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.aspect.patch.builder.DatasetPropertiesPatchBuilder; import com.linkedin.mxe.MetadataChangeProposal; import datahub.client.MetadataWriteResponse; -import datahub.client.patch.dataset.DatasetPropertiesPatchBuilder; import datahub.client.rest.RestEmitter; import java.io.IOException; import java.util.concurrent.ExecutionException; diff --git a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAddRemove.java b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAddRemove.java index 0a89e87060698c..eb8f700c4b0684 100644 --- a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAddRemove.java +++ b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesAddRemove.java @@ -1,9 +1,9 @@ package io.datahubproject.examples; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.aspect.patch.builder.DatasetPropertiesPatchBuilder; import com.linkedin.mxe.MetadataChangeProposal; import datahub.client.MetadataWriteResponse; -import datahub.client.patch.dataset.DatasetPropertiesPatchBuilder; import datahub.client.rest.RestEmitter; import java.io.IOException; import java.util.concurrent.ExecutionException; diff --git a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesReplace.java b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesReplace.java index 053c1f068e0482..1586d9b069b245 100644 --- a/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesReplace.java +++ b/metadata-integration/java/examples/src/main/java/io/datahubproject/examples/DatasetCustomPropertiesReplace.java @@ -1,9 +1,9 @@ package io.datahubproject.examples; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.aspect.patch.builder.DatasetPropertiesPatchBuilder; import com.linkedin.mxe.MetadataChangeProposal; import datahub.client.MetadataWriteResponse; -import datahub.client.patch.dataset.DatasetPropertiesPatchBuilder; import datahub.client.rest.RestEmitter; import java.io.IOException; import java.util.HashMap; diff --git a/metadata-integration/java/spark-lineage/build.gradle b/metadata-integration/java/spark-lineage/build.gradle index c5dd9b5012c290..8d6160631bf45b 100644 --- a/metadata-integration/java/spark-lineage/build.gradle +++ b/metadata-integration/java/spark-lineage/build.gradle @@ -102,6 +102,7 @@ shadowJar { // prevent jni conflict with spark exclude '**/libzstd-jni.*' exclude '**/com_github_luben_zstd_*' + exclude '**/log4j*.xml' relocate 'com.fasterxml.jackson', 'datahub.shaded.jackson' relocate 'org.slf4j','datahub.shaded.org.slf4j' @@ -113,6 +114,10 @@ shadowJar { relocate 'io.opentracing','datahub.spark2.shaded.io.opentracing' relocate 'io.netty','datahub.spark2.shaded.io.netty' relocate 'ch.randelshofer', 'datahub.shaded.ch.randelshofer' + relocate 'com.sun', 'datahub.shaded.com.sun' + relocate 'avroutil1', 'datahub.shaded.avroutil1' + relocate 'com.github', 'datahub.shaded.com.github' + relocate 'org.apache.maven', 'datahub.shaded.org.apache.maven' finalizedBy checkShadowJar } diff --git a/metadata-integration/java/spark-lineage/spark-smoke-test/setup_spark_smoke_test.sh b/metadata-integration/java/spark-lineage/spark-smoke-test/setup_spark_smoke_test.sh index 33cac9d562cd89..90a90be768a513 100755 --- a/metadata-integration/java/spark-lineage/spark-smoke-test/setup_spark_smoke_test.sh +++ b/metadata-integration/java/spark-lineage/spark-smoke-test/setup_spark_smoke_test.sh @@ -30,7 +30,7 @@ echo "--------------------------------------------------------------------" cd "${SMOKE_TEST_ROOT_DIR}"/docker #bring up spark cluster -docker-compose -f spark-docker-compose.yml up -d +docker compose -f spark-docker-compose.yml up -d echo "--------------------------------------------------------------------" echo "Executing spark-submit jobs" diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/EntityClientAspectRetriever.java b/metadata-io/src/main/java/com/linkedin/metadata/client/EntityClientAspectRetriever.java new file mode 100644 index 00000000000000..974406c0be0df1 --- /dev/null +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/EntityClientAspectRetriever.java @@ -0,0 +1,35 @@ +package com.linkedin.metadata.client; + +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.Getter; + +@Builder +public class EntityClientAspectRetriever implements AspectRetriever { + @Getter private final EntityRegistry entityRegistry; + private final SystemEntityClient entityClient; + + @Nullable + @Override + public Aspect getLatestAspectObject(@Nonnull Urn urn, @Nonnull String aspectName) + throws RemoteInvocationException, URISyntaxException { + return entityClient.getLatestAspectObject(urn, aspectName); + } + + @Nonnull + @Override + public Map> getLatestAspectObjects( + Set urns, Set aspectNames) throws RemoteInvocationException, URISyntaxException { + return entityClient.getLatestAspects(urns, aspectNames); + } +} diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java index 9b3f42a37b45d5..0ebe9ed1d1b666 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java @@ -4,6 +4,7 @@ import static com.linkedin.metadata.search.utils.SearchUtils.*; import com.datahub.authentication.Authentication; +import com.datahub.plugins.auth.authorization.Authorizer; import com.datahub.util.RecordUtils; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -18,7 +19,6 @@ import com.linkedin.entity.Entity; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; -import com.linkedin.entity.client.RestliEntityClient; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.EnvelopedAspect; import com.linkedin.metadata.aspect.EnvelopedAspectArray; @@ -31,7 +31,6 @@ import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.IngestResult; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.event.EventProducer; import com.linkedin.metadata.graph.LineageDirection; import com.linkedin.metadata.query.AutoCompleteResult; @@ -48,6 +47,7 @@ import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.search.SearchService; import com.linkedin.metadata.search.client.CachingEntitySearchService; +import com.linkedin.metadata.service.RollbackService; import com.linkedin.metadata.shared.ValidationUtils; import com.linkedin.metadata.timeseries.TimeseriesAspectService; import com.linkedin.metadata.utils.metrics.MetricUtils; @@ -85,15 +85,15 @@ public class JavaEntityClient implements EntityClient { private final Clock _clock = Clock.systemUTC(); - private final EntityService _entityService; + private final EntityService _entityService; private final DeleteEntityService _deleteEntityService; private final EntitySearchService _entitySearchService; private final CachingEntitySearchService _cachingEntitySearchService; private final SearchService _searchService; private final LineageSearchService _lineageSearchService; private final TimeseriesAspectService _timeseriesAspectService; + private final RollbackService rollbackService; private final EventProducer _eventProducer; - private final RestliEntityClient _restliEntityClient; @Nullable public EntityResponse getV2( @@ -713,11 +713,7 @@ public String ingestProposal( Stream.concat(Stream.of(metadataChangeProposal), additionalChanges.stream()); AspectsBatch batch = AspectsBatchImpl.builder() - .mcps( - proposalStream.collect(Collectors.toList()), - auditStamp, - _entityService.getEntityRegistry(), - this) + .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) .build(); IngestResult one = _entityService.ingestProposal(batch, async).stream().findFirst().get(); @@ -780,9 +776,10 @@ public void producePlatformEvent( } @Override - public void rollbackIngestion(@Nonnull String runId, @Nonnull Authentication authentication) + public void rollbackIngestion( + @Nonnull String runId, @Nonnull Authorizer authorizer, @Nonnull Authentication authentication) throws Exception { - _restliEntityClient.rollbackIngestion(runId, authentication); + rollbackService.rollbackIngestion(runId, false, true, authorizer, authentication); } private void tryIndexRunId(Urn entityUrn, @Nullable SystemMetadata systemMetadata) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/SystemJavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/SystemJavaEntityClient.java index 31c2846a9c9f3b..fa020903c34f0a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/SystemJavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/SystemJavaEntityClient.java @@ -2,18 +2,18 @@ import com.datahub.authentication.Authentication; import com.linkedin.entity.client.EntityClientCache; -import com.linkedin.entity.client.RestliEntityClient; import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.config.cache.client.EntityClientCacheConfig; import com.linkedin.metadata.entity.DeleteEntityService; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.event.EventProducer; import com.linkedin.metadata.search.EntitySearchService; import com.linkedin.metadata.search.LineageSearchService; import com.linkedin.metadata.search.SearchService; import com.linkedin.metadata.search.client.CachingEntitySearchService; +import com.linkedin.metadata.service.RollbackService; import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import javax.annotation.Nonnull; import lombok.Getter; /** Java backed SystemEntityClient */ @@ -24,16 +24,16 @@ public class SystemJavaEntityClient extends JavaEntityClient implements SystemEn private final Authentication systemAuthentication; public SystemJavaEntityClient( - EntityService entityService, + EntityService entityService, DeleteEntityService deleteEntityService, EntitySearchService entitySearchService, CachingEntitySearchService cachingEntitySearchService, SearchService searchService, LineageSearchService lineageSearchService, TimeseriesAspectService timeseriesAspectService, + RollbackService rollbackService, EventProducer eventProducer, - RestliEntityClient restliEntityClient, - Authentication systemAuthentication, + @Nonnull Authentication systemAuthentication, EntityClientCacheConfig cacheConfig) { super( entityService, @@ -43,8 +43,8 @@ public SystemJavaEntityClient( searchService, lineageSearchService, timeseriesAspectService, - eventProducer, - restliEntityClient); + rollbackService, + eventProducer); this.systemAuthentication = systemAuthentication; this.entityClientCache = buildEntityClientCache(SystemJavaEntityClient.class, systemAuthentication, cacheConfig); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index ed69e919a7b242..b3b11d200ec0dc 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -12,6 +12,7 @@ import static com.linkedin.metadata.Constants.UI_SOURCE; import static com.linkedin.metadata.search.utils.BrowsePathUtils.buildDataPlatformUrn; import static com.linkedin.metadata.search.utils.BrowsePathUtils.getDefaultBrowsePath; +import static com.linkedin.metadata.utils.GenericRecordUtils.entityResponseToAspectMap; import static com.linkedin.metadata.utils.PegasusUtils.constructMCL; import static com.linkedin.metadata.utils.PegasusUtils.getDataTemplateClassFromSchema; import static com.linkedin.metadata.utils.PegasusUtils.urnToEntityName; @@ -46,7 +47,6 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; -import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.Aspect; @@ -84,6 +84,7 @@ import com.linkedin.mxe.MetadataChangeLog; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; +import com.linkedin.r2.RemoteInvocationException; import com.linkedin.util.Pair; import io.ebean.PagedList; import io.ebean.Transaction; @@ -166,14 +167,12 @@ public class EntityServiceImpl implements EntityService { private final Integer ebeanMaxTransactionRetry; - private SystemEntityClient systemEntityClient; - public EntityServiceImpl( @Nonnull final AspectDao aspectDao, @Nonnull final EventProducer producer, @Nonnull final EntityRegistry entityRegistry, final boolean alwaysEmitChangeLog, - final UpdateIndicesService updateIndicesService, + @Nullable final UpdateIndicesService updateIndicesService, final PreProcessHooks preProcessHooks) { this( aspectDao, @@ -190,9 +189,9 @@ public EntityServiceImpl( @Nonnull final EventProducer producer, @Nonnull final EntityRegistry entityRegistry, final boolean alwaysEmitChangeLog, - final UpdateIndicesService updateIndicesService, + @Nullable final UpdateIndicesService updateIndicesService, final PreProcessHooks preProcessHooks, - final Integer retry) { + @Nullable final Integer retry) { _aspectDao = aspectDao; _producer = producer; @@ -200,21 +199,13 @@ public EntityServiceImpl( _entityToValidAspects = buildEntityToValidAspects(entityRegistry); _alwaysEmitChangeLog = alwaysEmitChangeLog; _updateIndicesService = updateIndicesService; + if (_updateIndicesService != null) { + _updateIndicesService.initializeAspectRetriever(this); + } _preProcessHooks = preProcessHooks; ebeanMaxTransactionRetry = retry != null ? retry : DEFAULT_MAX_TRANSACTION_RETRY; } - @Override - public void setSystemEntityClient(SystemEntityClient systemEntityClient) { - this.systemEntityClient = systemEntityClient; - this._updateIndicesService.setSystemEntityClient(systemEntityClient); - } - - @Override - public SystemEntityClient getSystemEntityClient() { - return this.systemEntityClient; - } - @Override public RecordTemplate getLatestAspect(@Nonnull Urn urn, @Nonnull String aspectName) { log.debug("Invoked getLatestAspect with urn {}, aspect {}", urn, aspectName); @@ -634,7 +625,7 @@ public List ingestAspects( .aspect(pair.getValue()) .systemMetadata(systemMetadata) .auditStamp(auditStamp) - .build(_entityRegistry, systemEntityClient)) + .build(this)) .collect(Collectors.toList()); return ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); } @@ -693,7 +684,7 @@ private List ingestAspectsToLocalDB( // 1. Convert patches to full upserts // 2. Run any entity/aspect level hooks Pair>, List> updatedItems = - aspectsBatch.toUpsertBatchItems(latestAspects, _entityRegistry, systemEntityClient); + aspectsBatch.toUpsertBatchItems(latestAspects, this); // Fetch additional information if needed final Map> updatedLatestAspects; @@ -725,8 +716,7 @@ private List ingestAspectsToLocalDB( previousAspect == null ? null : previousAspect.getRecordTemplate(_entityRegistry), - _entityRegistry, - systemEntityClient); + this); } catch (AspectValidationException e) { throw new RuntimeException(e); } @@ -934,7 +924,7 @@ public RecordTemplate ingestAspectIfNotPresent( .aspect(newValue) .systemMetadata(systemMetadata) .auditStamp(auditStamp) - .build(_entityRegistry, systemEntityClient)) + .build(this)) .build(); List ingested = ingestAspects(aspectsBatch, true, false); @@ -954,10 +944,7 @@ public RecordTemplate ingestAspectIfNotPresent( public IngestResult ingestProposal( MetadataChangeProposal proposal, AuditStamp auditStamp, final boolean async) { return ingestProposal( - AspectsBatchImpl.builder() - .mcps(List.of(proposal), auditStamp, getEntityRegistry(), systemEntityClient) - .build(), - async) + AspectsBatchImpl.builder().mcps(List.of(proposal), auditStamp, this).build(), async) .stream() .findFirst() .get(); @@ -1545,7 +1532,7 @@ protected Map getSnapshotRecords( @Nonnull protected Map> getLatestAspectUnions( @Nonnull final Set urns, @Nonnull final Set aspectNames) { - return getLatestAspects(urns, aspectNames).entrySet().stream() + return this.getLatestAspects(urns, aspectNames).entrySet().stream() .collect( Collectors.toMap( Map.Entry::getKey, @@ -1694,7 +1681,7 @@ private void ingestSnapshotUnion( .aspect(pair.getValue()) .auditStamp(auditStamp) .systemMetadata(systemMetadata) - .build(_entityRegistry, systemEntityClient)) + .build(this)) .collect(Collectors.toList())) .build(); @@ -1796,6 +1783,7 @@ private static Map> buildEntityToValidAspects( } @Override + @Nonnull public EntityRegistry getEntityRegistry() { return _entityRegistry; } @@ -2487,4 +2475,12 @@ private static boolean shouldAspectEmitChangeLog(@Nonnull final AspectSpec aspec aspectSpec.getRelationshipFieldSpecs(); return relationshipFieldSpecs.stream().anyMatch(RelationshipFieldSpec::isLineageRelationship); } + + @Nonnull + @Override + public Map> getLatestAspectObjects( + Set urns, Set aspectNames) throws RemoteInvocationException, URISyntaxException { + String entityName = urns.stream().findFirst().map(Urn::getEntityType).get(); + return entityResponseToAspectMap(getEntitiesV2(entityName, urns, aspectNames)); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java index 4d3ac9a550553e..f353e5142755d1 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityUtils.java @@ -58,17 +58,11 @@ public static AuditStamp getAuditStamp(Urn actor) { public static void ingestChangeProposals( @Nonnull List changes, - @Nonnull EntityService entityService, + @Nonnull EntityService entityService, @Nonnull Urn actor, @Nonnull Boolean async) { entityService.ingestProposal( - AspectsBatchImpl.builder() - .mcps( - changes, - getAuditStamp(actor), - entityService.getEntityRegistry(), - entityService.getSystemEntityClient()) - .build(), + AspectsBatchImpl.builder().mcps(changes, getAuditStamp(actor), entityService).build(), async); } @@ -85,7 +79,7 @@ public static void ingestChangeProposals( public static RecordTemplate getAspectFromEntity( String entityUrn, String aspectName, - EntityService entityService, + EntityService entityService, RecordTemplate defaultValue) { Urn urn = getUrnFromString(entityUrn); if (urn == null) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraRetentionService.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraRetentionService.java index f1b7d761087b47..4d9d2b3c416b7b 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraRetentionService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraRetentionService.java @@ -17,12 +17,12 @@ import com.linkedin.common.urn.Urn; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.batch.AspectsBatch; +import com.linkedin.metadata.aspect.batch.UpsertItem; import com.linkedin.metadata.entity.EntityAspect; import com.linkedin.metadata.entity.EntityAspectIdentifier; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.RetentionService; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.entity.retention.BulkApplyRetentionArgs; import com.linkedin.metadata.entity.retention.BulkApplyRetentionResult; import com.linkedin.mxe.MetadataChangeProposal; @@ -45,28 +45,22 @@ @Slf4j @RequiredArgsConstructor -public class CassandraRetentionService extends RetentionService { - private final EntityService _entityService; +public class CassandraRetentionService extends RetentionService { + private final EntityService _entityService; private final CqlSession _cqlSession; private final int _batchSize; private final Clock _clock = Clock.systemUTC(); @Override - public EntityService getEntityService() { + public EntityService getEntityService() { return _entityService; } @Override protected AspectsBatch buildAspectsBatch( List mcps, @Nonnull AuditStamp auditStamp) { - return AspectsBatchImpl.builder() - .mcps( - mcps, - auditStamp, - _entityService.getEntityRegistry(), - _entityService.getSystemEntityClient()) - .build(); + return AspectsBatchImpl.builder().mcps(mcps, auditStamp, _entityService).build(); } @Override diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanRetentionService.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanRetentionService.java index d1f54f8a7e6e52..eba550714766b8 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanRetentionService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanRetentionService.java @@ -5,10 +5,10 @@ import com.linkedin.common.urn.Urn; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.batch.AspectsBatch; +import com.linkedin.metadata.aspect.batch.UpsertItem; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.RetentionService; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.entity.retention.BulkApplyRetentionArgs; import com.linkedin.metadata.entity.retention.BulkApplyRetentionResult; import com.linkedin.mxe.MetadataChangeProposal; @@ -40,28 +40,22 @@ @Slf4j @RequiredArgsConstructor -public class EbeanRetentionService extends RetentionService { - private final EntityService _entityService; +public class EbeanRetentionService extends RetentionService { + private final EntityService _entityService; private final Database _server; private final int _batchSize; private final Clock _clock = Clock.systemUTC(); @Override - public EntityService getEntityService() { + public EntityService getEntityService() { return _entityService; } @Override protected AspectsBatch buildAspectsBatch( List mcps, @Nonnull AuditStamp auditStamp) { - return AspectsBatchImpl.builder() - .mcps( - mcps, - auditStamp, - _entityService.getEntityRegistry(), - _entityService.getSystemEntityClient()) - .build(); + return AspectsBatchImpl.builder().mcps(mcps, auditStamp, _entityService).build(); } @Override diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java index 4b75fe73a12e5d..80fb4e3e1b940e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java @@ -8,7 +8,6 @@ import com.linkedin.metadata.aspect.batch.SystemAspect; import com.linkedin.metadata.aspect.batch.UpsertItem; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; import com.linkedin.util.Pair; @@ -33,15 +32,12 @@ public class AspectsBatchImpl implements AspectsBatch { * Convert patches to upserts, apply hooks at the aspect and batch level. * * @param latestAspects latest version in the database - * @param entityRegistry entity registry * @return The new urn/aspectnames and the uniform upserts, possibly expanded/mutated by the * various hooks */ @Override public Pair>, List> toUpsertBatchItems( - final Map> latestAspects, - EntityRegistry entityRegistry, - AspectRetriever aspectRetriever) { + final Map> latestAspects, AspectRetriever aspectRetriever) { LinkedList upsertBatchItems = items.stream() @@ -59,25 +55,27 @@ public Pair>, List> toUpsertBatchItems( // patch to upsert MCPPatchBatchItem patchBatchItem = (MCPPatchBatchItem) item; final RecordTemplate currentValue = - latest != null ? latest.getRecordTemplate(entityRegistry) : null; - upsertItem = - patchBatchItem.applyPatch(entityRegistry, currentValue, aspectRetriever); + latest != null + ? latest.getRecordTemplate(aspectRetriever.getEntityRegistry()) + : null; + upsertItem = patchBatchItem.applyPatch(currentValue, aspectRetriever); } // Apply hooks final SystemMetadata oldSystemMetadata = latest != null ? latest.getSystemMetadata() : null; final RecordTemplate oldAspectValue = - latest != null ? latest.getRecordTemplate(entityRegistry) : null; - upsertItem.applyMutationHooks( - oldAspectValue, oldSystemMetadata, entityRegistry, aspectRetriever); + latest != null + ? latest.getRecordTemplate(aspectRetriever.getEntityRegistry()) + : null; + upsertItem.applyMutationHooks(oldAspectValue, oldSystemMetadata, aspectRetriever); return upsertItem; }) .collect(Collectors.toCollection(LinkedList::new)); LinkedList newItems = - applyMCPSideEffects(upsertBatchItems, entityRegistry, aspectRetriever) + applyMCPSideEffects(upsertBatchItems, aspectRetriever) .collect(Collectors.toCollection(LinkedList::new)); Map> newUrnAspectNames = getNewUrnAspectsMap(getUrnAspectsMap(), newItems); upsertBatchItems.addAll(newItems); @@ -98,20 +96,17 @@ public AspectsBatchImplBuilder one(BatchItem data) { } public AspectsBatchImplBuilder mcps( - List mcps, - AuditStamp auditStamp, - EntityRegistry entityRegistry, - AspectRetriever aspectRetriever) { + List mcps, AuditStamp auditStamp, AspectRetriever aspectRetriever) { this.items = mcps.stream() .map( mcp -> { if (mcp.getChangeType().equals(ChangeType.PATCH)) { return MCPPatchBatchItem.MCPPatchBatchItemBuilder.build( - mcp, auditStamp, entityRegistry); + mcp, auditStamp, aspectRetriever.getEntityRegistry()); } else { return MCPUpsertBatchItem.MCPUpsertBatchItemBuilder.build( - mcp, auditStamp, entityRegistry, aspectRetriever); + mcp, auditStamp, aspectRetriever); } }) .collect(Collectors.toList()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java index f61280bac4b223..6563765657d6d0 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java @@ -40,18 +40,24 @@ public class MCLBatchItemImpl implements MCLBatchItem { public static class MCLBatchItemImplBuilder { + // Ensure use of other builders + private MCLBatchItemImpl build() { + return null; + } + public MCLBatchItemImpl build( - MetadataChangeLog metadataChangeLog, - EntityRegistry entityRegistry, - AspectRetriever aspectRetriever) { - return MCLBatchItemImpl.builder() - .metadataChangeLog(metadataChangeLog) - .build(entityRegistry, aspectRetriever); + MetadataChangeLog metadataChangeLog, AspectRetriever aspectRetriever) { + return MCLBatchItemImpl.builder().metadataChangeLog(metadataChangeLog).build(aspectRetriever); } - public MCLBatchItemImpl build(EntityRegistry entityRegistry, AspectRetriever aspectRetriever) { + public MCLBatchItemImpl build(AspectRetriever aspectRetriever) { + EntityRegistry entityRegistry = aspectRetriever.getEntityRegistry(); + log.debug("entity type = {}", this.metadataChangeLog.getEntityType()); - entitySpec(entityRegistry.getEntitySpec(this.metadataChangeLog.getEntityType())); + entitySpec( + aspectRetriever + .getEntityRegistry() + .getEntitySpec(this.metadataChangeLog.getEntityType())); aspectSpec(validateAspect(this.metadataChangeLog, this.entitySpec)); Urn urn = this.metadataChangeLog.getEntityUrn(); @@ -75,7 +81,6 @@ public MCLBatchItemImpl build(EntityRegistry entityRegistry, AspectRetriever asp // validate new ValidationUtils.validateRecordTemplate( this.metadataChangeLog.getChangeType(), - entityRegistry, this.entitySpec, this.aspectSpec, urn, diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java index 3adf384f3b0ed8..be333af2f75398 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java @@ -16,13 +16,13 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.batch.PatchItem; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.entity.EntityUtils; import com.linkedin.metadata.entity.validation.ValidationUtils; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.registry.EntityRegistry; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.SystemMetadataUtils; import com.linkedin.mxe.MetadataChangeProposal; @@ -73,9 +73,7 @@ public ChangeType getChangeType() { } public MCPUpsertBatchItem applyPatch( - EntityRegistry entityRegistry, - RecordTemplate recordTemplate, - AspectRetriever aspectRetriever) { + RecordTemplate recordTemplate, AspectRetriever aspectRetriever) { MCPUpsertBatchItem.MCPUpsertBatchItemBuilder builder = MCPUpsertBatchItem.builder() .urn(getUrn()) @@ -84,7 +82,8 @@ public MCPUpsertBatchItem applyPatch( .auditStamp(auditStamp) .systemMetadata(getSystemMetadata()); - AspectTemplateEngine aspectTemplateEngine = entityRegistry.getAspectTemplateEngine(); + AspectTemplateEngine aspectTemplateEngine = + aspectRetriever.getEntityRegistry().getAspectTemplateEngine(); RecordTemplate currentValue = recordTemplate != null @@ -106,7 +105,7 @@ public MCPUpsertBatchItem applyPatch( throw new RuntimeException(e); } - return builder.build(entityRegistry, aspectRetriever); + return builder.build(aspectRetriever); } public static class MCPPatchBatchItemBuilder { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java index 9d41b141dcd608..89209c44f10c77 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java @@ -4,12 +4,14 @@ import static com.linkedin.metadata.entity.AspectUtils.validateAspect; import com.datahub.util.exception.ModelConversionException; +import com.github.fge.jsonpatch.JsonPatchException; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.batch.SystemAspect; import com.linkedin.metadata.aspect.batch.UpsertItem; +import com.linkedin.metadata.aspect.patch.template.common.GenericPatchTemplate; import com.linkedin.metadata.aspect.plugins.hooks.MutationHook; import com.linkedin.metadata.aspect.plugins.validation.AspectPayloadValidator; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; @@ -19,12 +21,12 @@ import com.linkedin.metadata.entity.validation.ValidationUtils; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.metadata.utils.SystemMetadataUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; +import java.io.IOException; import java.sql.Timestamp; import java.util.Objects; import javax.annotation.Nonnull; @@ -39,6 +41,31 @@ @Builder(toBuilder = true) public class MCPUpsertBatchItem extends UpsertItem { + public static MCPUpsertBatchItem fromPatch( + @Nonnull Urn urn, + @Nonnull AspectSpec aspectSpec, + @Nullable RecordTemplate recordTemplate, + GenericPatchTemplate genericPatchTemplate, + @Nonnull AuditStamp auditStamp, + AspectRetriever aspectRetriever) { + MCPUpsertBatchItem.MCPUpsertBatchItemBuilder builder = + MCPUpsertBatchItem.builder() + .urn(urn) + .auditStamp(auditStamp) + .aspectName(aspectSpec.getName()); + + RecordTemplate currentValue = + recordTemplate != null ? recordTemplate : genericPatchTemplate.getDefault(); + + try { + builder.aspect(genericPatchTemplate.applyPatch(currentValue)); + } catch (JsonPatchException | IOException e) { + throw new RuntimeException(e); + } + + return builder.build(aspectRetriever); + } + // urn an urn associated with the new aspect @Nonnull private final Urn urn; @@ -66,12 +93,12 @@ public ChangeType getChangeType() { public void applyMutationHooks( @Nullable RecordTemplate oldAspectValue, @Nullable SystemMetadata oldSystemMetadata, - @Nonnull EntityRegistry entityRegistry, @Nonnull AspectRetriever aspectRetriever) { // add audit stamp/system meta if needed for (MutationHook mutationHook : - entityRegistry.getMutationHooks( - getChangeType(), entitySpec.getName(), aspectSpec.getName())) { + aspectRetriever + .getEntityRegistry() + .getMutationHooks(getChangeType(), entitySpec.getName(), aspectSpec.getName())) { mutationHook.applyMutation( getChangeType(), entitySpec, @@ -99,14 +126,14 @@ public SystemAspect toLatestEntityAspect() { @Override public void validatePreCommit( - @Nullable RecordTemplate previous, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) + @Nullable RecordTemplate previous, @Nonnull AspectRetriever aspectRetriever) throws AspectValidationException { for (AspectPayloadValidator validator : - entityRegistry.getAspectPayloadValidators( - getChangeType(), entitySpec.getName(), aspectSpec.getName())) { + aspectRetriever + .getEntityRegistry() + .getAspectPayloadValidators( + getChangeType(), entitySpec.getName(), aspectSpec.getName())) { validator.validatePreCommit( getChangeType(), urn, getAspectSpec(), previous, this.aspect, aspectRetriever); } @@ -125,12 +152,11 @@ public MCPUpsertBatchItemBuilder systemMetadata(SystemMetadata systemMetadata) { } @SneakyThrows - public MCPUpsertBatchItem build( - EntityRegistry entityRegistry, AspectRetriever aspectRetriever) { - EntityUtils.validateUrn(entityRegistry, this.urn); + public MCPUpsertBatchItem build(AspectRetriever aspectRetriever) { + EntityUtils.validateUrn(aspectRetriever.getEntityRegistry(), this.urn); log.debug("entity type = {}", this.urn.getEntityType()); - entitySpec(entityRegistry.getEntitySpec(this.urn.getEntityType())); + entitySpec(aspectRetriever.getEntityRegistry().getEntitySpec(this.urn.getEntityType())); log.debug("entity spec = {}", this.entitySpec); aspectSpec(ValidationUtils.validate(this.entitySpec, this.aspectName)); @@ -138,7 +164,6 @@ public MCPUpsertBatchItem build( ValidationUtils.validateRecordTemplate( ChangeType.UPSERT, - entityRegistry, this.entitySpec, this.aspectSpec, this.urn, @@ -157,17 +182,15 @@ public MCPUpsertBatchItem build( } public static MCPUpsertBatchItem build( - MetadataChangeProposal mcp, - AuditStamp auditStamp, - EntityRegistry entityRegistry, - AspectRetriever aspectRetriever) { + MetadataChangeProposal mcp, AuditStamp auditStamp, AspectRetriever aspectRetriever) { if (!mcp.getChangeType().equals(ChangeType.UPSERT)) { throw new IllegalArgumentException( "Invalid MCP, this class only supports change type of UPSERT."); } log.debug("entity type = {}", mcp.getEntityType()); - EntitySpec entitySpec = entityRegistry.getEntitySpec(mcp.getEntityType()); + EntitySpec entitySpec = + aspectRetriever.getEntityRegistry().getEntitySpec(mcp.getEntityType()); AspectSpec aspectSpec = validateAspect(mcp, entitySpec); if (!isValidChangeType(ChangeType.UPSERT, aspectSpec)) { @@ -191,7 +214,7 @@ public static MCPUpsertBatchItem build( .metadataChangeProposal(mcp) .auditStamp(auditStamp) .aspect(convertToRecordTemplate(mcp, aspectSpec)) - .build(entityRegistry, aspectRetriever); + .build(aspectRetriever); } private MCPUpsertBatchItemBuilder entitySpec(EntitySpec entitySpec) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/validation/ValidationUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/validation/ValidationUtils.java index 97f7aa06340d2d..947f0116b587c6 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/validation/ValidationUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/validation/ValidationUtils.java @@ -67,12 +67,12 @@ public static AspectSpec validate(EntitySpec entitySpec, String aspectName) { public static void validateRecordTemplate( ChangeType changeType, - EntityRegistry entityRegistry, EntitySpec entitySpec, AspectSpec aspectSpec, Urn urn, @Nullable RecordTemplate aspect, @Nonnull AspectRetriever aspectRetriever) { + EntityRegistry entityRegistry = aspectRetriever.getEntityRegistry(); EntityRegistryUrnValidator validator = new EntityRegistryUrnValidator(entityRegistry); validator.setCurrentEntitySpec(entitySpec); Consumer resultFunction = @@ -83,6 +83,7 @@ public static void validateRecordTemplate( + "\n Cause: " + validationResult.getMessages()); }; + RecordTemplateValidator.validate( EntityUtils.buildKeyAspect(entityRegistry, urn), resultFunction, validator); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java index 0d8b7655fddeb1..24e272dee7a254 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java @@ -10,6 +10,7 @@ import com.linkedin.metadata.graph.Edge; import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.graph.RelatedEntitiesResult; +import com.linkedin.metadata.graph.RelatedEntitiesScrollResult; import com.linkedin.metadata.graph.RelatedEntity; import com.linkedin.metadata.models.registry.LineageRegistry; import com.linkedin.metadata.query.filter.Criterion; @@ -17,6 +18,7 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; import io.dgraph.DgraphClient; import io.dgraph.DgraphProto.Mutation; import io.dgraph.DgraphProto.NQuad; @@ -779,4 +781,21 @@ public void clear() { // setup urn, type and key relationships getSchema(); } + + @Nonnull + @Override + public RelatedEntitiesScrollResult scrollRelatedEntities( + @Nullable List sourceTypes, + @Nonnull Filter sourceEntityFilter, + @Nullable List destinationTypes, + @Nonnull Filter destinationEntityFilter, + @Nonnull List relationshipTypes, + @Nonnull RelationshipFilter relationshipFilter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis) { + throw new IllegalArgumentException("Not implemented"); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java index 97cb186ce948ce..3051319aa54cf3 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java @@ -23,6 +23,8 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.search.elasticsearch.query.request.SearchAfterWrapper; import com.linkedin.metadata.search.utils.ESUtils; import com.linkedin.metadata.utils.ConcurrencyUtils; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; @@ -81,7 +83,7 @@ public class ESGraphQueryDAO { @Nonnull public static void addFilterToQueryBuilder( - @Nonnull Filter filter, String node, BoolQueryBuilder rootQuery) { + @Nonnull Filter filter, @Nullable String node, BoolQueryBuilder rootQuery) { BoolQueryBuilder orQuery = new BoolQueryBuilder(); for (ConjunctiveCriterion conjunction : filter.getOr()) { final BoolQueryBuilder andQuery = new BoolQueryBuilder(); @@ -93,12 +95,13 @@ public static void addFilterToQueryBuilder( } criterionArray.forEach( criterion -> - andQuery.must( + andQuery.filter( QueryBuilders.termQuery( - node + "." + criterion.getField(), criterion.getValue()))); + (node == null ? "" : node + ".") + criterion.getField(), + criterion.getValue()))); orQuery.should(andQuery); } - rootQuery.must(orQuery); + rootQuery.filter(orQuery); } private SearchResponse executeSearchQuery( @@ -174,9 +177,9 @@ public SearchResponse getSearchResponse( public static BoolQueryBuilder buildQuery( @Nullable final List sourceTypes, - @Nonnull final Filter sourceEntityFilter, + @Nullable final Filter sourceEntityFilter, @Nullable final List destinationTypes, - @Nonnull final Filter destinationEntityFilter, + @Nullable final Filter destinationEntityFilter, @Nonnull final List relationshipTypes, @Nonnull final RelationshipFilter relationshipFilter) { BoolQueryBuilder finalQuery = QueryBuilders.boolQuery(); @@ -187,17 +190,22 @@ public static BoolQueryBuilder buildQuery( String sourceNode = relationshipDirection == RelationshipDirection.OUTGOING ? SOURCE : DESTINATION; if (sourceTypes != null && sourceTypes.size() > 0) { - finalQuery.must(QueryBuilders.termsQuery(sourceNode + ".entityType", sourceTypes)); + finalQuery.filter(QueryBuilders.termsQuery(sourceNode + ".entityType", sourceTypes)); + } + if (sourceEntityFilter != null) { + addFilterToQueryBuilder(sourceEntityFilter, sourceNode, finalQuery); } - addFilterToQueryBuilder(sourceEntityFilter, sourceNode, finalQuery); // set destination filter String destinationNode = relationshipDirection == RelationshipDirection.OUTGOING ? DESTINATION : SOURCE; if (destinationTypes != null && destinationTypes.size() > 0) { - finalQuery.must(QueryBuilders.termsQuery(destinationNode + ".entityType", destinationTypes)); + finalQuery.filter( + QueryBuilders.termsQuery(destinationNode + ".entityType", destinationTypes)); + } + if (destinationEntityFilter != null) { + addFilterToQueryBuilder(destinationEntityFilter, destinationNode, finalQuery); } - addFilterToQueryBuilder(destinationEntityFilter, destinationNode, finalQuery); // set relationship filter if (relationshipTypes.size() > 0) { @@ -206,8 +214,14 @@ public static BoolQueryBuilder buildQuery( relationshipType -> relationshipQuery.should( QueryBuilders.termQuery(RELATIONSHIP_TYPE, relationshipType))); - finalQuery.must(relationshipQuery); + finalQuery.filter(relationshipQuery); + } + + // general filter + if (relationshipFilter.getOr() != null) { + addFilterToQueryBuilder(new Filter().setOr(relationshipFilter.getOr()), null, finalQuery); } + return finalQuery; } @@ -659,4 +673,60 @@ public static class LineageResponse { int total; List lineageRelationships; } + + public SearchResponse getSearchResponse( + @Nullable final List sourceTypes, + @Nullable final Filter sourceEntityFilter, + @Nullable final List destinationTypes, + @Nullable final Filter destinationEntityFilter, + @Nonnull final List relationshipTypes, + @Nonnull final RelationshipFilter relationshipFilter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count) { + + BoolQueryBuilder finalQuery = + buildQuery( + sourceTypes, + sourceEntityFilter, + destinationTypes, + destinationEntityFilter, + relationshipTypes, + relationshipFilter); + + return executeScrollSearchQuery(finalQuery, sortCriterion, scrollId, count); + } + + private SearchResponse executeScrollSearchQuery( + @Nonnull final QueryBuilder query, + @Nonnull List sortCriterion, + @Nullable String scrollId, + final int count) { + + Object[] sort = null; + if (scrollId != null) { + SearchAfterWrapper searchAfterWrapper = SearchAfterWrapper.fromScrollId(scrollId); + sort = searchAfterWrapper.getSort(); + } + + SearchRequest searchRequest = new SearchRequest(); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + + searchSourceBuilder.size(count); + searchSourceBuilder.query(query); + ESUtils.buildSortOrder(searchSourceBuilder, sortCriterion, List.of(), false); + searchRequest.source(searchSourceBuilder); + ESUtils.setSearchAfter(searchSourceBuilder, sort, null, null); + + searchRequest.indices(indexConvention.getIndexName(INDEX_NAME)); + + try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "esQuery").time()) { + MetricUtils.counter(this.getClass(), SEARCH_EXECUTIONS_METRIC).inc(); + return client.search(searchRequest, RequestOptions.DEFAULT); + } catch (Exception e) { + log.error("Search query failed", e); + throw new ESQueryException("Search query failed:", e); + } + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java index 6c828c0e7c6ae0..67590ffd6e7c10 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java @@ -11,7 +11,9 @@ import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.graph.LineageDirection; import com.linkedin.metadata.graph.LineageRelationshipArray; +import com.linkedin.metadata.graph.RelatedEntities; import com.linkedin.metadata.graph.RelatedEntitiesResult; +import com.linkedin.metadata.graph.RelatedEntitiesScrollResult; import com.linkedin.metadata.graph.RelatedEntity; import com.linkedin.metadata.models.registry.LineageRegistry; import com.linkedin.metadata.query.filter.Condition; @@ -22,11 +24,14 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ESIndexBuilder; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; +import com.linkedin.metadata.search.elasticsearch.query.request.SearchAfterWrapper; import com.linkedin.metadata.search.elasticsearch.update.ESBulkProcessor; import com.linkedin.metadata.shared.ElasticSearchIndexed; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; +import com.linkedin.structured.StructuredPropertyDefinition; import io.opentelemetry.extension.annotations.WithSpan; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -35,6 +40,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -47,6 +53,7 @@ import lombok.extern.slf4j.Slf4j; import org.opensearch.action.search.SearchResponse; import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.SearchHit; @Slf4j @RequiredArgsConstructor @@ -165,8 +172,6 @@ public RelatedEntitiesResult findRelatedEntities( } final RelationshipDirection relationshipDirection = relationshipFilter.getDirection(); - String destinationNode = - relationshipDirection == RelationshipDirection.OUTGOING ? "destination" : "source"; SearchResponse response = _graphReadDAO.getSearchResponse( @@ -185,28 +190,8 @@ public RelatedEntitiesResult findRelatedEntities( int totalCount = (int) response.getHits().getTotalHits().value; final List relationships = - Arrays.stream(response.getHits().getHits()) - .map( - hit -> { - final String urnStr = - ((HashMap) - hit.getSourceAsMap().getOrDefault(destinationNode, EMPTY_HASH)) - .getOrDefault("urn", null); - final String relationshipType = - (String) hit.getSourceAsMap().get("relationshipType"); - - if (urnStr == null || relationshipType == null) { - log.error( - String.format( - "Found null urn string, relationship type, aspect name or path spec in Elastic index. " - + "urnStr: %s, relationshipType: %s", - urnStr, relationshipType)); - return null; - } - - return new RelatedEntity(relationshipType, urnStr); - }) - .filter(Objects::nonNull) + searchHitsToRelatedEntities(response.getHits().getHits(), relationshipDirection).stream() + .map(RelatedEntities::asRelatedEntity) .collect(Collectors.toList()); return new RelatedEntitiesResult(offset, relationships.size(), totalCount, relationships); @@ -328,6 +313,12 @@ public List buildReindexConfigs() throws IOException { Collections.emptyMap())); } + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException { + return buildReindexConfigs(); + } + @Override public void reindexAll() { configure(); @@ -344,4 +335,88 @@ public void clear() { public boolean supportsMultiHop() { return true; } + + @Nonnull + @Override + public RelatedEntitiesScrollResult scrollRelatedEntities( + @Nullable List sourceTypes, + @Nullable Filter sourceEntityFilter, + @Nullable List destinationTypes, + @Nullable Filter destinationEntityFilter, + @Nonnull List relationshipTypes, + @Nonnull RelationshipFilter relationshipFilter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis) { + + final RelationshipDirection relationshipDirection = relationshipFilter.getDirection(); + + SearchResponse response = + _graphReadDAO.getSearchResponse( + sourceTypes, + sourceEntityFilter, + destinationTypes, + destinationEntityFilter, + relationshipTypes, + relationshipFilter, + sortCriterion, + scrollId, + count); + + if (response == null) { + return new RelatedEntitiesScrollResult(0, 0, null, ImmutableList.of()); + } + + int totalCount = (int) response.getHits().getTotalHits().value; + final List relationships = + searchHitsToRelatedEntities(response.getHits().getHits(), relationshipDirection); + + SearchHit[] searchHits = response.getHits().getHits(); + // Only return next scroll ID if there are more results, indicated by full size results + String nextScrollId = null; + if (searchHits.length == count) { + Object[] sort = searchHits[searchHits.length - 1].getSortValues(); + nextScrollId = new SearchAfterWrapper(sort, null, 0L).toScrollId(); + } + + return RelatedEntitiesScrollResult.builder() + .entities(relationships) + .pageSize(relationships.size()) + .numResults(totalCount) + .scrollId(nextScrollId) + .build(); + } + + private static List searchHitsToRelatedEntities( + SearchHit[] searchHits, RelationshipDirection relationshipDirection) { + return Arrays.stream(searchHits) + .map( + hit -> { + final String destinationUrnStr = + ((HashMap) + hit.getSourceAsMap().getOrDefault("destination", EMPTY_HASH)) + .getOrDefault("urn", null); + final String sourceUrnStr = + ((HashMap) + hit.getSourceAsMap().getOrDefault("source", EMPTY_HASH)) + .getOrDefault("urn", null); + final String relationshipType = (String) hit.getSourceAsMap().get("relationshipType"); + + if (destinationUrnStr == null || sourceUrnStr == null || relationshipType == null) { + log.error( + String.format( + "Found null urn string, relationship type, aspect name or path spec in Elastic index. " + + "destinationUrnStr: %s, sourceUrnStr: %s, relationshipType: %s", + destinationUrnStr, sourceUrnStr, relationshipType)); + return null; + } + + return new RelatedEntities( + relationshipType, sourceUrnStr, destinationUrnStr, relationshipDirection); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java index c8d3147711eba5..a1f73a134ec8ef 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java @@ -17,6 +17,7 @@ import com.linkedin.metadata.graph.LineageRelationship; import com.linkedin.metadata.graph.LineageRelationshipArray; import com.linkedin.metadata.graph.RelatedEntitiesResult; +import com.linkedin.metadata.graph.RelatedEntitiesScrollResult; import com.linkedin.metadata.graph.RelatedEntity; import com.linkedin.metadata.models.registry.LineageRegistry; import com.linkedin.metadata.query.filter.Condition; @@ -25,6 +26,7 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.utils.metrics.MetricUtils; import com.linkedin.util.Pair; import io.opentelemetry.extension.annotations.WithSpan; @@ -882,4 +884,21 @@ private boolean isSourceDestReversed( return null; } } + + @Nonnull + @Override + public RelatedEntitiesScrollResult scrollRelatedEntities( + @Nullable List sourceTypes, + @Nonnull Filter sourceEntityFilter, + @Nullable List destinationTypes, + @Nonnull Filter destinationEntityFilter, + @Nonnull List relationshipTypes, + @Nonnull RelationshipFilter relationshipFilter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis) { + throw new IllegalArgumentException("Not implemented"); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java index fd7491fe32ea34..7cba2e0ecc8cb4 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java @@ -18,6 +18,9 @@ import com.linkedin.metadata.search.utils.ESUtils; import com.linkedin.metadata.search.utils.SearchUtils; import com.linkedin.metadata.shared.ElasticSearchIndexed; +import com.linkedin.structured.StructuredPropertyDefinition; +import java.io.IOException; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; @@ -47,6 +50,12 @@ public List buildReindexConfigs() { return indexBuilders.buildReindexConfigs(); } + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException { + return indexBuilders.buildReindexConfigsWithAllStructProps(properties); + } + @Override public void reindexAll() { configure(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ESIndexBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ESIndexBuilder.java index 388dcea784cbb4..cc6a0f3e3d6f99 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ESIndexBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ESIndexBuilder.java @@ -1,5 +1,8 @@ package com.linkedin.metadata.search.elasticsearch.indexbuilder; +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.search.elasticsearch.indexbuilder.MappingsBuilder.PROPERTIES; + import com.google.common.collect.ImmutableMap; import com.linkedin.metadata.config.search.ElasticSearchConfiguration; import com.linkedin.metadata.search.utils.ESUtils; @@ -22,6 +25,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -125,12 +129,20 @@ public ESIndexBuilder( public ReindexConfig buildReindexState( String indexName, Map mappings, Map settings) throws IOException { + return buildReindexState(indexName, mappings, settings, false); + } + + public ReindexConfig buildReindexState( + String indexName, + Map mappings, + Map settings, + boolean copyStructuredPropertyMappings) + throws IOException { ReindexConfig.ReindexConfigBuilder builder = ReindexConfig.builder() .name(indexName) .enableIndexSettingsReindex(enableIndexSettingsReindex) .enableIndexMappingsReindex(enableIndexMappingsReindex) - .targetMappings(mappings) .version(gitVersion.getVersion()); Map baseSettings = new HashMap<>(settings); @@ -148,6 +160,7 @@ public ReindexConfig buildReindexState( // If index doesn't exist, no reindex if (!exists) { + builder.targetMappings(mappings); return builder.build(); } @@ -173,6 +186,35 @@ public ReindexConfig buildReindexState( .getSourceAsMap(); builder.currentMappings(currentMappings); + if (copyStructuredPropertyMappings) { + Map currentStructuredProperties = + (Map) + ((Map) + ((Map) + currentMappings.getOrDefault(PROPERTIES, new TreeMap())) + .getOrDefault(STRUCTURED_PROPERTY_MAPPING_FIELD, new TreeMap())) + .getOrDefault(PROPERTIES, new TreeMap()); + + if (!currentStructuredProperties.isEmpty()) { + HashMap> props = + (HashMap>) + ((Map) mappings.get(PROPERTIES)) + .computeIfAbsent( + STRUCTURED_PROPERTY_MAPPING_FIELD, + (key) -> new HashMap<>(Map.of(PROPERTIES, new HashMap<>()))); + + props.merge( + PROPERTIES, + currentStructuredProperties, + (targetValue, currentValue) -> { + HashMap merged = new HashMap<>(currentValue); + merged.putAll(targetValue); + return merged.isEmpty() ? null : merged; + }); + } + } + + builder.targetMappings(mappings); return builder.build(); } @@ -251,7 +293,7 @@ public void buildIndex(ReindexConfig indexState) throws IOException { * @throws IOException communication issues with ES */ public void applyMappings(ReindexConfig indexState, boolean suppressError) throws IOException { - if (indexState.isPureMappingsAddition()) { + if (indexState.isPureMappingsAddition() || indexState.isPureStructuredProperty()) { log.info("Updating index {} mappings in place.", indexState.name()); PutMappingRequest request = new PutMappingRequest(indexState.name()).source(indexState.targetMappings()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/EntityIndexBuilders.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/EntityIndexBuilders.java index 4489c661bb2edf..4322ea90edf1fa 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/EntityIndexBuilders.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/EntityIndexBuilders.java @@ -3,9 +3,12 @@ import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.shared.ElasticSearchIndexed; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; +import com.linkedin.structured.StructuredPropertyDefinition; import java.io.IOException; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -41,6 +44,24 @@ public List buildReindexConfigs() { entitySpec -> { try { Map mappings = MappingsBuilder.getMappings(entitySpec); + return indexBuilder.buildReindexState( + indexConvention.getIndexName(entitySpec), mappings, settings, true); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); + } + + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) { + Map settings = settingsBuilder.getSettings(); + return entityRegistry.getEntitySpecs().values().stream() + .map( + entitySpec -> { + try { + Map mappings = MappingsBuilder.getMappings(entitySpec, properties); return indexBuilder.buildReindexState( indexConvention.getIndexName(entitySpec), mappings, settings); } catch (IOException e) { @@ -49,4 +70,31 @@ public List buildReindexConfigs() { }) .collect(Collectors.toList()); } + + /** + * Given a structured property generate all entity index configurations impacted by it, preserving + * existing properties + * + * @param property the new property + * @return index configurations impacted by the new property + */ + public List buildReindexConfigsWithNewStructProp( + StructuredPropertyDefinition property) { + Map settings = settingsBuilder.getSettings(); + return entityRegistry.getEntitySpecs().values().stream() + .map( + entitySpec -> { + try { + Map mappings = + MappingsBuilder.getMappings(entitySpec, List.of(property)); + return indexBuilder.buildReindexState( + indexConvention.getIndexName(entitySpec), mappings, settings, true); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .filter(Objects::nonNull) + .filter(ReindexConfig::hasNewStructuredProperty) + .collect(Collectors.toList()); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/MappingsBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/MappingsBuilder.java index f85a0dcb06a075..79f530f18a3451 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/MappingsBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/MappingsBuilder.java @@ -1,13 +1,21 @@ package com.linkedin.metadata.search.elasticsearch.indexbuilder; +import static com.linkedin.metadata.Constants.ENTITY_TYPE_URN_PREFIX; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_MAPPING_FIELD; +import static com.linkedin.metadata.models.StructuredPropertyUtils.sanitizeStructuredPropertyFQN; import static com.linkedin.metadata.search.elasticsearch.indexbuilder.SettingsBuilder.*; import com.google.common.collect.ImmutableMap; +import com.linkedin.common.urn.Urn; import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.LogicalValueType; import com.linkedin.metadata.models.SearchScoreFieldSpec; import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.annotation.SearchableAnnotation.FieldType; import com.linkedin.metadata.search.utils.ESUtils; +import com.linkedin.structured.StructuredPropertyDefinition; +import java.net.URISyntaxException; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,6 +56,53 @@ public static Map getPartialNgramConfigWithOverrides( private MappingsBuilder() {} + /** + * Builds mappings from entity spec and a collection of structured properties for the entity. + * + * @param entitySpec entity's spec + * @param structuredProperties structured properties for the entity + * @return mappings + */ + public static Map getMappings( + @Nonnull final EntitySpec entitySpec, + Collection structuredProperties) { + Map mappings = getMappings(entitySpec); + + String entityName = entitySpec.getEntityAnnotation().getName(); + Map structuredPropertiesForEntity = + getMappingsForStructuredProperty( + structuredProperties.stream() + .filter( + prop -> { + try { + return prop.getEntityTypes() + .contains(Urn.createFromString(ENTITY_TYPE_URN_PREFIX + entityName)); + } catch (URISyntaxException e) { + return false; + } + }) + .collect(Collectors.toSet())); + + if (!structuredPropertiesForEntity.isEmpty()) { + HashMap> props = + (HashMap>) + ((Map) mappings.get(PROPERTIES)) + .computeIfAbsent( + STRUCTURED_PROPERTY_MAPPING_FIELD, + (key) -> new HashMap<>(Map.of(PROPERTIES, new HashMap<>()))); + + props.merge( + PROPERTIES, + structuredPropertiesForEntity, + (oldValue, newValue) -> { + HashMap merged = new HashMap<>(oldValue); + merged.putAll(newValue); + return merged.isEmpty() ? null : merged; + }); + } + return mappings; + } + public static Map getMappings(@Nonnull final EntitySpec entitySpec) { Map mappings = new HashMap<>(); @@ -89,6 +144,30 @@ private static Map getMappingsForRunId() { return ImmutableMap.builder().put(TYPE, ESUtils.KEYWORD_FIELD_TYPE).build(); } + public static Map getMappingsForStructuredProperty( + Collection properties) { + return properties.stream() + .map( + property -> { + Map mappingForField = new HashMap<>(); + String valueType = property.getValueType().getId(); + if (valueType.equalsIgnoreCase(LogicalValueType.STRING.name())) { + mappingForField = getMappingsForKeyword(); + } else if (valueType.equalsIgnoreCase(LogicalValueType.RICH_TEXT.name())) { + mappingForField = getMappingsForSearchText(FieldType.TEXT_PARTIAL); + } else if (valueType.equalsIgnoreCase(LogicalValueType.DATE.name())) { + mappingForField.put(TYPE, ESUtils.DATE_FIELD_TYPE); + } else if (valueType.equalsIgnoreCase(LogicalValueType.URN.name())) { + mappingForField = getMappingsForUrn(); + } else if (valueType.equalsIgnoreCase(LogicalValueType.NUMBER.name())) { + mappingForField.put(TYPE, ESUtils.DOUBLE_FIELD_TYPE); + } + return Map.entry( + sanitizeStructuredPropertyFQN(property.getQualifiedName()), mappingForField); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + private static Map getMappingsForField( @Nonnull final SearchableFieldSpec searchableFieldSpec) { FieldType fieldType = searchableFieldSpec.getSearchableAnnotation().getFieldType(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ReindexConfig.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ReindexConfig.java index e3155c9f943cc0..bb6905139f49d5 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ReindexConfig.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/indexbuilder/ReindexConfig.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -65,6 +66,8 @@ public class ReindexConfig { private final boolean requiresApplyMappings; private final boolean isPureMappingsAddition; private final boolean isSettingsReindex; + private final boolean hasNewStructuredProperty; + private final boolean isPureStructuredProperty; public static ReindexConfigBuilder builder() { return new CalculatedBuilder(); @@ -92,6 +95,14 @@ private ReindexConfigBuilder isSettingsReindexRequired(boolean ignored) { return this; } + private ReindexConfigBuilder hasNewStructuredProperty(boolean ignored) { + return this; + } + + private ReindexConfigBuilder isPureStructuredProperty(boolean ignored) { + return this; + } + // ensure sorted public ReindexConfigBuilder currentMappings(Map currentMappings) { this.currentMappings = sortMap(currentMappings); @@ -141,6 +152,15 @@ public ReindexConfig build() { super.requiresApplyMappings = !mappingsDiff.entriesDiffering().isEmpty() || !mappingsDiff.entriesOnlyOnRight().isEmpty(); + super.isPureStructuredProperty = + mappingsDiff + .entriesDiffering() + .keySet() + .equals(Set.of(STRUCTURED_PROPERTY_MAPPING_FIELD)) + || mappingsDiff + .entriesOnlyOnRight() + .keySet() + .equals(Set.of(STRUCTURED_PROPERTY_MAPPING_FIELD)); super.isPureMappingsAddition = super.requiresApplyMappings && mappingsDiff.entriesDiffering().isEmpty() @@ -157,6 +177,19 @@ public ReindexConfig build() { super.name, mappingsDiff.entriesDiffering()); } + super.hasNewStructuredProperty = + (mappingsDiff.entriesDiffering().containsKey(STRUCTURED_PROPERTY_MAPPING_FIELD) + || mappingsDiff + .entriesOnlyOnRight() + .containsKey(STRUCTURED_PROPERTY_MAPPING_FIELD)) + && getOrDefault( + super.currentMappings, + List.of("properties", STRUCTURED_PROPERTY_MAPPING_FIELD, "properties")) + .size() + < getOrDefault( + super.targetMappings, + List.of("properties", STRUCTURED_PROPERTY_MAPPING_FIELD, "properties")) + .size(); /* Consider analysis and settings changes */ super.requiresApplySettings = !isSettingsEqual() || !isAnalysisEqual(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java index b35c0258d09f0f..0eb44edfb11dea 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java @@ -1,7 +1,7 @@ package com.linkedin.metadata.search.elasticsearch.query; import static com.linkedin.metadata.Constants.*; -import static com.linkedin.metadata.models.registry.template.util.TemplateUtil.*; +import static com.linkedin.metadata.aspect.patch.template.TemplateUtil.*; import static com.linkedin.metadata.utils.SearchUtil.*; import com.codahale.metrics.Timer; @@ -303,7 +303,7 @@ public AutoCompleteResult autoComplete( /** * Returns number of documents per field value given the field and filters * - * @param entityName name of the entity, if null, aggregates over all entities + * @param entityNames names of the entities, if null, aggregates over all entities * @param field the field name for aggregate * @param requestParams filters to apply before aggregating * @param limit the number of aggregations to return diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java index 522c8e510dcf8c..0f22b75b69f107 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java @@ -1,8 +1,10 @@ package com.linkedin.metadata.search.elasticsearch.query.request; +import static com.linkedin.metadata.Constants.*; import static com.linkedin.metadata.utils.SearchUtil.*; import com.linkedin.metadata.config.search.SearchConfiguration; +import com.linkedin.metadata.models.StructuredPropertyUtils; import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.search.utils.ESUtils; import java.util.ArrayList; @@ -72,8 +74,12 @@ private Set getAllFacetFields(final List annotatio } private boolean isValidAggregate(final String inputFacet) { - Set facets = Set.of(inputFacet.split(AGGREGATION_SEPARATOR_CHAR)); - boolean isValid = !facets.isEmpty() && _allFacetFields.containsAll(facets); + List facets = List.of(inputFacet.split(AGGREGATION_SEPARATOR_CHAR)); + boolean isValid = + !facets.isEmpty() + && ((facets.size() == 1 + && facets.get(0).startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD + ".")) + || _allFacetFields.containsAll(facets)); if (!isValid) { log.warn( String.format( @@ -89,6 +95,13 @@ private AggregationBuilder facetToAggregationBuilder(final String inputFacet) { AggregationBuilder lastAggBuilder = null; for (int i = facets.size() - 1; i >= 0; i--) { String facet = facets.get(i); + if (facet.startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD + ".")) { + String structPropFqn = facet.substring(STRUCTURED_PROPERTY_MAPPING_FIELD.length() + 1); + facet = + STRUCTURED_PROPERTY_MAPPING_FIELD + + "." + + StructuredPropertyUtils.sanitizeStructuredPropertyFQN(structPropFqn); + } AggregationBuilder aggBuilder; if (facet.contains(AGGREGATION_SPECIAL_TYPE_DELIMITER)) { List specialTypeFields = List.of(facet.split(AGGREGATION_SPECIAL_TYPE_DELIMITER)); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchAfterWrapper.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchAfterWrapper.java index 1fe4a74968e424..452e50a6e8d628 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchAfterWrapper.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchAfterWrapper.java @@ -1,6 +1,6 @@ package com.linkedin.metadata.search.elasticsearch.query.request; -import static com.linkedin.metadata.models.registry.template.util.TemplateUtil.*; +import static com.linkedin.metadata.aspect.patch.template.TemplateUtil.*; import java.io.IOException; import java.io.Serializable; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java index 4d51de39c88e3d..05fa6f45fcb30e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java @@ -93,6 +93,7 @@ public class SearchRequestHandler { private final Set _defaultQueryFieldNames; private final HighlightBuilder _highlights; private final Map _filtersToDisplayName; + private final SearchConfiguration _configs; private final SearchQueryBuilder _searchQueryBuilder; private final AggregationQueryBuilder _aggregationQueryBuilder; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/features/Features.java b/metadata-io/src/main/java/com/linkedin/metadata/search/features/Features.java index 2a9571b18b7265..6cadb39d5970d8 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/features/Features.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/features/Features.java @@ -17,7 +17,8 @@ public class Features { public enum Name { SEARCH_BACKEND_SCORE, // Score returned by search backend NUM_ENTITIES_PER_TYPE, // Number of entities per entity type - RANK_WITHIN_TYPE; // Rank within the entity type + RANK_WITHIN_TYPE, + ONLY_MATCH_CUSTOM_PROPERTIES; // Rank within the entity type } public Double getNumericFeature(Name featureName, double defaultValue) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/transformer/SearchDocumentTransformer.java b/metadata-io/src/main/java/com/linkedin/metadata/search/transformer/SearchDocumentTransformer.java index bfeb9933905717..d52a80d685fd5b 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/transformer/SearchDocumentTransformer.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/transformer/SearchDocumentTransformer.java @@ -1,5 +1,8 @@ package com.linkedin.metadata.search.transformer; +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.models.StructuredPropertyUtils.sanitizeStructuredPropertyFQN; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -7,17 +10,26 @@ import com.linkedin.common.urn.Urn; import com.linkedin.data.schema.DataSchema; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.entity.Aspect; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; +import com.linkedin.metadata.aspect.validation.StructuredPropertiesValidator; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.LogicalValueType; import com.linkedin.metadata.models.SearchScoreFieldSpec; import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.annotation.SearchableAnnotation.FieldType; import com.linkedin.metadata.models.extractor.FieldExtractor; +import com.linkedin.r2.RemoteInvocationException; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyDefinition; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -41,7 +53,7 @@ public class SearchDocumentTransformer { // Maximum customProperties value length private final int maxValueLength; - private SystemEntityClient entityClient; + private AspectRetriever aspectRetriever; private static final String BROWSE_PATH_V2_DELIMITER = "␟"; @@ -77,7 +89,8 @@ public Optional transformAspect( final Urn urn, final RecordTemplate aspect, final AspectSpec aspectSpec, - final Boolean forDelete) { + final Boolean forDelete) + throws RemoteInvocationException, URISyntaxException { final Map> extractedSearchableFields = FieldExtractor.extractFields(aspect, aspectSpec.getSearchableFieldSpecs(), maxValueLength); final Map> extractedSearchScoreFields = @@ -93,6 +106,12 @@ public Optional transformAspect( extractedSearchScoreFields.forEach( (key, values) -> setSearchScoreValue(key, values, searchDocument, forDelete)); result = Optional.of(searchDocument.toString()); + } else if (STRUCTURED_PROPERTIES_ASPECT_NAME.equals(aspectSpec.getName())) { + final ObjectNode searchDocument = JsonNodeFactory.instance.objectNode(); + searchDocument.put("urn", urn.toString()); + setStructuredPropertiesSearchValue( + new StructuredProperties(aspect.data()), searchDocument, forDelete); + result = Optional.of(searchDocument.toString()); } return result; @@ -277,4 +296,93 @@ private String getBrowsePathV2Value(@Nonnull final List fieldValues) { } return aggregatedValue; } + + private void setStructuredPropertiesSearchValue( + final StructuredProperties values, final ObjectNode searchDocument, final Boolean forDelete) + throws RemoteInvocationException, URISyntaxException { + Map> propertyMap = + values.getProperties().stream() + .collect( + Collectors.groupingBy( + StructuredPropertyValueAssignment::getPropertyUrn, Collectors.toSet())); + + Map> definitions = + aspectRetriever.getLatestAspectObjects( + propertyMap.keySet(), Set.of(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME)); + + if (definitions.size() < propertyMap.size()) { + String message = + String.format( + "Missing property definitions. %s", + propertyMap.keySet().stream() + .filter(k -> !definitions.containsKey(k)) + .collect(Collectors.toSet())); + log.error(message); + } + + propertyMap + .entrySet() + .forEach( + propertyEntry -> { + StructuredPropertyDefinition definition = + new StructuredPropertyDefinition( + definitions + .get(propertyEntry.getKey()) + .get(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME) + .data()); + String fieldName = + String.join( + ".", + List.of( + STRUCTURED_PROPERTY_MAPPING_FIELD, + sanitizeStructuredPropertyFQN(definition.getQualifiedName()))); + + if (forDelete) { + searchDocument.set(fieldName, JsonNodeFactory.instance.nullNode()); + } else { + LogicalValueType logicalValueType = + StructuredPropertiesValidator.getLogicalValueType(definition.getValueType()); + + ArrayNode arrayNode = JsonNodeFactory.instance.arrayNode(); + + propertyEntry + .getValue() + .forEach( + property -> + property + .getValues() + .forEach( + propertyValue -> { + final Optional searchValue; + switch (logicalValueType) { + case UNKNOWN: + log.warn( + "Unable to transform UNKNOWN logical value type."); + searchValue = Optional.empty(); + break; + case NUMBER: + Double doubleValue = + propertyValue.getDouble() != null + ? propertyValue.getDouble() + : Double.valueOf(propertyValue.getString()); + searchValue = + Optional.of( + JsonNodeFactory.instance.numberNode(doubleValue)); + break; + default: + searchValue = + propertyValue.getString().isEmpty() + ? Optional.empty() + : Optional.of( + JsonNodeFactory.instance.textNode( + propertyValue.getString())); + break; + } + searchValue.ifPresent(arrayNode::add); + })); + + searchDocument.set(fieldName, arrayNode); + } + }); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java index 982b5c8d5f367c..aa854149de43ae 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java @@ -1,5 +1,7 @@ package com.linkedin.metadata.search.utils; +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.search.elasticsearch.indexbuilder.SettingsBuilder.*; import static com.linkedin.metadata.search.elasticsearch.query.request.SearchFieldConfig.KEYWORD_FIELDS; import static com.linkedin.metadata.search.elasticsearch.query.request.SearchFieldConfig.PATH_HIERARCHY_FIELDS; import static com.linkedin.metadata.search.utils.SearchUtils.isUrn; @@ -8,6 +10,7 @@ import com.google.common.collect.ImmutableSet; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.SearchableFieldSpec; +import com.linkedin.metadata.models.StructuredPropertyUtils; import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; @@ -97,6 +100,7 @@ public class ESUtils { } }; + // TODO - This has been expanded for has* in another branch public static final Set BOOLEAN_FIELDS = ImmutableSet.of("removed"); /* @@ -203,6 +207,9 @@ public static BoolQueryBuilder buildConjunctiveFilterQuery( public static QueryBuilder getQueryBuilderFromCriterion( @Nonnull final Criterion criterion, boolean isTimeseries) { final String fieldName = toFacetField(criterion.getField()); + if (fieldName.startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD)) { + criterion.setField(fieldName); + } /* * Check the field-name for a "sibling" field, or one which should ALWAYS @@ -260,46 +267,69 @@ public static void buildSortOrder( @Nonnull SearchSourceBuilder searchSourceBuilder, @Nullable SortCriterion sortCriterion, List entitySpecs) { - if (sortCriterion == null) { + buildSortOrder( + searchSourceBuilder, + sortCriterion == null ? List.of() : List.of(sortCriterion), + entitySpecs, + true); + } + + /** + * Allow disabling default sort, used when you know uniqueness is present without urn field. For + * example, edge indices where the unique constraint is determined by multiple fields (src urn, + * dst urn, relation type). + * + * @param enableDefaultSort enable/disable default sorting logic + */ + public static void buildSortOrder( + @Nonnull SearchSourceBuilder searchSourceBuilder, + @Nonnull List sortCriterion, + List entitySpecs, + boolean enableDefaultSort) { + if (sortCriterion.isEmpty() && enableDefaultSort) { searchSourceBuilder.sort(new ScoreSortBuilder().order(SortOrder.DESC)); } else { - Optional fieldTypeForDefault = Optional.empty(); - for (EntitySpec entitySpec : entitySpecs) { - List fieldSpecs = entitySpec.getSearchableFieldSpecs(); - for (SearchableFieldSpec fieldSpec : fieldSpecs) { - SearchableAnnotation annotation = fieldSpec.getSearchableAnnotation(); - if (annotation.getFieldName().equals(sortCriterion.getField()) - || annotation.getFieldNameAliases().contains(sortCriterion.getField())) { - fieldTypeForDefault = Optional.of(fieldSpec.getSearchableAnnotation().getFieldType()); + for (SortCriterion sortCriteria : sortCriterion) { + Optional fieldTypeForDefault = Optional.empty(); + for (EntitySpec entitySpec : entitySpecs) { + List fieldSpecs = entitySpec.getSearchableFieldSpecs(); + for (SearchableFieldSpec fieldSpec : fieldSpecs) { + SearchableAnnotation annotation = fieldSpec.getSearchableAnnotation(); + if (annotation.getFieldName().equals(sortCriteria.getField()) + || annotation.getFieldNameAliases().contains(sortCriteria.getField())) { + fieldTypeForDefault = Optional.of(fieldSpec.getSearchableAnnotation().getFieldType()); + break; + } + } + if (fieldTypeForDefault.isPresent()) { break; } } - if (fieldTypeForDefault.isPresent()) { - break; + if (fieldTypeForDefault.isEmpty()) { + log.warn( + "Sort criterion field " + + sortCriteria.getField() + + " was not found in any entity spec to be searched"); } - } - if (fieldTypeForDefault.isEmpty()) { - log.warn( - "Sort criterion field " - + sortCriterion.getField() - + " was not found in any entity spec to be searched"); - } - final SortOrder esSortOrder = - (sortCriterion.getOrder() == com.linkedin.metadata.query.filter.SortOrder.ASCENDING) - ? SortOrder.ASC - : SortOrder.DESC; - FieldSortBuilder sortBuilder = - new FieldSortBuilder(sortCriterion.getField()).order(esSortOrder); - if (fieldTypeForDefault.isPresent()) { - String esFieldtype = getElasticTypeForFieldType(fieldTypeForDefault.get()); - if (esFieldtype != null) { - sortBuilder.unmappedType(esFieldtype); + final SortOrder esSortOrder = + (sortCriteria.getOrder() == com.linkedin.metadata.query.filter.SortOrder.ASCENDING) + ? SortOrder.ASC + : SortOrder.DESC; + FieldSortBuilder sortBuilder = + new FieldSortBuilder(sortCriteria.getField()).order(esSortOrder); + if (fieldTypeForDefault.isPresent()) { + String esFieldtype = getElasticTypeForFieldType(fieldTypeForDefault.get()); + if (esFieldtype != null) { + sortBuilder.unmappedType(esFieldtype); + } } + searchSourceBuilder.sort(sortBuilder); } - searchSourceBuilder.sort(sortBuilder); } - if (sortCriterion == null - || !sortCriterion.getField().equals(DEFAULT_SEARCH_RESULTS_SORT_BY_FIELD)) { + if (enableDefaultSort + && (sortCriterion.isEmpty() + || sortCriterion.stream() + .noneMatch(c -> c.getField().equals(DEFAULT_SEARCH_RESULTS_SORT_BY_FIELD)))) { searchSourceBuilder.sort( new FieldSortBuilder(DEFAULT_SEARCH_RESULTS_SORT_BY_FIELD).order(SortOrder.ASC)); } @@ -335,7 +365,15 @@ public static String escapeReservedCharacters(@Nonnull String input) { @Nonnull public static String toFacetField(@Nonnull final String filterField) { - return filterField.replace(ESUtils.KEYWORD_SUFFIX, ""); + String fieldName = filterField; + if (fieldName.startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD + ".")) { + fieldName = + STRUCTURED_PROPERTY_MAPPING_FIELD + + "." + + StructuredPropertyUtils.sanitizeStructuredPropertyFQN( + fieldName.substring(STRUCTURED_PROPERTY_MAPPING_FIELD.length() + 1)); + } + return fieldName.replace(ESUtils.KEYWORD_SUFFIX, ""); } @Nonnull diff --git a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java index 247d542604da70..1f39a3947c47a3 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java @@ -10,15 +10,16 @@ import com.linkedin.common.InputField; import com.linkedin.common.InputFields; import com.linkedin.common.Status; +import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; import com.linkedin.dataset.FineGrainedLineage; import com.linkedin.dataset.UpstreamLineage; -import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.batch.MCLBatchItem; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.entity.ebean.batch.MCLBatchItemImpl; import com.linkedin.metadata.graph.Edge; import com.linkedin.metadata.graph.GraphIndexUtils; @@ -43,6 +44,7 @@ import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.mxe.MetadataChangeLog; import com.linkedin.mxe.SystemMetadata; +import com.linkedin.structured.StructuredPropertyDefinition; import com.linkedin.util.Pair; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -70,11 +72,11 @@ public class UpdateIndicesService { private final EntitySearchService _entitySearchService; private final TimeseriesAspectService _timeseriesAspectService; private final SystemMetadataService _systemMetadataService; - private final EntityRegistry _entityRegistry; private final SearchDocumentTransformer _searchDocumentTransformer; private final EntityIndexBuilders _entityIndexBuilders; - private SystemEntityClient systemEntityClient; + private AspectRetriever aspectRetriever; + private EntityRegistry _entityRegistry; @Value("${featureFlags.graphServiceDiffModeEnabled:true}") private boolean _graphDiffMode; @@ -82,6 +84,12 @@ public class UpdateIndicesService { @Value("${featureFlags.searchServiceDiffModeEnabled:true}") private boolean _searchDiffMode; + @Value("${structuredProperties.enabled}") + private boolean _structuredPropertiesHookEnabled; + + @Value("${structuredProperties.writeEnabled}") + private boolean _structuredPropertiesWriteEnabled; + private static final Set UPDATE_CHANGE_TYPES = ImmutableSet.of(ChangeType.UPSERT, ChangeType.RESTATE, ChangeType.PATCH); @@ -100,31 +108,26 @@ public UpdateIndicesService( EntitySearchService entitySearchService, TimeseriesAspectService timeseriesAspectService, SystemMetadataService systemMetadataService, - EntityRegistry entityRegistry, SearchDocumentTransformer searchDocumentTransformer, EntityIndexBuilders entityIndexBuilders) { _graphService = graphService; _entitySearchService = entitySearchService; _timeseriesAspectService = timeseriesAspectService; _systemMetadataService = systemMetadataService; - _entityRegistry = entityRegistry; _searchDocumentTransformer = searchDocumentTransformer; _entityIndexBuilders = entityIndexBuilders; } public void handleChangeEvent(@Nonnull final MetadataChangeLog event) { try { - MCLBatchItemImpl batch = - MCLBatchItemImpl.builder().build(event, _entityRegistry, systemEntityClient); + MCLBatchItemImpl batch = MCLBatchItemImpl.builder().build(event, aspectRetriever); Stream sideEffects = _entityRegistry .getMCLSideEffects( event.getChangeType(), event.getEntityType(), event.getAspectName()) .stream() - .flatMap( - mclSideEffect -> - mclSideEffect.apply(List.of(batch), _entityRegistry, systemEntityClient)); + .flatMap(mclSideEffect -> mclSideEffect.apply(List.of(batch), aspectRetriever)); for (MCLBatchItem mclBatchItem : Stream.concat(Stream.of(batch), sideEffects).toList()) { MetadataChangeLog hookEvent = mclBatchItem.getMetadataChangeLog(); @@ -173,11 +176,14 @@ private void handleUpdateChangeEvent(@Nonnull final MCLBatchItem event) throws I updateSystemMetadata(event.getSystemMetadata(), urn, aspectSpec, aspect); } - // Step 1. For all aspects, attempt to update Search + // Step 1. Handle StructuredProperties Index Mapping changes + updateIndexMappings(entitySpec, aspectSpec, aspect, previousAspect); + + // Step 2. For all aspects, attempt to update Search updateSearchService( entitySpec.getName(), urn, aspectSpec, aspect, event.getSystemMetadata(), previousAspect); - // Step 2. For all aspects, attempt to update Graph + // Step 3. For all aspects, attempt to update Graph SystemMetadata systemMetadata = event.getSystemMetadata(); if (_graphDiffMode && !(_graphService instanceof DgraphGraphService) @@ -190,6 +196,46 @@ private void handleUpdateChangeEvent(@Nonnull final MCLBatchItem event) throws I } } + public void updateIndexMappings( + EntitySpec entitySpec, + AspectSpec aspectSpec, + RecordTemplate newValue, + RecordTemplate oldValue) + throws IOException { + if (_structuredPropertiesHookEnabled + && STRUCTURED_PROPERTY_ENTITY_NAME.equals(entitySpec.getName()) + && STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME.equals(aspectSpec.getName())) { + + UrnArray oldEntityTypes = + Optional.ofNullable(oldValue) + .map( + recordTemplate -> + new StructuredPropertyDefinition(recordTemplate.data()).getEntityTypes()) + .orElse(new UrnArray()); + + StructuredPropertyDefinition newDefinition = + new StructuredPropertyDefinition(newValue.data()); + newDefinition.getEntityTypes().removeAll(oldEntityTypes); + + if (newDefinition.getEntityTypes().size() > 0) { + _entityIndexBuilders + .buildReindexConfigsWithNewStructProp(newDefinition) + .forEach( + reindexState -> { + try { + log.info( + "Applying new structured property {} to index {}", + newDefinition, + reindexState.name()); + _entityIndexBuilders.getIndexBuilder().applyMappings(reindexState, false); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + } + /** * This very important method processes {@link MetadataChangeLog} deletion events to cleanup the * Metadata Graph when an aspect or entity is removed. @@ -617,13 +663,13 @@ private EntitySpec getEventEntitySpec(@Nonnull final MetadataChangeLog event) { } /** - * Allow internal use of the system entity client. Solves recursive dependencies between the - * UpdateIndicesService and the SystemJavaEntityClient + * Solves recursive dependencies between the UpdateIndicesService and EntityService * - * @param systemEntityClient system entity client + * @param aspectRetriever aspect Retriever */ - public void setSystemEntityClient(SystemEntityClient systemEntityClient) { - this.systemEntityClient = systemEntityClient; - _searchDocumentTransformer.setEntityClient(systemEntityClient); + public void initializeAspectRetriever(AspectRetriever aspectRetriever) { + this.aspectRetriever = aspectRetriever; + this._entityRegistry = aspectRetriever.getEntityRegistry(); + this._searchDocumentTransformer.setAspectRetriever(aspectRetriever); } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/shared/ElasticSearchIndexed.java b/metadata-io/src/main/java/com/linkedin/metadata/shared/ElasticSearchIndexed.java index 9aa0cdca99f68b..e894558e3d1afd 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/shared/ElasticSearchIndexed.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/shared/ElasticSearchIndexed.java @@ -1,7 +1,9 @@ package com.linkedin.metadata.shared; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; +import com.linkedin.structured.StructuredPropertyDefinition; import java.io.IOException; +import java.util.Collection; import java.util.List; public interface ElasticSearchIndexed { @@ -12,6 +14,15 @@ public interface ElasticSearchIndexed { */ List buildReindexConfigs() throws IOException; + /** + * The index configurations for the given service with StructuredProperties applied. + * + * @param properties The structured properties to apply to the index mappings + * @return List of reindex configurations + */ + List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException; + /** * Mirrors the service's functions which are expected to build/reindex as needed based on the * reindex configurations above diff --git a/metadata-io/src/main/java/com/linkedin/metadata/systemmetadata/ElasticSearchSystemMetadataService.java b/metadata-io/src/main/java/com/linkedin/metadata/systemmetadata/ElasticSearchSystemMetadataService.java index 6fbe7cfe882ce4..36eab7b69e6a12 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/systemmetadata/ElasticSearchSystemMetadataService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/systemmetadata/ElasticSearchSystemMetadataService.java @@ -13,12 +13,14 @@ import com.linkedin.metadata.shared.ElasticSearchIndexed; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; import com.linkedin.mxe.SystemMetadata; +import com.linkedin.structured.StructuredPropertyDefinition; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -245,6 +247,12 @@ public List buildReindexConfigs() throws IOException { Collections.emptyMap())); } + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException { + return buildReindexConfigs(); + } + @Override public void reindexAll() { configure(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java index f9ab86d41335db..71ffd603c999f6 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java @@ -20,12 +20,15 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; +import com.linkedin.metadata.search.elasticsearch.query.request.SearchAfterWrapper; import com.linkedin.metadata.search.elasticsearch.update.ESBulkProcessor; import com.linkedin.metadata.search.utils.ESUtils; import com.linkedin.metadata.search.utils.QueryUtils; import com.linkedin.metadata.shared.ElasticSearchIndexed; import com.linkedin.metadata.timeseries.BatchWriteOperationsOptions; +import com.linkedin.metadata.timeseries.GenericTimeseriesDocument; import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.metadata.timeseries.TimeseriesScrollResult; import com.linkedin.metadata.timeseries.elastic.indexbuilder.MappingsBuilder; import com.linkedin.metadata.timeseries.elastic.indexbuilder.TimeseriesAspectIndexBuilders; import com.linkedin.metadata.timeseries.elastic.query.ESAggregatedStatsDAO; @@ -33,6 +36,7 @@ import com.linkedin.metadata.utils.metrics.MetricUtils; import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.SystemMetadata; +import com.linkedin.structured.StructuredPropertyDefinition; import com.linkedin.timeseries.AggregationSpec; import com.linkedin.timeseries.DeleteAspectValuesResult; import com.linkedin.timeseries.GenericTable; @@ -43,9 +47,11 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -86,8 +92,6 @@ public class ElasticSearchTimeseriesAspectService .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build()); } - private static final String TIMESTAMP_FIELD = "timestampMillis"; - private static final String EVENT_FIELD = "event"; private static final Integer DEFAULT_LIMIT = 10000; private final IndexConvention _indexConvention; @@ -118,7 +122,7 @@ public ElasticSearchTimeseriesAspectService( private static EnvelopedAspect parseDocument(@Nonnull SearchHit doc) { Map docFields = doc.getSourceAsMap(); EnvelopedAspect envelopedAspect = new EnvelopedAspect(); - Object event = docFields.get(EVENT_FIELD); + Object event = docFields.get(MappingsBuilder.EVENT_FIELD); GenericAspect genericAspect; try { genericAspect = @@ -147,6 +151,61 @@ private static EnvelopedAspect parseDocument(@Nonnull SearchHit doc) { return envelopedAspect; } + private static Set commonFields = + Set.of( + MappingsBuilder.URN_FIELD, + MappingsBuilder.RUN_ID_FIELD, + MappingsBuilder.EVENT_GRANULARITY, + MappingsBuilder.IS_EXPLODED_FIELD, + MappingsBuilder.MESSAGE_ID_FIELD, + MappingsBuilder.PARTITION_SPEC_PARTITION, + MappingsBuilder.PARTITION_SPEC, + MappingsBuilder.SYSTEM_METADATA_FIELD, + MappingsBuilder.TIMESTAMP_MILLIS_FIELD, + MappingsBuilder.TIMESTAMP_FIELD, + MappingsBuilder.EVENT_FIELD); + + private static Pair toEnvAspectGenericDocument( + @Nonnull SearchHit doc) { + EnvelopedAspect envelopedAspect = null; + + Map documentFieldMap = doc.getSourceAsMap(); + + GenericTimeseriesDocument.GenericTimeseriesDocumentBuilder builder = + GenericTimeseriesDocument.builder() + .urn((String) documentFieldMap.get(MappingsBuilder.URN_FIELD)) + .timestampMillis((Long) documentFieldMap.get(MappingsBuilder.TIMESTAMP_MILLIS_FIELD)) + .timestamp((Long) documentFieldMap.get(MappingsBuilder.TIMESTAMP_FIELD)); + + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.RUN_ID_FIELD)) + .ifPresent(d -> builder.runId((String) d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.EVENT_GRANULARITY)) + .ifPresent(d -> builder.eventGranularity((String) d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.IS_EXPLODED_FIELD)) + .ifPresent(d -> builder.isExploded((Boolean) d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.MESSAGE_ID_FIELD)) + .ifPresent(d -> builder.messageId((String) d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.PARTITION_SPEC_PARTITION)) + .ifPresent(d -> builder.partition((String) d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.PARTITION_SPEC)) + .ifPresent(d -> builder.partitionSpec(d)); + Optional.ofNullable(documentFieldMap.get(MappingsBuilder.SYSTEM_METADATA_FIELD)) + .ifPresent(d -> builder.systemMetadata(d)); + + if (documentFieldMap.get(MappingsBuilder.EVENT_FIELD) != null) { + envelopedAspect = parseDocument(doc); + builder.event(documentFieldMap.get(MappingsBuilder.EVENT_FIELD)); + } else { + // If no event, the event is any non-common field + builder.event( + documentFieldMap.entrySet().stream() + .filter(entry -> !commonFields.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + return Pair.of(envelopedAspect, builder.build()); + } + @Override public void configure() { _indexBuilders.reindexAll(); @@ -157,6 +216,12 @@ public List buildReindexConfigs() { return _indexBuilders.buildReindexConfigs(); } + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException { + return _indexBuilders.buildReindexConfigsWithAllStructProps(properties); + } + public String reindexAsync( String index, @Nullable QueryBuilder filterQuery, BatchWriteOperationsOptions options) throws Exception { @@ -256,7 +321,7 @@ public List getAspectValues( if (startTimeMillis != null) { Criterion startTimeCriterion = new Criterion() - .setField(TIMESTAMP_FIELD) + .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) .setValue(startTimeMillis.toString()); filterQueryBuilder.must(ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true)); @@ -264,7 +329,7 @@ public List getAspectValues( if (endTimeMillis != null) { Criterion endTimeCriterion = new Criterion() - .setField(TIMESTAMP_FIELD) + .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) .setValue(endTimeMillis.toString()); filterQueryBuilder.must(ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true)); @@ -421,4 +486,88 @@ public DeleteAspectValuesResult rollbackTimeseriesAspects(@Nonnull String runId) return rollbackResult; } + + @Nonnull + @Override + public TimeseriesScrollResult scrollAspects( + @Nonnull String entityName, + @Nonnull String aspectName, + @Nullable Filter filter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis) { + final BoolQueryBuilder filterQueryBuilder = + QueryBuilders.boolQuery().filter(ESUtils.buildFilterQuery(filter, true)); + + if (startTimeMillis != null) { + Criterion startTimeCriterion = + new Criterion() + .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) + .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) + .setValue(startTimeMillis.toString()); + filterQueryBuilder.filter(ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true)); + } + if (endTimeMillis != null) { + Criterion endTimeCriterion = + new Criterion() + .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) + .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) + .setValue(endTimeMillis.toString()); + filterQueryBuilder.filter(ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true)); + } + + SearchResponse response = + executeScrollSearchQuery( + entityName, aspectName, filterQueryBuilder, sortCriterion, scrollId, count); + int totalCount = (int) response.getHits().getTotalHits().value; + + List> resultPairs = + Arrays.stream(response.getHits().getHits()) + .map(ElasticSearchTimeseriesAspectService::toEnvAspectGenericDocument) + .toList(); + + return TimeseriesScrollResult.builder() + .numResults(totalCount) + .pageSize(response.getHits().getHits().length) + .events(resultPairs.stream().map(Pair::getFirst).collect(Collectors.toList())) + .documents(resultPairs.stream().map(Pair::getSecond).collect(Collectors.toList())) + .build(); + } + + private SearchResponse executeScrollSearchQuery( + @Nonnull final String entityNname, + @Nonnull final String aspectName, + @Nonnull final QueryBuilder query, + @Nonnull List sortCriterion, + @Nullable String scrollId, + final int count) { + + Object[] sort = null; + if (scrollId != null) { + SearchAfterWrapper searchAfterWrapper = SearchAfterWrapper.fromScrollId(scrollId); + sort = searchAfterWrapper.getSort(); + } + + SearchRequest searchRequest = new SearchRequest(); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + + searchSourceBuilder.size(count); + searchSourceBuilder.query(query); + ESUtils.buildSortOrder(searchSourceBuilder, sortCriterion, List.of(), false); + searchRequest.source(searchSourceBuilder); + ESUtils.setSearchAfter(searchSourceBuilder, sort, null, null); + + searchRequest.indices(_indexConvention.getTimeseriesAspectIndexName(entityNname, aspectName)); + + try (Timer.Context ignored = + MetricUtils.timer(this.getClass(), "scrollAspects_search").time()) { + return _searchClient.search(searchRequest, RequestOptions.DEFAULT); + } catch (Exception e) { + log.error("Search query failed", e); + throw new ESQueryException("Search query failed:", e); + } + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/indexbuilder/TimeseriesAspectIndexBuilders.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/indexbuilder/TimeseriesAspectIndexBuilders.java index 564bcb2a242cb9..6437bbc390d829 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/indexbuilder/TimeseriesAspectIndexBuilders.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/indexbuilder/TimeseriesAspectIndexBuilders.java @@ -7,8 +7,10 @@ import com.linkedin.metadata.shared.ElasticSearchIndexed; import com.linkedin.metadata.timeseries.BatchWriteOperationsOptions; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; +import com.linkedin.structured.StructuredPropertyDefinition; import com.linkedin.util.Pair; import java.io.IOException; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -91,4 +93,10 @@ public List buildReindexConfigs() { }) .collect(Collectors.toList()); } + + @Override + public List buildReindexConfigsWithAllStructProps( + Collection properties) throws IOException { + return buildReindexConfigs(); + } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java b/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java index 252ac2d633b98e..451b7327224986 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java @@ -41,7 +41,7 @@ public static Map ingestCorpUserKeyAspects( .aspect(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) - .build(entityService.getEntityRegistry(), entityService.getSystemEntityClient())); + .build(entityService)); } entityService.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); return aspects; @@ -71,7 +71,7 @@ public static Map ingestCorpUserInfoAspects( .aspect(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) - .build(entityService.getEntityRegistry(), entityService.getSystemEntityClient())); + .build(entityService)); } entityService.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); return aspects; @@ -102,7 +102,7 @@ public static Map ingestChartInfoAspects( .aspect(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) - .build(entityService.getEntityRegistry(), entityService.getSystemEntityClient())); + .build(entityService)); } entityService.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); return aspects; diff --git a/metadata-io/src/test/java/com/linkedin/metadata/client/JavaEntityClientTest.java b/metadata-io/src/test/java/com/linkedin/metadata/client/JavaEntityClientTest.java index fba11f24f4c447..5a4443904e2602 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/client/JavaEntityClientTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/client/JavaEntityClientTest.java @@ -6,7 +6,6 @@ import com.codahale.metrics.Counter; import com.linkedin.data.template.RequiredFieldNotPresentException; -import com.linkedin.entity.client.RestliEntityClient; import com.linkedin.metadata.entity.DeleteEntityService; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.event.EventProducer; @@ -14,6 +13,7 @@ import com.linkedin.metadata.search.LineageSearchService; import com.linkedin.metadata.search.SearchService; import com.linkedin.metadata.search.client.CachingEntitySearchService; +import com.linkedin.metadata.service.RollbackService; import com.linkedin.metadata.timeseries.TimeseriesAspectService; import com.linkedin.metadata.utils.metrics.MetricUtils; import java.util.function.Supplier; @@ -32,8 +32,8 @@ public class JavaEntityClientTest { private LineageSearchService _lineageSearchService; private TimeseriesAspectService _timeseriesAspectService; private EventProducer _eventProducer; - private RestliEntityClient _restliEntityClient; private MockedStatic _metricUtils; + private RollbackService rollbackService; private Counter _counter; @BeforeMethod @@ -45,8 +45,8 @@ public void setupTest() { _searchService = mock(SearchService.class); _lineageSearchService = mock(LineageSearchService.class); _timeseriesAspectService = mock(TimeseriesAspectService.class); + rollbackService = mock(RollbackService.class); _eventProducer = mock(EventProducer.class); - _restliEntityClient = mock(RestliEntityClient.class); _metricUtils = mockStatic(MetricUtils.class); _counter = mock(Counter.class); when(MetricUtils.counter(any(), any())).thenReturn(_counter); @@ -66,8 +66,8 @@ private JavaEntityClient getJavaEntityClient() { _searchService, _lineageSearchService, _timeseriesAspectService, - _eventProducer, - _restliEntityClient); + rollbackService, + _eventProducer); } @Test diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java index 45e992576676d6..c45306e5f022bb 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java @@ -124,21 +124,21 @@ public void testIngestListLatestAspects() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) .aspect(writeAspect2) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) .aspect(writeAspect3) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // List aspects @@ -193,21 +193,21 @@ public void testIngestListUrns() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) .aspect(writeAspect2) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) .aspect(writeAspect3) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // List aspects urns @@ -451,13 +451,7 @@ public void run() { auditStamp.setActor(Urn.createFromString(Constants.DATAHUB_ACTOR)); auditStamp.setTime(System.currentTimeMillis()); AspectsBatchImpl batch = - AspectsBatchImpl.builder() - .mcps( - mcps, - auditStamp, - entityService.getEntityRegistry(), - entityService.getSystemEntityClient()) - .build(); + AspectsBatchImpl.builder().mcps(mcps, auditStamp, entityService).build(); entityService.ingestProposal(batch, false); } } catch (InterruptedException | URISyntaxException ie) { diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java index ee21b56cea7c02..db749f3575a064 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java @@ -12,6 +12,7 @@ import com.google.common.collect.ImmutableSet; import com.linkedin.common.AuditStamp; import com.linkedin.common.Status; +import com.linkedin.common.UrnArray; import com.linkedin.common.VersionedUrn; import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.TupleKey; @@ -29,6 +30,7 @@ import com.linkedin.entity.Entity; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.identity.CorpUserInfo; import com.linkedin.metadata.AspectGenerationUtils; @@ -58,6 +60,12 @@ import com.linkedin.retention.DataHubRetentionConfig; import com.linkedin.retention.Retention; import com.linkedin.retention.VersionBasedRetention; +import com.linkedin.structured.PrimitivePropertyValue; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyDefinition; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; import com.linkedin.util.Pair; import jakarta.annotation.Nonnull; import java.util.ArrayList; @@ -67,6 +75,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.junit.Assert; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @@ -847,28 +857,28 @@ public void testRollbackAspect() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) .aspect(writeAspect2) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) .aspect(writeAspect3) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(aspectName) .aspect(writeAspect1Overwrite) .systemMetadata(metadata2) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // this should no-op since this run has been overwritten @@ -926,21 +936,21 @@ public void testRollbackKey() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(keyAspectName) .aspect(writeKey1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(aspectName) .aspect(writeAspect1Overwrite) .systemMetadata(metadata2) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // this should no-op since the key should have been written in the furst run @@ -1006,35 +1016,35 @@ public void testRollbackUrn() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(keyAspectName) .aspect(writeKey1) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) .aspect(writeAspect2) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) .aspect(writeAspect3) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(aspectName) .aspect(writeAspect1Overwrite) .systemMetadata(metadata2) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // this should no-op since the key should have been written in the furst run @@ -1073,7 +1083,7 @@ public void testIngestGetLatestAspect() throws AssertionError { .aspect(writeAspect1) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #1 @@ -1104,7 +1114,7 @@ public void testIngestGetLatestAspect() throws AssertionError { .aspect(writeAspect2) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata2) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #2 @@ -1150,7 +1160,7 @@ public void testIngestGetLatestEnvelopedAspect() throws Exception { .aspect(writeAspect1) .auditStamp(TEST_AUDIT_STAMP) .systemMetadata(metadata1) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #1 @@ -1170,7 +1180,7 @@ public void testIngestGetLatestEnvelopedAspect() throws Exception { .aspect(writeAspect2) .systemMetadata(metadata2) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #2 @@ -1215,7 +1225,7 @@ public void testIngestSameAspect() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #1 @@ -1246,7 +1256,7 @@ public void testIngestSameAspect() throws AssertionError { .aspect(writeAspect2) .systemMetadata(metadata2) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); // Validate retrieval of CorpUserInfo Aspect #2 @@ -1299,42 +1309,42 @@ public void testRetention() throws AssertionError { .aspect(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName) .aspect(writeAspect1a) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName) .aspect(writeAspect1b) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName2) .aspect(writeAspect2) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName2) .aspect(writeAspect2a) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName2) .aspect(writeAspect2b) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); assertEquals(_entityServiceImpl.getAspect(entityUrn, aspectName, 1), writeAspect1); @@ -1366,14 +1376,14 @@ public void testRetention() throws AssertionError { .aspect(writeAspect1c) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient()), + .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(aspectName2) .aspect(writeAspect2c) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) - .build(_testEntityRegistry, _entityServiceImpl.getSystemEntityClient())); + .build(_entityServiceImpl)); _entityServiceImpl.ingestAspects(AspectsBatchImpl.builder().items(items).build(), true, true); assertNull(_entityServiceImpl.getAspect(entityUrn, aspectName, 1)); @@ -1637,6 +1647,172 @@ public void testUIPreProcessedProposal() throws Exception { assertEquals(UI_SOURCE, captor.getValue().getSystemMetadata().getProperties().get(APP_SOURCE)); } + @Test + public void testStructuredPropertyIngestProposal() throws Exception { + String urnStr = "urn:li:dataset:(urn:li:dataPlatform:looker,sample_dataset_unique,PROD)"; + Urn entityUrn = UrnUtils.getUrn(urnStr); + + // Ingest one structured property definition + String definitionAspectName = "propertyDefinition"; + Urn firstPropertyUrn = UrnUtils.getUrn("urn:li:structuredProperty:firstStructuredProperty"); + MetadataChangeProposal gmce = new MetadataChangeProposal(); + gmce.setEntityUrn(firstPropertyUrn); + gmce.setChangeType(ChangeType.UPSERT); + gmce.setEntityType("structuredProperty"); + gmce.setAspectName(definitionAspectName); + StructuredPropertyDefinition structuredPropertyDefinition = + new StructuredPropertyDefinition() + .setQualifiedName("firstStructuredProperty") + .setValueType(Urn.createFromString(DATA_TYPE_URN_PREFIX + "string")) + .setEntityTypes(new UrnArray(Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"))); + JacksonDataTemplateCodec dataTemplateCodec = new JacksonDataTemplateCodec(); + byte[] definitionSerialized = + dataTemplateCodec.dataTemplateToBytes(structuredPropertyDefinition); + GenericAspect genericAspect = new GenericAspect(); + genericAspect.setValue(ByteString.unsafeWrap(definitionSerialized)); + genericAspect.setContentType("application/json"); + gmce.setAspect(genericAspect); + _entityServiceImpl.ingestProposal(gmce, TEST_AUDIT_STAMP, false); + ArgumentCaptor captor = ArgumentCaptor.forClass(MetadataChangeLog.class); + verify(_mockProducer, times(1)) + .produceMetadataChangeLog(Mockito.eq(firstPropertyUrn), Mockito.any(), captor.capture()); + assertEquals( + _entityServiceImpl.getAspect(firstPropertyUrn, definitionAspectName, 0), + structuredPropertyDefinition); + Urn secondPropertyUrn = UrnUtils.getUrn("urn:li:structuredProperty:secondStructuredProperty"); + assertNull(_entityServiceImpl.getAspect(secondPropertyUrn, definitionAspectName, 0)); + assertEquals( + _entityServiceImpl.getAspect(firstPropertyUrn, definitionAspectName, 0), + structuredPropertyDefinition); + Set defs = + _aspectDao + .streamAspects( + STRUCTURED_PROPERTY_ENTITY_NAME, STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME) + .map( + entityAspect -> + EntityUtils.toAspectRecord( + STRUCTURED_PROPERTY_ENTITY_NAME, + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, + entityAspect.getMetadata(), + _testEntityRegistry)) + .map(recordTemplate -> (StructuredPropertyDefinition) recordTemplate) + .collect(Collectors.toSet()); + assertEquals(defs.size(), 1); + assertEquals(defs, Set.of(structuredPropertyDefinition)); + + SystemEntityClient mockSystemEntityClient = Mockito.mock(SystemEntityClient.class); + Mockito.when( + mockSystemEntityClient.getLatestAspectObject(firstPropertyUrn, "propertyDefinition")) + .thenReturn(new com.linkedin.entity.Aspect(structuredPropertyDefinition.data())); + + // Add a value for that property + PrimitivePropertyValueArray propertyValues = new PrimitivePropertyValueArray(); + propertyValues.add(PrimitivePropertyValue.create("hello")); + StructuredPropertyValueAssignment assignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn(firstPropertyUrn) + .setValues(propertyValues); + StructuredProperties structuredProperties = + new StructuredProperties() + .setProperties(new StructuredPropertyValueAssignmentArray(assignment)); + MetadataChangeProposal asgnMce = new MetadataChangeProposal(); + asgnMce.setEntityUrn(entityUrn); + asgnMce.setChangeType(ChangeType.UPSERT); + asgnMce.setEntityType("dataset"); + asgnMce.setAspectName("structuredProperties"); + JacksonDataTemplateCodec asgnTemplateCodec = new JacksonDataTemplateCodec(); + byte[] asgnSerialized = asgnTemplateCodec.dataTemplateToBytes(structuredProperties); + GenericAspect asgnGenericAspect = new GenericAspect(); + asgnGenericAspect.setValue(ByteString.unsafeWrap(asgnSerialized)); + asgnGenericAspect.setContentType("application/json"); + asgnMce.setAspect(asgnGenericAspect); + _entityServiceImpl.ingestProposal(asgnMce, TEST_AUDIT_STAMP, false); + assertEquals( + _entityServiceImpl.getAspect(entityUrn, "structuredProperties", 0), structuredProperties); + + // Ingest second structured property definition + MetadataChangeProposal gmce2 = new MetadataChangeProposal(); + gmce2.setEntityUrn(secondPropertyUrn); + gmce2.setChangeType(ChangeType.UPSERT); + gmce2.setEntityType("structuredProperty"); + gmce2.setAspectName(definitionAspectName); + StructuredPropertyDefinition secondDefinition = + new StructuredPropertyDefinition() + .setQualifiedName("secondStructuredProperty") + .setValueType(Urn.createFromString(DATA_TYPE_URN_PREFIX + "number")) + .setEntityTypes(new UrnArray(Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"))); + JacksonDataTemplateCodec secondDataTemplate = new JacksonDataTemplateCodec(); + byte[] secondDefinitionSerialized = secondDataTemplate.dataTemplateToBytes(secondDefinition); + GenericAspect secondGenericAspect = new GenericAspect(); + secondGenericAspect.setValue(ByteString.unsafeWrap(secondDefinitionSerialized)); + secondGenericAspect.setContentType("application/json"); + gmce2.setAspect(secondGenericAspect); + _entityServiceImpl.ingestProposal(gmce2, TEST_AUDIT_STAMP, false); + ArgumentCaptor secondCaptor = + ArgumentCaptor.forClass(MetadataChangeLog.class); + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(secondPropertyUrn), Mockito.any(), secondCaptor.capture()); + assertEquals( + _entityServiceImpl.getAspect(firstPropertyUrn, definitionAspectName, 0), + structuredPropertyDefinition); + assertEquals( + _entityServiceImpl.getAspect(secondPropertyUrn, definitionAspectName, 0), secondDefinition); + defs = + _aspectDao + .streamAspects( + STRUCTURED_PROPERTY_ENTITY_NAME, STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME) + .map( + entityAspect -> + EntityUtils.toAspectRecord( + STRUCTURED_PROPERTY_ENTITY_NAME, + STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, + entityAspect.getMetadata(), + _testEntityRegistry)) + .map(recordTemplate -> (StructuredPropertyDefinition) recordTemplate) + .collect(Collectors.toSet()); + assertEquals(defs.size(), 2); + assertEquals(defs, Set.of(secondDefinition, structuredPropertyDefinition)); + + Mockito.when( + mockSystemEntityClient.getLatestAspectObject(secondPropertyUrn, "propertyDefinition")) + .thenReturn(new com.linkedin.entity.Aspect(secondDefinition.data())); + + // Get existing value for first structured property + assertEquals( + _entityServiceImpl.getAspect(entityUrn, "structuredProperties", 0), structuredProperties); + + // Add a value for second property + propertyValues = new PrimitivePropertyValueArray(); + propertyValues.add(PrimitivePropertyValue.create(15.0)); + StructuredPropertyValueAssignment secondAssignment = + new StructuredPropertyValueAssignment() + .setPropertyUrn(secondPropertyUrn) + .setValues(propertyValues); + StructuredProperties secondPropertyArr = + new StructuredProperties() + .setProperties( + new StructuredPropertyValueAssignmentArray(assignment, secondAssignment)); + MetadataChangeProposal asgn2Mce = new MetadataChangeProposal(); + asgn2Mce.setEntityUrn(entityUrn); + asgn2Mce.setChangeType(ChangeType.UPSERT); + asgn2Mce.setEntityType("dataset"); + asgn2Mce.setAspectName("structuredProperties"); + JacksonDataTemplateCodec asgnTemplateCodec2 = new JacksonDataTemplateCodec(); + byte[] asgnSerialized2 = asgnTemplateCodec2.dataTemplateToBytes(secondPropertyArr); + GenericAspect asgnGenericAspect2 = new GenericAspect(); + asgnGenericAspect2.setValue(ByteString.unsafeWrap(asgnSerialized2)); + asgnGenericAspect2.setContentType("application/json"); + asgn2Mce.setAspect(asgnGenericAspect2); + _entityServiceImpl.ingestProposal(asgn2Mce, TEST_AUDIT_STAMP, false); + StructuredProperties expectedProperties = + new StructuredProperties() + .setProperties( + new StructuredPropertyValueAssignmentArray(assignment, secondAssignment)); + assertEquals( + _entityServiceImpl.getAspect(entityUrn, "structuredProperties", 0), expectedProperties); + } + @Nonnull protected com.linkedin.entity.Entity createCorpUserEntity(Urn entityUrn, String email) throws Exception { diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/TestEntityRegistry.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/TestEntityRegistry.java index 680d4079851ebe..15852e0cbe35b8 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/TestEntityRegistry.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/TestEntityRegistry.java @@ -1,11 +1,11 @@ package com.linkedin.metadata.entity; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.EntitySpecBuilder; import com.linkedin.metadata.models.EventSpec; import com.linkedin.metadata.models.registry.EntityRegistry; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.metadata.snapshot.Snapshot; import java.util.Collections; import java.util.HashMap; diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java index 2f8fba0083aa70..bd500cd4691008 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java @@ -337,26 +337,26 @@ public void testTimestampLineage() throws Exception { // Without timestamps EntityLineageResult upstreamResult = getUpstreamLineage(datasetTwoUrn, null, null); EntityLineageResult downstreamResult = getDownstreamLineage(datasetTwoUrn, null, null); - Assert.assertEquals(new Integer(1), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(3), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(3), downstreamResult.getTotal()); // Timestamp before upstreamResult = getUpstreamLineage(datasetTwoUrn, 0L, initialTime - 10); downstreamResult = getDownstreamLineage(datasetTwoUrn, 0L, initialTime - 10); - Assert.assertEquals(new Integer(0), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(1), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(0), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), downstreamResult.getTotal()); // Timestamp after upstreamResult = getUpstreamLineage(datasetTwoUrn, initialTime + 10, initialTime + 100); downstreamResult = getDownstreamLineage(datasetTwoUrn, initialTime + 10, initialTime + 100); - Assert.assertEquals(new Integer(0), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(1), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(0), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), downstreamResult.getTotal()); // Timestamp included upstreamResult = getUpstreamLineage(datasetTwoUrn, initialTime - 10, initialTime + 10); downstreamResult = getDownstreamLineage(datasetTwoUrn, initialTime - 10, initialTime + 10); - Assert.assertEquals(new Integer(1), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(3), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(3), downstreamResult.getTotal()); // Update only one of the downstream edges Long updatedTime = 2000L; @@ -387,20 +387,20 @@ public void testTimestampLineage() throws Exception { // Without timestamps upstreamResult = getUpstreamLineage(datasetTwoUrn, null, null); downstreamResult = getDownstreamLineage(datasetTwoUrn, null, null); - Assert.assertEquals(new Integer(1), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(3), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(3), downstreamResult.getTotal()); // Window includes initial time and updated time upstreamResult = getUpstreamLineage(datasetTwoUrn, initialTime - 10, updatedTime + 10); downstreamResult = getDownstreamLineage(datasetTwoUrn, initialTime - 10, updatedTime + 10); - Assert.assertEquals(new Integer(1), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(3), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(3), downstreamResult.getTotal()); // Window includes updated time but not initial time upstreamResult = getUpstreamLineage(datasetTwoUrn, initialTime + 10, updatedTime + 10); downstreamResult = getDownstreamLineage(datasetTwoUrn, initialTime + 10, updatedTime + 10); - Assert.assertEquals(new Integer(1), upstreamResult.getTotal()); - Assert.assertEquals(new Integer(2), downstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(1), upstreamResult.getTotal()); + Assert.assertEquals(Integer.valueOf(2), downstreamResult.getTotal()); } /** diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java index fba9d5359d29f3..d2aef982750bda 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java @@ -7,7 +7,7 @@ import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.generated.EntityType; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.MatchedFieldArray; import com.linkedin.metadata.search.SearchEntityArray; diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/IndexBuilderTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/IndexBuilderTestBase.java index 2c395875a1d6b5..a54e8aa1c9191b 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/IndexBuilderTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/IndexBuilderTestBase.java @@ -1,14 +1,19 @@ package com.linkedin.metadata.search.indexbuilder; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_MAPPING_FIELD; import static org.testng.Assert.*; import com.google.common.collect.ImmutableMap; import com.linkedin.metadata.config.search.ElasticSearchConfiguration; import com.linkedin.metadata.search.elasticsearch.indexbuilder.ESIndexBuilder; +import com.linkedin.metadata.search.elasticsearch.indexbuilder.ReindexConfig; +import com.linkedin.metadata.search.elasticsearch.indexbuilder.SettingsBuilder; +import com.linkedin.metadata.search.utils.ESUtils; import com.linkedin.metadata.systemmetadata.SystemMetadataMappingsBuilder; import com.linkedin.metadata.version.GitVersion; import java.io.IOException; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -295,4 +300,117 @@ public void testSettingsNoReindex() throws Exception { wipe(); } } + + @Test + public void testCopyStructuredPropertyMappings() throws Exception { + GitVersion gitVersion = new GitVersion("0.0.0-test", "123456", Optional.empty()); + ESIndexBuilder enabledMappingReindex = + new ESIndexBuilder( + getSearchClient(), + 1, + 0, + 0, + 0, + Map.of(), + false, + true, + new ElasticSearchConfiguration(), + gitVersion); + + ReindexConfig reindexConfigNoIndexBefore = + enabledMappingReindex.buildReindexState( + TEST_INDEX_NAME, SystemMetadataMappingsBuilder.getMappings(), Map.of()); + assertNull(reindexConfigNoIndexBefore.currentMappings()); + assertEquals( + reindexConfigNoIndexBefore.targetMappings(), SystemMetadataMappingsBuilder.getMappings()); + assertFalse(reindexConfigNoIndexBefore.requiresApplyMappings()); + assertFalse(reindexConfigNoIndexBefore.isPureMappingsAddition()); + + // Create index + enabledMappingReindex.buildIndex( + TEST_INDEX_NAME, SystemMetadataMappingsBuilder.getMappings(), Map.of()); + + // Test build reindex config with no structured properties added + ReindexConfig reindexConfigNoChange = + enabledMappingReindex.buildReindexState( + TEST_INDEX_NAME, SystemMetadataMappingsBuilder.getMappings(), Map.of()); + assertEquals( + reindexConfigNoChange.currentMappings(), SystemMetadataMappingsBuilder.getMappings()); + assertEquals( + reindexConfigNoChange.targetMappings(), SystemMetadataMappingsBuilder.getMappings()); + assertFalse(reindexConfigNoIndexBefore.requiresApplyMappings()); + assertFalse(reindexConfigNoIndexBefore.isPureMappingsAddition()); + + // Test add new field to the mappings + Map targetMappingsNewField = + new HashMap<>(SystemMetadataMappingsBuilder.getMappings()); + ((Map) targetMappingsNewField.get("properties")) + .put("myNewField", Map.of(SettingsBuilder.TYPE, SettingsBuilder.KEYWORD)); + + // Test build reindex config for new fields with no structured properties added + ReindexConfig reindexConfigNewField = + enabledMappingReindex.buildReindexState(TEST_INDEX_NAME, targetMappingsNewField, Map.of()); + assertEquals( + reindexConfigNewField.currentMappings(), SystemMetadataMappingsBuilder.getMappings()); + assertEquals(reindexConfigNewField.targetMappings(), targetMappingsNewField); + assertTrue(reindexConfigNewField.requiresApplyMappings()); + assertTrue(reindexConfigNewField.isPureMappingsAddition()); + + // Add structured properties to index + Map mappingsWithStructuredProperties = + new HashMap<>(SystemMetadataMappingsBuilder.getMappings()); + ((Map) mappingsWithStructuredProperties.get("properties")) + .put( + STRUCTURED_PROPERTY_MAPPING_FIELD + ".myStringProp", + Map.of(SettingsBuilder.TYPE, SettingsBuilder.KEYWORD)); + ((Map) mappingsWithStructuredProperties.get("properties")) + .put( + STRUCTURED_PROPERTY_MAPPING_FIELD + ".myNumberProp", + Map.of(SettingsBuilder.TYPE, ESUtils.DOUBLE_FIELD_TYPE)); + + enabledMappingReindex.buildIndex(TEST_INDEX_NAME, mappingsWithStructuredProperties, Map.of()); + + // Test build reindex config with structured properties not copied + ReindexConfig reindexConfigNoCopy = + enabledMappingReindex.buildReindexState( + TEST_INDEX_NAME, SystemMetadataMappingsBuilder.getMappings(), Map.of()); + Map expectedMappingsStructPropsNested = + new HashMap<>(SystemMetadataMappingsBuilder.getMappings()); + ((Map) expectedMappingsStructPropsNested.get("properties")) + .put( + "structuredProperties", + Map.of( + "properties", + Map.of( + "myNumberProp", + Map.of(SettingsBuilder.TYPE, ESUtils.DOUBLE_FIELD_TYPE), + "myStringProp", + Map.of(SettingsBuilder.TYPE, SettingsBuilder.KEYWORD)))); + assertEquals(reindexConfigNoCopy.currentMappings(), expectedMappingsStructPropsNested); + assertEquals(reindexConfigNoCopy.targetMappings(), SystemMetadataMappingsBuilder.getMappings()); + assertFalse(reindexConfigNoCopy.isPureMappingsAddition()); + + // Test build reindex config with structured properties copied + ReindexConfig reindexConfigCopy = + enabledMappingReindex.buildReindexState( + TEST_INDEX_NAME, SystemMetadataMappingsBuilder.getMappings(), Map.of(), true); + assertEquals(reindexConfigCopy.currentMappings(), expectedMappingsStructPropsNested); + assertEquals(reindexConfigCopy.targetMappings(), expectedMappingsStructPropsNested); + assertFalse(reindexConfigCopy.requiresApplyMappings()); + assertFalse(reindexConfigCopy.isPureMappingsAddition()); + + // Test build reindex config with new field added and structured properties copied + ReindexConfig reindexConfigCopyAndNewField = + enabledMappingReindex.buildReindexState( + TEST_INDEX_NAME, targetMappingsNewField, Map.of(), true); + assertEquals(reindexConfigCopyAndNewField.currentMappings(), expectedMappingsStructPropsNested); + Map targetMappingsNewFieldAndStructProps = + new HashMap<>(expectedMappingsStructPropsNested); + ((Map) targetMappingsNewFieldAndStructProps.get("properties")) + .put("myNewField", Map.of(SettingsBuilder.TYPE, SettingsBuilder.KEYWORD)); + assertEquals( + reindexConfigCopyAndNewField.targetMappings(), targetMappingsNewFieldAndStructProps); + assertTrue(reindexConfigCopyAndNewField.requiresApplyMappings()); + assertTrue(reindexConfigCopyAndNewField.isPureMappingsAddition()); + } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java index 02bd186ccc183f..6df31b35fecde2 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java @@ -1,11 +1,16 @@ package com.linkedin.metadata.search.indexbuilder; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertTrue; +import static com.linkedin.metadata.Constants.*; +import static org.testng.Assert.*; import com.google.common.collect.ImmutableMap; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; import com.linkedin.metadata.TestEntitySpecBuilder; import com.linkedin.metadata.search.elasticsearch.indexbuilder.MappingsBuilder; +import com.linkedin.structured.StructuredPropertyDefinition; +import java.net.URISyntaxException; +import java.util.List; import java.util.Map; import org.testng.annotations.Test; @@ -54,14 +59,6 @@ public void testMappingsBuilder() { Map keyPart3FieldSubfields = (Map) keyPart3Field.get("fields"); assertEquals(keyPart3FieldSubfields.size(), 1); assertTrue(keyPart3FieldSubfields.containsKey("keyword")); - Map customPropertiesField = - (Map) properties.get("customProperties"); - assertEquals(customPropertiesField.get("type"), "keyword"); - assertEquals(customPropertiesField.get("normalizer"), "keyword_normalizer"); - Map customPropertiesFieldSubfields = - (Map) customPropertiesField.get("fields"); - assertEquals(customPropertiesFieldSubfields.size(), 1); - assertTrue(customPropertiesFieldSubfields.containsKey("keyword")); // TEXT Map nestedArrayStringField = (Map) properties.get("nestedArrayStringField"); @@ -81,6 +78,15 @@ public void testMappingsBuilder() { assertEquals(nestedArrayArrayFieldSubfields.size(), 2); assertTrue(nestedArrayArrayFieldSubfields.containsKey("delimited")); assertTrue(nestedArrayArrayFieldSubfields.containsKey("keyword")); + Map customPropertiesField = + (Map) properties.get("customProperties"); + assertEquals(customPropertiesField.get("type"), "keyword"); + assertEquals(customPropertiesField.get("normalizer"), "keyword_normalizer"); + Map customPropertiesFieldSubfields = + (Map) customPropertiesField.get("fields"); + assertEquals(customPropertiesFieldSubfields.size(), 2); + assertTrue(customPropertiesFieldSubfields.containsKey("delimited")); + assertTrue(customPropertiesFieldSubfields.containsKey("keyword")); // TEXT with addToFilters Map textField = (Map) properties.get("textFieldOverride"); @@ -153,4 +159,115 @@ public void testMappingsBuilder() { Map doubleField = (Map) properties.get("doubleField"); assertEquals(doubleField.get("type"), "double"); } + + @Test + public void testGetMappingsWithStructuredProperty() throws URISyntaxException { + // Baseline comparison: Mappings with no structured props + Map resultWithoutStructuredProps = + MappingsBuilder.getMappings(TestEntitySpecBuilder.getSpec()); + + // Test that a structured property that does not apply to the entity does not alter the mappings + StructuredPropertyDefinition structPropNotForThisEntity = + new StructuredPropertyDefinition() + .setQualifiedName("propNotForThis") + .setDisplayName("propNotForThis") + .setEntityTypes(new UrnArray(Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"))) + .setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + Map resultWithOnlyUnrelatedStructuredProp = + MappingsBuilder.getMappings( + TestEntitySpecBuilder.getSpec(), List.of(structPropNotForThisEntity)); + assertEquals(resultWithOnlyUnrelatedStructuredProp, resultWithoutStructuredProps); + + // Test that a structured property that does apply to this entity is included in the mappings + String fqnOfRelatedProp = "propForThis"; + StructuredPropertyDefinition structPropForThisEntity = + new StructuredPropertyDefinition() + .setQualifiedName(fqnOfRelatedProp) + .setDisplayName("propForThis") + .setEntityTypes( + new UrnArray( + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"), + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "testEntity"))) + .setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + Map resultWithOnlyRelatedStructuredProp = + MappingsBuilder.getMappings( + TestEntitySpecBuilder.getSpec(), List.of(structPropForThisEntity)); + assertNotEquals(resultWithOnlyRelatedStructuredProp, resultWithoutStructuredProps); + Map fieldsBefore = + (Map) resultWithoutStructuredProps.get("properties"); + Map fieldsAfter = + (Map) resultWithOnlyRelatedStructuredProp.get("properties"); + assertEquals(fieldsAfter.size(), fieldsBefore.size() + 1); + + Map structProps = (Map) fieldsAfter.get("structuredProperties"); + fieldsAfter = (Map) structProps.get("properties"); + + String newField = + fieldsAfter.keySet().stream() + .filter(field -> !fieldsBefore.containsKey(field)) + .findFirst() + .get(); + assertEquals(newField, fqnOfRelatedProp); + assertEquals( + fieldsAfter.get(newField), + Map.of( + "normalizer", + "keyword_normalizer", + "type", + "keyword", + "fields", + Map.of("keyword", Map.of("type", "keyword")))); + + // Test that only structured properties that apply are included + Map resultWithBothStructuredProps = + MappingsBuilder.getMappings( + TestEntitySpecBuilder.getSpec(), + List.of(structPropForThisEntity, structPropNotForThisEntity)); + assertEquals(resultWithBothStructuredProps, resultWithOnlyRelatedStructuredProp); + } + + @Test + public void testGetMappingsForStructuredProperty() throws URISyntaxException { + StructuredPropertyDefinition testStructProp = + new StructuredPropertyDefinition() + .setQualifiedName("testProp") + .setDisplayName("exampleProp") + .setEntityTypes( + new UrnArray( + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"), + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "testEntity"))) + .setValueType(Urn.createFromString("urn:li:logicalType:STRING")); + Map structuredPropertyFieldMappings = + MappingsBuilder.getMappingsForStructuredProperty(List.of(testStructProp)); + assertEquals(structuredPropertyFieldMappings.size(), 1); + String keyInMap = structuredPropertyFieldMappings.keySet().stream().findFirst().get(); + assertEquals(keyInMap, "testProp"); + Object mappings = structuredPropertyFieldMappings.get(keyInMap); + assertEquals( + mappings, + Map.of( + "type", + "keyword", + "normalizer", + "keyword_normalizer", + "fields", + Map.of("keyword", Map.of("type", "keyword")))); + + StructuredPropertyDefinition propWithNumericType = + new StructuredPropertyDefinition() + .setQualifiedName("testPropNumber") + .setDisplayName("examplePropNumber") + .setEntityTypes( + new UrnArray( + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "dataset"), + Urn.createFromString(ENTITY_TYPE_URN_PREFIX + "testEntity"))) + .setValueType(Urn.createFromString("urn:li:logicalType:NUMBER")); + Map structuredPropertyFieldMappingsNumber = + MappingsBuilder.getMappingsForStructuredProperty(List.of(propWithNumericType)); + assertEquals(structuredPropertyFieldMappingsNumber.size(), 1); + keyInMap = structuredPropertyFieldMappingsNumber.keySet().stream().findFirst().get(); + assertEquals("testPropNumber", keyInMap); + mappings = structuredPropertyFieldMappingsNumber.get(keyInMap); + assertEquals(Map.of("type", "double"), mappings); + } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java index 6269827104faf6..9e8855622ced4b 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java @@ -13,6 +13,7 @@ import java.util.Set; import java.util.stream.Collectors; import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; import org.testng.Assert; import org.testng.annotations.Test; @@ -20,7 +21,6 @@ public class AggregationQueryBuilderTest { @Test public void testGetDefaultAggregationsHasFields() { - SearchableAnnotation annotation = new SearchableAnnotation( "test", @@ -82,7 +82,6 @@ public void testGetDefaultAggregationsFields() { @Test public void testGetSpecificAggregationsHasFields() { - SearchableAnnotation annotation1 = new SearchableAnnotation( "test1", @@ -135,6 +134,100 @@ public void testGetSpecificAggregationsHasFields() { Assert.assertEquals(aggs.size(), 0); } + @Test + public void testAggregateOverStructuredProperty() { + SearchConfiguration config = new SearchConfiguration(); + config.setMaxTermBucketSize(25); + + AggregationQueryBuilder builder = new AggregationQueryBuilder(config, List.of()); + + List aggs = + builder.getAggregations(List.of("structuredProperties.ab.fgh.ten")); + Assert.assertEquals(aggs.size(), 1); + AggregationBuilder aggBuilder = aggs.get(0); + Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder); + TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder; + // Check that field name is sanitized to correct field name + Assert.assertEquals(agg.field(), "structuredProperties.ab_fgh_ten"); + + // Two structured properties + aggs = + builder.getAggregations( + List.of("structuredProperties.ab.fgh.ten", "structuredProperties.hello")); + Assert.assertEquals(aggs.size(), 2); + Assert.assertEquals( + aggs.stream() + .map(aggr -> ((TermsAggregationBuilder) aggr).field()) + .collect(Collectors.toSet()), + Set.of("structuredProperties.ab_fgh_ten", "structuredProperties.hello")); + } + + @Test + public void testAggregateOverFieldsAndStructProp() { + SearchableAnnotation annotation1 = + new SearchableAnnotation( + "test1", + SearchableAnnotation.FieldType.KEYWORD, + true, + true, + false, + false, + Optional.empty(), + Optional.of("Has Test"), + 1.0, + Optional.of("hasTest1"), + Optional.empty(), + Collections.emptyMap(), + Collections.emptyList(), + false); + + SearchableAnnotation annotation2 = + new SearchableAnnotation( + "test2", + SearchableAnnotation.FieldType.KEYWORD, + true, + true, + false, + false, + Optional.of("Test Filter"), + Optional.empty(), + 1.0, + Optional.empty(), + Optional.empty(), + Collections.emptyMap(), + Collections.emptyList(), + false); + + SearchConfiguration config = new SearchConfiguration(); + config.setMaxTermBucketSize(25); + + AggregationQueryBuilder builder = + new AggregationQueryBuilder(config, ImmutableList.of(annotation1, annotation2)); + + // Aggregate over fields and structured properties + List aggs = + builder.getAggregations( + ImmutableList.of( + "test1", + "test2", + "hasTest1", + "structuredProperties.ab.fgh.ten", + "structuredProperties.hello")); + Assert.assertEquals(aggs.size(), 5); + Set facets = + aggs.stream() + .map(aggB -> ((TermsAggregationBuilder) aggB).field()) + .collect(Collectors.toSet()); + Assert.assertEquals( + facets, + ImmutableSet.of( + "test1.keyword", + "test2.keyword", + "hasTest1", + "structuredProperties.ab_fgh_ten", + "structuredProperties.hello")); + } + @Test public void testMissingAggregation() { diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/CustomizedQueryHandlerTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/CustomizedQueryHandlerTest.java index 105ee2652dc30a..47d18fe0d299cd 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/CustomizedQueryHandlerTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/CustomizedQueryHandlerTest.java @@ -1,8 +1,10 @@ package com.linkedin.metadata.search.query.request; +import static com.linkedin.metadata.Constants.*; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; +import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.linkedin.metadata.config.search.CustomConfiguration; @@ -30,6 +32,14 @@ public class CustomizedQueryHandlerTest { static { try { + int maxSize = + Integer.parseInt( + System.getenv() + .getOrDefault(INGESTION_MAX_SERIALIZED_STRING_LENGTH, MAX_JACKSON_STRING_SIZE)); + TEST_MAPPER + .getFactory() + .setStreamReadConstraints( + StreamReadConstraints.builder().maxStringLength(maxSize).build()); CustomConfiguration customConfiguration = new CustomConfiguration(); customConfiguration.setEnabled(true); customConfiguration.setFile("search_config_test.yml"); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchQueryBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchQueryBuilderTest.java index 8cb28d3658ee75..38d630bc302f4e 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchQueryBuilderTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchQueryBuilderTest.java @@ -140,7 +140,8 @@ public void testQueryBuilderFulltext() { "urn.delimited", 7.0f, "textArrayField.delimited", 0.4f, "nestedArrayStringField.delimited", 0.4f, - "wordGramField.delimited", 0.4f)); + "wordGramField.delimited", 0.4f, + "customProperties.delimited", 0.4f)); BoolQueryBuilder boolPrefixQuery = (BoolQueryBuilder) shouldQueries.get(1); assertTrue(boolPrefixQuery.should().size() > 0); @@ -165,7 +166,7 @@ public void testQueryBuilderFulltext() { }) .collect(Collectors.toList()); - assertEquals(prefixFieldWeights.size(), 28); + assertEquals(prefixFieldWeights.size(), 29); List.of( Pair.of("urn", 100.0f), @@ -200,7 +201,7 @@ public void testQueryBuilderStructured() { assertEquals(keywordQuery.queryString(), "testQuery"); assertNull(keywordQuery.analyzer()); Map keywordFields = keywordQuery.fields(); - assertEquals(keywordFields.size(), 21); + assertEquals(keywordFields.size(), 22); assertEquals(keywordFields.get("keyPart1").floatValue(), 10.0f); assertFalse(keywordFields.containsKey("keyPart3")); assertEquals(keywordFields.get("textFieldOverride").floatValue(), 1.0f); @@ -360,7 +361,7 @@ public void testGetStandardFieldsEntitySpec() { public void testGetStandardFields() { Set fieldConfigs = TEST_CUSTOM_BUILDER.getStandardFields(ImmutableList.of(TestEntitySpecBuilder.getSpec())); - assertEquals(fieldConfigs.size(), 21); + assertEquals(fieldConfigs.size(), 22); assertEquals( fieldConfigs.stream().map(SearchFieldConfig::fieldName).collect(Collectors.toSet()), Set.of( @@ -384,7 +385,8 @@ public void testGetStandardFields() { "wordGramField.wordGrams3", "textFieldOverride.delimited", "urn", - "wordGramField.wordGrams2")); + "wordGramField.wordGrams2", + "customProperties.delimited")); // customProperties.delimited Saas only assertEquals( fieldConfigs.stream() @@ -467,9 +469,9 @@ public void testGetStandardFields() { fieldConfigs = TEST_CUSTOM_BUILDER.getStandardFields( ImmutableList.of(TestEntitySpecBuilder.getSpec(), mockEntitySpec)); - // Same 21 from the original entity + newFieldNotInOriginal + 3 word gram fields from the + // Same 22 from the original entity + newFieldNotInOriginal + 3 word gram fields from the // textFieldOverride - assertEquals(fieldConfigs.size(), 26); + assertEquals(fieldConfigs.size(), 27); assertEquals( fieldConfigs.stream().map(SearchFieldConfig::fieldName).collect(Collectors.toSet()), Set.of( @@ -498,7 +500,8 @@ public void testGetStandardFields() { "fieldDoesntExistInOriginal.delimited", "textFieldOverride.wordGrams2", "textFieldOverride.wordGrams3", - "textFieldOverride.wordGrams4")); + "textFieldOverride.wordGrams4", + "customProperties.delimited")); // Field which only exists in first one: Should be the same assertEquals( diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java index 03abd9ffe29d75..980b82194536e6 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java @@ -252,4 +252,75 @@ public void testGetQueryBuilderFromCriterionFieldToExpand() { + "}"; Assert.assertEquals(result.toString(), expected); } + + @Test + public void testGetQueryBuilderFromStructPropEqualsValue() { + + final Criterion singleValueCriterion = + new Criterion() + .setField("structuredProperties.ab.fgh.ten") + .setCondition(Condition.EQUAL) + .setValues(new StringArray(ImmutableList.of("value1"))); + + QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + String expected = + "{\n" + + " \"terms\" : {\n" + + " \"structuredProperties.ab_fgh_ten\" : [\n" + + " \"value1\"\n" + + " ],\n" + + " \"boost\" : 1.0,\n" + + " \"_name\" : \"structuredProperties.ab_fgh_ten\"\n" + + " }\n" + + "}"; + Assert.assertEquals(result.toString(), expected); + } + + @Test + public void testGetQueryBuilderFromStructPropExists() { + final Criterion singleValueCriterion = + new Criterion().setField("structuredProperties.ab.fgh.ten").setCondition(Condition.EXISTS); + + QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + String expected = + "{\n" + + " \"bool\" : {\n" + + " \"must\" : [\n" + + " {\n" + + " \"exists\" : {\n" + + " \"field\" : \"structuredProperties.ab_fgh_ten\",\n" + + " \"boost\" : 1.0\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"adjust_pure_negative\" : true,\n" + + " \"boost\" : 1.0,\n" + + " \"_name\" : \"structuredProperties.ab_fgh_ten\"\n" + + " }\n" + + "}"; + Assert.assertEquals(result.toString(), expected); + + // No diff in the timeseries field case for this condition. + final Criterion timeseriesField = + new Criterion().setField("myTestField").setCondition(Condition.EXISTS); + + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + expected = + "{\n" + + " \"bool\" : {\n" + + " \"must\" : [\n" + + " {\n" + + " \"exists\" : {\n" + + " \"field\" : \"myTestField\",\n" + + " \"boost\" : 1.0\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"adjust_pure_negative\" : true,\n" + + " \"boost\" : 1.0,\n" + + " \"_name\" : \"myTestField\"\n" + + " }\n" + + "}"; + Assert.assertEquals(result.toString(), expected); + } } diff --git a/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java b/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java index 58ea020e425657..a22a774065852f 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java +++ b/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java @@ -10,9 +10,9 @@ import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.FilterOperator; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.datahub.graphql.types.SearchableEntityType; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.graph.LineageDirection; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.search.LineageSearchResult; diff --git a/metadata-io/src/test/resources/forms/form_assignment_test_definition_complex.json b/metadata-io/src/test/resources/forms/form_assignment_test_definition_complex.json new file mode 100644 index 00000000000000..e68cbbd9aeff03 --- /dev/null +++ b/metadata-io/src/test/resources/forms/form_assignment_test_definition_complex.json @@ -0,0 +1,145 @@ +{ + "on": { + "types": ["dataset", "container", "dataJob", "dataFlow", "chart", "dashboard"], + "conditions": { + "or": [ + { + "or": [ + { + "property": "forms.incompleteForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + }, + { + "property": "forms.completedForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + } + ] + }, + { + "or": [ + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:hive"] + }, + { + "property": "container.container", + "operator": "equals", + "values": ["urn:li:container:test"] + }, + { + "property": "entityType", + "operator": "equals", + "values": ["dataset"] + }, + { + "property": "domains.domains", + "operator": "equals", + "values": ["urn:li:domain:test"] + } + ] + }, + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:snowflake"] + }, + { + "property": "container.container", + "operator": "equals", + "values": ["urn:li:container:test-2"] + }, + { + "property": "entityType", + "operator": "equals", + "values": ["dashboard"] + }, + { + "property": "domains.domains", + "operator": "equals", + "values": ["urn:li:domain:test-2"] + } + ] + } + ] + } + ] + } + }, + "rules": { + "or": [ + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:hive"] + }, + { + "property": "container.container", + "operator": "equals", + "values": ["urn:li:container:test"] + }, + { + "property": "entityType", + "operator": "equals", + "values": ["dataset"] + }, + { + "property": "domains.domains", + "operator": "equals", + "values": ["urn:li:domain:test"] + } + ] + }, + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:snowflake"] + }, + { + "property": "container.container", + "operator": "equals", + "values": ["urn:li:container:test-2"] + }, + { + "property": "entityType", + "operator": "equals", + "values": ["dashboard"] + }, + { + "property": "domains.domains", + "operator": "equals", + "values": ["urn:li:domain:test-2"] + } + ] + } + ] + }, + "actions": { + "passing": [ + { + "type": "ASSIGN_FORM", + "params": { + "formUrn": "urn:li:form:test" + } + } + ], + "failing": [ + { + "type": "UNASSIGN_FORM", + "params": { + "formUrn": "urn:li:form:test" + } + } + ] + } +} \ No newline at end of file diff --git a/metadata-io/src/test/resources/forms/form_assignment_test_definition_simple.json b/metadata-io/src/test/resources/forms/form_assignment_test_definition_simple.json new file mode 100644 index 00000000000000..a09fbc801414cf --- /dev/null +++ b/metadata-io/src/test/resources/forms/form_assignment_test_definition_simple.json @@ -0,0 +1,67 @@ +{ + "on": { + "types": ["dataset", "container", "dataJob", "dataFlow", "chart", "dashboard"], + "conditions": { + "or": [ + { + "or": [ + { + "property": "forms.incompleteForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + }, + { + "property": "forms.completedForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + } + ] + }, + { + "or": [ + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:hive"] + } + ] + } + ] + } + ] + } + }, + "rules": { + "or": [ + { + "and": [ + { + "property": "dataPlatformInstance.platform", + "operator": "equals", + "values": ["urn:li:dataPlatform:hive"] + } + ] + } + ] + }, + "actions": { + "passing": [ + { + "type": "ASSIGN_FORM", + "params": { + "formUrn": "urn:li:form:test" + } + } + ], + "failing": [ + { + "type": "UNASSIGN_FORM", + "params": { + "formUrn": "urn:li:form:test" + } + } + ] + } +} \ No newline at end of file diff --git a/metadata-io/src/test/resources/forms/form_prompt_test_definition.json b/metadata-io/src/test/resources/forms/form_prompt_test_definition.json new file mode 100644 index 00000000000000..d797db7e251805 --- /dev/null +++ b/metadata-io/src/test/resources/forms/form_prompt_test_definition.json @@ -0,0 +1,39 @@ +{ + "on": { + "types": ["dataset", "container", "dataJob", "dataFlow", "chart", "dashboard"], + "conditions": { + "or": [ + { + "property": "forms.incompleteForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + }, + { + "property": "forms.completedForms.urn", + "operator": "equals", + "values": ["urn:li:form:test"] + } + ] + } + }, + "rules": { + "and": [ + { + "property": "structuredProperties.urn:li:structuredProperty:test.id", + "operator": "exists" + } + ] + }, + "actions": { + "passing": [], + "failing": [ + { + "type": "SET_FORM_PROMPT_INCOMPLETE", + "params": { + "formUrn": "urn:li:form:test", + "formPromptId": "test-id" + } + } + ] + } +} \ No newline at end of file diff --git a/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java b/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java index e695788e09726c..ae208c053d69fb 100644 --- a/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java +++ b/metadata-jobs/mae-consumer-job/src/main/java/com/linkedin/metadata/kafka/MaeConsumerApplication.java @@ -14,14 +14,14 @@ exclude = {ElasticsearchRestClientAutoConfiguration.class, CassandraAutoConfiguration.class}) @ComponentScan( basePackages = { - // "com.linkedin.gms.factory.config", - // "com.linkedin.gms.factory.common", "com.linkedin.gms.factory.kafka", "com.linkedin.metadata.boot.kafka", "com.linkedin.metadata.kafka", "com.linkedin.metadata.dao.producer", "com.linkedin.gms.factory.config", "com.linkedin.gms.factory.entity.update.indices", + "com.linkedin.gms.factory.entityclient", + "com.linkedin.gms.factory.form", "com.linkedin.gms.factory.timeline.eventgenerator", "io.datahubproject.metadata.jobs.common.health.kafka" }, diff --git a/metadata-jobs/mae-consumer-job/src/main/resources/application.properties b/metadata-jobs/mae-consumer-job/src/main/resources/application.properties index 7df61c93ab66d4..f8b979e6fbac06 100644 --- a/metadata-jobs/mae-consumer-job/src/main/resources/application.properties +++ b/metadata-jobs/mae-consumer-job/src/main/resources/application.properties @@ -3,4 +3,4 @@ management.endpoints.web.exposure.include=metrics, health, info spring.mvc.servlet.path=/ management.health.elasticsearch.enabled=false management.health.neo4j.enabled=false -entityClient.preferredImpl=restli +entityClient.impl=restli diff --git a/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTestConfiguration.java b/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTestConfiguration.java index 7135e4e44d4593..b409a41600bd70 100644 --- a/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTestConfiguration.java +++ b/metadata-jobs/mae-consumer-job/src/test/java/com/linkedin/metadata/kafka/MaeConsumerApplicationTestConfiguration.java @@ -1,6 +1,5 @@ package com.linkedin.metadata.kafka; -import com.linkedin.entity.client.SystemRestliEntityClient; import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; import com.linkedin.metadata.dao.producer.KafkaHealthChecker; import com.linkedin.metadata.entity.EntityServiceImpl; @@ -22,8 +21,6 @@ public class MaeConsumerApplicationTestConfiguration { @MockBean private EntityServiceImpl _entityServiceImpl; - @MockBean private SystemRestliEntityClient restliEntityClient; - @MockBean private Database ebeanServer; @MockBean private EntityRegistry entityRegistry; diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeLogProcessor.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeLogProcessor.java index f2eeef6e2c8e62..278c52030b5fc0 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeLogProcessor.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeLogProcessor.java @@ -9,6 +9,7 @@ import com.linkedin.metadata.kafka.hook.MetadataChangeLogHook; import com.linkedin.metadata.kafka.hook.UpdateIndicesHook; import com.linkedin.metadata.kafka.hook.event.EntityChangeEventGeneratorHook; +import com.linkedin.metadata.kafka.hook.form.FormAssignmentHook; import com.linkedin.metadata.kafka.hook.ingestion.IngestionSchedulerHook; import com.linkedin.metadata.kafka.hook.siblings.SiblingAssociationHook; import com.linkedin.metadata.utils.metrics.MetricUtils; @@ -36,7 +37,8 @@ IngestionSchedulerHook.class, EntityChangeEventGeneratorHook.class, KafkaEventConsumerFactory.class, - SiblingAssociationHook.class + SiblingAssociationHook.class, + FormAssignmentHook.class }) @EnableKafka public class MetadataChangeLogProcessor { @@ -95,6 +97,7 @@ public void consume(final ConsumerRecord consumerRecord) // Here - plug in additional "custom processor hooks" for (MetadataChangeLogHook hook : this.hooks) { if (!hook.isEnabled()) { + log.debug(String.format("Skipping disabled hook %s", hook.getClass())); continue; } try (Timer.Context ignored = @@ -102,7 +105,7 @@ public void consume(final ConsumerRecord consumerRecord) .time()) { hook.invoke(event); } catch (Exception e) { - // Just skip this hook and continue. - Note that this represents "at most once" + // Just skip this hook and continue. - Note that this represents "at most once"// // processing. MetricUtils.counter(this.getClass(), hook.getClass().getSimpleName() + "_failure").inc(); log.error( diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/config/EntityHydratorConfig.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/config/EntityHydratorConfig.java index 036968f9f67599..d8a959c0be624e 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/config/EntityHydratorConfig.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/config/EntityHydratorConfig.java @@ -1,23 +1,17 @@ package com.linkedin.metadata.kafka.config; import com.google.common.collect.ImmutableSet; -import com.linkedin.entity.client.SystemRestliEntityClient; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.kafka.hydrator.EntityHydrator; import com.linkedin.metadata.models.registry.EntityRegistry; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; @Configuration -@Import({RestliEntityClientFactory.class}) public class EntityHydratorConfig { - @Autowired - @Qualifier("systemRestliEntityClient") - private SystemRestliEntityClient _entityClient; + @Autowired private SystemEntityClient entityClient; @Autowired private EntityRegistry _entityRegistry; @@ -34,6 +28,6 @@ public class EntityHydratorConfig { @Bean public EntityHydrator getEntityHydrator() { - return new EntityHydrator(_entityRegistry, _entityClient); + return new EntityHydrator(_entityRegistry, entityClient); } } diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHook.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHook.java index f3b5a09708ceed..375d1580dab51e 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHook.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHook.java @@ -6,8 +6,7 @@ import com.linkedin.data.DataMap; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.SetMode; -import com.linkedin.entity.client.SystemRestliEntityClient; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.entityregistry.EntityRegistryFactory; import com.linkedin.metadata.Constants; import com.linkedin.metadata.kafka.hook.MetadataChangeLogHook; @@ -43,7 +42,7 @@ */ @Slf4j @Component -@Import({EntityRegistryFactory.class, RestliEntityClientFactory.class}) +@Import({EntityRegistryFactory.class}) public class EntityChangeEventGeneratorHook implements MetadataChangeLogHook { /** The list of aspects that are supported for generating semantic change events. */ @@ -78,7 +77,7 @@ public class EntityChangeEventGeneratorHook implements MetadataChangeLogHook { ImmutableSet.of("CREATE", "UPSERT", "DELETE"); private final EntityChangeEventGeneratorRegistry _entityChangeEventGeneratorRegistry; - private final SystemRestliEntityClient _entityClient; + private final SystemEntityClient _entityClient; private final EntityRegistry _entityRegistry; private final Boolean _isEnabled; @@ -86,7 +85,7 @@ public class EntityChangeEventGeneratorHook implements MetadataChangeLogHook { public EntityChangeEventGeneratorHook( @Nonnull @Qualifier("entityChangeEventGeneratorRegistry") final EntityChangeEventGeneratorRegistry entityChangeEventGeneratorRegistry, - @Nonnull final SystemRestliEntityClient entityClient, + @Nonnull final SystemEntityClient entityClient, @Nonnull final EntityRegistry entityRegistry, @Nonnull @Value("${entityChangeEvents.enabled:true}") Boolean isEnabled) { _entityChangeEventGeneratorRegistry = diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/form/FormAssignmentHook.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/form/FormAssignmentHook.java new file mode 100644 index 00000000000000..91e8e186b07f71 --- /dev/null +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/form/FormAssignmentHook.java @@ -0,0 +1,130 @@ +package com.linkedin.metadata.kafka.hook.form; + +import static com.linkedin.metadata.Constants.*; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; +import com.linkedin.gms.factory.form.FormServiceFactory; +import com.linkedin.metadata.kafka.hook.MetadataChangeLogHook; +import com.linkedin.metadata.service.FormService; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeLog; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Component; + +/** + * This hook is used for assigning / un-assigning forms for specific entities. + * + *

Specifically, this hook performs the following operations: + * + *

1. When a new dynamic form assignment is created, an automation (metadata test) with the form + * urn embedded is automatically generated, which is responsible for assigning the form to any + * entities in the target set. It also will attempt a removal of the form for any failing entities. + * + *

2. When a new form is created, or an existing one updated, automations (metadata tests) will + * be generated for each prompt in the metadata test which verifies that the entities with that test + * associated with it are complying with the prompt. When they are NOT, the test will mark the + * prompts as incomplete. + * + *

3. When a form is hard deleted, any automations used for assigning the form, or validating + * prompts, are automatically deleted. + * + *

Note that currently, Datasets, Dashboards, Charts, Data Jobs, Data Flows, Containers, are the + * only asset types supported for this hook. + * + *

TODO: In the future, let's decide whether we want to support automations to auto-mark form + * prompts as "completed" when they do in fact have the correct metadata. (Without user needing to + * explicitly fill out a form prompt response) + * + *

TODO: Write a unit test for this class. + */ +@Slf4j +@Component +@Singleton +@Import({FormServiceFactory.class, SystemAuthenticationFactory.class}) +public class FormAssignmentHook implements MetadataChangeLogHook { + + private static final Set SUPPORTED_UPDATE_TYPES = + ImmutableSet.of(ChangeType.UPSERT, ChangeType.CREATE, ChangeType.RESTATE); + + private final FormService _formService; + private final boolean _isEnabled; + + @Autowired + public FormAssignmentHook( + @Nonnull final FormService formService, + @Nonnull @Value("${forms.hook.enabled:true}") Boolean isEnabled) { + _formService = Objects.requireNonNull(formService, "formService is required"); + _isEnabled = isEnabled; + } + + @Override + public void init() {} + + @Override + public boolean isEnabled() { + return _isEnabled; + } + + @Override + public void invoke(@Nonnull final MetadataChangeLog event) { + if (_isEnabled && isEligibleForProcessing(event)) { + if (isFormDynamicFilterUpdated(event)) { + handleFormFilterUpdated(event); + } + } + } + + /** Handle an form filter update by adding updating the targeting automation for it. */ + private void handleFormFilterUpdated(@Nonnull final MetadataChangeLog event) { + // 1. Get the new form assignment + DynamicFormAssignment formFilters = + GenericRecordUtils.deserializeAspect( + event.getAspect().getValue(), + event.getAspect().getContentType(), + DynamicFormAssignment.class); + + // 2. Register a automation to assign it. + _formService.upsertFormAssignmentRunner(event.getEntityUrn(), formFilters); + } + + /** + * Returns true if the event should be processed, which is only true if the change is on the + * incident status aspect + */ + private boolean isEligibleForProcessing(@Nonnull final MetadataChangeLog event) { + return isFormPromptSetUpdated(event) + || isFormDynamicFilterUpdated(event) + || isFormDeleted(event); + } + + /** Returns true if an form is being hard-deleted. */ + private boolean isFormDeleted(@Nonnull final MetadataChangeLog event) { + return FORM_ENTITY_NAME.equals(event.getEntityType()) + && ChangeType.DELETE.equals(event.getChangeType()) + && FORM_KEY_ASPECT_NAME.equals(event.getAspectName()); + } + + /** Returns true if the event represents an update the prompt set of a form. */ + private boolean isFormPromptSetUpdated(@Nonnull final MetadataChangeLog event) { + return FORM_ENTITY_NAME.equals(event.getEntityType()) + && SUPPORTED_UPDATE_TYPES.contains(event.getChangeType()) + && FORM_INFO_ASPECT_NAME.equals(event.getAspectName()); + } + + /** Returns true if the event represents an update to the dynamic filter for a form. */ + private boolean isFormDynamicFilterUpdated(@Nonnull final MetadataChangeLog event) { + return FORM_ENTITY_NAME.equals(event.getEntityType()) + && SUPPORTED_UPDATE_TYPES.contains(event.getChangeType()) + && DYNAMIC_FORM_ASSIGNMENT_ASPECT_NAME.equals(event.getAspectName()); + } +} diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHook.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHook.java index 67198d13772a3e..7a1aaa7f6a0561 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHook.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHook.java @@ -14,9 +14,9 @@ import com.linkedin.dataset.UpstreamArray; import com.linkedin.dataset.UpstreamLineage; import com.linkedin.entity.EntityResponse; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; +import com.linkedin.gms.factory.entityclient.RestliEntityClientFactory; import com.linkedin.gms.factory.entityregistry.EntityRegistryFactory; import com.linkedin.gms.factory.search.EntitySearchServiceFactory; import com.linkedin.metadata.Constants; @@ -72,14 +72,14 @@ public class SiblingAssociationHook implements MetadataChangeLogHook { public static final String SOURCE_SUBTYPE_V2 = "Source"; private final EntityRegistry _entityRegistry; - private final SystemRestliEntityClient _entityClient; + private final SystemEntityClient _entityClient; private final EntitySearchService _searchService; private final boolean _isEnabled; @Autowired public SiblingAssociationHook( @Nonnull final EntityRegistry entityRegistry, - @Nonnull final SystemRestliEntityClient entityClient, + @Nonnull final SystemEntityClient entityClient, @Nonnull final EntitySearchService searchService, @Nonnull @Value("${siblings.enabled:true}") Boolean isEnabled) { _entityRegistry = entityRegistry; diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hydrator/EntityHydrator.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hydrator/EntityHydrator.java index 7a8fdd11fac431..6ad7cdbcad3e60 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hydrator/EntityHydrator.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hydrator/EntityHydrator.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.linkedin.common.urn.Urn; import com.linkedin.entity.EntityResponse; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.r2.RemoteInvocationException; @@ -24,7 +24,7 @@ public class EntityHydrator { private final EntityRegistry _entityRegistry; - private final SystemRestliEntityClient _entityClient; + private final SystemEntityClient entityClient; private final ChartHydrator _chartHydrator = new ChartHydrator(); private final CorpUserHydrator _corpUserHydrator = new CorpUserHydrator(); private final DashboardHydrator _dashboardHydrator = new DashboardHydrator(); @@ -55,7 +55,7 @@ public Optional getHydratedEntity(String entityTypeName, String urn) .collect(Collectors.toSet())) .orElse(Set.of()); entityResponse = - _entityClient.batchGetV2(Collections.singleton(urnObj), aspectNames).get(urnObj); + entityClient.batchGetV2(Collections.singleton(urnObj), aspectNames).get(urnObj); } catch (RemoteInvocationException | URISyntaxException e) { log.error("Error while calling GMS to hydrate entity for urn {}", urn); return Optional.empty(); diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/UpdateIndicesHookTest.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/UpdateIndicesHookTest.java index a227668e22e9b4..89ad6105be9cb0 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/UpdateIndicesHookTest.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/UpdateIndicesHookTest.java @@ -28,6 +28,7 @@ import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.Constants; import com.linkedin.metadata.boot.kafka.DataHubUpgradeKafkaListener; +import com.linkedin.metadata.client.EntityClientAspectRetriever; import com.linkedin.metadata.config.SystemUpdateConfiguration; import com.linkedin.metadata.config.search.ElasticSearchConfiguration; import com.linkedin.metadata.graph.Edge; @@ -121,9 +122,10 @@ public void setupTest() { _mockEntitySearchService, _mockTimeseriesAspectService, _mockSystemMetadataService, - ENTITY_REGISTRY, _searchDocumentTransformer, _mockEntityIndexBuilders); + _updateIndicesService.initializeAspectRetriever( + EntityClientAspectRetriever.builder().entityRegistry(ENTITY_REGISTRY).build()); _updateIndicesHook = new UpdateIndicesHook(_updateIndicesService, true); } @@ -198,9 +200,10 @@ public void testInputFieldsEdgesAreAdded() throws Exception { _mockEntitySearchService, _mockTimeseriesAspectService, _mockSystemMetadataService, - mockEntityRegistry, _searchDocumentTransformer, _mockEntityIndexBuilders); + _updateIndicesService.initializeAspectRetriever( + EntityClientAspectRetriever.builder().entityRegistry(mockEntityRegistry).build()); _updateIndicesHook = new UpdateIndicesHook(_updateIndicesService, true); _updateIndicesHook.invoke(event); diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java index 8400e19ce49a3b..021186404b2cb2 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java @@ -41,7 +41,7 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.DatasetKey; @@ -93,14 +93,14 @@ public class EntityChangeEventGeneratorHookTest { private static final String TEST_DATA_JOB_URN = "urn:li:dataJob:job"; private Urn actorUrn; - private SystemRestliEntityClient _mockClient; + private SystemEntityClient _mockClient; private EntityService _mockEntityService; private EntityChangeEventGeneratorHook _entityChangeEventHook; @BeforeMethod public void setupTest() throws URISyntaxException { actorUrn = Urn.createFromString(TEST_ACTOR_URN); - _mockClient = Mockito.mock(SystemRestliEntityClient.class); + _mockClient = Mockito.mock(SystemEntityClient.class); _mockEntityService = Mockito.mock(EntityService.class); EntityChangeEventGeneratorRegistry entityChangeEventGeneratorRegistry = createEntityChangeEventGeneratorRegistry(); @@ -776,12 +776,12 @@ private EntityRegistry createMockEntityRegistry() { } private void verifyProducePlatformEvent( - SystemRestliEntityClient mockClient, PlatformEvent platformEvent) throws Exception { + SystemEntityClient mockClient, PlatformEvent platformEvent) throws Exception { verifyProducePlatformEvent(mockClient, platformEvent, true); } private void verifyProducePlatformEvent( - SystemRestliEntityClient mockClient, PlatformEvent platformEvent, boolean noMoreInteractions) + SystemEntityClient mockClient, PlatformEvent platformEvent, boolean noMoreInteractions) throws Exception { // Verify event has been emitted. verify(mockClient, Mockito.times(1)) diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHookTest.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHookTest.java index d4c6d122a66899..3823668adeace9 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHookTest.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/siblings/SiblingAssociationHookTest.java @@ -21,7 +21,7 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.key.DatasetKey; import com.linkedin.metadata.models.registry.ConfigEntityRegistry; @@ -41,7 +41,7 @@ public class SiblingAssociationHookTest { private SiblingAssociationHook _siblingAssociationHook; - SystemRestliEntityClient _mockEntityClient; + SystemEntityClient _mockEntityClient; EntitySearchService _mockSearchService; @BeforeMethod @@ -51,7 +51,7 @@ public void setupTest() { SiblingAssociationHookTest.class .getClassLoader() .getResourceAsStream("test-entity-registry-siblings.yml")); - _mockEntityClient = Mockito.mock(SystemRestliEntityClient.class); + _mockEntityClient = Mockito.mock(SystemEntityClient.class); _mockSearchService = Mockito.mock(EntitySearchService.class); _siblingAssociationHook = new SiblingAssociationHook(registry, _mockEntityClient, _mockSearchService, true); diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/spring/MCLSpringTestConfiguration.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/spring/MCLSpringTestConfiguration.java index 44b2ce54e19c85..fc47679bebd395 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/spring/MCLSpringTestConfiguration.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/spring/MCLSpringTestConfiguration.java @@ -1,8 +1,11 @@ package com.linkedin.metadata.kafka.hook.spring; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import com.datahub.authentication.Authentication; import com.datahub.metadata.ingestion.IngestionScheduler; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.kafka.schemaregistry.SchemaRegistryConfig; import com.linkedin.metadata.boot.kafka.DataHubUpgradeKafkaListener; import com.linkedin.metadata.graph.elastic.ElasticSearchGraphService; @@ -14,7 +17,9 @@ import com.linkedin.metadata.systemmetadata.SystemMetadataService; import com.linkedin.metadata.timeseries.TimeseriesAspectService; import org.apache.avro.generic.GenericRecord; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.core.DefaultKafkaConsumerFactory; @@ -40,12 +45,18 @@ public class MCLSpringTestConfiguration { @MockBean public IngestionScheduler ingestionScheduler; - @MockBean(name = "systemRestliEntityClient") - public SystemRestliEntityClient entityClient; + @Bean + public SystemEntityClient systemEntityClient( + @Qualifier("systemAuthentication") Authentication systemAuthentication) { + SystemEntityClient systemEntityClient = mock(SystemEntityClient.class); + when(systemEntityClient.getSystemAuthentication()).thenReturn(systemAuthentication); + return systemEntityClient; + } @MockBean public ElasticSearchService searchService; - @MockBean public Authentication systemAuthentication; + @MockBean(name = "systemAuthentication") + public Authentication systemAuthentication; @MockBean(name = "dataHubUpgradeKafkaListener") public DataHubUpgradeKafkaListener dataHubUpgradeKafkaListener; diff --git a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java index 181a723e1cd25b..1210bf37059b43 100644 --- a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java +++ b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/kafka/MceConsumerApplication.java @@ -1,6 +1,5 @@ package com.linkedin.metadata.kafka; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; import com.linkedin.gms.factory.telemetry.ScheduledAnalyticsFactory; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import org.springframework.boot.SpringApplication; @@ -22,6 +21,7 @@ "com.linkedin.gms.factory.config", "com.linkedin.gms.factory.entity", "com.linkedin.gms.factory.entityregistry", + "com.linkedin.gms.factory.entityclient", "com.linkedin.gms.factory.kafka", "com.linkedin.gms.factory.search", "com.linkedin.gms.factory.secret", @@ -30,12 +30,14 @@ "com.linkedin.metadata.restli", "com.linkedin.metadata.kafka", "com.linkedin.metadata.dao.producer", + "com.linkedin.gms.factory.form", + "com.linkedin.metadata.dao.producer", "io.datahubproject.metadata.jobs.common.health.kafka" }, excludeFilters = { @ComponentScan.Filter( type = FilterType.ASSIGNABLE_TYPE, - classes = {ScheduledAnalyticsFactory.class, RestliEntityClientFactory.class}) + classes = {ScheduledAnalyticsFactory.class}) }) @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class MceConsumerApplication { diff --git a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java index a4747c72c20fa0..b41e6bc75af19c 100644 --- a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java +++ b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java @@ -1,12 +1,8 @@ package com.linkedin.metadata.restli; import com.datahub.auth.authentication.filter.AuthenticationFilter; -import com.linkedin.entity.client.RestliEntityClient; import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; -import com.linkedin.parseq.retry.backoff.ExponentialBackoff; -import com.linkedin.restli.client.Client; import com.linkedin.restli.server.RestliHandlerServlet; -import java.net.URI; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -14,7 +10,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Primary; @Configuration @Import({SystemAuthenticationFactory.class}) @@ -29,14 +24,6 @@ public class RestliServletConfig { @Value("${entityClient.numRetries:3}") private int numRetries; - @Bean("restliEntityClient") - @Primary - public RestliEntityClient restliEntityClient() { - String selfUri = String.format("http://localhost:%s/gms/", configuredPort); - final Client restClient = DefaultRestliClientFactory.getRestLiClient(URI.create(selfUri), null); - return new RestliEntityClient(restClient, new ExponentialBackoff(retryInterval), numRetries); - } - @Bean("restliServletRegistration") public ServletRegistrationBean restliServletRegistration( RestliHandlerServlet servlet) { diff --git a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java index 6d19db97fb39f0..bce8664689e2c5 100644 --- a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java +++ b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTest.java @@ -22,7 +22,7 @@ public class MceConsumerApplicationTest extends AbstractTestNGSpringContextTests @Autowired private TestRestTemplate restTemplate; - @Autowired private EntityService _mockEntityService; + @Autowired private EntityService _mockEntityService; @Autowired private KafkaHealthIndicator kafkaHealthIndicator; diff --git a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTestConfiguration.java b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTestConfiguration.java index 1a44265c7a92a8..93a6ae8fb47970 100644 --- a/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTestConfiguration.java +++ b/metadata-jobs/mce-consumer-job/src/test/java/com/linkedin/metadata/kafka/MceConsumerApplicationTestConfiguration.java @@ -1,7 +1,10 @@ package com.linkedin.metadata.kafka; -import com.linkedin.entity.client.RestliEntityClient; +import com.datahub.authentication.Authentication; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.entity.client.SystemRestliEntityClient; import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; +import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.dao.producer.KafkaHealthChecker; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.SiblingGraphService; @@ -15,6 +18,7 @@ import io.ebean.Database; import java.net.URI; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -30,14 +34,21 @@ public class MceConsumerApplicationTestConfiguration { @MockBean public KafkaHealthChecker kafkaHealthChecker; - @MockBean public EntityService _entityService; + @MockBean public EntityService _entityService; - @Bean("restliEntityClient") + @Bean @Primary - public RestliEntityClient restliEntityClient() { + public SystemEntityClient systemEntityClient( + @Qualifier("configurationProvider") final ConfigurationProvider configurationProvider, + @Qualifier("systemAuthentication") final Authentication systemAuthentication) { String selfUri = restTemplate.getRootUri(); final Client restClient = DefaultRestliClientFactory.getRestLiClient(URI.create(selfUri), null); - return new RestliEntityClient(restClient, new ExponentialBackoff(1), 1); + return new SystemRestliEntityClient( + restClient, + new ExponentialBackoff(1), + 1, + systemAuthentication, + configurationProvider.getCache().getClient().getEntityClient()); } @MockBean public Database ebeanServer; diff --git a/metadata-jobs/mce-consumer/build.gradle b/metadata-jobs/mce-consumer/build.gradle index 5fa65c06de7149..49604924acb686 100644 --- a/metadata-jobs/mce-consumer/build.gradle +++ b/metadata-jobs/mce-consumer/build.gradle @@ -53,4 +53,4 @@ processResources.dependsOn avroSchemaSources clean { project.delete("src/main/resources/avro") -} \ No newline at end of file +} diff --git a/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeEventsProcessor.java b/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeEventsProcessor.java index e22a8ba8137045..352fa93f56a040 100644 --- a/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeEventsProcessor.java +++ b/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeEventsProcessor.java @@ -5,8 +5,8 @@ import com.codahale.metrics.Timer; import com.datahub.authentication.Authentication; import com.linkedin.entity.Entity; -import com.linkedin.entity.client.SystemRestliEntityClient; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.gms.factory.entityclient.RestliEntityClientFactory; import com.linkedin.gms.factory.kafka.DataHubKafkaProducerFactory; import com.linkedin.gms.factory.kafka.KafkaEventConsumerFactory; import com.linkedin.metadata.EventUtils; @@ -48,7 +48,7 @@ public class MetadataChangeEventsProcessor { @NonNull private final Authentication systemAuthentication; - private final SystemRestliEntityClient entityClient; + private final SystemEntityClient entityClient; private final Producer kafkaProducer; private final Histogram kafkaLagStats = diff --git a/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeProposalsProcessor.java b/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeProposalsProcessor.java index 26d5f66f4929a7..a4f5a287bc8fd0 100644 --- a/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeProposalsProcessor.java +++ b/metadata-jobs/mce-consumer/src/main/java/com/linkedin/metadata/kafka/MetadataChangeProposalsProcessor.java @@ -3,8 +3,8 @@ import com.codahale.metrics.Histogram; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.Timer; -import com.linkedin.entity.client.SystemRestliEntityClient; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.gms.factory.entityclient.RestliEntityClientFactory; import com.linkedin.gms.factory.kafka.DataHubKafkaProducerFactory; import com.linkedin.gms.factory.kafka.KafkaEventConsumerFactory; import com.linkedin.metadata.EventUtils; @@ -42,7 +42,7 @@ @RequiredArgsConstructor public class MetadataChangeProposalsProcessor { - private final SystemRestliEntityClient entityClient; + private final SystemEntityClient entityClient; private final Producer kafkaProducer; private final Histogram kafkaLagStats = diff --git a/metadata-jobs/pe-consumer/src/main/java/com/datahub/event/PlatformEventProcessor.java b/metadata-jobs/pe-consumer/src/main/java/com/datahub/event/PlatformEventProcessor.java index b61858aef22cdd..955d5c67c09a78 100644 --- a/metadata-jobs/pe-consumer/src/main/java/com/datahub/event/PlatformEventProcessor.java +++ b/metadata-jobs/pe-consumer/src/main/java/com/datahub/event/PlatformEventProcessor.java @@ -46,7 +46,7 @@ public PlatformEventProcessor() { public void consume(final ConsumerRecord consumerRecord) { try (Timer.Context i = MetricUtils.timer(this.getClass(), "consume").time()) { - log.info("Consuming a Platform Event"); + log.debug("Consuming a Platform Event"); kafkaLagStats.update(System.currentTimeMillis() - consumerRecord.timestamp()); final GenericRecord record = consumerRecord.value(); diff --git a/metadata-models-custom/README.md b/metadata-models-custom/README.md index 94399a67806a65..10801c3d8ed23a 100644 --- a/metadata-models-custom/README.md +++ b/metadata-models-custom/README.md @@ -396,6 +396,26 @@ public class CustomDataQualityRulesMCLSideEffect extends MCLSideEffect { return timeseriesOptional.stream(); } + + private Optional buildEvent(MetadataChangeLog originMCP) { + if (originMCP.getAspect() != null) { + DataQualityRuleEvent event = new DataQualityRuleEvent(); + if (event.getActor() != null) { + event.setActor(event.getActor()); + } + event.setEventTimestamp(originMCP.getSystemMetadata().getLastObserved()); + event.setTimestampMillis(originMCP.getSystemMetadata().getLastObserved()); + if (originMCP.getPreviousAspectValue() == null) { + event.setEventType("RuleCreated"); + } else { + event.setEventType("RuleUpdated"); + } + event.setAffectedDataset(originMCP.getEntityUrn()); + + return Optional.of(event); + } + return Optional.empty(); + } } ``` diff --git a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCLSideEffect.java b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCLSideEffect.java index a8735bae1521a9..ba72a979088462 100644 --- a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCLSideEffect.java +++ b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCLSideEffect.java @@ -4,7 +4,6 @@ import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.entity.ebean.batch.MCLBatchItemImpl; -import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeLog; import com.mycompany.dq.DataQualityRuleEvent; @@ -20,9 +19,7 @@ public CustomDataQualityRulesMCLSideEffect(AspectPluginConfig config) { @Override protected Stream applyMCLSideEffect( - @Nonnull MCLBatchItem input, - @Nonnull EntityRegistry entityRegistry, - @Nonnull AspectRetriever aspectRetriever) { + @Nonnull MCLBatchItem input, @Nonnull AspectRetriever aspectRetriever) { // Generate Timeseries event aspect based on non-Timeseries aspect MetadataChangeLog originMCP = input.getMetadataChangeLog(); @@ -42,9 +39,7 @@ protected Stream applyMCLSideEffect( }) .map( eventMCP -> - MCLBatchItemImpl.builder() - .metadataChangeLog(eventMCP) - .build(entityRegistry, aspectRetriever)); + MCLBatchItemImpl.builder().metadataChangeLog(eventMCP).build(aspectRetriever)); return timeseriesOptional.stream(); } diff --git a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java index 2c989725f4f9de..d2041c443503ed 100644 --- a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java +++ b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java @@ -6,7 +6,6 @@ import com.linkedin.metadata.aspect.plugins.config.AspectPluginConfig; import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; -import com.linkedin.metadata.models.registry.EntityRegistry; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -18,7 +17,7 @@ public CustomDataQualityRulesMCPSideEffect(AspectPluginConfig aspectPluginConfig @Override protected Stream applyMCPSideEffect( - UpsertItem input, EntityRegistry entityRegistry, @Nonnull AspectRetriever aspectRetriever) { + UpsertItem input, @Nonnull AspectRetriever aspectRetriever) { // Mirror aspects to another URN in SQL & Search Urn mirror = UrnUtils.getUrn(input.getUrn().toString().replace(",PROD)", ",DEV)")); return Stream.of( @@ -28,6 +27,6 @@ protected Stream applyMCPSideEffect( .aspect(input.getAspect()) .auditStamp(input.getAuditStamp()) .systemMetadata(input.getSystemMetadata()) - .build(entityRegistry, aspectRetriever)); + .build(aspectRetriever)); } } diff --git a/metadata-models/build.gradle b/metadata-models/build.gradle index 04c90fa444f0ca..86f404adb7fef3 100644 --- a/metadata-models/build.gradle +++ b/metadata-models/build.gradle @@ -43,11 +43,10 @@ mainAvroSchemaJar.dependsOn generateAvroSchema pegasus.main.generationModes = [PegasusGenerationMode.PEGASUS, PegasusGenerationMode.AVRO] -tasks.register('generateJsonSchema', GenerateJsonSchemaTask) { +task generateJsonSchema(type: GenerateJsonSchemaTask, dependsOn: 'generateAvroSchema') { it.setInputDirectory("$projectDir/src/mainGeneratedAvroSchema") it.setOutputDirectory("$projectDir/src/generatedJsonSchema") it.setEntityRegistryYaml("${project(':metadata-models').projectDir}/src/main/resources/entity-registry.yml") - dependsOn generateAvroSchema } // https://github.com/int128/gradle-swagger-generator-plugin#task-type-generateswaggercode diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/CustomProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/CustomProperties.pdl index 8390a05846c83b..cc70bb5c60fc6a 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/common/CustomProperties.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/common/CustomProperties.pdl @@ -9,6 +9,7 @@ record CustomProperties { */ @Searchable = { "/*": { + "fieldType": "TEXT", "queryByDefault": true } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/FieldFormPromptAssociation.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/FieldFormPromptAssociation.pdl new file mode 100644 index 00000000000000..d05f2308d82a53 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/FieldFormPromptAssociation.pdl @@ -0,0 +1,17 @@ +namespace com.linkedin.common + +/** + * Information about the status of a particular prompt for a specific schema field + * on an entity. + */ +record FieldFormPromptAssociation { + /** + * The field path on a schema field. + */ + fieldPath: string + + /** + * The last time this prompt was touched for the field on the entity (set, unset) + */ + lastModified: AuditStamp +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/FormAssociation.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/FormAssociation.pdl new file mode 100644 index 00000000000000..558672478c19bd --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/FormAssociation.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.common + +/** + * Properties of an applied form. + */ +record FormAssociation { + /** + * Urn of the applied form + */ + urn: Urn + + /** + * A list of prompts that are not yet complete for this form. + */ + incompletePrompts: array[FormPromptAssociation] = [] + + /** + * A list of prompts that have been completed for this form. + */ + completedPrompts: array[FormPromptAssociation] = [] +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptAssociation.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptAssociation.pdl new file mode 100644 index 00000000000000..ee0f1041e23c47 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptAssociation.pdl @@ -0,0 +1,23 @@ +namespace com.linkedin.common + +/** + * Information about the status of a particular prompt. + * Note that this is where we can add additional information about individual responses: + * actor, timestamp, and the response itself. + */ +record FormPromptAssociation { + /** + * The id for the prompt. This must be GLOBALLY UNIQUE. + */ + id: string + + /** + * The last time this prompt was touched for the entity (set, unset) + */ + lastModified: AuditStamp + + /** + * Optional information about the field-level prompt associations. + */ + fieldAssociations: optional FormPromptFieldAssociations +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptFieldAssociations.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptFieldAssociations.pdl new file mode 100644 index 00000000000000..419aa8aa3921dc --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/FormPromptFieldAssociations.pdl @@ -0,0 +1,16 @@ +namespace com.linkedin.common + +/** + * Information about the field-level prompt associations on a top-level prompt association. + */ +record FormPromptFieldAssociations { + /** + * A list of field-level prompt associations that are not yet complete for this form. + */ + completedFieldPrompts: optional array[FieldFormPromptAssociation] + + /** + * A list of field-level prompt associations that are complete for this form. + */ + incompleteFieldPrompts: optional array[FieldFormPromptAssociation] +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/FormVerificationAssociation.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/FormVerificationAssociation.pdl new file mode 100644 index 00000000000000..066e72f2f2a20e --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/FormVerificationAssociation.pdl @@ -0,0 +1,17 @@ +namespace com.linkedin.common + +/** + * An association between a verification and an entity that has been granted + * via completion of one or more forms of type 'VERIFICATION'. + */ +record FormVerificationAssociation { + /** + * The urn of the form that granted this verification. + */ + form: Urn + + /** + * An audit stamp capturing who and when verification was applied for this form. + */ + lastModified: optional AuditStamp +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/Forms.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/Forms.pdl new file mode 100644 index 00000000000000..0a97c7d5099ed8 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/Forms.pdl @@ -0,0 +1,66 @@ +namespace com.linkedin.common + +/** + * Forms that are assigned to this entity to be filled out + */ +@Aspect = { + "name": "forms" +} +record Forms { + /** + * All incomplete forms assigned to the entity. + */ + @Searchable = { + "/*/urn": { + "fieldType": "URN", + "fieldName": "incompleteForms" + }, + "/*/completedPrompts/*/id" : { + "fieldType": "KEYWORD", + "fieldName": "incompleteFormsCompletedPromptIds", + }, + "/*/incompletePrompts/*/id" : { + "fieldType": "KEYWORD", + "fieldName": "incompleteFormsIncompletePromptIds", + }, + "/*/completedPrompts/*/lastModified/time" : { + "fieldType": "DATETIME", + "fieldName": "incompleteFormsCompletedPromptResponseTimes", + } + } + incompleteForms: array[FormAssociation] + + /** + * All complete forms assigned to the entity. + */ + @Searchable = { + "/*/urn": { + "fieldType": "URN", + "fieldName": "completedForms" + }, + "/*/completedPrompts/*/id" : { + "fieldType": "KEYWORD", + "fieldName": "completedFormsCompletedPromptIds", + }, + "/*/incompletePrompts/*/id" : { + "fieldType": "KEYWORD", + "fieldName": "completedFormsIncompletePromptIds", + }, + "/*/completedPrompts/*/lastModified/time" : { + "fieldType": "DATETIME", + "fieldName": "completedFormsCompletedPromptResponseTimes", + } + } + completedForms: array[FormAssociation] + + /** + * Verifications that have been applied to the entity via completed forms. + */ + @Searchable = { + "/*/form": { + "fieldType": "URN", + "fieldName": "verifiedForms" + } + } + verifications: array[FormVerificationAssociation] = [] +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/GlossaryTermAssociation.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/GlossaryTermAssociation.pdl index 9f0f0ff6f24a2d..80dc07981816aa 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/common/GlossaryTermAssociation.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/common/GlossaryTermAssociation.pdl @@ -20,8 +20,14 @@ record GlossaryTermAssociation { } urn: GlossaryTermUrn + /** + * The user URN which will be credited for adding associating this term to the entity + */ + actor: optional Urn + /** * Additional context about the association */ context: optional string + } diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/PropertyValue.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/PropertyValue.pdl new file mode 100644 index 00000000000000..c8f1e4d5009dc0 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/PropertyValue.pdl @@ -0,0 +1,13 @@ +namespace com.linkedin.common + +record PropertyValue { + value: union [ + string, + double + ] + + /** + * Optional description of the property value + */ + description: optional string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/datahub/DataHubSearchConfig.pdl b/metadata-models/src/main/pegasus/com/linkedin/datahub/DataHubSearchConfig.pdl new file mode 100644 index 00000000000000..2d09d828d10bd7 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/datahub/DataHubSearchConfig.pdl @@ -0,0 +1,87 @@ +namespace com.linkedin.datahub + +/** +* Configuration for how any given field should be indexed and matched in the DataHub search index. +**/ +record DataHubSearchConfig { + + /** + * Name of the field in the search index. Defaults to the field name otherwise + **/ + fieldName: optional string + + /** + * Type of the field. Defines how the field is indexed and matched + **/ + fieldType: optional enum SearchFieldType { + KEYWORD, + TEXT, + TEXT_PARTIAL, + BROWSE_PATH, + URN, + URN_PARTIAL, + BOOLEAN, + COUNT, + DATETIME, + OBJECT, + BROWSE_PATH_V2, + WORD_GRAM + } + + /** + * Whether we should match the field for the default search query + **/ + queryByDefault: boolean = false + + /** + * Whether we should use the field for default autocomplete + **/ + enableAutocomplete: boolean = false + + /** + * Whether or not to add field to filters. + **/ + addToFilters: boolean = false + + /** + * Whether or not to add the "has values" to filters. + * check if this is conditional on addToFilters being true + **/ + addHasValuesToFilters: boolean = true + + /** + * Display name of the filter + **/ + filterNameOverride: optional string + + /** + * Display name of the has values filter + **/ + hasValuesFilterNameOverride: optional string + + /** + * Boost multiplier to the match score. Matches on fields with higher boost score ranks higher + **/ + boostScore: double = 1.0 + + /** + * If set, add a index field of the given name that checks whether the field exists + **/ + hasValuesFieldName: optional string + + /** + * If set, add a index field of the given name that checks the number of elements + **/ + numValuesFieldName: optional string + + /** + * (Optional) Weights to apply to score for a given value + **/ + weightsPerFieldValue: optional map[string, double] + + /** + * (Optional) Aliases for this given field that can be used for sorting etc. + **/ + fieldNameAliases: optional array[string] + +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeInfo.pdl new file mode 100644 index 00000000000000..4e3ea9d01e92d6 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeInfo.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.datatype + +@Aspect = { + "name": "dataTypeInfo" +} +record DataTypeInfo { + /** + * The qualified name for the data type. Usually a unique namespace + name, e.g. datahub.string + */ + qualifiedName: string + + /** + * An optional display name for the data type. + */ + displayName: optional string + + /** + * An optional description for the data type. + */ + description: optional string +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeKey.pdl new file mode 100644 index 00000000000000..e0ea2b69743811 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/datatype/DataTypeKey.pdl @@ -0,0 +1,11 @@ +namespace com.linkedin.datatype + +@Aspect = { + "name": "dataTypeKey" +} +record DataTypeKey { + /** + * A unique id for a data type. Usually this will be a unique namespace + data type name. + */ + id: string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeInfo.pdl new file mode 100644 index 00000000000000..3a741a4d8f0b85 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeInfo.pdl @@ -0,0 +1,22 @@ +namespace com.linkedin.entitytype + +@Aspect = { + "name": "entityTypeInfo" +} +record EntityTypeInfo { + /** + * The fully qualified name for the entity type, which usually consists of a namespace + * plus an identifier or name, e.g. datahub.dataset + */ + qualifiedName: string + + /** + * The display name for the Entity Type. + */ + displayName: optional string + + /** + * A description for the Entity Type: what is it for? + */ + description: optional string +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeKey.pdl new file mode 100644 index 00000000000000..d857c7ff611e30 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/entitytype/EntityTypeKey.pdl @@ -0,0 +1,11 @@ +namespace com.linkedin.entitytype + +@Aspect = { + "name": "entityTypeKey" +} +record EntityTypeKey { + /** + * A unique id for an entity type. Usually this will be a unique namespace + entity name. + */ + id: string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/form/DynamicFormAssignment.pdl b/metadata-models/src/main/pegasus/com/linkedin/form/DynamicFormAssignment.pdl new file mode 100644 index 00000000000000..93ecf017efb3aa --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/form/DynamicFormAssignment.pdl @@ -0,0 +1,19 @@ +namespace com.linkedin.form + +import com.linkedin.metadata.query.filter.Filter + +/** + * Information about how a form is assigned to entities dynamically. Provide a filter to + * match a set of entities instead of explicitly applying a form to specific entities. + */ +@Aspect = { + "name": "dynamicFormAssignment" +} +record DynamicFormAssignment { + /** + * The filter applied when assigning this form to entities. Entities that match this filter + * will have this form applied to them. Right now this filter only supports filtering by + * platform, entity type, container, and domain through the UI. + */ + filter: Filter +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl b/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl new file mode 100644 index 00000000000000..e58eb4c7c56a81 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.form + +import com.linkedin.common.Urn + +record FormActorAssignment { + /** + * Whether the form should be assigned to the owners of assets that it is applied to. + * This is the default. + */ + owners: boolean = true + + /** + * Optional: Specific set of groups that are targeted by this form assignment. + */ + groups: optional array[Urn] + + /** + * Optional: Specific set of users that are targeted by this form assignment. + */ + users: optional array[Urn] +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/form/FormInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/form/FormInfo.pdl new file mode 100644 index 00000000000000..b17bd1537a17c3 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/form/FormInfo.pdl @@ -0,0 +1,51 @@ +namespace com.linkedin.form + +import com.linkedin.common.Urn + +/** + * Information about a form to help with filling out metadata on entities. + */ +@Aspect = { + "name": "formInfo" +} +record FormInfo { + /** + * Display name of the form + */ + @Searchable = { + "fieldType": "TEXT_PARTIAL" + } + name: string + + /** + * Description of the form + */ + description: optional string + + /** + * The type of this form + */ + @Searchable = { + "fieldType": "KEYWORD" + } + type: enum FormType { + /** + * A form simply used for collecting metadata fields for an entity. + */ + COMPLETION + /** + * This form is used for "verifying" that entities comply with a policy via presence of a specific set of metadata fields. + */ + VERIFICATION + } = "COMPLETION" + + /** + * List of prompts to present to the user to encourage filling out metadata + */ + prompts: array[FormPrompt] = [] + + /** + * Who the form is assigned to, e.g. who should see the form when visiting the entity page or governance center + */ + actors: FormActorAssignment = { "owners": true } +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/form/FormPrompt.pdl b/metadata-models/src/main/pegasus/com/linkedin/form/FormPrompt.pdl new file mode 100644 index 00000000000000..73f06552d46aba --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/form/FormPrompt.pdl @@ -0,0 +1,53 @@ +namespace com.linkedin.form + +import com.linkedin.common.Urn + +/** + * A prompt to present to the user to encourage filling out metadata + */ +record FormPrompt { + /** + * The unique id for this prompt. This must be GLOBALLY unique. + */ + id: string + + /** + * The title of this prompt + */ + title: string + + /** + * The description of this prompt + */ + description: optional string + + /** + * The type of prompt + */ + type: enum FormPromptType { + /** + * This prompt is meant to apply a structured property to an entity + */ + STRUCTURED_PROPERTY + /** + * This prompt is meant to apply a structured property to a schema fields entity + */ + FIELDS_STRUCTURED_PROPERTY + } + + /** + * An optional set of information specific to structured properties prompts. + * This should be filled out if the prompt is type STRUCTURED_PROPERTY or FIELDS_STRUCTURED_PROPERTY. + */ + structuredPropertyParams: optional record StructuredPropertyParams { + /** + * The structured property that is required on this entity + */ + urn: Urn + } + + /** + * Whether the prompt is required to be completed, in order for the form to be marked as complete. + */ + required: boolean = true +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryNodeInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryNodeInfo.pdl index c3388d4f462d49..b4a6f4b47b2212 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryNodeInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryNodeInfo.pdl @@ -1,5 +1,6 @@ namespace com.linkedin.glossary +import com.linkedin.common.CustomProperties import com.linkedin.common.GlossaryNodeUrn /** @@ -8,7 +9,7 @@ import com.linkedin.common.GlossaryNodeUrn @Aspect = { "name": "glossaryNodeInfo" } -record GlossaryNodeInfo { +record GlossaryNodeInfo includes CustomProperties { /** * Definition of business node diff --git a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl index e987a71be7131d..1de826f1b2aa65 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl @@ -3,6 +3,7 @@ namespace com.linkedin.glossary import com.linkedin.common.Url import com.linkedin.common.GlossaryNodeUrn import com.linkedin.common.CustomProperties +import com.linkedin.schema.PrimitiveValueDataType /** * Properties associated with a GlossaryTerm @@ -76,4 +77,5 @@ record GlossaryTermInfo includes CustomProperties { */ @deprecated rawSchema: optional string + } diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/FormKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/FormKey.pdl new file mode 100644 index 00000000000000..124d65d0e7452b --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/FormKey.pdl @@ -0,0 +1,14 @@ +namespace com.linkedin.metadata.key + +/** + * Key for a Form + */ +@Aspect = { + "name": "formKey", +} +record FormKey { + /** + * Unique id for the form. + */ + id: string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/PrimitivePropertyValue.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/PrimitivePropertyValue.pdl new file mode 100644 index 00000000000000..93dbb14c7f9694 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/PrimitivePropertyValue.pdl @@ -0,0 +1,9 @@ +namespace com.linkedin.structured + +/** +* Represents a stored primitive property value +**/ +typeref PrimitivePropertyValue = union [ + string, + double + ] \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/PropertyValue.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/PropertyValue.pdl new file mode 100644 index 00000000000000..012ce5416364fe --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/PropertyValue.pdl @@ -0,0 +1,10 @@ +namespace com.linkedin.structured + +record PropertyValue { + value: PrimitivePropertyValue + + /** + * Optional description of the property value + */ + description: optional string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredProperties.pdl new file mode 100644 index 00000000000000..f79e8fd86e825c --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredProperties.pdl @@ -0,0 +1,14 @@ +namespace com.linkedin.structured + +/** + * Properties about an entity governed by StructuredPropertyDefinition + */ +@Aspect = { + "name": "structuredProperties" +} +record StructuredProperties { + /** + * Custom property bag. + */ + properties: array[StructuredPropertyValueAssignment] +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl new file mode 100644 index 00000000000000..1b263b679531a5 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl @@ -0,0 +1,74 @@ +namespace com.linkedin.structured + +import com.linkedin.common.Urn +import com.linkedin.datahub.DataHubSearchConfig + +@Aspect = { + "name": "propertyDefinition" +} +record StructuredPropertyDefinition { + /** + * The fully qualified name of the property. e.g. io.acryl.datahub.myProperty + */ + @Searchable = {} + qualifiedName: string + + /** + * The display name of the property. This is the name that will be shown in the UI and can be used to look up the property id. + */ + @Searchable = {} + displayName: optional string + + /** + * The value type of the property. Must be a dataType. + * e.g. To indicate that the property is of type DATE, use urn:li:dataType:datahub.date + */ + valueType: Urn + + /** + * A map that allows for type specialization of the valueType. + * e.g. a valueType of urn:li:dataType:datahub.urn + * can be specialized to be a USER or GROUP URN by adding a typeQualifier like + * { "allowedTypes": ["urn:li:entityType:datahub.corpuser", "urn:li:entityType:datahub.corpGroup"] } + */ + typeQualifier: optional map[string, array[string]] + + /** + * A list of allowed values that the property is allowed to take. + * If this is not specified, then the property can take any value of given type. + */ + allowedValues: optional array[PropertyValue] + + /** + * The cardinality of the property. If not specified, then the property is assumed to be single valued.. + */ + cardinality: optional enum PropertyCardinality { + SINGLE + MULTIPLE + } = "SINGLE" + + @Relationship = { + "/*": { + "name": "StructuredPropertyOf", + "entityTypes": [ "entityType" ] + } + } + @Searchable = { + "/*": { + "fieldName": "entityTypes" + } + } + entityTypes: array[Urn] + + /** + * The description of the property. This is the description that will be shown in the UI. + */ + description: optional string + + /** + * Search configuration for this property. If not specified, then the property is indexed using the default mapping. + * from the logical type. + */ + searchConfiguration: optional DataHubSearchConfig +} + diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyKey.pdl new file mode 100644 index 00000000000000..16fec7b2a5ab6d --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyKey.pdl @@ -0,0 +1,11 @@ +namespace com.linkedin.structured + +@Aspect = { + "name": "structuredPropertyKey" +} +record StructuredPropertyKey { + /** + * The id for a structured proeprty. + */ + id: string +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyValueAssignment.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyValueAssignment.pdl new file mode 100644 index 00000000000000..d8b8a93a3edb60 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyValueAssignment.pdl @@ -0,0 +1,29 @@ +namespace com.linkedin.structured +import com.linkedin.common.Urn +import com.linkedin.common.AuditStamp + +record StructuredPropertyValueAssignment { + + /** + * The property that is being assigned a value. + */ + propertyUrn: Urn + + /** + * The value assigned to the property. + */ + values: array[PrimitivePropertyValue] + + /** + * Audit stamp containing who created this relationship edge and when + */ + created: optional AuditStamp + + /** + * Audit stamp containing who last modified this relationship edge and when + */ + lastModified: optional AuditStamp + +} + + diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index 9d8c4bfdab0da9..65382c747a16a5 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -42,6 +42,8 @@ entities: - dataPlatformInstance - browsePathsV2 - access + - structuredProperties + - forms - name: dataHubPolicy doc: DataHub Policies represent access policies granted to users or groups on metadata operations like edit, view etc. category: internal @@ -67,6 +69,7 @@ entities: - institutionalMemory - dataPlatformInstance - browsePathsV2 + - structuredProperties - subTypes - name: dataFlow category: core @@ -85,6 +88,7 @@ entities: - institutionalMemory - dataPlatformInstance - browsePathsV2 + - structuredProperties - name: dataProcess keyAspect: dataProcessKey aspects: @@ -409,7 +413,8 @@ entities: - name: schemaField category: core keyAspect: schemaFieldKey - aspects: [] + aspects: + - structuredProperties - name: globalSettings doc: Global settings for an the platform category: internal @@ -468,5 +473,51 @@ entities: - dataContractProperties - dataContractStatus - status - + - name: entityType + doc: A type of entity in the DataHub Metadata Model. + category: core + keyAspect: entityTypeKey + aspects: + - entityTypeInfo + - institutionalMemory + - status + - name: dataType + doc: A type of data element stored within DataHub. + category: core + keyAspect: dataTypeKey + aspects: + - dataTypeInfo + - institutionalMemory + - status + - name: structuredProperty + doc: Structured Property represents a property meant for extending the core model of a logical entity + category: core + keyAspect: structuredPropertyKey + aspects: + - propertyDefinition + - institutionalMemory + - status + - name: form + category: core + keyAspect: formKey + aspects: + - formInfo + - dynamicFormAssignment + - ownership events: +plugins: + aspectPayloadValidators: + - className: 'com.linkedin.metadata.aspect.validation.PropertyDefinitionValidator' + enabled: true + supportedOperations: + - UPSERT + supportedEntityAspectNames: + - entityName: structuredProperty + aspectName: propertyDefinition + - className: 'com.linkedin.metadata.aspect.validation.StructuredPropertiesValidator' + enabled: true + supportedOperations: + - UPSERT + supportedEntityAspectNames: + - entityName: '*' + aspectName: structuredProperties \ No newline at end of file diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java index 8ce7675edf5805..c4b01fea8c09d2 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/group/GroupService.java @@ -28,6 +28,9 @@ import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -175,6 +178,17 @@ public void migrateGroupMembershipToNativeGroupMembership( userUrnList.forEach(userUrn -> addUserToNativeGroup(userUrn, groupUrn, authentication)); } + public List getGroupsForUser( + @Nonnull final Urn userUrn, @Nonnull final Authentication authentication) throws Exception { + final NativeGroupMembership nativeGroupMembership = + getExistingNativeGroupMembership(userUrn, authentication); + final GroupMembership groupMembership = getExistingGroupMembership(userUrn, authentication); + final List allGroups = new ArrayList<>(); + allGroups.addAll(nativeGroupMembership.getNativeGroups()); + allGroups.addAll(groupMembership.getGroups()); + return allGroups; + } + NativeGroupMembership getExistingNativeGroupMembership( @Nonnull final Urn userUrn, final Authentication authentication) throws Exception { final EntityResponse entityResponse = @@ -186,7 +200,7 @@ NativeGroupMembership getExistingNativeGroupMembership( authentication) .get(userUrn); - NativeGroupMembership nativeGroupMembership; + final NativeGroupMembership nativeGroupMembership; if (entityResponse == null || !entityResponse.getAspects().containsKey(NATIVE_GROUP_MEMBERSHIP_ASPECT_NAME)) { // If the user doesn't have the NativeGroupMembership aspect, create one. @@ -204,6 +218,32 @@ NativeGroupMembership getExistingNativeGroupMembership( return nativeGroupMembership; } + GroupMembership getExistingGroupMembership( + @Nonnull final Urn userUrn, @Nonnull final Authentication authentication) + throws RemoteInvocationException, URISyntaxException { + final EntityResponse entityResponse = + _entityClient + .batchGetV2( + CORP_USER_ENTITY_NAME, + Collections.singleton(userUrn), + Collections.singleton(GROUP_MEMBERSHIP_ASPECT_NAME), + authentication) + .get(userUrn); + + final GroupMembership groupMembership; + if (entityResponse == null + || !entityResponse.getAspects().containsKey(GROUP_MEMBERSHIP_ASPECT_NAME)) { + // If the user doesn't have the GroupMembership aspect, create one. + groupMembership = new GroupMembership(); + groupMembership.setGroups(new UrnArray()); + } else { + groupMembership = + new GroupMembership( + entityResponse.getAspects().get(GROUP_MEMBERSHIP_ASPECT_NAME).getValue().data()); + } + return groupMembership; + } + String createGroupInfo( @Nonnull final CorpGroupKey corpGroupKey, @Nonnull final String groupName, diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java index 40555107f4c79e..e072a59ae77ffd 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java @@ -13,7 +13,6 @@ import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.key.DataHubAccessTokenKey; import com.linkedin.metadata.utils.AuditStampUtils; import com.linkedin.metadata.utils.GenericRecordUtils; @@ -41,7 +40,7 @@ @Slf4j public class StatefulTokenService extends StatelessTokenService { - private final EntityService _entityService; + private final EntityService _entityService; private final LoadingCache _revokedTokenCache; private final String salt; @@ -49,7 +48,7 @@ public StatefulTokenService( @Nonnull final String signingKey, @Nonnull final String signingAlgorithm, @Nullable final String iss, - @Nonnull final EntityService entityService, + @Nonnull final EntityService entityService, @Nonnull final String salt) { super(signingKey, signingAlgorithm, iss); this._entityService = entityService; @@ -154,11 +153,7 @@ public String generateAccessToken( _entityService.ingestProposal( AspectsBatchImpl.builder() - .mcps( - proposalStream.collect(Collectors.toList()), - auditStamp, - _entityService.getEntityRegistry(), - _entityService.getSystemEntityClient()) + .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) .build(), false); diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/VisualConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/VisualConfiguration.java index bc749a373c5b0b..eb5243c0e5e4a6 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/VisualConfiguration.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/VisualConfiguration.java @@ -8,9 +8,18 @@ public class VisualConfiguration { /** Asset related configurations */ public AssetsConfiguration assets; + /** Custom app title to show in the browse tab */ + public String appTitle; + /** Queries tab related configurations */ public QueriesTabConfig queriesTab; + /** + * Boolean flag disabling viewing the Business Glossary page for users without the 'Manage + * Glossaries' privilege + */ + public boolean hideGlossary; + /** Queries tab related configurations */ public EntityProfileConfig entityProfile; diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index cfc84491ab0aec..2b202d513c9bf8 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -1,3 +1,6 @@ +# The base URL where DataHub is accessible to users. +baseUrl: ${DATAHUB_BASE_URL:http://localhost:9002} + # App Layer authentication: # Enable if you want all requests to the Metadata Service to be authenticated. Disabled by default. @@ -113,7 +116,9 @@ visualConfig: queriesTabResultSize: ${REACT_APP_QUERIES_TAB_RESULT_SIZE:5} assets: logoUrl: ${REACT_APP_LOGO_URL:/assets/platforms/datahublogo.png} - faviconUrl: ${REACT_APP_FAVICON_URL:/assets/favicon.ico} + faviconUrl: ${REACT_APP_FAVICON_URL:/assets/icons/favicon.ico} + appTitle: ${REACT_APP_TITLE:} + hideGlossary: ${REACT_APP_HIDE_GLOSSARY:false} entityProfile: # we only support default tab for domains right now. In order to implement for other entities, update React code domainDefaultTab: ${DOMAIN_DEFAULT_TAB:} # set to DOCUMENTATION_TAB to show documentation tab first @@ -305,6 +310,11 @@ systemUpdate: backOffFactor: ${BOOTSTRAP_SYSTEM_UPDATE_BACK_OFF_FACTOR:2} # Multiplicative factor for back off, default values will result in waiting 5min 15s waitForSystemUpdate: ${BOOTSTRAP_SYSTEM_UPDATE_WAIT_FOR_SYSTEM_UPDATE:true} +structuredProperties: + enabled: ${ENABLE_STRUCTURED_PROPERTIES_HOOK:true} # applies structured properties mappings + writeEnabled: ${ENABLE_STRUCTURED_PROPERTIES_WRITE:true} # write structured property values + systemUpdateEnabled: ${ENABLE_STRUCTURED_PROPERTIES_SYSTEM_UPDATE:false} # applies structured property mappings in system update job + healthCheck: cacheDurationSeconds: ${HEALTH_CHECK_CACHE_DURATION_SECONDS:5} @@ -324,6 +334,7 @@ featureFlags: uiEnabled: ${PRE_PROCESS_HOOKS_UI_ENABLED:true} # Circumvents Kafka for processing index updates for UI changes sourced from GraphQL to avoid processing delays showAcrylInfo: ${SHOW_ACRYL_INFO:false} # Show different CTAs within DataHub around moving to Managed DataHub. Set to true for the demo site. nestedDomainsEnabled: ${NESTED_DOMAINS_ENABLED:true} # Enables the nested Domains feature that allows users to have sub-Domains. If this is off, Domains appear "flat" again + schemaFieldEntityFetchEnabled: ${SCHEMA_FIELD_ENTITY_FETCH_ENABLED:true} # Enables fetching for schema field entities from the database when we hydrate them on schema fields entityChangeEvents: enabled: ${ENABLE_ENTITY_CHANGE_EVENTS_HOOK:true} @@ -375,5 +386,12 @@ cache: status: 20 corpUserCredentials: 20 corpUserSettings: 20 + structuredProperty: + propertyDefinition: 86400 # 1 day + structuredPropertyKey: 86400 # 1 day springdoc.api-docs.groups.enabled: true + +forms: + hook: + enabled: {$FORMS_HOOK_ENABLED:true} \ No newline at end of file diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/AuthorizerChainFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/AuthorizerChainFactory.java index ec398388ae77bb..7b823e552da972 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/AuthorizerChainFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/AuthorizerChainFactory.java @@ -1,6 +1,5 @@ package com.linkedin.gms.factory.auth; -import com.datahub.authentication.Authentication; import com.datahub.authorization.AuthorizerChain; import com.datahub.authorization.AuthorizerContext; import com.datahub.authorization.DataHubAuthorizer; @@ -18,8 +17,8 @@ import com.datahub.plugins.loader.IsolatedClassLoader; import com.datahub.plugins.loader.PluginPermissionManagerImpl; import com.google.common.collect.ImmutableMap; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.metadata.client.JavaEntityClient; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import jakarta.annotation.Nonnull; import java.nio.file.Path; @@ -47,39 +46,29 @@ public class AuthorizerChainFactory { @Qualifier("configurationProvider") private ConfigurationProvider configurationProvider; - @Autowired - @Qualifier("dataHubAuthorizer") - private DataHubAuthorizer dataHubAuthorizer; - - @Autowired - @Qualifier("systemAuthentication") - private Authentication systemAuthentication; - - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient entityClient; - @Bean(name = "authorizerChain") @Scope("singleton") @Nonnull - protected AuthorizerChain getInstance() { - final EntitySpecResolver resolver = initResolver(); + protected AuthorizerChain getInstance( + final DataHubAuthorizer dataHubAuthorizer, final SystemEntityClient systemEntityClient) { + final EntitySpecResolver resolver = initResolver(systemEntityClient); // Extract + initialize customer authorizers from application configs. final List authorizers = new ArrayList<>(initCustomAuthorizers(resolver)); if (configurationProvider.getAuthorization().getDefaultAuthorizer().isEnabled()) { AuthorizerContext ctx = new AuthorizerContext(Collections.emptyMap(), resolver); - this.dataHubAuthorizer.init(Collections.emptyMap(), ctx); + dataHubAuthorizer.init(Collections.emptyMap(), ctx); log.info("Default DataHubAuthorizer is enabled. Appending it to the authorization chain."); - authorizers.add(this.dataHubAuthorizer); + authorizers.add(dataHubAuthorizer); } return new AuthorizerChain(authorizers, dataHubAuthorizer); } - private EntitySpecResolver initResolver() { - return new DefaultEntitySpecResolver(systemAuthentication, entityClient); + private EntitySpecResolver initResolver(SystemEntityClient systemEntityClient) { + return new DefaultEntitySpecResolver( + systemEntityClient.getSystemAuthentication(), systemEntityClient); } private List initCustomAuthorizers(EntitySpecResolver resolver) { @@ -121,7 +110,7 @@ private void registerAuthorizer( // Get security mode set by user SecurityMode securityMode = SecurityMode.valueOf( - this.configurationProvider.getDatahub().getPlugin().getPluginSecurityMode()); + configurationProvider.getDatahub().getPlugin().getPluginSecurityMode()); // Create permission manager with security mode PluginPermissionManager permissionManager = new PluginPermissionManagerImpl(securityMode); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java index 3b23243f767424..0935e8ad0e7d4d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java @@ -1,33 +1,19 @@ package com.linkedin.gms.factory.auth; -import com.datahub.authentication.Authentication; import com.datahub.authorization.DataHubAuthorizer; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.Scope; @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) -@Import({RestliEntityClientFactory.class}) public class DataHubAuthorizerFactory { - @Autowired - @Qualifier("systemAuthentication") - private Authentication systemAuthentication; - - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient entityClient; - @Value("${authorization.defaultAuthorizer.cacheRefreshIntervalSecs}") private Integer policyCacheRefreshIntervalSeconds; @@ -40,7 +26,7 @@ public class DataHubAuthorizerFactory { @Bean(name = "dataHubAuthorizer") @Scope("singleton") @Nonnull - protected DataHubAuthorizer getInstance() { + protected DataHubAuthorizer dataHubAuthorizer(final SystemEntityClient systemEntityClient) { final DataHubAuthorizer.AuthorizationMode mode = policiesEnabled @@ -48,8 +34,8 @@ protected DataHubAuthorizer getInstance() { : DataHubAuthorizer.AuthorizationMode.ALLOW_ALL; return new DataHubAuthorizer( - systemAuthentication, - entityClient, + systemEntityClient.getSystemAuthentication(), + systemEntityClient, 10, policyCacheRefreshIntervalSeconds, mode, diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubTokenServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubTokenServiceFactory.java index 83544e4165ae30..beb467d6149300 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubTokenServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubTokenServiceFactory.java @@ -28,16 +28,16 @@ public class DataHubTokenServiceFactory { @Value("${authentication.tokenService.issuer:datahub-metadata-service}") private String issuer; - /** + @Inject + @Named("entityService") + private EntityService _entityService; + */ + /** + @Inject + @Named("entityService") + private EntityService _entityService; + */ @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Bean(name = "dataHubTokenService") @Scope("singleton") @Nonnull protected StatefulTokenService getInstance() { return new StatefulTokenService( - this.signingKey, this.signingAlgorithm, this.issuer, this._entityService, this.saltingKey); + signingKey, signingAlgorithm, issuer, _entityService, saltingKey); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/GroupServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/GroupServiceFactory.java index 7c6c4384d7343b..47af58a8d8626e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/GroupServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/GroupServiceFactory.java @@ -1,7 +1,7 @@ package com.linkedin.gms.factory.auth; import com.datahub.authentication.group.GroupService; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphClient; import com.linkedin.metadata.spring.YamlPropertySourceFactory; @@ -18,11 +18,7 @@ public class GroupServiceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; - - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; + private EntityService _entityService; @Autowired @Qualifier("graphClient") @@ -31,7 +27,8 @@ public class GroupServiceFactory { @Bean(name = "groupService") @Scope("singleton") @Nonnull - protected GroupService getInstance() throws Exception { - return new GroupService(this._javaEntityClient, this._entityService, this._graphClient); + protected GroupService getInstance(@Qualifier("entityClient") final EntityClient entityClient) + throws Exception { + return new GroupService(entityClient, _entityService, _graphClient); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java index c44eada46794d5..7a2b14fdb0f287 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/InviteTokenServiceFactory.java @@ -1,7 +1,7 @@ package com.linkedin.gms.factory.auth; import com.datahub.authentication.invite.InviteTokenService; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; @@ -15,9 +15,6 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class InviteTokenServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; @Autowired @Qualifier("dataHubSecretService") @@ -26,7 +23,8 @@ public class InviteTokenServiceFactory { @Bean(name = "inviteTokenService") @Scope("singleton") @Nonnull - protected InviteTokenService getInstance() throws Exception { - return new InviteTokenService(this._javaEntityClient, this._secretService); + protected InviteTokenService getInstance( + @Qualifier("entityClient") final EntityClient entityClient) throws Exception { + return new InviteTokenService(entityClient, _secretService); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/NativeUserServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/NativeUserServiceFactory.java index 844f3a094b6b73..0ed8f1a4b7af4d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/NativeUserServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/NativeUserServiceFactory.java @@ -1,8 +1,8 @@ package com.linkedin.gms.factory.auth; import com.datahub.authentication.user.NativeUserService; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.metadata.client.JavaEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; @@ -19,11 +19,7 @@ public class NativeUserServiceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; - - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; + private EntityService _entityService; @Autowired @Qualifier("dataHubSecretService") @@ -34,11 +30,8 @@ public class NativeUserServiceFactory { @Bean(name = "nativeUserService") @Scope("singleton") @Nonnull - protected NativeUserService getInstance() throws Exception { + protected NativeUserService getInstance(final SystemEntityClient entityClient) throws Exception { return new NativeUserService( - _entityService, - _javaEntityClient, - _secretService, - _configurationProvider.getAuthentication()); + _entityService, entityClient, _secretService, _configurationProvider.getAuthentication()); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java index a6ae703576a3ee..317d8583ef1c38 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java @@ -1,10 +1,9 @@ package com.linkedin.gms.factory.auth; import com.datahub.authentication.post.PostService; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,14 +13,12 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class PostServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; @Bean(name = "postService") @Scope("singleton") @Nonnull - protected PostService getInstance() throws Exception { - return new PostService(this._javaEntityClient); + protected PostService getInstance(@Qualifier("entityClient") final EntityClient entityClient) + throws Exception { + return new PostService(entityClient); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java index 7696d5201493a2..9321e2544a4938 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/RoleServiceFactory.java @@ -1,10 +1,9 @@ package com.linkedin.gms.factory.auth; import com.datahub.authorization.role.RoleService; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,14 +14,11 @@ @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class RoleServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; - @Bean(name = "roleService") @Scope("singleton") @Nonnull - protected RoleService getInstance() throws Exception { - return new RoleService(this._javaEntityClient); + protected RoleService getInstance(@Qualifier("entityClient") final EntityClient entityClient) + throws Exception { + return new RoleService(entityClient); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/SystemAuthenticationFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/SystemAuthenticationFactory.java index 52d13b05a654d8..efe688ceee3ff3 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/SystemAuthenticationFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/SystemAuthenticationFactory.java @@ -34,8 +34,8 @@ public class SystemAuthenticationFactory { @Nonnull protected Authentication getInstance() { // TODO: Change to service - final Actor systemActor = new Actor(ActorType.USER, this.systemClientId); + final Actor systemActor = new Actor(ActorType.USER, systemClientId); return new Authentication( - systemActor, String.format("Basic %s:%s", this.systemClientId, this.systemSecret)); + systemActor, String.format("Basic %s:%s", systemClientId, systemSecret)); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/SiblingGraphServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/SiblingGraphServiceFactory.java index 5663162186b83f..465d28542f3710 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/SiblingGraphServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/SiblingGraphServiceFactory.java @@ -18,7 +18,7 @@ public class SiblingGraphServiceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Autowired @Qualifier("graphService") diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java index 5c7c2370ab337f..e969793fac1ef4 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java @@ -60,9 +60,15 @@ public class ConfigurationProvider { /** System Update configurations */ private SystemUpdateConfiguration systemUpdate; + /** The base URL where DataHub is hosted. */ + private String baseUrl; + /** Configuration for caching */ private CacheConfiguration cache; /** Configuration for the health check server */ private HealthCheckConfiguration healthCheck; + + /** Structured properties related configurations */ + private StructuredPropertiesConfiguration structuredProperties; } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/StructuredPropertiesConfiguration.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/StructuredPropertiesConfiguration.java new file mode 100644 index 00000000000000..6d4d4ea30c8630 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/StructuredPropertiesConfiguration.java @@ -0,0 +1,10 @@ +package com.linkedin.gms.factory.config; + +import lombok.Data; + +@Data +public class StructuredPropertiesConfiguration { + private boolean enabled; + private boolean writeEnabled; + private boolean systemUpdateEnabled; +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/dataproduct/DataProductServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/dataproduct/DataProductServiceFactory.java index 739211855cacd6..39d42b6fb7568e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/dataproduct/DataProductServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/dataproduct/DataProductServiceFactory.java @@ -1,6 +1,6 @@ package com.linkedin.gms.factory.dataproduct; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.graph.GraphClient; import com.linkedin.metadata.service.DataProductService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; @@ -15,9 +15,6 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class DataProductServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; @Autowired @Qualifier("graphClient") @@ -26,7 +23,8 @@ public class DataProductServiceFactory { @Bean(name = "dataProductService") @Scope("singleton") @Nonnull - protected DataProductService getInstance() throws Exception { - return new DataProductService(_javaEntityClient, _graphClient); + protected DataProductService getInstance( + @Qualifier("entityClient") final EntityClient entityClient) throws Exception { + return new DataProductService(entityClient, _graphClient); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/CassandraSessionFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/CassandraSessionFactory.java index 326537ee07cbdf..788dc3777e5398 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/CassandraSessionFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/CassandraSessionFactory.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.net.ssl.SSLContext; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -16,6 +17,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; +@Slf4j @Configuration public class CassandraSessionFactory { @@ -50,7 +52,7 @@ protected CqlSession createSession() { try { csb = csb.withSslContext(SSLContext.getDefault()); } catch (Exception e) { - e.printStackTrace(); + log.error("Error creating cassandra ssl session", e); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/DeleteEntityServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/DeleteEntityServiceFactory.java index 86443277472810..6bc2d3c7be63f9 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/DeleteEntityServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/DeleteEntityServiceFactory.java @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Import; @Configuration @@ -16,14 +15,13 @@ public class DeleteEntityServiceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Autowired @Qualifier("graphService") private GraphService _graphService; @Bean(name = "deleteEntityService") - @DependsOn({"entityService"}) @Nonnull protected DeleteEntityService createDeleteEntityService() { return new DeleteEntityService(_entityService, _graphService); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java index 88a3f5749343b1..5fd64b02d08a8d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java @@ -48,16 +48,14 @@ protected EntityService createInstance( final KafkaEventProducer eventProducer = new KafkaEventProducer(producer, convention, kafkaHealthChecker); FeatureFlags featureFlags = configurationProvider.getFeatureFlags(); - EntityService entityService = - new EntityServiceImpl( - aspectDao, - eventProducer, - entityRegistry, - featureFlags.isAlwaysEmitChangeLog(), - updateIndicesService, - featureFlags.getPreProcessHooks(), - _ebeanMaxTransactionRetry); - return entityService; + return new EntityServiceImpl( + aspectDao, + eventProducer, + entityRegistry, + featureFlags.isAlwaysEmitChangeLog(), + updateIndicesService, + featureFlags.getPreProcessHooks(), + _ebeanMaxTransactionRetry); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/JavaEntityClientFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/JavaEntityClientFactory.java deleted file mode 100644 index c550fc161b6062..00000000000000 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/JavaEntityClientFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.linkedin.gms.factory.entity; - -import com.datahub.authentication.Authentication; -import com.linkedin.entity.client.RestliEntityClient; -import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.gms.factory.kafka.DataHubKafkaProducerFactory; -import com.linkedin.metadata.client.JavaEntityClient; -import com.linkedin.metadata.client.SystemJavaEntityClient; -import com.linkedin.metadata.entity.DeleteEntityService; -import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; -import com.linkedin.metadata.event.EventProducer; -import com.linkedin.metadata.search.EntitySearchService; -import com.linkedin.metadata.search.LineageSearchService; -import com.linkedin.metadata.search.SearchService; -import com.linkedin.metadata.search.client.CachingEntitySearchService; -import com.linkedin.metadata.timeseries.TimeseriesAspectService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; - -@Configuration -@ConditionalOnExpression("'${entityClient.preferredImpl:java}'.equals('java')") -@Import({DataHubKafkaProducerFactory.class}) -public class JavaEntityClientFactory { - - @Autowired - @Qualifier("entityService") - private EntityService _entityService; - - @Autowired - @Qualifier("deleteEntityService") - private DeleteEntityService _deleteEntityService; - - @Autowired - @Qualifier("searchService") - private SearchService _searchService; - - @Autowired - @Qualifier("entitySearchService") - private EntitySearchService _entitySearchService; - - @Autowired - @Qualifier("cachingEntitySearchService") - private CachingEntitySearchService _cachingEntitySearchService; - - @Autowired - @Qualifier("timeseriesAspectService") - private TimeseriesAspectService _timeseriesAspectService; - - @Autowired - @Qualifier("relationshipSearchService") - private LineageSearchService _lineageSearchService; - - @Autowired - @Qualifier("kafkaEventProducer") - private EventProducer _eventProducer; - - @Bean("javaEntityClient") - public JavaEntityClient getJavaEntityClient( - @Qualifier("restliEntityClient") final RestliEntityClient restliEntityClient) { - return new JavaEntityClient( - _entityService, - _deleteEntityService, - _entitySearchService, - _cachingEntitySearchService, - _searchService, - _lineageSearchService, - _timeseriesAspectService, - _eventProducer, - restliEntityClient); - } - - @Bean("systemJavaEntityClient") - public SystemJavaEntityClient systemJavaEntityClient( - @Qualifier("configurationProvider") final ConfigurationProvider configurationProvider, - @Qualifier("systemAuthentication") final Authentication systemAuthentication, - @Qualifier("systemRestliEntityClient") final RestliEntityClient restliEntityClient) { - SystemJavaEntityClient systemJavaEntityClient = - new SystemJavaEntityClient( - _entityService, - _deleteEntityService, - _entitySearchService, - _cachingEntitySearchService, - _searchService, - _lineageSearchService, - _timeseriesAspectService, - _eventProducer, - restliEntityClient, - systemAuthentication, - configurationProvider.getCache().getClient().getEntityClient()); - - _entityService.setSystemEntityClient(systemJavaEntityClient); - - return systemJavaEntityClient; - } -} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RetentionServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RetentionServiceFactory.java index dae5f903d7d803..31ad933b9579d1 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RetentionServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RetentionServiceFactory.java @@ -33,9 +33,9 @@ public class RetentionServiceFactory { @DependsOn({"cassandraSession", "entityService"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "cassandra") @Nonnull - protected RetentionService createCassandraInstance(CqlSession session) { - RetentionService retentionService = - new CassandraRetentionService(_entityService, session, _batchSize); + protected RetentionService createCassandraInstance(CqlSession session) { + RetentionService retentionService = + new CassandraRetentionService<>(_entityService, session, _batchSize); _entityService.setRetentionService(retentionService); return retentionService; } @@ -44,9 +44,9 @@ protected RetentionService createCassandraInstance(CqlSession session) { @DependsOn({"ebeanServer", "entityService"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull - protected RetentionService createEbeanInstance(Database server) { - RetentionService retentionService = - new EbeanRetentionService(_entityService, server, _batchSize); + protected RetentionService createEbeanInstance(Database server) { + RetentionService retentionService = + new EbeanRetentionService<>(_entityService, server, _batchSize); _entityService.setRetentionService(retentionService); return retentionService; } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RollbackServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RollbackServiceFactory.java new file mode 100644 index 00000000000000..e1055835616ea7 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RollbackServiceFactory.java @@ -0,0 +1,27 @@ +package com.linkedin.gms.factory.entity; + +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.service.RollbackService; +import com.linkedin.metadata.systemmetadata.SystemMetadataService; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RollbackServiceFactory { + + @Value("${authorization.restApiAuthorization:false}") + boolean restApiAuthorizationEnabled; + + @Bean + @Nonnull + protected RollbackService rollbackService( + final EntityService entityService, + final SystemMetadataService systemMetadataService, + final TimeseriesAspectService timeseriesAspectService) { + return new RollbackService( + entityService, systemMetadataService, timeseriesAspectService, restApiAuthorizationEnabled); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/update/indices/UpdateIndicesServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/update/indices/UpdateIndicesServiceFactory.java index d8c1422f988c27..34c1887d67c56f 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/update/indices/UpdateIndicesServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/update/indices/UpdateIndicesServiceFactory.java @@ -1,7 +1,8 @@ package com.linkedin.gms.factory.entity.update.indices; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.search.EntityIndexBuildersFactory; +import com.linkedin.metadata.client.EntityClientAspectRetriever; import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.search.EntitySearchService; @@ -22,7 +23,7 @@ public class UpdateIndicesServiceFactory { @Autowired private ApplicationContext context; - @Value("${entityClient.preferredImpl:java}") + @Value("${entityClient.impl:java}") private String entityClientImpl; @Bean @@ -34,18 +35,27 @@ public UpdateIndicesService updateIndicesService( EntityRegistry entityRegistry, SearchDocumentTransformer searchDocumentTransformer, EntityIndexBuilders entityIndexBuilders) { + UpdateIndicesService updateIndicesService = new UpdateIndicesService( graphService, entitySearchService, timeseriesAspectService, systemMetadataService, - entityRegistry, searchDocumentTransformer, entityIndexBuilders); if ("restli".equals(entityClientImpl)) { - updateIndicesService.setSystemEntityClient(context.getBean(SystemRestliEntityClient.class)); + /* + When restli mode the EntityService is not available. Wire in an AspectRetriever here instead + based on the entity client + */ + SystemEntityClient systemEntityClient = context.getBean(SystemEntityClient.class); + updateIndicesService.initializeAspectRetriever( + EntityClientAspectRetriever.builder() + .entityRegistry(entityRegistry) + .entityClient(systemEntityClient) + .build()); } return updateIndicesService; diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/EntityClientConfigFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/EntityClientConfigFactory.java new file mode 100644 index 00000000000000..c6fe0d6e95f485 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/EntityClientConfigFactory.java @@ -0,0 +1,20 @@ +package com.linkedin.gms.factory.entityclient; + +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.config.cache.client.EntityClientCacheConfig; +import com.linkedin.metadata.spring.YamlPropertySourceFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +public class EntityClientConfigFactory { + + @Bean + public EntityClientCacheConfig entityClientCacheConfig( + @Qualifier("configurationProvider") final ConfigurationProvider configurationProvider) { + return configurationProvider.getCache().getClient().getEntityClient(); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/JavaEntityClientFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/JavaEntityClientFactory.java new file mode 100644 index 00000000000000..530136e32662f8 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/JavaEntityClientFactory.java @@ -0,0 +1,85 @@ +package com.linkedin.gms.factory.entityclient; + +import com.datahub.authentication.Authentication; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.metadata.client.SystemJavaEntityClient; +import com.linkedin.metadata.config.cache.client.EntityClientCacheConfig; +import com.linkedin.metadata.entity.DeleteEntityService; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.event.EventProducer; +import com.linkedin.metadata.search.EntitySearchService; +import com.linkedin.metadata.search.LineageSearchService; +import com.linkedin.metadata.search.SearchService; +import com.linkedin.metadata.search.client.CachingEntitySearchService; +import com.linkedin.metadata.service.RollbackService; +import com.linkedin.metadata.spring.YamlPropertySourceFactory; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import javax.inject.Singleton; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +/** The *Java* Entity Client should be preferred if executing within the GMS service. */ +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +@ConditionalOnProperty(name = "entityClient.impl", havingValue = "java", matchIfMissing = true) +public class JavaEntityClientFactory { + + @Bean("entityClient") + @Singleton + public EntityClient entityClient( + final @Qualifier("entityService") EntityService _entityService, + final @Qualifier("deleteEntityService") DeleteEntityService _deleteEntityService, + final @Qualifier("searchService") SearchService _searchService, + final @Qualifier("entitySearchService") EntitySearchService _entitySearchService, + final @Qualifier("cachingEntitySearchService") CachingEntitySearchService + _cachingEntitySearchService, + final @Qualifier("timeseriesAspectService") TimeseriesAspectService _timeseriesAspectService, + final @Qualifier("relationshipSearchService") LineageSearchService _lineageSearchService, + final @Qualifier("kafkaEventProducer") EventProducer _eventProducer, + final RollbackService rollbackService) { + return new JavaEntityClient( + _entityService, + _deleteEntityService, + _entitySearchService, + _cachingEntitySearchService, + _searchService, + _lineageSearchService, + _timeseriesAspectService, + rollbackService, + _eventProducer); + } + + @Bean("systemEntityClient") + @Singleton + public SystemEntityClient systemEntityClient( + final @Qualifier("entityService") EntityService _entityService, + final @Qualifier("deleteEntityService") DeleteEntityService _deleteEntityService, + final @Qualifier("searchService") SearchService _searchService, + final @Qualifier("entitySearchService") EntitySearchService _entitySearchService, + final @Qualifier("cachingEntitySearchService") CachingEntitySearchService + _cachingEntitySearchService, + final @Qualifier("timeseriesAspectService") TimeseriesAspectService _timeseriesAspectService, + final @Qualifier("relationshipSearchService") LineageSearchService _lineageSearchService, + final @Qualifier("kafkaEventProducer") EventProducer _eventProducer, + final RollbackService rollbackService, + final EntityClientCacheConfig entityClientCacheConfig, + @Qualifier("systemAuthentication") final Authentication systemAuthentication) { + return new SystemJavaEntityClient( + _entityService, + _deleteEntityService, + _entitySearchService, + _cachingEntitySearchService, + _searchService, + _lineageSearchService, + _timeseriesAspectService, + rollbackService, + _eventProducer, + systemAuthentication, + entityClientCacheConfig); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RestliEntityClientFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/RestliEntityClientFactory.java similarity index 53% rename from metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RestliEntityClientFactory.java rename to metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/RestliEntityClientFactory.java index 1dee8c4aa4d27c..88989b1833e78d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/RestliEntityClientFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entityclient/RestliEntityClientFactory.java @@ -1,47 +1,40 @@ -package com.linkedin.gms.factory.entity; +package com.linkedin.gms.factory.entityclient; import com.datahub.authentication.Authentication; +import com.linkedin.entity.client.EntityClient; import com.linkedin.entity.client.RestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.entity.client.SystemRestliEntityClient; -import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.config.cache.client.EntityClientCacheConfig; import com.linkedin.metadata.restli.DefaultRestliClientFactory; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import com.linkedin.parseq.retry.backoff.ExponentialBackoff; import com.linkedin.restli.client.Client; import java.net.URI; +import javax.inject.Singleton; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; +/** The Java Entity Client should be preferred if executing within the GMS service. */ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +@ConditionalOnProperty(name = "entityClient.impl", havingValue = "restli") public class RestliEntityClientFactory { - @Value("${datahub.gms.host}") - private String gmsHost; - - @Value("${datahub.gms.port}") - private int gmsPort; - - @Value("${datahub.gms.useSSL}") - private boolean gmsUseSSL; - - @Value("${datahub.gms.uri}") - private String gmsUri; - - @Value("${datahub.gms.sslContext.protocol}") - private String gmsSslProtocol; - - @Value("${entityClient.retryInterval:2}") - private int retryInterval; - - @Value("${entityClient.numRetries:3}") - private int numRetries; - - @Bean("restliEntityClient") - public RestliEntityClient getRestliEntityClient() { + @Bean("entityClient") + @Singleton + public EntityClient entityClient( + @Value("${datahub.gms.host}") String gmsHost, + @Value("${datahub.gms.port}") int gmsPort, + @Value("${datahub.gms.useSSL}") boolean gmsUseSSL, + @Value("${datahub.gms.uri}") String gmsUri, + @Value("${datahub.gms.sslContext.protocol}") String gmsSslProtocol, + @Value("${entityClient.retryInterval:2}") int retryInterval, + @Value("${entityClient.numRetries:3}") int numRetries) { final Client restClient; if (gmsUri != null) { restClient = DefaultRestliClientFactory.getRestLiClient(URI.create(gmsUri), gmsSslProtocol); @@ -52,10 +45,19 @@ public RestliEntityClient getRestliEntityClient() { return new RestliEntityClient(restClient, new ExponentialBackoff(retryInterval), numRetries); } - @Bean("systemRestliEntityClient") - public SystemRestliEntityClient systemRestliEntityClient( - @Qualifier("configurationProvider") final ConfigurationProvider configurationProvider, + @Bean("systemEntityClient") + @Singleton + public SystemEntityClient systemEntityClient( + @Value("${datahub.gms.host}") String gmsHost, + @Value("${datahub.gms.port}") int gmsPort, + @Value("${datahub.gms.useSSL}") boolean gmsUseSSL, + @Value("${datahub.gms.uri}") String gmsUri, + @Value("${datahub.gms.sslContext.protocol}") String gmsSslProtocol, + @Value("${entityClient.retryInterval:2}") int retryInterval, + @Value("${entityClient.numRetries:3}") int numRetries, + final EntityClientCacheConfig entityClientCacheConfig, @Qualifier("systemAuthentication") final Authentication systemAuthentication) { + final Client restClient; if (gmsUri != null) { restClient = DefaultRestliClientFactory.getRestLiClient(URI.create(gmsUri), gmsSslProtocol); @@ -68,6 +70,6 @@ public SystemRestliEntityClient systemRestliEntityClient( new ExponentialBackoff(retryInterval), numRetries, systemAuthentication, - configurationProvider.getCache().getClient().getEntityClient()); + entityClientCacheConfig); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/form/FormServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/form/FormServiceFactory.java new file mode 100644 index 00000000000000..73be819028f57c --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/form/FormServiceFactory.java @@ -0,0 +1,21 @@ +package com.linkedin.gms.factory.form; + +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.metadata.service.FormService; +import com.linkedin.metadata.spring.YamlPropertySourceFactory; +import javax.annotation.Nonnull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.Scope; + +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +public class FormServiceFactory { + @Bean(name = "formService") + @Scope("singleton") + @Nonnull + protected FormService getInstance(final SystemEntityClient entityClient) throws Exception { + return new FormService(entityClient, entityClient.getSystemAuthentication()); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java index 723715a13b1c1c..60697e57a9afbd 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java @@ -10,17 +10,16 @@ import com.linkedin.datahub.graphql.GmsGraphQLEngineArgs; import com.linkedin.datahub.graphql.GraphQLEngine; import com.linkedin.datahub.graphql.analytics.service.AnalyticsService; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.auth.DataHubTokenServiceFactory; import com.linkedin.gms.factory.common.GitVersionFactory; import com.linkedin.gms.factory.common.IndexConventionFactory; import com.linkedin.gms.factory.common.RestHighLevelClientFactory; import com.linkedin.gms.factory.common.SiblingGraphServiceFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; import com.linkedin.gms.factory.entityregistry.EntityRegistryFactory; import com.linkedin.gms.factory.recommendation.RecommendationServiceFactory; -import com.linkedin.metadata.client.JavaEntityClient; -import com.linkedin.metadata.client.SystemJavaEntityClient; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphClient; import com.linkedin.metadata.graph.GraphService; @@ -29,6 +28,7 @@ import com.linkedin.metadata.recommendation.RecommendationsService; import com.linkedin.metadata.secret.SecretService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; import com.linkedin.metadata.service.OwnershipTypeService; import com.linkedin.metadata.service.QueryService; @@ -52,7 +52,6 @@ @Import({ RestHighLevelClientFactory.class, IndexConventionFactory.class, - RestliEntityClientFactory.class, RecommendationServiceFactory.class, EntityRegistryFactory.class, DataHubTokenServiceFactory.class, @@ -68,14 +67,6 @@ public class GraphQLEngineFactory { @Qualifier(IndexConventionFactory.INDEX_CONVENTION_BEAN) private IndexConvention indexConvention; - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _entityClient; - - @Autowired - @Qualifier("systemJavaEntityClient") - private SystemJavaEntityClient _systemEntityClient; - @Autowired @Qualifier("graphClient") private GraphClient _graphClient; @@ -86,7 +77,7 @@ public class GraphQLEngineFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Autowired @Qualifier("graphService") @@ -172,15 +163,21 @@ public class GraphQLEngineFactory { @Qualifier("dataProductService") private DataProductService _dataProductService; + @Autowired + @Qualifier("formService") + private FormService _formService; + @Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED private Boolean isAnalyticsEnabled; @Bean(name = "graphQLEngine") @Nonnull - protected GraphQLEngine getInstance() { + protected GraphQLEngine getInstance( + @Qualifier("entityClient") final EntityClient entityClient, + @Qualifier("systemEntityClient") final SystemEntityClient systemEntityClient) { GmsGraphQLEngineArgs args = new GmsGraphQLEngineArgs(); - args.setEntityClient(_entityClient); - args.setSystemEntityClient(_systemEntityClient); + args.setEntityClient(entityClient); + args.setSystemEntityClient(systemEntityClient); args.setGraphClient(_graphClient); args.setUsageClient(_usageClient); if (isAnalyticsEnabled) { @@ -215,6 +212,7 @@ protected GraphQLEngine getInstance() { args.setLineageService(_lineageService); args.setQueryService(_queryService); args.setFeatureFlags(_configProvider.getFeatureFlags()); + args.setFormService(_formService); args.setDataProductService(_dataProductService); return new GmsGraphQLEngine(args).builder().build(); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ingestion/IngestionSchedulerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ingestion/IngestionSchedulerFactory.java index 78b9c5d52efdd1..0ba953d66730c2 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ingestion/IngestionSchedulerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ingestion/IngestionSchedulerFactory.java @@ -1,11 +1,9 @@ package com.linkedin.gms.factory.ingestion; -import com.datahub.authentication.Authentication; import com.datahub.metadata.ingestion.IngestionScheduler; -import com.linkedin.entity.client.RestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.gms.factory.entity.RestliEntityClientFactory; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; import org.springframework.beans.factory.annotation.Autowired; @@ -16,18 +14,10 @@ import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.Scope; -@Import({SystemAuthenticationFactory.class, RestliEntityClientFactory.class}) +@Import({SystemAuthenticationFactory.class}) @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class IngestionSchedulerFactory { - @Autowired - @Qualifier("systemAuthentication") - private Authentication _systemAuthentication; - - @Autowired - @Qualifier("restliEntityClient") - private RestliEntityClient _entityClient; - @Autowired @Qualifier("configurationProvider") private ConfigurationProvider _configProvider; @@ -43,10 +33,10 @@ public class IngestionSchedulerFactory { @Bean(name = "ingestionScheduler") @Scope("singleton") @Nonnull - protected IngestionScheduler getInstance() { + protected IngestionScheduler getInstance(final SystemEntityClient entityClient) { return new IngestionScheduler( - _systemAuthentication, - _entityClient, + entityClient.getSystemAuthentication(), + entityClient, _configProvider.getIngestion(), _delayIntervalSeconds, _refreshIntervalSeconds); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java index d82a789c9c0865..0d00218d1990e5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/KafkaEventConsumerFactory.java @@ -127,7 +127,6 @@ use DefaultErrorHandler (does back-off retry and then logs) rather than stopping DeserializationException.class, new CommonContainerStoppingErrorHandler()); factory.setCommonErrorHandler(delegatingErrorHandler); } - log.info( String.format( "Event-based KafkaListenerContainerFactory built successfully. Consumer concurrency = %s", diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/AwsGlueSchemaRegistryFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/AwsGlueSchemaRegistryFactory.java index a88e1d971973b0..c06ebae27f3afd 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/AwsGlueSchemaRegistryFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/AwsGlueSchemaRegistryFactory.java @@ -35,7 +35,7 @@ public class AwsGlueSchemaRegistryFactory { @Bean("schemaRegistryConfig") @Nonnull - protected SchemaRegistryConfig getInstance(ConfigurationProvider configurationProvider) { + protected SchemaRegistryConfig getInstance(final ConfigurationProvider configurationProvider) { Map props = new HashMap<>(); // FIXME: Properties for this factory should come from ConfigurationProvider object, // specifically under the diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/lineage/LineageServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/lineage/LineageServiceFactory.java index 1589b33862bfeb..d81df694c420dc 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/lineage/LineageServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/lineage/LineageServiceFactory.java @@ -1,10 +1,9 @@ package com.linkedin.gms.factory.lineage; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.service.LineageService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,14 +13,12 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class LineageServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; @Bean(name = "lineageService") @Scope("singleton") @Nonnull - protected LineageService getInstance() throws Exception { - return new LineageService(this._javaEntityClient); + protected LineageService getInstance(@Qualifier("entityClient") final EntityClient entityClient) + throws Exception { + return new LineageService(entityClient); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ownership/OwnershipTypeServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ownership/OwnershipTypeServiceFactory.java index ff48a922adf22f..5403ca80fa5a8e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ownership/OwnershipTypeServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/ownership/OwnershipTypeServiceFactory.java @@ -1,12 +1,9 @@ package com.linkedin.gms.factory.ownership; -import com.datahub.authentication.Authentication; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.service.OwnershipTypeService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -15,18 +12,12 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class OwnershipTypeServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; - - @Autowired - @Qualifier("systemAuthentication") - private Authentication _authentication; @Bean(name = "ownerShipTypeService") @Scope("singleton") @Nonnull - protected OwnershipTypeService getInstance() throws Exception { - return new OwnershipTypeService(_javaEntityClient, _authentication); + protected OwnershipTypeService getInstance(final SystemEntityClient entityClient) + throws Exception { + return new OwnershipTypeService(entityClient, entityClient.getSystemAuthentication()); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/query/QueryServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/query/QueryServiceFactory.java index cf81cbf70d5eb5..64af400708e6cc 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/query/QueryServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/query/QueryServiceFactory.java @@ -1,12 +1,9 @@ package com.linkedin.gms.factory.query; -import com.datahub.authentication.Authentication; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.service.QueryService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -15,18 +12,11 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class QueryServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; - - @Autowired - @Qualifier("systemAuthentication") - private Authentication _authentication; @Bean(name = "queryService") @Scope("singleton") @Nonnull - protected QueryService getInstance() throws Exception { - return new QueryService(_javaEntityClient, _authentication); + protected QueryService getInstance(final SystemEntityClient entityClient) throws Exception { + return new QueryService(entityClient, entityClient.getSystemAuthentication()); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/MostPopularCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/MostPopularCandidateSourceFactory.java index f3be4db147399e..9b8707b746b29f 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/MostPopularCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/MostPopularCandidateSourceFactory.java @@ -31,7 +31,7 @@ public class MostPopularCandidateSourceFactory { @Autowired @Qualifier("entityService") - private EntityService entityService; + private EntityService entityService; @Bean(name = "mostPopularCandidateSource") @Nonnull diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyEditedCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyEditedCandidateSourceFactory.java index ac227faf06c4c3..cfdb705dc3f6db 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyEditedCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyEditedCandidateSourceFactory.java @@ -31,7 +31,7 @@ public class RecentlyEditedCandidateSourceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Bean(name = "recentlyEditedCandidateSource") @Nonnull diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyViewedCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyViewedCandidateSourceFactory.java index 6f17846efc1cd6..742ed685fd6e1b 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyViewedCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/RecentlyViewedCandidateSourceFactory.java @@ -31,7 +31,7 @@ public class RecentlyViewedCandidateSourceFactory { @Autowired @Qualifier("entityService") - private EntityService entityService; + private EntityService entityService; @Bean(name = "recentlyViewedCandidateSource") @Nonnull diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopPlatformsCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopPlatformsCandidateSourceFactory.java index ad241e77175455..8b1ef069423ee7 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopPlatformsCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopPlatformsCandidateSourceFactory.java @@ -18,7 +18,7 @@ public class TopPlatformsCandidateSourceFactory { @Autowired @Qualifier("entityService") - private EntityService entityService; + private EntityService entityService; @Autowired @Qualifier("entitySearchService") diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/ElasticSearchServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/ElasticSearchServiceFactory.java index 2b6d495e4fe33d..7b5f4e18d4d539 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/ElasticSearchServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/ElasticSearchServiceFactory.java @@ -1,5 +1,8 @@ package com.linkedin.gms.factory.search; +import static com.linkedin.metadata.Constants.*; + +import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.linkedin.gms.factory.config.ConfigurationProvider; @@ -32,6 +35,16 @@ public class ElasticSearchServiceFactory { private static final ObjectMapper YAML_MAPPER = new YAMLMapper(); + static { + int maxSize = + Integer.parseInt( + System.getenv() + .getOrDefault(INGESTION_MAX_SERIALIZED_STRING_LENGTH, MAX_JACKSON_STRING_SIZE)); + YAML_MAPPER + .getFactory() + .setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build()); + } + @Autowired @Qualifier("baseElasticSearchComponents") private BaseElasticSearchComponentsFactory.BaseElasticSearchComponents components; diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/LineageSearchServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/LineageSearchServiceFactory.java index 17103240c938b9..0d7d2e9c1855f5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/LineageSearchServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/LineageSearchServiceFactory.java @@ -19,6 +19,8 @@ @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class LineageSearchServiceFactory { + public static final String LINEAGE_SEARCH_SERVICE_CACHE_NAME = "relationshipSearchService"; + @Bean(name = "relationshipSearchService") @Primary @Nonnull @@ -31,7 +33,7 @@ protected LineageSearchService getInstance( return new LineageSearchService( searchService, graphService, - cacheEnabled ? cacheManager.getCache("relationshipSearchService") : null, + cacheEnabled ? cacheManager.getCache(LINEAGE_SEARCH_SERVICE_CACHE_NAME) : null, cacheEnabled, configurationProvider.getCache().getSearch().getLineage()); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/views/ViewServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/views/ViewServiceFactory.java index 32ad2175c9052d..1fddb51065a1df 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/views/ViewServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/search/views/ViewServiceFactory.java @@ -1,12 +1,9 @@ package com.linkedin.gms.factory.search.views; -import com.datahub.authentication.Authentication; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.service.ViewService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -15,18 +12,11 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class ViewServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; - - @Autowired - @Qualifier("systemAuthentication") - private Authentication _authentication; @Bean(name = "viewService") @Scope("singleton") @Nonnull - protected ViewService getInstance() throws Exception { - return new ViewService(_javaEntityClient, _authentication); + protected ViewService getInstance(final SystemEntityClient entityClient) throws Exception { + return new ViewService(entityClient, entityClient.getSystemAuthentication()); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/settings/SettingsServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/settings/SettingsServiceFactory.java index f0d09a815628d6..a3f533a22f7eea 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/settings/SettingsServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/settings/SettingsServiceFactory.java @@ -1,12 +1,9 @@ package com.linkedin.gms.factory.settings; -import com.datahub.authentication.Authentication; -import com.linkedin.metadata.client.JavaEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.service.SettingsService; import com.linkedin.metadata.spring.YamlPropertySourceFactory; import javax.annotation.Nonnull; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -15,18 +12,10 @@ @Configuration @PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) public class SettingsServiceFactory { - @Autowired - @Qualifier("javaEntityClient") - private JavaEntityClient _javaEntityClient; - - @Autowired - @Qualifier("systemAuthentication") - private Authentication _authentication; - @Bean(name = "settingsService") @Scope("singleton") @Nonnull - protected SettingsService getInstance() throws Exception { - return new SettingsService(_javaEntityClient, _authentication); + protected SettingsService getInstance(final SystemEntityClient entityClient) throws Exception { + return new SettingsService(entityClient, entityClient.getSystemAuthentication()); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/DailyReport.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/DailyReport.java index b735e490f583e9..393bbdf155485c 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/DailyReport.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/DailyReport.java @@ -25,7 +25,7 @@ public class DailyReport { private final IndexConvention _indexConvention; private final RestHighLevelClient _elasticClient; private final ConfigurationProvider _configurationProvider; - private final EntityService _entityService; + private final EntityService _entityService; private final GitVersion _gitVersion; private static final String MIXPANEL_TOKEN = "5ee83d940754d63cacbf7d34daa6f44a"; @@ -36,7 +36,7 @@ public DailyReport( IndexConvention indexConvention, RestHighLevelClient elasticClient, ConfigurationProvider configurationProvider, - EntityService entityService, + EntityService entityService, GitVersion gitVersion) { this._indexConvention = indexConvention; this._elasticClient = elasticClient; diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/ScheduledAnalyticsFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/ScheduledAnalyticsFactory.java index 4986e705fd7b4d..7d3638d44769b0 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/ScheduledAnalyticsFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/ScheduledAnalyticsFactory.java @@ -24,7 +24,7 @@ public DailyReport dailyReport( @Qualifier("elasticSearchRestHighLevelClient") RestHighLevelClient elasticClient, @Qualifier(IndexConventionFactory.INDEX_CONVENTION_BEAN) IndexConvention indexConvention, ConfigurationProvider configurationProvider, - EntityService entityService, + EntityService entityService, GitVersion gitVersion) { return new DailyReport( indexConvention, elasticClient, configurationProvider, entityService, gitVersion); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TelemetryUtils.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TelemetryUtils.java index 748acb4a9499e9..2e8317df6b14b3 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TelemetryUtils.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TelemetryUtils.java @@ -17,7 +17,7 @@ public final class TelemetryUtils { private static String _clientId; - public static String getClientId(EntityService entityService) { + public static String getClientId(EntityService entityService) { if (_clientId == null) { createClientIdIfNotPresent(entityService); RecordTemplate clientIdTemplate = @@ -28,7 +28,7 @@ public static String getClientId(EntityService entityService) { return _clientId; } - private static void createClientIdIfNotPresent(EntityService entityService) { + private static void createClientIdIfNotPresent(EntityService entityService) { String uuid = UUID.randomUUID().toString(); TelemetryClientId clientId = new TelemetryClientId().setClientId(uuid); final AuditStamp clientIdStamp = new AuditStamp(); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TrackingServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TrackingServiceFactory.java index 4e858fb5cdefd2..cb0ef29b50a89d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TrackingServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/telemetry/TrackingServiceFactory.java @@ -32,7 +32,7 @@ public class TrackingServiceFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Autowired @Qualifier("gitVersion") diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/timeline/eventgenerator/EntityChangeEventGeneratorRegistryFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/timeline/eventgenerator/EntityChangeEventGeneratorRegistryFactory.java index 53a98977413e4a..50d4125257fb26 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/timeline/eventgenerator/EntityChangeEventGeneratorRegistryFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/timeline/eventgenerator/EntityChangeEventGeneratorRegistryFactory.java @@ -2,8 +2,7 @@ import static com.linkedin.metadata.Constants.*; -import com.datahub.authentication.Authentication; -import com.linkedin.entity.client.SystemRestliEntityClient; +import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.metadata.timeline.eventgenerator.AssertionRunEventChangeEventGenerator; import com.linkedin.metadata.timeline.eventgenerator.DataProcessInstanceRunEventChangeEventGenerator; import com.linkedin.metadata.timeline.eventgenerator.DatasetPropertiesChangeEventGenerator; @@ -32,12 +31,10 @@ public class EntityChangeEventGeneratorRegistryFactory { @Autowired ApplicationContext applicationContext; @Bean(name = "entityChangeEventGeneratorRegistry") - @DependsOn({"restliEntityClient", "systemAuthentication"}) + @DependsOn({"systemEntityClient"}) @Nonnull protected EntityChangeEventGeneratorRegistry entityChangeEventGeneratorRegistry() { - final SystemRestliEntityClient entityClient = - applicationContext.getBean(SystemRestliEntityClient.class); - final Authentication systemAuthentication = applicationContext.getBean(Authentication.class); + final SystemEntityClient entityClient = applicationContext.getBean(SystemEntityClient.class); final EntityChangeEventGeneratorRegistry registry = new EntityChangeEventGeneratorRegistry(); registry.register(SCHEMA_METADATA_ASPECT_NAME, new SchemaMetadataChangeEventGenerator()); registry.register( diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java index dc82fc4907edca..7ff91affdf765d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java @@ -40,7 +40,7 @@ static Urn getUpgradeUrn(String upgradeId) { new DataHubUpgradeKey().setId(upgradeId), Constants.DATA_HUB_UPGRADE_ENTITY_NAME); } - static void setUpgradeResult(Urn urn, EntityService entityService) throws URISyntaxException { + static void setUpgradeResult(Urn urn, EntityService entityService) throws URISyntaxException { final AuditStamp auditStamp = new AuditStamp() .setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)) diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/UpgradeStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/UpgradeStep.java index ff5d3f215d86bb..ed8a53aa594c8d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/UpgradeStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/UpgradeStep.java @@ -7,7 +7,6 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.key.DataHubUpgradeKey; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; @@ -21,12 +20,12 @@ @Slf4j public abstract class UpgradeStep implements BootstrapStep { - protected final EntityService _entityService; + protected final EntityService _entityService; private final String _version; private final String _upgradeId; private final Urn _upgradeUrn; - public UpgradeStep(EntityService entityService, String version, String upgradeId) { + public UpgradeStep(EntityService entityService, String version, String upgradeId) { this._entityService = entityService; this._version = version; this._upgradeId = upgradeId; diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java index 70fa91ae618610..b808c3da5d8d0c 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/BootstrapManagerFactory.java @@ -13,7 +13,9 @@ import com.linkedin.metadata.boot.steps.IndexDataPlatformsStep; import com.linkedin.metadata.boot.steps.IngestDataPlatformInstancesStep; import com.linkedin.metadata.boot.steps.IngestDataPlatformsStep; +import com.linkedin.metadata.boot.steps.IngestDataTypesStep; import com.linkedin.metadata.boot.steps.IngestDefaultGlobalSettingsStep; +import com.linkedin.metadata.boot.steps.IngestEntityTypesStep; import com.linkedin.metadata.boot.steps.IngestOwnershipTypesStep; import com.linkedin.metadata.boot.steps.IngestPoliciesStep; import com.linkedin.metadata.boot.steps.IngestRetentionPoliciesStep; @@ -54,7 +56,7 @@ public class BootstrapManagerFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Autowired @Qualifier("entityRegistry") @@ -131,6 +133,8 @@ protected BootstrapManager createInstance() { new WaitForSystemUpdateStep(_dataHubUpgradeKafkaListener, _configurationProvider); final IngestOwnershipTypesStep ingestOwnershipTypesStep = new IngestOwnershipTypesStep(_entityService, _ownershipTypesResource); + final IngestDataTypesStep ingestDataTypesStep = new IngestDataTypesStep(_entityService); + final IngestEntityTypesStep ingestEntityTypesStep = new IngestEntityTypesStep(_entityService); final List finalSteps = new ArrayList<>( @@ -148,7 +152,9 @@ protected BootstrapManager createInstance() { removeClientIdAspectStep, restoreDbtSiblingsIndices, indexDataPlatformsStep, - restoreColumnLineageIndices)); + restoreColumnLineageIndices, + ingestDataTypesStep, + ingestEntityTypesStep)); if (_upgradeDefaultBrowsePathsEnabled) { finalSteps.add(new UpgradeDefaultBrowsePathsStep(_entityService)); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/IngestRetentionPoliciesStepFactory.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/IngestRetentionPoliciesStepFactory.java index 2436938c6c0268..f13037c1e21c76 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/IngestRetentionPoliciesStepFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/factories/IngestRetentionPoliciesStepFactory.java @@ -26,7 +26,7 @@ public class IngestRetentionPoliciesStepFactory { @Autowired @Qualifier("entityService") - private EntityService _entityService; + private EntityService _entityService; @Value("${entityService.retention.enabled}") private Boolean _enableRetention; diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java index 770c0d2840fe84..80e139dcd5c65b 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java @@ -47,7 +47,7 @@ public class BackfillBrowsePathsV2Step extends UpgradeStep { private final SearchService _searchService; - public BackfillBrowsePathsV2Step(EntityService entityService, SearchService searchService) { + public BackfillBrowsePathsV2Step(EntityService entityService, SearchService searchService) { super(entityService, VERSION, UPGRADE_ID); _searchService = searchService; } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IndexDataPlatformsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IndexDataPlatformsStep.java index c46cfdd61158d8..591082235ff30f 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IndexDataPlatformsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IndexDataPlatformsStep.java @@ -34,7 +34,7 @@ public class IndexDataPlatformsStep extends UpgradeStep { private final EntityRegistry _entityRegistry; public IndexDataPlatformsStep( - EntityService entityService, + EntityService entityService, EntitySearchService entitySearchService, EntityRegistry entityRegistry) { super(entityService, VERSION, UPGRADE_ID); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java index e2f0b70526af52..716ae292338ed2 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java @@ -28,7 +28,7 @@ public class IngestDataPlatformInstancesStep implements BootstrapStep { private static final int BATCH_SIZE = 1000; - private final EntityService _entityService; + private final EntityService _entityService; private final AspectMigrationsDao _migrationsDao; @Override @@ -81,8 +81,7 @@ public void execute() throws Exception { .aspectName(DATA_PLATFORM_INSTANCE_ASPECT_NAME) .aspect(dataPlatformInstance.get()) .auditStamp(aspectAuditStamp) - .build( - _entityService.getEntityRegistry(), _entityService.getSystemEntityClient())); + .build(_entityService)); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java index 37eac6d5ec4708..89ed493e162ccf 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java @@ -31,7 +31,7 @@ public class IngestDataPlatformsStep implements BootstrapStep { private static final String PLATFORM_ASPECT_NAME = "dataPlatformInfo"; - private final EntityService _entityService; + private final EntityService _entityService; @Override public String name() { @@ -91,9 +91,7 @@ public void execute() throws IOException, URISyntaxException { new AuditStamp() .setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)) .setTime(System.currentTimeMillis())) - .build( - _entityService.getEntityRegistry(), - _entityService.getSystemEntityClient()); + .build(_entityService); } catch (URISyntaxException e) { throw new RuntimeException(e); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java new file mode 100644 index 00000000000000..6f3a415b521e41 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java @@ -0,0 +1,103 @@ +package com.linkedin.metadata.boot.steps; + +import static com.linkedin.metadata.Constants.*; + +import com.datahub.util.RecordUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.datatype.DataTypeInfo; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.boot.BootstrapStep; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.utils.EntityKeyUtils; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.GenericAspect; +import com.linkedin.mxe.MetadataChangeProposal; +import java.util.Objects; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; + +/** This bootstrap step is responsible for ingesting default data types. */ +@Slf4j +public class IngestDataTypesStep implements BootstrapStep { + + private static final String DEFAULT_FILE_PATH = "./boot/data_types.json"; + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private final EntityService _entityService; + private final String _resourcePath; + + public IngestDataTypesStep(@Nonnull final EntityService entityService) { + this(entityService, DEFAULT_FILE_PATH); + } + + public IngestDataTypesStep( + @Nonnull final EntityService entityService, @Nonnull final String filePath) { + _entityService = Objects.requireNonNull(entityService, "entityService must not be null"); + _resourcePath = filePath; + } + + @Override + public String name() { + return "IngestDataTypesStep"; + } + + @Override + public void execute() throws Exception { + log.info("Ingesting default data types..."); + + // 1. Read from the file into JSON. + final JsonNode dataTypesObj = + JSON_MAPPER.readTree(new ClassPathResource(_resourcePath).getFile()); + + if (!dataTypesObj.isArray()) { + throw new RuntimeException( + String.format( + "Found malformed data types file, expected an Array but found %s", + dataTypesObj.getNodeType())); + } + + log.info("Ingesting {} data types types", dataTypesObj.size()); + int numIngested = 0; + for (final JsonNode roleObj : dataTypesObj) { + final Urn urn = Urn.createFromString(roleObj.get("urn").asText()); + final DataTypeInfo info = + RecordUtils.toRecordTemplate(DataTypeInfo.class, roleObj.get("info").toString()); + log.info(String.format("Ingesting default data type with urn %s", urn)); + ingestDataType(urn, info); + numIngested++; + } + log.info("Ingested {} new data types", numIngested); + } + + private void ingestDataType(final Urn dataTypeUrn, final DataTypeInfo info) throws Exception { + // Write key + final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); + final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(dataTypeUrn.getEntityType()); + GenericAspect keyAspect = + GenericRecordUtils.serializeAspect( + EntityKeyUtils.convertUrnToEntityKey(dataTypeUrn, keyAspectSpec)); + keyAspectProposal.setAspect(keyAspect); + keyAspectProposal.setAspectName(keyAspectSpec.getName()); + keyAspectProposal.setEntityType(DATA_TYPE_ENTITY_NAME); + keyAspectProposal.setChangeType(ChangeType.UPSERT); + keyAspectProposal.setEntityUrn(dataTypeUrn); + + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(dataTypeUrn); + proposal.setEntityType(DATA_TYPE_ENTITY_NAME); + proposal.setAspectName(DATA_TYPE_INFO_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(info)); + proposal.setChangeType(ChangeType.UPSERT); + + _entityService.ingestProposal( + proposal, + new AuditStamp() + .setActor(Urn.createFromString(SYSTEM_ACTOR)) + .setTime(System.currentTimeMillis()), + false); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStep.java index 194e1ddd73c2c0..1420ec116be8f5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStep.java @@ -41,15 +41,15 @@ public class IngestDefaultGlobalSettingsStep implements BootstrapStep { private static final String DEFAULT_SETTINGS_RESOURCE_PATH = "./boot/global_settings.json"; - private final EntityService _entityService; + private final EntityService _entityService; private final String _resourcePath; - public IngestDefaultGlobalSettingsStep(@Nonnull final EntityService entityService) { + public IngestDefaultGlobalSettingsStep(@Nonnull final EntityService entityService) { this(entityService, DEFAULT_SETTINGS_RESOURCE_PATH); } public IngestDefaultGlobalSettingsStep( - @Nonnull final EntityService entityService, @Nonnull final String resourcePath) { + @Nonnull final EntityService entityService, @Nonnull final String resourcePath) { _entityService = Objects.requireNonNull(entityService); _resourcePath = Objects.requireNonNull(resourcePath); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java new file mode 100644 index 00000000000000..b2213eda71cae1 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java @@ -0,0 +1,88 @@ +package com.linkedin.metadata.boot.steps; + +import static com.linkedin.metadata.Constants.*; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.entitytype.EntityTypeInfo; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.boot.BootstrapStep; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.utils.EntityKeyUtils; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.GenericAspect; +import com.linkedin.mxe.MetadataChangeProposal; +import java.util.Objects; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +/** This bootstrap step is responsible for ingesting default data types. */ +@Slf4j +public class IngestEntityTypesStep implements BootstrapStep { + + private static final String DATAHUB_NAMESPACE = "datahub"; + private final EntityService _entityService; + + public IngestEntityTypesStep(@Nonnull final EntityService entityService) { + _entityService = Objects.requireNonNull(entityService, "entityService must not be null"); + } + + @Override + public String name() { + return "IngestEntityTypesStep"; + } + + @Override + public void execute() throws Exception { + log.info("Ingesting entity types from base entity registry..."); + + log.info( + "Ingesting {} entity types", _entityService.getEntityRegistry().getEntitySpecs().size()); + int numIngested = 0; + for (final EntitySpec spec : _entityService.getEntityRegistry().getEntitySpecs().values()) { + final Urn entityTypeUrn = + UrnUtils.getUrn( + String.format("urn:li:entityType:%s.%s", DATAHUB_NAMESPACE, spec.getName())); + final EntityTypeInfo info = + new EntityTypeInfo() + .setDisplayName(spec.getName()) // TODO: Support display name in the entity registry. + .setQualifiedName(entityTypeUrn.getId()); + log.info(String.format("Ingesting entity type with urn %s", entityTypeUrn)); + ingestEntityType(entityTypeUrn, info); + numIngested++; + } + log.info("Ingested {} new entity types", numIngested); + } + + private void ingestEntityType(final Urn entityTypeUrn, final EntityTypeInfo info) + throws Exception { + // Write key + final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); + final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(entityTypeUrn.getEntityType()); + GenericAspect keyAspect = + GenericRecordUtils.serializeAspect( + EntityKeyUtils.convertUrnToEntityKey(entityTypeUrn, keyAspectSpec)); + keyAspectProposal.setAspect(keyAspect); + keyAspectProposal.setAspectName(keyAspectSpec.getName()); + keyAspectProposal.setEntityType(ENTITY_TYPE_ENTITY_NAME); + keyAspectProposal.setChangeType(ChangeType.UPSERT); + keyAspectProposal.setEntityUrn(entityTypeUrn); + + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(entityTypeUrn); + proposal.setEntityType(ENTITY_TYPE_ENTITY_NAME); + proposal.setAspectName(ENTITY_TYPE_INFO_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(info)); + proposal.setChangeType(ChangeType.UPSERT); + + _entityService.ingestProposal( + proposal, + new AuditStamp() + .setActor(Urn.createFromString(SYSTEM_ACTOR)) + .setTime(System.currentTimeMillis()), + false); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestOwnershipTypesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestOwnershipTypesStep.java index fc1c82fc6d631d..02d965b44fc88f 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestOwnershipTypesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestOwnershipTypesStep.java @@ -34,7 +34,7 @@ public class IngestOwnershipTypesStep implements BootstrapStep { private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); - private final EntityService _entityService; + private final EntityService _entityService; private final Resource _ownershipTypesResource; @Override @@ -100,11 +100,7 @@ private void ingestOwnershipType( _entityService.ingestProposal( AspectsBatchImpl.builder() - .mcps( - List.of(keyAspectProposal, proposal), - auditStamp, - _entityService.getEntityRegistry(), - _entityService.getSystemEntityClient()) + .mcps(List.of(keyAspectProposal, proposal), auditStamp, _entityService) .build(), false); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestPoliciesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestPoliciesStep.java index 9b9feb8e146389..f925c96e333fd5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestPoliciesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestPoliciesStep.java @@ -46,7 +46,7 @@ public class IngestPoliciesStep implements BootstrapStep { private static final String POLICY_INFO_ASPECT_NAME = "dataHubPolicyInfo"; private final EntityRegistry _entityRegistry; - private final EntityService _entityService; + private final EntityService _entityService; private final EntitySearchService _entitySearchService; private final SearchDocumentTransformer _searchDocumentTransformer; @@ -210,8 +210,7 @@ private void ingestPolicy(final Urn urn, final DataHubPolicyInfo info) throws UR new AuditStamp() .setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)) .setTime(System.currentTimeMillis()), - _entityRegistry, - _entityService.getSystemEntityClient()) + _entityService) .build(), false); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java index 9ce4d9ce644a82..28b556e78de123 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRolesStep.java @@ -31,7 +31,7 @@ @RequiredArgsConstructor public class IngestRolesStep implements BootstrapStep { private static final int SLEEP_SECONDS = 60; - private final EntityService _entityService; + private final EntityService _entityService; private final EntityRegistry _entityRegistry; @Override @@ -130,8 +130,7 @@ private void ingestRole( new AuditStamp() .setActor(Urn.createFromString(SYSTEM_ACTOR)) .setTime(System.currentTimeMillis()), - _entityRegistry, - _entityService.getSystemEntityClient()) + _entityService) .build(), false); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRootUserStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRootUserStep.java index 9e00b960482c5f..1f8127d8be108a 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRootUserStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestRootUserStep.java @@ -29,7 +29,7 @@ public class IngestRootUserStep implements BootstrapStep { private static final String USER_INFO_ASPECT_NAME = "corpUserInfo"; - private final EntityService _entityService; + private final EntityService _entityService; @Override public String name() { diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndices.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndices.java index 919ba93c9213e7..2e60df54452cc7 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndices.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndices.java @@ -10,7 +10,6 @@ import com.linkedin.metadata.boot.UpgradeStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ListResult; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.ExtraInfo; @@ -31,8 +30,7 @@ public class RestoreColumnLineageIndices extends UpgradeStep { private final EntityRegistry _entityRegistry; public RestoreColumnLineageIndices( - @Nonnull final EntityService entityService, - @Nonnull final EntityRegistry entityRegistry) { + @Nonnull final EntityService entityService, @Nonnull final EntityRegistry entityRegistry) { super(entityService, VERSION, UPGRADE_ID); _entityRegistry = Objects.requireNonNull(entityRegistry, "entityRegistry must not be null"); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreDbtSiblingsIndices.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreDbtSiblingsIndices.java index e2d367a034491b..789a4cbd11878b 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreDbtSiblingsIndices.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreDbtSiblingsIndices.java @@ -13,7 +13,6 @@ import com.linkedin.metadata.Constants; import com.linkedin.metadata.boot.BootstrapStep; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.key.DataHubUpgradeKey; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.registry.EntityRegistry; @@ -47,7 +46,7 @@ public class RestoreDbtSiblingsIndices implements BootstrapStep { private static final Integer BATCH_SIZE = 1000; private static final Integer SLEEP_SECONDS = 120; - private final EntityService _entityService; + private final EntityService _entityService; private final EntityRegistry _entityRegistry; @Override diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndices.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndices.java index 319bbd084e05ce..5c2b2c28e6dcf3 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndices.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndices.java @@ -38,7 +38,7 @@ public class RestoreGlossaryIndices extends UpgradeStep { private final EntityRegistry _entityRegistry; public RestoreGlossaryIndices( - EntityService entityService, + EntityService entityService, EntitySearchService entitySearchService, EntityRegistry entityRegistry) { super(entityService, VERSION, UPGRADE_ID); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java index e2d59b505a5681..3eedbb48aaecaf 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java @@ -39,7 +39,7 @@ public class UpgradeDefaultBrowsePathsStep extends UpgradeStep { private static final String UPGRADE_ID = "upgrade-default-browse-paths-step"; private static final Integer BATCH_SIZE = 5000; - public UpgradeDefaultBrowsePathsStep(EntityService entityService) { + public UpgradeDefaultBrowsePathsStep(EntityService entityService) { super(entityService, VERSION, UPGRADE_ID); } diff --git a/metadata-service/factories/src/test/java/com/linkedin/gms/factory/search/ElasticSearchIndexBuilderFactoryDefaultsTest.java b/metadata-service/factories/src/test/java/com/linkedin/gms/factory/search/ElasticSearchIndexBuilderFactoryDefaultsTest.java new file mode 100644 index 00000000000000..87f1546bd9557d --- /dev/null +++ b/metadata-service/factories/src/test/java/com/linkedin/gms/factory/search/ElasticSearchIndexBuilderFactoryDefaultsTest.java @@ -0,0 +1,27 @@ +package com.linkedin.gms.factory.search; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.search.elasticsearch.indexbuilder.ESIndexBuilder; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.testng.annotations.Test; + +@TestPropertySource(locations = "classpath:/application.yml") +@SpringBootTest(classes = {ElasticSearchIndexBuilderFactory.class}) +@EnableConfigurationProperties(ConfigurationProvider.class) +public class ElasticSearchIndexBuilderFactoryDefaultsTest extends AbstractTestNGSpringContextTests { + @Autowired ESIndexBuilder test; + + @Test + void testInjection() { + assertNotNull(test); + assertEquals(Map.of(), test.getIndexSettingOverrides()); + } +} diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java index 8268eeff48c5ef..06571415620893 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java @@ -76,7 +76,7 @@ public class BackfillBrowsePathsV2StepTest { @Test public void testExecuteNoExistingBrowsePaths() throws Exception { - final EntityService mockService = initMockService(); + final EntityService mockService = initMockService(); final SearchService mockSearchService = initMockSearchService(); final Urn upgradeEntityUrn = Urn.createFromString(UPGRADE_URN); @@ -110,7 +110,7 @@ public void testExecuteNoExistingBrowsePaths() throws Exception { @Test public void testDoesNotRunWhenAlreadyExecuted() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final SearchService mockSearchService = initMockSearchService(); final Urn upgradeEntityUrn = Urn.createFromString(UPGRADE_URN); @@ -140,8 +140,8 @@ public void testDoesNotRunWhenAlreadyExecuted() throws Exception { Mockito.anyBoolean()); } - private EntityService initMockService() throws URISyntaxException { - final EntityService mockService = Mockito.mock(EntityService.class); + private EntityService initMockService() throws URISyntaxException { + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry registry = new UpgradeDefaultBrowsePathsStepTest.TestEntityRegistry(); Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java index 41672a07a23898..1ac0f2f4f914a6 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java @@ -39,7 +39,7 @@ public class IngestDataPlatformInstancesStepTest { @Test public void testExecuteDoesNothingWhenDataPlatformInstanceAspectsAlreadyExists() throws Exception { - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); final AspectMigrationsDao migrationsDao = mock(AspectMigrationsDao.class); mockDBWithDataPlatformInstanceAspects(migrationsDao); @@ -55,7 +55,7 @@ public void testExecuteDoesNothingWhenDataPlatformInstanceAspectsAlreadyExists() @Test public void testExecuteCopesWithEmptyDB() throws Exception { - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); final AspectMigrationsDao migrationsDao = mock(AspectMigrationsDao.class); mockEmptyDB(migrationsDao); @@ -73,7 +73,7 @@ public void testExecuteCopesWithEmptyDB() throws Exception { @Test public void testExecuteChecksKeySpecForAllUrns() throws Exception { final EntityRegistry entityRegistry = getTestEntityRegistry(); - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); final AspectMigrationsDao migrationsDao = mock(AspectMigrationsDao.class); final int countOfCorpUserEntities = 2; final int countOfChartEntities = 4; @@ -96,7 +96,7 @@ public void testExecuteChecksKeySpecForAllUrns() throws Exception { @Test public void testExecuteWhenSomeEntitiesShouldReceiveDataPlatformInstance() throws Exception { final EntityRegistry entityRegistry = getTestEntityRegistry(); - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); final AspectMigrationsDao migrationsDao = mock(AspectMigrationsDao.class); final int countOfCorpUserEntities = 5; final int countOfChartEntities = 7; @@ -161,7 +161,7 @@ private void mockEmptyDB(AspectMigrationsDao migrationsDao) { private void mockDBWithWorkToDo( EntityRegistry entityRegistry, - EntityService entityService, + EntityService entityService, AspectMigrationsDao migrationsDao, int countOfCorpUserEntities, int countOfChartEntities) { @@ -194,7 +194,7 @@ private List insertMockEntities( String entity, String urnTemplate, EntityRegistry entityRegistry, - EntityService entityService) { + EntityService entityService) { EntitySpec entitySpec = entityRegistry.getEntitySpec(entity); AspectSpec keySpec = entitySpec.getKeyAspectSpec(); List urns = new ArrayList<>(); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java new file mode 100644 index 00000000000000..2bbd06c8a61a43 --- /dev/null +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java @@ -0,0 +1,81 @@ +package com.linkedin.metadata.boot.steps; + +import static com.linkedin.metadata.Constants.*; +import static org.mockito.Mockito.*; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datatype.DataTypeInfo; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.models.registry.ConfigEntityRegistry; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import org.jetbrains.annotations.NotNull; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class IngestDataTypesStepTest { + + private static final Urn TEST_DATA_TYPE_URN = UrnUtils.getUrn("urn:li:dataType:datahub.test"); + + @Test + public void testExecuteValidDataTypesNoExistingDataTypes() throws Exception { + EntityRegistry testEntityRegistry = getTestEntityRegistry(); + final EntityService entityService = mock(EntityService.class); + when(entityService.getEntityRegistry()).thenReturn(testEntityRegistry); + when(entityService.getKeyAspectSpec(anyString())) + .thenAnswer( + args -> testEntityRegistry.getEntitySpec(args.getArgument(0)).getKeyAspectSpec()); + + final IngestDataTypesStep step = + new IngestDataTypesStep(entityService, "./boot/test_data_types_valid.json"); + + step.execute(); + + DataTypeInfo expectedResult = new DataTypeInfo(); + expectedResult.setDescription("Test Description"); + expectedResult.setDisplayName("Test Name"); + expectedResult.setQualifiedName("datahub.test"); + + Mockito.verify(entityService, times(1)) + .ingestProposal( + Mockito.eq(buildUpdateDataTypeProposal(expectedResult)), + Mockito.any(AuditStamp.class), + Mockito.eq(false)); + } + + @Test + public void testExecuteInvalidJson() throws Exception { + final EntityService entityService = mock(EntityService.class); + + final IngestDataTypesStep step = + new IngestDataTypesStep(entityService, "./boot/test_data_types_invalid.json"); + + Assert.assertThrows(RuntimeException.class, step::execute); + + // Verify no interactions + verifyNoInteractions(entityService); + } + + private static MetadataChangeProposal buildUpdateDataTypeProposal(final DataTypeInfo info) { + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(TEST_DATA_TYPE_URN); + mcp.setEntityType(DATA_TYPE_ENTITY_NAME); + mcp.setAspectName(DATA_TYPE_INFO_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(info)); + return mcp; + } + + @NotNull + private ConfigEntityRegistry getTestEntityRegistry() { + return new ConfigEntityRegistry( + IngestDataPlatformInstancesStepTest.class + .getClassLoader() + .getResourceAsStream("test-entity-registry.yaml")); + } +} diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStepTest.java index b28a6e9f5cc5be..783c82934599cb 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDefaultGlobalSettingsStepTest.java @@ -25,7 +25,7 @@ public class IngestDefaultGlobalSettingsStepTest { @Test public void testExecuteValidSettingsNoExistingSettings() throws Exception { - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); configureEntityServiceMock(entityService, null); final IngestDefaultGlobalSettingsStep step = @@ -49,7 +49,7 @@ public void testExecuteValidSettingsNoExistingSettings() throws Exception { public void testExecuteValidSettingsExistingSettings() throws Exception { // Verify that the user provided settings overrides are NOT overwritten. - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); final GlobalSettingsInfo existingSettings = new GlobalSettingsInfo() .setViews( @@ -77,7 +77,7 @@ public void testExecuteValidSettingsExistingSettings() throws Exception { @Test public void testExecuteInvalidJsonSettings() throws Exception { - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); configureEntityServiceMock(entityService, null); final IngestDefaultGlobalSettingsStep step = @@ -92,7 +92,7 @@ public void testExecuteInvalidJsonSettings() throws Exception { @Test public void testExecuteInvalidModelSettings() throws Exception { - final EntityService entityService = mock(EntityService.class); + final EntityService entityService = mock(EntityService.class); configureEntityServiceMock(entityService, null); final IngestDefaultGlobalSettingsStep step = @@ -106,7 +106,7 @@ public void testExecuteInvalidModelSettings() throws Exception { } private static void configureEntityServiceMock( - final EntityService mockService, final GlobalSettingsInfo settingsInfo) { + final EntityService mockService, final GlobalSettingsInfo settingsInfo) { Mockito.when( mockService.getAspect( Mockito.eq(GLOBAL_SETTINGS_URN), diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStepTest.java new file mode 100644 index 00000000000000..0b87283fbe2f79 --- /dev/null +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStepTest.java @@ -0,0 +1,91 @@ +package com.linkedin.metadata.boot.steps; + +import static com.linkedin.metadata.Constants.*; +import static org.mockito.Mockito.*; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.entitytype.EntityTypeInfo; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.models.registry.ConfigEntityRegistry; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import org.jetbrains.annotations.NotNull; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class IngestEntityTypesStepTest { + + @Test + public void testExecuteTestEntityRegistry() throws Exception { + EntityRegistry testEntityRegistry = getTestEntityRegistry(); + final EntityService entityService = mock(EntityService.class); + when(entityService.getEntityRegistry()).thenReturn(testEntityRegistry); + when(entityService.getKeyAspectSpec(anyString())) + .thenAnswer( + args -> testEntityRegistry.getEntitySpec(args.getArgument(0)).getKeyAspectSpec()); + + final IngestEntityTypesStep step = new IngestEntityTypesStep(entityService); + + step.execute(); + + Urn userUrn = + Urn.createFromString(String.format("urn:li:entityType:datahub.%s", CORP_USER_ENTITY_NAME)); + EntityTypeInfo userInfo = new EntityTypeInfo(); + userInfo.setDisplayName("corpuser"); + userInfo.setQualifiedName("datahub.corpuser"); + + Urn chartUrn = + Urn.createFromString(String.format("urn:li:entityType:datahub.%s", CHART_ENTITY_NAME)); + EntityTypeInfo chartInfo = new EntityTypeInfo(); + chartInfo.setDisplayName("chart"); + chartInfo.setQualifiedName("datahub.chart"); + + Urn dataPlatformUrn = + Urn.createFromString( + String.format("urn:li:entityType:datahub.%s", DATA_PLATFORM_ENTITY_NAME)); + EntityTypeInfo dataPlatformInfo = new EntityTypeInfo(); + dataPlatformInfo.setDisplayName("dataPlatform"); + dataPlatformInfo.setQualifiedName("datahub.dataPlatform"); + + // Verify all entities were ingested. + Mockito.verify(entityService, times(1)) + .ingestProposal( + Mockito.eq(buildUpdateEntityTypeProposal(userUrn, userInfo)), + Mockito.any(AuditStamp.class), + Mockito.eq(false)); + + Mockito.verify(entityService, times(1)) + .ingestProposal( + Mockito.eq(buildUpdateEntityTypeProposal(chartUrn, chartInfo)), + Mockito.any(AuditStamp.class), + Mockito.eq(false)); + + Mockito.verify(entityService, times(1)) + .ingestProposal( + Mockito.eq(buildUpdateEntityTypeProposal(dataPlatformUrn, dataPlatformInfo)), + Mockito.any(AuditStamp.class), + Mockito.eq(false)); + } + + private static MetadataChangeProposal buildUpdateEntityTypeProposal( + final Urn entityTypeUrn, final EntityTypeInfo info) { + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(entityTypeUrn); + mcp.setEntityType(ENTITY_TYPE_ENTITY_NAME); + mcp.setAspectName(ENTITY_TYPE_INFO_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(info)); + return mcp; + } + + @NotNull + private ConfigEntityRegistry getTestEntityRegistry() { + return new ConfigEntityRegistry( + IngestDataPlatformInstancesStepTest.class + .getClassLoader() + .getResourceAsStream("test-entity-registry.yaml")); + } +} diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndicesTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndicesTest.java index 3b23368d8e99f3..9e647da9ef2e95 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndicesTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreColumnLineageIndicesTest.java @@ -46,7 +46,7 @@ public class RestoreColumnLineageIndicesTest { @Test public void testExecuteFirstTime() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); mockGetUpgradeStep(false, VERSION_1, mockService); @@ -109,7 +109,7 @@ public void testExecuteFirstTime() throws Exception { @Test public void testExecuteWithNewVersion() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); mockGetUpgradeStep(true, VERSION_2, mockService); @@ -172,7 +172,7 @@ public void testExecuteWithNewVersion() throws Exception { @Test public void testDoesNotExecuteWithSameVersion() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); mockGetUpgradeStep(true, VERSION_1, mockService); @@ -233,7 +233,8 @@ public void testDoesNotExecuteWithSameVersion() throws Exception { Mockito.eq(ChangeType.RESTATE)); } - private void mockGetUpstreamLineage(@Nonnull Urn datasetUrn, @Nonnull EntityService mockService) { + private void mockGetUpstreamLineage( + @Nonnull Urn datasetUrn, @Nonnull EntityService mockService) { final List extraInfos = ImmutableList.of( new ExtraInfo() @@ -276,7 +277,7 @@ private void mockGetUpstreamLineage(@Nonnull Urn datasetUrn, @Nonnull EntityServ } private void mockGetInputFields( - @Nonnull Urn entityUrn, @Nonnull String entityName, @Nonnull EntityService mockService) { + @Nonnull Urn entityUrn, @Nonnull String entityName, @Nonnull EntityService mockService) { final List extraInfos = ImmutableList.of( new ExtraInfo() @@ -325,7 +326,7 @@ private AspectSpec mockAspectSpecs(@Nonnull EntityRegistry mockRegistry) { } private void mockGetUpgradeStep( - boolean shouldReturnResponse, @Nonnull String version, @Nonnull EntityService mockService) + boolean shouldReturnResponse, @Nonnull String version, @Nonnull EntityService mockService) throws Exception { final Urn upgradeEntityUrn = UrnUtils.getUrn(COLUMN_LINEAGE_UPGRADE_URN); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndicesTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndicesTest.java index a4f0c5e0aaba05..4a4532763f02bc 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndicesTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/RestoreGlossaryIndicesTest.java @@ -40,7 +40,7 @@ public class RestoreGlossaryIndicesTest { "urn:li:%s:%s", Constants.DATA_HUB_UPGRADE_ENTITY_NAME, "restore-glossary-indices-ui"); private void mockGetTermInfo( - Urn glossaryTermUrn, EntitySearchService mockSearchService, EntityService mockService) + Urn glossaryTermUrn, EntitySearchService mockSearchService, EntityService mockService) throws Exception { Map termInfoAspects = new HashMap<>(); termInfoAspects.put( @@ -79,7 +79,7 @@ private void mockGetTermInfo( } private void mockGetNodeInfo( - Urn glossaryNodeUrn, EntitySearchService mockSearchService, EntityService mockService) + Urn glossaryNodeUrn, EntitySearchService mockSearchService, EntityService mockService) throws Exception { Map nodeInfoAspects = new HashMap<>(); nodeInfoAspects.put( @@ -140,7 +140,7 @@ public void testExecuteFirstTime() throws Exception { Urn.createFromString("urn:li:glossaryTerm:11115397daf94708a8822b8106cfd451"); final Urn glossaryNodeUrn = Urn.createFromString("urn:li:glossaryNode:22225397daf94708a8822b8106cfd451"); - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntitySearchService mockSearchService = Mockito.mock(EntitySearchService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); @@ -215,7 +215,7 @@ public void testExecutesWithNewVersion() throws Exception { Urn.createFromString("urn:li:glossaryTerm:11115397daf94708a8822b8106cfd451"); final Urn glossaryNodeUrn = Urn.createFromString("urn:li:glossaryNode:22225397daf94708a8822b8106cfd451"); - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntitySearchService mockSearchService = Mockito.mock(EntitySearchService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); @@ -298,7 +298,7 @@ public void testDoesNotRunWhenAlreadyExecuted() throws Exception { Urn.createFromString("urn:li:glossaryTerm:11115397daf94708a8822b8106cfd451"); final Urn glossaryNodeUrn = Urn.createFromString("urn:li:glossaryNode:22225397daf94708a8822b8106cfd451"); - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntitySearchService mockSearchService = Mockito.mock(EntitySearchService.class); final EntityRegistry mockRegistry = Mockito.mock(EntityRegistry.class); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java index 17159ba1baf532..024ad7b16a8447 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java @@ -12,6 +12,7 @@ import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ListResult; import com.linkedin.metadata.models.AspectSpec; @@ -19,7 +20,6 @@ import com.linkedin.metadata.models.EntitySpecBuilder; import com.linkedin.metadata.models.EventSpec; import com.linkedin.metadata.models.registry.EntityRegistry; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import com.linkedin.metadata.query.ExtraInfo; import com.linkedin.metadata.query.ExtraInfoArray; import com.linkedin.metadata.query.ListResultMetadata; @@ -48,7 +48,7 @@ public class UpgradeDefaultBrowsePathsStepTest { @Test public void testExecuteNoExistingBrowsePaths() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry registry = new TestEntityRegistry(); Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); @@ -104,7 +104,7 @@ public void testExecuteFirstTime() throws Exception { Urn testUrn2 = UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset2,PROD)"); - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry registry = new TestEntityRegistry(); Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); Mockito.when(mockService.buildDefaultBrowsePath(Mockito.eq(testUrn1))) @@ -193,7 +193,7 @@ public void testDoesNotRunWhenBrowsePathIsNotQualified() throws Exception { "urn:li:dataset:(urn:li:dataPlatform:kafka,SampleKafkaDataset4,PROD)"); // Do not // migrate - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry registry = new TestEntityRegistry(); Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); @@ -269,7 +269,7 @@ public void testDoesNotRunWhenBrowsePathIsNotQualified() throws Exception { @Test public void testDoesNotRunWhenAlreadyExecuted() throws Exception { - final EntityService mockService = Mockito.mock(EntityService.class); + final EntityService mockService = Mockito.mock(EntityService.class); final Urn upgradeEntityUrn = Urn.createFromString(UPGRADE_URN); com.linkedin.upgrade.DataHubUpgradeRequest upgradeRequest = @@ -297,7 +297,7 @@ public void testDoesNotRunWhenAlreadyExecuted() throws Exception { Mockito.anyBoolean()); } - private void initMockServiceOtherEntities(EntityService mockService) { + private void initMockServiceOtherEntities(EntityService mockService) { List skippedEntityTypes = ImmutableList.of( Constants.DASHBOARD_ENTITY_NAME, diff --git a/metadata-service/factories/src/test/java/io/datahubproject/telemetry/TelemetryUtilsTest.java b/metadata-service/factories/src/test/java/io/datahubproject/telemetry/TelemetryUtilsTest.java index 9931f044931b6f..17bf7810f71e40 100644 --- a/metadata-service/factories/src/test/java/io/datahubproject/telemetry/TelemetryUtilsTest.java +++ b/metadata-service/factories/src/test/java/io/datahubproject/telemetry/TelemetryUtilsTest.java @@ -12,7 +12,7 @@ public class TelemetryUtilsTest { - EntityService _entityService; + EntityService _entityService; @BeforeMethod public void init() { diff --git a/metadata-service/factories/src/test/resources/boot/test_data_types_invalid.json b/metadata-service/factories/src/test/resources/boot/test_data_types_invalid.json new file mode 100644 index 00000000000000..ed1d8a7b45abe0 --- /dev/null +++ b/metadata-service/factories/src/test/resources/boot/test_data_types_invalid.json @@ -0,0 +1,9 @@ +[ + { + "urn": "urn:li:dataType:datahub.test", + "badField": { + "qualifiedName":"datahub.test", + "description": "Test Description" + } + } +] \ No newline at end of file diff --git a/metadata-service/factories/src/test/resources/boot/test_data_types_valid.json b/metadata-service/factories/src/test/resources/boot/test_data_types_valid.json new file mode 100644 index 00000000000000..3694c92947aa18 --- /dev/null +++ b/metadata-service/factories/src/test/resources/boot/test_data_types_valid.json @@ -0,0 +1,10 @@ +[ + { + "urn": "urn:li:dataType:datahub.test", + "info": { + "qualifiedName":"datahub.test", + "displayName": "Test Name", + "description": "Test Description" + } + } +] \ No newline at end of file diff --git a/metadata-service/factories/src/test/resources/test-entity-registry.yaml b/metadata-service/factories/src/test/resources/test-entity-registry.yaml index fe32b413751e69..400b22446c1861 100644 --- a/metadata-service/factories/src/test/resources/test-entity-registry.yaml +++ b/metadata-service/factories/src/test/resources/test-entity-registry.yaml @@ -13,4 +13,20 @@ entities: category: core keyAspect: dataPlatformKey aspects: - - dataPlatformInfo \ No newline at end of file + - dataPlatformInfo + - name: entityType + doc: A type of entity in the DataHub Metadata Model. + category: core + keyAspect: entityTypeKey + aspects: + - entityTypeInfo + - institutionalMemory + - status + - name: dataType + doc: A type of data element stored within DataHub. + category: core + keyAspect: dataTypeKey + aspects: + - dataTypeInfo + - institutionalMemory + - status \ No newline at end of file diff --git a/metadata-service/openapi-entity-servlet/build.gradle b/metadata-service/openapi-entity-servlet/build.gradle index fb49727fa70d1f..016ac6693f55b2 100644 --- a/metadata-service/openapi-entity-servlet/build.gradle +++ b/metadata-service/openapi-entity-servlet/build.gradle @@ -75,7 +75,7 @@ task openApiGenerate(type: GenerateSwaggerCode, dependsOn: [mergeApiComponents, 'java11' : "true", 'modelPropertyNaming': "original", 'modelPackage' : "io.datahubproject.openapi.generated", - 'apiPackage' : "io.datahubproject.openapi.generated.controller", + 'apiPackage' : "io.datahubproject.openapi.v2.generated.controller", 'delegatePattern' : "false" ] } diff --git a/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java b/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImpl.java similarity index 86% rename from metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java rename to metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImpl.java index d7c82689035082..39a7e4722988e1 100644 --- a/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/delegates/EntityApiDelegateImpl.java +++ b/metadata-service/openapi-entity-servlet/src/main/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImpl.java @@ -1,4 +1,4 @@ -package io.datahubproject.openapi.delegates; +package io.datahubproject.openapi.v2.delegates; import static io.datahubproject.openapi.util.ReflectionCache.toLowerFirst; @@ -35,10 +35,16 @@ import io.datahubproject.openapi.generated.DeprecationAspectResponseV2; import io.datahubproject.openapi.generated.DomainsAspectRequestV2; import io.datahubproject.openapi.generated.DomainsAspectResponseV2; +import io.datahubproject.openapi.generated.DynamicFormAssignmentAspectRequestV2; +import io.datahubproject.openapi.generated.DynamicFormAssignmentAspectResponseV2; import io.datahubproject.openapi.generated.EditableChartPropertiesAspectRequestV2; import io.datahubproject.openapi.generated.EditableChartPropertiesAspectResponseV2; import io.datahubproject.openapi.generated.EditableDatasetPropertiesAspectRequestV2; import io.datahubproject.openapi.generated.EditableDatasetPropertiesAspectResponseV2; +import io.datahubproject.openapi.generated.FormInfoAspectRequestV2; +import io.datahubproject.openapi.generated.FormInfoAspectResponseV2; +import io.datahubproject.openapi.generated.FormsAspectRequestV2; +import io.datahubproject.openapi.generated.FormsAspectResponseV2; import io.datahubproject.openapi.generated.GlobalTagsAspectRequestV2; import io.datahubproject.openapi.generated.GlobalTagsAspectResponseV2; import io.datahubproject.openapi.generated.GlossaryTermsAspectRequestV2; @@ -66,7 +72,7 @@ public class EntityApiDelegateImpl { private final EntityRegistry _entityRegistry; - private final EntityService _entityService; + private final EntityService _entityService; private final SearchService _searchService; private final EntitiesController _v1Controller; private final AuthorizerChain _authorizationChain; @@ -79,7 +85,7 @@ public class EntityApiDelegateImpl { private final StackWalker walker = StackWalker.getInstance(); public EntityApiDelegateImpl( - EntityService entityService, + EntityService entityService, SearchService searchService, EntitiesController entitiesController, boolean restApiAuthorizationEnabled, @@ -732,4 +738,111 @@ public ResponseEntity deleteDataProductProperties(String urn) { walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); return deleteAspect(urn, methodNameToAspectName(methodName)); } + + public ResponseEntity createForms(FormsAspectRequestV2 body, String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return createAspect( + urn, + methodNameToAspectName(methodName), + body, + FormsAspectRequestV2.class, + FormsAspectResponseV2.class); + } + + public ResponseEntity deleteForms(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return deleteAspect(urn, methodNameToAspectName(methodName)); + } + + public ResponseEntity getForms( + String urn, @jakarta.validation.Valid Boolean systemMetadata) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return getAspect( + urn, + systemMetadata, + methodNameToAspectName(methodName), + _respClazz, + FormsAspectResponseV2.class); + } + + public ResponseEntity headForms(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return headAspect(urn, methodNameToAspectName(methodName)); + } + + public ResponseEntity createDynamicFormAssignment( + DynamicFormAssignmentAspectRequestV2 body, String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return createAspect( + urn, + methodNameToAspectName(methodName), + body, + DynamicFormAssignmentAspectRequestV2.class, + DynamicFormAssignmentAspectResponseV2.class); + } + + public ResponseEntity createFormInfo( + FormInfoAspectRequestV2 body, String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return createAspect( + urn, + methodNameToAspectName(methodName), + body, + FormInfoAspectRequestV2.class, + FormInfoAspectResponseV2.class); + } + + public ResponseEntity deleteDynamicFormAssignment(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return deleteAspect(urn, methodNameToAspectName(methodName)); + } + + public ResponseEntity headDynamicFormAssignment(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return headAspect(urn, methodNameToAspectName(methodName)); + } + + public ResponseEntity headFormInfo(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return headAspect(urn, methodNameToAspectName(methodName)); + } + + public ResponseEntity getFormInfo( + String urn, @jakarta.validation.Valid Boolean systemMetadata) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return getAspect( + urn, + systemMetadata, + methodNameToAspectName(methodName), + _respClazz, + FormInfoAspectResponseV2.class); + } + + public ResponseEntity getDynamicFormAssignment( + String urn, @jakarta.validation.Valid Boolean systemMetadata) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return getAspect( + urn, + systemMetadata, + methodNameToAspectName(methodName), + _respClazz, + DynamicFormAssignmentAspectResponseV2.class); + } + + public ResponseEntity deleteFormInfo(String urn) { + String methodName = + walker.walk(frames -> frames.findFirst().map(StackWalker.StackFrame::getMethodName)).get(); + return deleteAspect(urn, methodNameToAspectName(methodName)); + } } diff --git a/metadata-service/openapi-entity-servlet/src/main/resources/JavaSpring/apiController.mustache b/metadata-service/openapi-entity-servlet/src/main/resources/JavaSpring/apiController.mustache index 4a29b95eabc5de..7ac087f220561f 100644 --- a/metadata-service/openapi-entity-servlet/src/main/resources/JavaSpring/apiController.mustache +++ b/metadata-service/openapi-entity-servlet/src/main/resources/JavaSpring/apiController.mustache @@ -1,6 +1,6 @@ package {{package}}; -import io.datahubproject.openapi.delegates.EntityApiDelegateImpl; +import io.datahubproject.openapi.v2.delegates.EntityApiDelegateImpl; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.search.SearchService; import io.datahubproject.openapi.entities.EntitiesController; diff --git a/metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/delegates/EntityApiDelegateImplTest.java b/metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImplTest.java similarity index 97% rename from metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/delegates/EntityApiDelegateImplTest.java rename to metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImplTest.java index 1f8f0a50235133..d4217c9fd1b66c 100644 --- a/metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/delegates/EntityApiDelegateImplTest.java +++ b/metadata-service/openapi-entity-servlet/src/test/java/io/datahubproject/openapi/v2/delegates/EntityApiDelegateImplTest.java @@ -1,4 +1,4 @@ -package io.datahubproject.openapi.delegates; +package io.datahubproject.openapi.v2.delegates; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.testng.Assert.*; @@ -32,8 +32,8 @@ import io.datahubproject.openapi.generated.Status; import io.datahubproject.openapi.generated.StatusAspectRequestV2; import io.datahubproject.openapi.generated.TagAssociation; -import io.datahubproject.openapi.generated.controller.ChartApiController; -import io.datahubproject.openapi.generated.controller.DatasetApiController; +import io.datahubproject.openapi.v2.generated.controller.ChartApiController; +import io.datahubproject.openapi.v2.generated.controller.DatasetApiController; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -51,7 +51,7 @@ import org.testng.annotations.Test; @SpringBootTest(classes = {SpringWebConfig.class}) -@ComponentScan(basePackages = {"io.datahubproject.openapi.generated.controller"}) +@ComponentScan(basePackages = {"io.datahubproject.openapi.v2.generated.controller"}) @Import({OpenAPIEntityTestConfiguration.class}) @AutoConfigureMockMvc public class EntityApiDelegateImplTest extends AbstractTestNGSpringContextTests { diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/GlobalControllerExceptionHandler.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/GlobalControllerExceptionHandler.java index cc040d29657b27..f4689a98628253 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/GlobalControllerExceptionHandler.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/GlobalControllerExceptionHandler.java @@ -1,14 +1,25 @@ package io.datahubproject.openapi; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.ConversionNotSupportedException; +import org.springframework.core.Ordered; import org.springframework.core.convert.ConversionFailedException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver; +@Slf4j @ControllerAdvice -public class GlobalControllerExceptionHandler { - @ExceptionHandler(ConversionFailedException.class) +public class GlobalControllerExceptionHandler extends DefaultHandlerExceptionResolver { + + public GlobalControllerExceptionHandler() { + setOrder(Ordered.HIGHEST_PRECEDENCE); + setWarnLogCategory(getClass().getName()); + } + + @ExceptionHandler({ConversionFailedException.class, ConversionNotSupportedException.class}) public ResponseEntity handleConflict(RuntimeException ex) { return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); } diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/config/SpringWebConfig.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/config/SpringWebConfig.java index a8721b23d1fa2b..2336bea565e590 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/config/SpringWebConfig.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/config/SpringWebConfig.java @@ -4,7 +4,9 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.servers.Server; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,6 +25,20 @@ servers = {@Server(url = "/openapi/", description = "Default Server URL")}) @Configuration public class SpringWebConfig implements WebMvcConfigurer { + private static final Set OPERATIONS_PACKAGES = + Set.of("io.datahubproject.openapi.operations", "io.datahubproject.openapi.health"); + private static final Set V2_PACKAGES = Set.of("io.datahubproject.openapi.v2"); + private static final Set SCHEMA_REGISTRY_PACKAGES = + Set.of("io.datahubproject.openapi.schema.registry"); + + public static final Set NONDEFAULT_OPENAPI_PACKAGES; + + static { + NONDEFAULT_OPENAPI_PACKAGES = new HashSet<>(); + NONDEFAULT_OPENAPI_PACKAGES.addAll(OPERATIONS_PACKAGES); + NONDEFAULT_OPENAPI_PACKAGES.addAll(V2_PACKAGES); + NONDEFAULT_OPENAPI_PACKAGES.addAll(SCHEMA_REGISTRY_PACKAGES); + } @Override public void configureMessageConverters(List> messageConverters) { @@ -41,16 +57,23 @@ public void addFormatters(FormatterRegistry registry) { public GroupedOpenApi defaultOpenApiGroup() { return GroupedOpenApi.builder() .group("default") - .packagesToExclude( - "io.datahubproject.openapi.operations", "io.datahubproject.openapi.health") + .packagesToExclude(NONDEFAULT_OPENAPI_PACKAGES.toArray(String[]::new)) .build(); } @Bean public GroupedOpenApi operationsOpenApiGroup() { return GroupedOpenApi.builder() - .group("operations") - .packagesToScan("io.datahubproject.openapi.operations", "io.datahubproject.openapi.health") + .group("Operations") + .packagesToScan(OPERATIONS_PACKAGES.toArray(String[]::new)) + .build(); + } + + @Bean + public GroupedOpenApi openApiGroupV3() { + return GroupedOpenApi.builder() + .group("OpenAPI v2") + .packagesToScan(V2_PACKAGES.toArray(String[]::new)) .build(); } } diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java index c87820465dc889..a7e88966e4f874 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java @@ -465,11 +465,7 @@ public static Pair ingestProposal( AspectsBatch batch = AspectsBatchImpl.builder() - .mcps( - proposalStream.collect(Collectors.toList()), - auditStamp, - entityService.getEntityRegistry(), - entityService.getSystemEntityClient()) + .mcps(proposalStream.collect(Collectors.toList()), auditStamp, entityService) .build(); Set proposalResult = entityService.ingestProposal(batch, async); diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/ReflectionCache.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/ReflectionCache.java index 31577429df72d5..6c0474dc6cfb6b 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/ReflectionCache.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/ReflectionCache.java @@ -134,11 +134,34 @@ public Method lookupMethod( return lookupMethod(builderPair.getFirst(), method, parameters); } + /** + * Convert class name to the pdl model names. Upper case first letter unless the 3rd character is + * upper case. Reverse of {link ReflectionCache.toUpperFirst} i.e. MLModel -> mlModel Dataset -> + * dataset DataProduct -> dataProduct + * + * @param s input string + * @return class name + */ public static String toLowerFirst(String s) { - return s.substring(0, 1).toLowerCase() + s.substring(1); + if (s.length() > 2 && s.substring(2, 3).equals(s.substring(2, 3).toUpperCase())) { + return s.substring(0, 2).toLowerCase() + s.substring(2); + } else { + return s.substring(0, 1).toLowerCase() + s.substring(1); + } } + /** + * Convert the pdl model names to desired class names. Upper case first letter unless the 3rd + * character is upper case. i.e. mlModel -> MLModel dataset -> Dataset dataProduct -> DataProduct + * + * @param s input string + * @return class name + */ public static String toUpperFirst(String s) { - return s.substring(0, 1).toUpperCase() + s.substring(1); + if (s.length() > 2 && s.substring(2, 3).equals(s.substring(2, 3).toUpperCase())) { + return s.substring(0, 2).toUpperCase() + s.substring(2); + } else { + return s.substring(0, 1).toUpperCase() + s.substring(1); + } } } diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java new file mode 100644 index 00000000000000..503330fdc8a2e5 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java @@ -0,0 +1,507 @@ +package io.datahubproject.openapi.v2.controller; + +import static io.datahubproject.openapi.v2.utils.ControllerUtil.checkAuthorized; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.Authentication; +import com.datahub.authentication.AuthenticationContext; +import com.datahub.authorization.AuthorizerChain; +import com.datahub.util.RecordUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.ByteString; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.metadata.aspect.batch.UpsertItem; +import com.linkedin.metadata.aspect.patch.GenericJsonPatch; +import com.linkedin.metadata.aspect.patch.template.common.GenericPatchTemplate; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.UpdateAspectResult; +import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; +import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.query.SearchFlags; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.ScrollResult; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchService; +import com.linkedin.metadata.utils.AuditStampUtils; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.metadata.utils.SearchUtil; +import com.linkedin.mxe.SystemMetadata; +import com.linkedin.util.Pair; +import io.datahubproject.openapi.v2.models.GenericEntity; +import io.datahubproject.openapi.v2.models.GenericScrollResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.lang.reflect.InvocationTargetException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/entity") +@Slf4j +public class EntityController { + private static final SearchFlags DEFAULT_SEARCH_FLAGS = + new SearchFlags().setFulltext(false).setSkipAggregates(true).setSkipHighlighting(true); + @Autowired private EntityRegistry entityRegistry; + @Autowired private SearchService searchService; + @Autowired private EntityService entityService; + @Autowired private AuthorizerChain authorizationChain; + @Autowired private boolean restApiAuthorizationEnabled; + @Autowired private ObjectMapper objectMapper; + + @Tag(name = "Generic Entities", description = "API for interacting with generic entities.") + @GetMapping(value = "/{entityName}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Scroll entities") + public ResponseEntity> getEntities( + @PathVariable("entityName") String entityName, + @RequestParam(value = "aspectNames", defaultValue = "") Set aspectNames, + @RequestParam(value = "count", defaultValue = "10") Integer count, + @RequestParam(value = "query", defaultValue = "*") String query, + @RequestParam(value = "scrollId", required = false) String scrollId, + @RequestParam(value = "sort", required = false, defaultValue = "urn") String sortField, + @RequestParam(value = "sortOrder", required = false, defaultValue = "ASCENDING") + String sortOrder, + @RequestParam(value = "systemMetadata", required = false, defaultValue = "false") + Boolean withSystemMetadata) + throws URISyntaxException { + + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + // TODO: support additional and multiple sort params + SortCriterion sortCriterion = SearchUtil.sortBy(sortField, SortOrder.valueOf(sortOrder)); + + ScrollResult result = + searchService.scrollAcrossEntities( + List.of(entitySpec.getName()), + query, + null, + sortCriterion, + scrollId, + null, + count, + DEFAULT_SEARCH_FLAGS); + + return ResponseEntity.ok( + GenericScrollResult.builder() + .results(toRecordTemplates(result.getEntities(), aspectNames, withSystemMetadata)) + .scrollId(result.getScrollId()) + .build()); + } + + @Tag(name = "Generic Entities") + @GetMapping(value = "/{entityName}/{entityUrn}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Get an entity") + public ResponseEntity getEntity( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @RequestParam(value = "aspectNames", defaultValue = "") Set aspectNames, + @RequestParam(value = "systemMetadata", required = false, defaultValue = "false") + Boolean withSystemMetadata) + throws URISyntaxException { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return ResponseEntity.of( + toRecordTemplates(List.of(UrnUtils.getUrn(entityUrn)), aspectNames, withSystemMetadata) + .stream() + .findFirst()); + } + + @Tag(name = "Generic Entities") + @RequestMapping( + value = "/{entityName}/{entityUrn}", + method = {RequestMethod.HEAD}) + @Operation(summary = "Entity exists") + public ResponseEntity headEntity( + @PathVariable("entityName") String entityName, @PathVariable("entityUrn") String entityUrn) { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return exists(UrnUtils.getUrn(entityUrn), null) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + @Tag(name = "Generic Aspects", description = "API for generic aspects.") + @GetMapping( + value = "/{entityName}/{entityUrn}/{aspectName}", + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Get an entity's generic aspect.") + public ResponseEntity getAspect( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @PathVariable("aspectName") String aspectName) + throws URISyntaxException { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return ResponseEntity.of( + toRecordTemplates(List.of(UrnUtils.getUrn(entityUrn)), Set.of(aspectName), true).stream() + .findFirst() + .flatMap(e -> e.getAspects().values().stream().findFirst())); + } + + @Tag(name = "Generic Aspects") + @RequestMapping( + value = "/{entityName}/{entityUrn}/{aspectName}", + method = {RequestMethod.HEAD}) + @Operation(summary = "Whether an entity aspect exists.") + public ResponseEntity headAspect( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @PathVariable("aspectName") String aspectName) { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return exists(UrnUtils.getUrn(entityUrn), aspectName) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + @Tag(name = "Generic Entities") + @DeleteMapping(value = "/{entityName}/{entityUrn}") + @Operation(summary = "Delete an entity") + public void deleteEntity( + @PathVariable("entityName") String entityName, @PathVariable("entityUrn") String entityUrn) { + + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.DELETE_ENTITY_PRIVILEGE.getType())); + } + + entityService.deleteAspect(entityUrn, entitySpec.getKeyAspectName(), Map.of(), true); + } + + @Tag(name = "Generic Aspects") + @DeleteMapping(value = "/{entityName}/{entityUrn}/{aspectName}") + @Operation(summary = "Delete an entity aspect.") + public void deleteAspect( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @PathVariable("aspectName") String aspectName) { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.DELETE_ENTITY_PRIVILEGE.getType())); + } + + entityService.deleteAspect(entityUrn, aspectName, Map.of(), true); + } + + @Tag(name = "Generic Aspects") + @PostMapping( + value = "/{entityName}/{entityUrn}/{aspectName}", + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Create an entity aspect.") + public ResponseEntity createAspect( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @PathVariable("aspectName") String aspectName, + @RequestParam(value = "systemMetadata", required = false, defaultValue = "false") + Boolean withSystemMetadata, + @RequestBody @Nonnull String jsonAspect) + throws URISyntaxException { + + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + Authentication authentication = AuthenticationContext.getAuthentication(); + + if (restApiAuthorizationEnabled) { + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType())); + } + + AspectSpec aspectSpec = entitySpec.getAspectSpec(aspectName); + UpsertItem upsert = + toUpsertItem(UrnUtils.getUrn(entityUrn), aspectSpec, jsonAspect, authentication.getActor()); + + List results = + entityService.ingestAspects( + AspectsBatchImpl.builder().items(List.of(upsert)).build(), true, true); + + return ResponseEntity.of( + results.stream() + .findFirst() + .map( + result -> + GenericEntity.builder() + .urn(result.getUrn().toString()) + .build( + objectMapper, + Map.of( + aspectName, + Pair.of( + result.getNewValue(), + withSystemMetadata ? result.getNewSystemMetadata() : null))))); + } + + @Tag(name = "Generic Aspects") + @PatchMapping( + value = "/{entityName}/{entityUrn}/{aspectName}", + consumes = "application/json-patch+json", + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Patch an entity aspect. (Experimental)") + public ResponseEntity patchAspect( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @PathVariable("aspectName") String aspectName, + @RequestParam(value = "systemMetadata", required = false, defaultValue = "false") + Boolean withSystemMetadata, + @RequestBody @Nonnull GenericJsonPatch patch) + throws URISyntaxException, + NoSuchMethodException, + InvocationTargetException, + InstantiationException, + IllegalAccessException { + + EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + Authentication authentication = AuthenticationContext.getAuthentication(); + + if (restApiAuthorizationEnabled) { + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpec, + entityUrn, + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType())); + } + + RecordTemplate currentValue = + entityService.getAspect(UrnUtils.getUrn(entityUrn), aspectName, 0); + + AspectSpec aspectSpec = entitySpec.getAspectSpec(aspectName); + GenericPatchTemplate genericPatchTemplate = + GenericPatchTemplate.builder() + .genericJsonPatch(patch) + .templateType(aspectSpec.getDataTemplateClass()) + .templateDefault( + aspectSpec.getDataTemplateClass().getDeclaredConstructor().newInstance()) + .build(); + UpsertItem upsert = + toUpsertItem( + UrnUtils.getUrn(entityUrn), + aspectSpec, + currentValue, + genericPatchTemplate, + authentication.getActor()); + + List results = + entityService.ingestAspects( + AspectsBatchImpl.builder().items(List.of(upsert)).build(), true, true); + + return ResponseEntity.of( + results.stream() + .findFirst() + .map( + result -> + GenericEntity.builder() + .urn(result.getUrn().toString()) + .build( + objectMapper, + Map.of( + aspectName, + Pair.of( + result.getNewValue(), + withSystemMetadata ? result.getNewSystemMetadata() : null))))); + } + + private List toRecordTemplates( + SearchEntityArray searchEntities, Set aspectNames, boolean withSystemMetadata) + throws URISyntaxException { + return toRecordTemplates( + searchEntities.stream().map(SearchEntity::getEntity).collect(Collectors.toList()), + aspectNames, + withSystemMetadata); + } + + private Boolean exists(Urn urn, @Nullable String aspect) { + return aspect == null ? entityService.exists(urn, true) : entityService.exists(urn, aspect); + } + + private List toRecordTemplates( + List urns, Set aspectNames, boolean withSystemMetadata) + throws URISyntaxException { + if (urns.isEmpty()) { + return List.of(); + } else { + Set urnsSet = new HashSet<>(urns); + + Map> aspects = + entityService.getLatestEnvelopedAspects( + urnsSet, resolveAspectNames(urnsSet, aspectNames)); + + return urns.stream() + .map( + u -> + GenericEntity.builder() + .urn(u.toString()) + .build( + objectMapper, + toAspectMap(u, aspects.getOrDefault(u, List.of()), withSystemMetadata))) + .collect(Collectors.toList()); + } + } + + private Set resolveAspectNames(Set urns, Set requestedNames) { + if (requestedNames.isEmpty()) { + return urns.stream() + .flatMap(u -> entityRegistry.getEntitySpec(u.getEntityType()).getAspectSpecs().stream()) + .map(AspectSpec::getName) + .collect(Collectors.toSet()); + } else { + // ensure key is always present + return Stream.concat( + requestedNames.stream(), + urns.stream() + .map(u -> entityRegistry.getEntitySpec(u.getEntityType()).getKeyAspectName())) + .collect(Collectors.toSet()); + } + } + + private Map> toAspectMap( + Urn urn, List aspects, boolean withSystemMetadata) { + return aspects.stream() + .map( + a -> + Map.entry( + a.getName(), + Pair.of( + toRecordTemplate(lookupAspectSpec(urn, a.getName()), a), + withSystemMetadata ? a.getSystemMetadata() : null))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private AspectSpec lookupAspectSpec(Urn urn, String aspectName) { + return entityRegistry.getEntitySpec(urn.getEntityType()).getAspectSpec(aspectName); + } + + private RecordTemplate toRecordTemplate(AspectSpec aspectSpec, EnvelopedAspect envelopedAspect) { + return RecordUtils.toRecordTemplate( + aspectSpec.getDataTemplateClass(), envelopedAspect.getValue().data()); + } + + private UpsertItem toUpsertItem( + Urn entityUrn, AspectSpec aspectSpec, String jsonAspect, Actor actor) + throws URISyntaxException { + return MCPUpsertBatchItem.builder() + .urn(entityUrn) + .aspectName(aspectSpec.getName()) + .auditStamp(AuditStampUtils.createAuditStamp(actor.toUrnStr())) + .aspect( + GenericRecordUtils.deserializeAspect( + ByteString.copyString(jsonAspect, StandardCharsets.UTF_8), + GenericRecordUtils.JSON, + aspectSpec)) + .build(entityService); + } + + private UpsertItem toUpsertItem( + @Nonnull Urn urn, + @Nonnull AspectSpec aspectSpec, + @Nullable RecordTemplate currentValue, + @Nonnull GenericPatchTemplate genericPatchTemplate, + @Nonnull Actor actor) + throws URISyntaxException { + return MCPUpsertBatchItem.fromPatch( + urn, + aspectSpec, + currentValue, + genericPatchTemplate, + AuditStampUtils.createAuditStamp(actor.toUrnStr()), + entityService); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/RelationshipController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/RelationshipController.java new file mode 100644 index 00000000000000..3550a86163f51c --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/RelationshipController.java @@ -0,0 +1,228 @@ +package io.datahubproject.openapi.v2.controller; + +import static io.datahubproject.openapi.v2.utils.ControllerUtil.checkAuthorized; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.AuthenticationContext; +import com.datahub.authorization.AuthorizerChain; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.graph.RelatedEntities; +import com.linkedin.metadata.graph.RelatedEntitiesScrollResult; +import com.linkedin.metadata.graph.elastic.ElasticSearchGraphService; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.query.filter.RelationshipDirection; +import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.utils.QueryUtils; +import com.linkedin.metadata.utils.SearchUtil; +import io.datahubproject.openapi.v2.models.GenericRelationship; +import io.datahubproject.openapi.v2.models.GenericScrollResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/relationship") +@Slf4j +@Tag( + name = "Generic Relationships", + description = "APIs for ingesting and accessing entity relationships.") +public class RelationshipController { + + private static final String[] SORT_FIELDS = {"source.urn", "destination.urn", "relationshipType"}; + private static final String[] SORT_ORDERS = {"ASCENDING", "ASCENDING", "ASCENDING"}; + private static final List EDGE_SORT_CRITERION; + + static { + EDGE_SORT_CRITERION = + IntStream.range(0, SORT_FIELDS.length) + .mapToObj( + idx -> SearchUtil.sortBy(SORT_FIELDS[idx], SortOrder.valueOf(SORT_ORDERS[idx]))) + .collect(Collectors.toList()); + } + + @Autowired private EntityRegistry entityRegistry; + @Autowired private ElasticSearchGraphService graphService; + @Autowired private AuthorizerChain authorizationChain; + + @Autowired private boolean restApiAuthorizationEnabled; + + /** + * Returns relationship edges by type + * + * @param relationshipType the relationship type + * @param count number of results + * @param scrollId scrolling id + * @return list of relation edges + */ + @GetMapping(value = "/{relationshipType}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Scroll relationships of the given type.") + public ResponseEntity> getRelationshipsByType( + @PathVariable("relationshipType") String relationshipType, + @RequestParam(value = "count", defaultValue = "10") Integer count, + @RequestParam(value = "scrollId", required = false) String scrollId) { + + RelatedEntitiesScrollResult result = + graphService.scrollRelatedEntities( + null, + null, + null, + null, + List.of(relationshipType), + new RelationshipFilter().setDirection(RelationshipDirection.UNDIRECTED), + EDGE_SORT_CRITERION, + scrollId, + count, + null, + null); + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + Set entitySpecs = + result.getEntities().stream() + .flatMap( + relatedEntity -> + Stream.of( + entityRegistry.getEntitySpec( + UrnUtils.getUrn(relatedEntity.getUrn()).getEntityType()), + entityRegistry.getEntitySpec( + UrnUtils.getUrn(relatedEntity.getSourceUrn()).getEntityType()))) + .collect(Collectors.toSet()); + + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpecs, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return ResponseEntity.ok( + GenericScrollResult.builder() + .results(toGenericRelationships(result.getEntities())) + .scrollId(result.getScrollId()) + .build()); + } + + /** + * Returns edges for a given urn + * + * @param relationshipTypes types of edges + * @param direction direction of the edges + * @param count number of results + * @param scrollId scroll id + * @return urn edges + */ + @GetMapping(value = "/{entityName}/{entityUrn}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Scroll relationships from a given entity.") + public ResponseEntity> getRelationshipsByEntity( + @PathVariable("entityName") String entityName, + @PathVariable("entityUrn") String entityUrn, + @RequestParam(value = "relationshipType[]", required = false, defaultValue = "*") + String[] relationshipTypes, + @RequestParam(value = "direction", defaultValue = "OUTGOING") String direction, + @RequestParam(value = "count", defaultValue = "10") Integer count, + @RequestParam(value = "scrollId", required = false) String scrollId) { + + final RelatedEntitiesScrollResult result; + + switch (RelationshipDirection.valueOf(direction.toUpperCase())) { + case INCOMING -> result = + graphService.scrollRelatedEntities( + null, + null, + null, + null, + relationshipTypes.length > 0 && !relationshipTypes[0].equals("*") + ? Arrays.stream(relationshipTypes).toList() + : List.of(), + new RelationshipFilter() + .setDirection(RelationshipDirection.UNDIRECTED) + .setOr(QueryUtils.newFilter("destination.urn", entityUrn).getOr()), + EDGE_SORT_CRITERION, + scrollId, + count, + null, + null); + case OUTGOING -> result = + graphService.scrollRelatedEntities( + null, + null, + null, + null, + relationshipTypes.length > 0 && !relationshipTypes[0].equals("*") + ? Arrays.stream(relationshipTypes).toList() + : List.of(), + new RelationshipFilter() + .setDirection(RelationshipDirection.UNDIRECTED) + .setOr(QueryUtils.newFilter("source.urn", entityUrn).getOr()), + EDGE_SORT_CRITERION, + scrollId, + count, + null, + null); + default -> throw new IllegalArgumentException("Direction must be INCOMING or OUTGOING"); + } + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + Set entitySpecs = + result.getEntities().stream() + .flatMap( + relatedEntity -> + Stream.of( + entityRegistry.getEntitySpec( + UrnUtils.getUrn(relatedEntity.getDestinationUrn()).getEntityType()), + entityRegistry.getEntitySpec( + UrnUtils.getUrn(relatedEntity.getSourceUrn()).getEntityType()))) + .collect(Collectors.toSet()); + + checkAuthorized( + authorizationChain, + authentication.getActor(), + entitySpecs, + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + return ResponseEntity.ok( + GenericScrollResult.builder() + .results(toGenericRelationships(result.getEntities())) + .scrollId(result.getScrollId()) + .build()); + } + + private List toGenericRelationships(List relatedEntities) { + return relatedEntities.stream() + .map( + result -> { + Urn source = UrnUtils.getUrn(result.getSourceUrn()); + Urn dest = UrnUtils.getUrn(result.getDestinationUrn()); + return GenericRelationship.builder() + .relationshipType(result.getRelationshipType()) + .source(GenericRelationship.GenericNode.fromUrn(source)) + .destination(GenericRelationship.GenericNode.fromUrn(dest)) + .build(); + }) + .collect(Collectors.toList()); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/TimeseriesController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/TimeseriesController.java new file mode 100644 index 00000000000000..ab12b683390110 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/TimeseriesController.java @@ -0,0 +1,115 @@ +package io.datahubproject.openapi.v2.controller; + +import static io.datahubproject.openapi.v2.utils.ControllerUtil.checkAuthorized; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.AuthenticationContext; +import com.datahub.authorization.AuthorizerChain; +import com.google.common.collect.ImmutableList; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.models.AspectSpec; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.timeseries.GenericTimeseriesDocument; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.metadata.timeseries.TimeseriesScrollResult; +import com.linkedin.metadata.utils.SearchUtil; +import io.datahubproject.openapi.v2.models.GenericScrollResult; +import io.datahubproject.openapi.v2.models.GenericTimeseriesAspect; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.URISyntaxException; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/timeseries") +@Slf4j +@Tag( + name = "Generic Timeseries Aspects", + description = "APIs for ingesting and accessing timeseries aspects") +public class TimeseriesController { + + @Autowired private EntityRegistry entityRegistry; + + @Autowired private TimeseriesAspectService timeseriesAspectService; + + @Autowired private AuthorizerChain authorizationChain; + + @Autowired private boolean restApiAuthorizationEnabled; + + @GetMapping(value = "/{entityName}/{aspectName}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAspects( + @PathVariable("entityName") String entityName, + @PathVariable("aspectName") String aspectName, + @RequestParam(value = "count", defaultValue = "10") Integer count, + @RequestParam(value = "scrollId", required = false) String scrollId, + @RequestParam(value = "startTimeMillis", required = false) Long startTimeMillis, + @RequestParam(value = "endTimeMillis", required = false) Long endTimeMillis, + @RequestParam(value = "systemMetadata", required = false, defaultValue = "false") + Boolean withSystemMetadata) + throws URISyntaxException { + + if (restApiAuthorizationEnabled) { + Authentication authentication = AuthenticationContext.getAuthentication(); + checkAuthorized( + authorizationChain, + authentication.getActor(), + entityRegistry.getEntitySpec(entityName), + ImmutableList.of(PoliciesConfig.GET_ENTITY_PRIVILEGE.getType())); + } + + AspectSpec aspectSpec = entityRegistry.getEntitySpec(entityName).getAspectSpec(aspectName); + if (!aspectSpec.isTimeseries()) { + throw new IllegalArgumentException("Only timeseries aspects are supported."); + } + + List sortCriterion = + List.of( + SearchUtil.sortBy("timestampMillis", SortOrder.DESCENDING), + SearchUtil.sortBy("messageId", SortOrder.DESCENDING)); + + TimeseriesScrollResult result = + timeseriesAspectService.scrollAspects( + entityName, + aspectName, + null, + sortCriterion, + scrollId, + count, + startTimeMillis, + endTimeMillis); + + return ResponseEntity.ok( + GenericScrollResult.builder() + .scrollId(result.getScrollId()) + .results(toGenericTimeseriesAspect(result.getDocuments(), withSystemMetadata)) + .build()); + } + + private static List toGenericTimeseriesAspect( + List docs, boolean withSystemMetadata) { + return docs.stream() + .map( + doc -> + GenericTimeseriesAspect.builder() + .urn(doc.getUrn()) + .messageId(doc.getMessageId()) + .timestampMillis(doc.getTimestampMillis()) + .systemMetadata(withSystemMetadata ? doc.getSystemMetadata() : null) + .event(doc.getEvent()) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericEntity.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericEntity.java new file mode 100644 index 00000000000000..f1e965ca05464f --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericEntity.java @@ -0,0 +1,57 @@ +package io.datahubproject.openapi.v2.models; + +import com.datahub.util.RecordUtils; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.mxe.SystemMetadata; +import com.linkedin.util.Pair; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GenericEntity { + private String urn; + private Map aspects; + + public static class GenericEntityBuilder { + + public GenericEntity build( + ObjectMapper objectMapper, Map> aspects) { + Map jsonObjectMap = + aspects.entrySet().stream() + .map( + e -> { + try { + Map valueMap = + Map.of( + "value", + objectMapper.readTree( + RecordUtils.toJsonString(e.getValue().getFirst()) + .getBytes(StandardCharsets.UTF_8))); + + if (e.getValue().getSecond() != null) { + return Map.entry( + e.getKey(), + Map.of( + "systemMetadata", e.getValue().getSecond(), + "value", valueMap.get("value"))); + } else { + return Map.entry(e.getKey(), Map.of("value", valueMap.get("value"))); + } + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return new GenericEntity(urn, jsonObjectMap); + } + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericRelationship.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericRelationship.java new file mode 100644 index 00000000000000..a4fb429c1eb185 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericRelationship.java @@ -0,0 +1,36 @@ +package io.datahubproject.openapi.v2.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.linkedin.common.urn.Urn; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GenericRelationship { + @Nonnull private String relationshipType; + @Nonnull private GenericNode destination; + @Nonnull private GenericNode source; + @Nullable private NodeProperties properties; + + @Data + @Builder + public static class GenericNode { + @Nonnull private String entityType; + @Nonnull private String urn; + + public static GenericNode fromUrn(@Nonnull Urn urn) { + return GenericNode.builder().entityType(urn.getEntityType()).urn(urn.toString()).build(); + } + } + + @Data + @Builder + public static class NodeProperties { + private List source; + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericScrollResult.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericScrollResult.java new file mode 100644 index 00000000000000..2befc83c003634 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericScrollResult.java @@ -0,0 +1,12 @@ +package io.datahubproject.openapi.v2.models; + +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class GenericScrollResult { + private String scrollId; + private List results; +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericTimeseriesAspect.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericTimeseriesAspect.java new file mode 100644 index 00000000000000..9d52ed28b20666 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/GenericTimeseriesAspect.java @@ -0,0 +1,18 @@ +package io.datahubproject.openapi.v2.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GenericTimeseriesAspect { + private long timestampMillis; + @Nonnull private String urn; + @Nonnull private Object event; + @Nullable private String messageId; + @Nullable private Object systemMetadata; +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/PatchOperation.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/PatchOperation.java new file mode 100644 index 00000000000000..c5323dfe68369d --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/models/PatchOperation.java @@ -0,0 +1,26 @@ +package io.datahubproject.openapi.v2.models; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.metadata.aspect.patch.PatchOperationType; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PatchOperation { + @Nonnull private String op; + @Nonnull private String path; + @Nullable private JsonNode value; + @Nullable private List arrayMapKey; + + public PatchOperationType getOp() { + return PatchOperationType.valueOf(op.toUpperCase()); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/utils/ControllerUtil.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/utils/ControllerUtil.java new file mode 100644 index 00000000000000..70d588721d3b3c --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/utils/ControllerUtil.java @@ -0,0 +1,67 @@ +package io.datahubproject.openapi.v2.utils; + +import com.datahub.authentication.Actor; +import com.datahub.authorization.AuthUtil; +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.datahub.plugins.auth.authorization.Authorizer; +import com.google.common.collect.ImmutableList; +import com.linkedin.metadata.models.EntitySpec; +import io.datahubproject.openapi.exception.UnauthorizedException; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class ControllerUtil { + private ControllerUtil() {} + + public static void checkAuthorized( + @Nonnull Authorizer authorizationChain, + @Nonnull Actor actor, + @Nonnull EntitySpec entitySpec, + @Nonnull List privileges) { + checkAuthorized(authorizationChain, actor, entitySpec, null, privileges); + } + + public static void checkAuthorized( + @Nonnull Authorizer authorizationChain, + @Nonnull Actor actor, + @Nonnull Set entitySpecs, + @Nonnull List privileges) { + DisjunctivePrivilegeGroup orGroup = + new DisjunctivePrivilegeGroup(ImmutableList.of(new ConjunctivePrivilegeGroup(privileges))); + List> resourceSpecs = + entitySpecs.stream() + .map( + entitySpec -> + Optional.of(new com.datahub.authorization.EntitySpec(entitySpec.getName(), ""))) + .collect(Collectors.toList()); + if (!AuthUtil.isAuthorizedForResources( + authorizationChain, actor.toUrnStr(), resourceSpecs, orGroup)) { + throw new UnauthorizedException(actor.toUrnStr() + " is unauthorized to get entities."); + } + } + + public static void checkAuthorized( + @Nonnull Authorizer authorizationChain, + @Nonnull Actor actor, + @Nonnull EntitySpec entitySpec, + @Nullable String entityUrn, + @Nonnull List privileges) { + DisjunctivePrivilegeGroup orGroup = + new DisjunctivePrivilegeGroup(ImmutableList.of(new ConjunctivePrivilegeGroup(privileges))); + + List> resourceSpecs = + List.of( + Optional.of( + new com.datahub.authorization.EntitySpec( + entitySpec.getName(), entityUrn != null ? entityUrn : ""))); + if (!AuthUtil.isAuthorizedForResources( + authorizationChain, actor.toUrnStr(), resourceSpecs, orGroup)) { + throw new UnauthorizedException(actor.toUrnStr() + " is unauthorized to get entities."); + } + } +} diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json index bca3e7161c8b83..ee45b8921143a1 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json @@ -319,6 +319,7 @@ "default" : { }, "Searchable" : { "/*" : { + "fieldType" : "TEXT", "queryByDefault" : true } } @@ -993,6 +994,11 @@ "filterNameOverride" : "Glossary Term", "hasValuesFieldName" : "hasGlossaryTerms" } + }, { + "name" : "actor", + "type" : "Urn", + "doc" : "The user URN which will be credited for adding associating this term to the entity", + "optional" : true }, { "name" : "context", "type" : "string", @@ -2049,6 +2055,7 @@ "name" : "GlossaryNodeInfo", "namespace" : "com.linkedin.glossary", "doc" : "Properties associated with a GlossaryNode", + "include" : [ "com.linkedin.common.CustomProperties" ], "fields" : [ { "name" : "definition", "type" : "string", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json index 69184856e4f9ea..505f44c52d5833 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json @@ -61,6 +61,7 @@ "default" : { }, "Searchable" : { "/*" : { + "fieldType" : "TEXT", "queryByDefault" : true } } @@ -993,6 +994,11 @@ "filterNameOverride" : "Glossary Term", "hasValuesFieldName" : "hasGlossaryTerms" } + }, { + "name" : "actor", + "type" : "Urn", + "doc" : "The user URN which will be credited for adding associating this term to the entity", + "optional" : true }, { "name" : "context", "type" : "string", @@ -5084,6 +5090,7 @@ "name" : "GlossaryNodeInfo", "namespace" : "com.linkedin.glossary", "doc" : "Properties associated with a GlossaryNode", + "include" : [ "com.linkedin.common.CustomProperties" ], "fields" : [ { "name" : "definition", "type" : "string", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json index 09c0185f74f3ad..e8c15d1b4ca04a 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json @@ -61,6 +61,7 @@ "default" : { }, "Searchable" : { "/*" : { + "fieldType" : "TEXT", "queryByDefault" : true } } @@ -735,6 +736,11 @@ "filterNameOverride" : "Glossary Term", "hasValuesFieldName" : "hasGlossaryTerms" } + }, { + "name" : "actor", + "type" : "Urn", + "doc" : "The user URN which will be credited for adding associating this term to the entity", + "optional" : true }, { "name" : "context", "type" : "string", @@ -1783,6 +1789,7 @@ "name" : "GlossaryNodeInfo", "namespace" : "com.linkedin.glossary", "doc" : "Properties associated with a GlossaryNode", + "include" : [ "com.linkedin.common.CustomProperties" ], "fields" : [ { "name" : "definition", "type" : "string", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json index eae0eed2dd50ba..67f70d40e010c4 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json @@ -61,6 +61,7 @@ "default" : { }, "Searchable" : { "/*" : { + "fieldType" : "TEXT", "queryByDefault" : true } } @@ -735,6 +736,11 @@ "filterNameOverride" : "Glossary Term", "hasValuesFieldName" : "hasGlossaryTerms" } + }, { + "name" : "actor", + "type" : "Urn", + "doc" : "The user URN which will be credited for adding associating this term to the entity", + "optional" : true }, { "name" : "context", "type" : "string", @@ -1777,6 +1783,7 @@ "name" : "GlossaryNodeInfo", "namespace" : "com.linkedin.glossary", "doc" : "Properties associated with a GlossaryNode", + "include" : [ "com.linkedin.common.CustomProperties" ], "fields" : [ { "name" : "definition", "type" : "string", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json index cb253c458e6c40..4c8cd1f20d4769 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json @@ -61,6 +61,7 @@ "default" : { }, "Searchable" : { "/*" : { + "fieldType" : "TEXT", "queryByDefault" : true } } @@ -993,6 +994,11 @@ "filterNameOverride" : "Glossary Term", "hasValuesFieldName" : "hasGlossaryTerms" } + }, { + "name" : "actor", + "type" : "Urn", + "doc" : "The user URN which will be credited for adding associating this term to the entity", + "optional" : true }, { "name" : "context", "type" : "string", @@ -5078,6 +5084,7 @@ "name" : "GlossaryNodeInfo", "namespace" : "com.linkedin.glossary", "doc" : "Properties associated with a GlossaryNode", + "include" : [ "com.linkedin.common.CustomProperties" ], "fields" : [ { "name" : "definition", "type" : "string", diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java index 64ae3632c353a2..2f470dca01f2a3 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java @@ -1,6 +1,9 @@ package com.linkedin.entity.client; +import static com.linkedin.metadata.utils.GenericRecordUtils.entityResponseToAspectMap; + import com.datahub.authentication.Authentication; +import com.datahub.plugins.auth.authorization.Authorizer; import com.linkedin.common.VersionedUrn; import com.linkedin.common.urn.Urn; import com.linkedin.data.DataMap; @@ -11,7 +14,6 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.metadata.aspect.EnvelopedAspect; import com.linkedin.metadata.aspect.VersionedAspect; -import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.browse.BrowseResult; import com.linkedin.metadata.browse.BrowseResultV2; import com.linkedin.metadata.graph.LineageDirection; @@ -40,7 +42,7 @@ import javax.annotation.Nullable; // Consider renaming this to datahub client. -public interface EntityClient extends AspectRetriever { +public interface EntityClient { @Nullable public EntityResponse getV2( @@ -623,14 +625,26 @@ public void producePlatformEvent( @Nonnull Authentication authentication) throws Exception; - public void rollbackIngestion(@Nonnull String runId, @Nonnull Authentication authentication) + public void rollbackIngestion( + @Nonnull String runId, @Nonnull Authorizer authorizer, @Nonnull Authentication authentication) throws Exception; - default Aspect getLatestAspectObject(@Nonnull Urn urn, @Nonnull String aspectName) + @Nullable + default Aspect getLatestAspectObject( + @Nonnull Urn urn, @Nonnull String aspectName, @Nonnull Authentication authentication) + throws RemoteInvocationException, URISyntaxException { + return getLatestAspects(Set.of(urn), Set.of(aspectName), authentication) + .getOrDefault(urn, Map.of()) + .get(aspectName); + } + + @Nonnull + default Map> getLatestAspects( + @Nonnull Set urns, + @Nonnull Set aspectNames, + @Nonnull Authentication authentication) throws RemoteInvocationException, URISyntaxException { - return getV2(urn.getEntityType(), urn, Set.of(aspectName), null) - .getAspects() - .get(aspectName) - .getValue(); + String entityName = urns.stream().findFirst().map(Urn::getEntityType).get(); + return entityResponseToAspectMap(batchGetV2(entityName, urns, aspectNames, authentication)); } } diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java index d68c472ea91709..3108345bd39376 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java @@ -1,6 +1,7 @@ package com.linkedin.entity.client; import com.datahub.authentication.Authentication; +import com.datahub.plugins.auth.authorization.Authorizer; import com.datahub.util.RecordUtils; import com.google.common.collect.ImmutableList; import com.linkedin.common.VersionedUrn; @@ -539,7 +540,9 @@ public SearchResult search( if (searchFlags != null) { requestBuilder.searchFlagsParam(searchFlags); - requestBuilder.fulltextParam(searchFlags.isFulltext()); + if (searchFlags.isFulltext() != null) { + requestBuilder.fulltextParam(searchFlags.isFulltext()); + } } return sendClientRequest(requestBuilder, authentication).getEntity(); @@ -1057,7 +1060,10 @@ public void producePlatformEvent( } @Override - public void rollbackIngestion(@Nonnull String runId, @Nonnull final Authentication authentication) + public void rollbackIngestion( + @Nonnull String runId, + @Nonnull Authorizer authorizer, + @Nonnull final Authentication authentication) throws Exception { final RunsDoRollbackRequestBuilder requestBuilder = RUNS_REQUEST_BUILDERS.actionRollback().runIdParam(runId).dryRunParam(false); diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemEntityClient.java index dfad20b5f52b29..243e8a40bf4b75 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemEntityClient.java @@ -4,24 +4,60 @@ import com.linkedin.common.urn.Urn; import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; -import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.config.cache.client.EntityClientCacheConfig; +import com.linkedin.metadata.query.SearchFlags; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.search.ScrollResult; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.PlatformEvent; import com.linkedin.r2.RemoteInvocationException; import java.net.URISyntaxException; +import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.Nonnull; import javax.annotation.Nullable; -/** Adds entity/aspect cache and assumes system authentication */ -public interface SystemEntityClient extends EntityClient, AspectRetriever { +/** Adds entity/aspect cache and assumes **system** authentication */ +public interface SystemEntityClient extends EntityClient { EntityClientCache getEntityClientCache(); Authentication getSystemAuthentication(); + /** + * Searches for entities matching to a given query and filters across multiple entity types + * + * @param entities entity types to search (if empty, searches all entities) + * @param input search query + * @param filter search filters + * @param scrollId opaque scroll ID indicating offset + * @param keepAlive string representation of time to keep point in time alive, ex: 5m + * @param count max number of search results requested + * @return Snapshot key + * @throws RemoteInvocationException + */ + @Nonnull + default ScrollResult scrollAcrossEntities( + @Nonnull List entities, + @Nonnull String input, + @Nullable Filter filter, + @Nullable String scrollId, + @Nullable String keepAlive, + int count, + @Nullable SearchFlags searchFlags) + throws RemoteInvocationException { + return scrollAcrossEntities( + entities, + input, + filter, + scrollId, + keepAlive, + count, + searchFlags, + getSystemAuthentication()); + } + /** * Builds the cache * @@ -101,11 +137,16 @@ default void setWritable(boolean canWrite) throws RemoteInvocationException { setWritable(canWrite, getSystemAuthentication()); } + @Nullable default Aspect getLatestAspectObject(@Nonnull Urn urn, @Nonnull String aspectName) throws RemoteInvocationException, URISyntaxException { - return getV2(urn.getEntityType(), urn, Set.of(aspectName), getSystemAuthentication()) - .getAspects() - .get(aspectName) - .getValue(); + return getLatestAspectObject(urn, aspectName, getSystemAuthentication()); + } + + @Nonnull + default Map> getLatestAspects( + @Nonnull Set urns, @Nonnull Set aspectNames) + throws RemoteInvocationException, URISyntaxException { + return getLatestAspects(urns, aspectNames, getSystemAuthentication()); } } diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemRestliEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemRestliEntityClient.java index a2f5596af9f4ea..0f179c4da7b74c 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemRestliEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/SystemRestliEntityClient.java @@ -17,7 +17,7 @@ public SystemRestliEntityClient( @Nonnull final Client restliClient, @Nonnull final BackoffPolicy backoffPolicy, int retryCount, - Authentication systemAuthentication, + @Nonnull Authentication systemAuthentication, EntityClientCacheConfig cacheConfig) { super(restliClient, backoffPolicy, retryCount); this.systemAuthentication = systemAuthentication; diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java index c5b019e85e0c9d..ffa3abe6806f99 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java @@ -252,14 +252,14 @@ public Task ingestProposal( if (asyncBool) { // if async we'll expand the getAdditionalChanges later, no need to do this early batch = AspectsBatchImpl.builder() - .mcps(List.of(metadataChangeProposal), auditStamp, _entityService.getEntityRegistry(), _entityService.getSystemEntityClient()) + .mcps(List.of(metadataChangeProposal), auditStamp, _entityService) .build(); } else { Stream proposalStream = Stream.concat(Stream.of(metadataChangeProposal), AspectUtils.getAdditionalChanges(metadataChangeProposal, _entityService).stream()); batch = AspectsBatchImpl.builder() - .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService.getEntityRegistry(), _entityService.getSystemEntityClient()) + .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) .build(); } diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/BatchIngestionRunResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/BatchIngestionRunResource.java index 294ded8a1e2556..869cfc7afdee83 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/BatchIngestionRunResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/BatchIngestionRunResource.java @@ -1,40 +1,25 @@ package com.linkedin.metadata.resources.entity; -import static com.linkedin.metadata.Constants.*; -import static com.linkedin.metadata.resources.restli.RestliUtils.*; +import static com.linkedin.metadata.service.RollbackService.ROLLBACK_FAILED_STATUS; import com.codahale.metrics.MetricRegistry; import com.datahub.authentication.Authentication; import com.datahub.authentication.AuthenticationContext; -import com.datahub.authorization.EntitySpec; +import com.datahub.authentication.AuthenticationException; import com.datahub.plugins.auth.authorization.Authorizer; -import com.google.common.collect.ImmutableList; -import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.entity.EnvelopedAspect; -import com.linkedin.events.metadata.ChangeType; -import com.linkedin.execution.ExecutionRequestResult; -import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.VersionedAspect; -import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.entity.RollbackRunResult; -import com.linkedin.metadata.key.ExecutionRequestKey; import com.linkedin.metadata.restli.RestliUtil; import com.linkedin.metadata.run.AspectRowSummary; import com.linkedin.metadata.run.AspectRowSummaryArray; import com.linkedin.metadata.run.IngestionRunSummary; import com.linkedin.metadata.run.IngestionRunSummaryArray; import com.linkedin.metadata.run.RollbackResponse; -import com.linkedin.metadata.run.UnsafeEntityInfo; -import com.linkedin.metadata.run.UnsafeEntityInfoArray; -import com.linkedin.metadata.search.utils.ESUtils; +import com.linkedin.metadata.service.RollbackService; import com.linkedin.metadata.systemmetadata.SystemMetadataService; -import com.linkedin.metadata.timeseries.TimeseriesAspectService; -import com.linkedin.metadata.utils.EntityKeyUtils; -import com.linkedin.metadata.utils.GenericRecordUtils; -import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.parseq.Task; import com.linkedin.restli.common.HttpStatus; import com.linkedin.restli.server.RestLiServiceException; @@ -43,13 +28,8 @@ import com.linkedin.restli.server.annotations.Optional; import com.linkedin.restli.server.annotations.RestLiCollection; import com.linkedin.restli.server.resources.CollectionResourceTaskTemplate; -import com.linkedin.timeseries.DeleteAspectValuesResult; import io.opentelemetry.extension.annotations.WithSpan; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.inject.Inject; @@ -64,14 +44,8 @@ public class BatchIngestionRunResource private static final Integer DEFAULT_OFFSET = 0; private static final Integer DEFAULT_PAGE_SIZE = 100; - private static final Integer DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE = 1000000; private static final boolean DEFAULT_INCLUDE_SOFT_DELETED = false; private static final boolean DEFAULT_HARD_DELETE = false; - private static final Integer ELASTIC_MAX_PAGE_SIZE = 10000; - private static final Integer ELASTIC_BATCH_DELETE_SLEEP_SEC = 5; - private static final String ROLLING_BACK_STATUS = "ROLLING_BACK"; - private static final String ROLLED_BACK_STATUS = "ROLLED_BACK"; - private static final String ROLLBACK_FAILED_STATUS = "ROLLBACK_FAILED"; @Inject @Named("systemMetadataService") @@ -79,15 +53,15 @@ public class BatchIngestionRunResource @Inject @Named("entityService") - private EntityService _entityService; + private EntityService _entityService; @Inject - @Named("timeseriesAspectService") - private TimeseriesAspectService _timeseriesAspectService; + @Named("rollbackService") + private RollbackService rollbackService; - @Inject - @Named("authorizerChain") - private Authorizer _authorizer; + @Inject + @Named("authorizerChain") + private Authorizer _authorizer; /** Rolls back an ingestion run */ @Action(name = "rollback") @@ -111,274 +85,23 @@ public Task rollback( try { return RestliUtil.toTask( () -> { - if (runId.equals(DEFAULT_RUN_ID)) { - throw new IllegalArgumentException( - String.format( - "%s is a default run-id provided for non labeled ingestion runs. You cannot delete using this reserved run-id", - runId)); - } - if (!dryRun) { - updateExecutionRequestStatus(runId, ROLLING_BACK_STATUS); - } - - RollbackResponse response = new RollbackResponse(); - List aspectRowsToDelete; - aspectRowsToDelete = - _systemMetadataService.findByRunId(runId, doHardDelete, 0, ESUtils.MAX_RESULT_SIZE); - Set urns = - aspectRowsToDelete.stream() - .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) - .keySet(); - List> resourceSpecs = - urns.stream() - .map(UrnUtils::getUrn) - .map( - urn -> - java.util.Optional.of( - new EntitySpec(urn.getEntityType(), urn.toString()))) - .collect(Collectors.toList()); - Authentication auth = AuthenticationContext.getAuthentication(); - if (Boolean.parseBoolean(System.getenv(REST_API_AUTHORIZATION_ENABLED_ENV)) - && !isAuthorized( - auth, - _authorizer, - ImmutableList.of(PoliciesConfig.DELETE_ENTITY_PRIVILEGE), - resourceSpecs)) { - throw new RestLiServiceException( - HttpStatus.S_401_UNAUTHORIZED, "User is unauthorized to delete entities."); - } - log.info("found {} rows to delete...", stringifyRowCount(aspectRowsToDelete.size())); - if (dryRun) { - - final Map> aspectsSplitByIsKeyAspects = - aspectRowsToDelete.stream() - .collect(Collectors.partitioningBy(AspectRowSummary::isKeyAspect)); - - final List keyAspects = aspectsSplitByIsKeyAspects.get(true); - - long entitiesDeleted = keyAspects.size(); - long aspectsReverted = aspectRowsToDelete.size(); - final long affectedEntities = - aspectRowsToDelete.stream() - .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) - .keySet() - .size(); - - final AspectRowSummaryArray rowSummaries = - new AspectRowSummaryArray( - aspectRowsToDelete.subList(0, Math.min(100, aspectRowsToDelete.size()))); - - // If we are soft deleting, remove key aspects from count of aspects being deleted - if (!doHardDelete) { - aspectsReverted -= keyAspects.size(); - rowSummaries.removeIf(AspectRowSummary::isKeyAspect); + Authentication auth = AuthenticationContext.getAuthentication(); + try { + return rollbackService.rollbackIngestion(runId, dryRun, doHardDelete, _authorizer, auth); + } catch (AuthenticationException authException) { + throw new RestLiServiceException( + HttpStatus.S_401_UNAUTHORIZED, authException.getMessage()); } - // Compute the aspects that exist referencing the key aspects we are deleting - final List affectedAspectsList = - keyAspects.stream() - .map( - (AspectRowSummary urn) -> - _systemMetadataService.findByUrn( - urn.getUrn(), false, 0, ESUtils.MAX_RESULT_SIZE)) - .flatMap(List::stream) - .filter( - row -> - !row.getRunId().equals(runId) - && !row.isKeyAspect() - && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) - .collect(Collectors.toList()); - - long affectedAspects = affectedAspectsList.size(); - long unsafeEntitiesCount = - affectedAspectsList.stream() - .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) - .keySet() - .size(); - - final List unsafeEntityInfos = - affectedAspectsList.stream() - .map(AspectRowSummary::getUrn) - .distinct() - .map( - urn -> { - UnsafeEntityInfo unsafeEntityInfo = new UnsafeEntityInfo(); - unsafeEntityInfo.setUrn(urn); - return unsafeEntityInfo; - }) - // Return at most 1 million rows - .limit(DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE) - .collect(Collectors.toList()); - - return response - .setAspectsAffected(affectedAspects) - .setAspectsReverted(aspectsReverted) - .setEntitiesAffected(affectedEntities) - .setEntitiesDeleted(entitiesDeleted) - .setUnsafeEntitiesCount(unsafeEntitiesCount) - .setUnsafeEntities(new UnsafeEntityInfoArray(unsafeEntityInfos)) - .setAspectRowSummaries(rowSummaries); - } - - RollbackRunResult rollbackRunResult = - _entityService.rollbackRun(aspectRowsToDelete, runId, doHardDelete); - final List deletedRows = rollbackRunResult.getRowsRolledBack(); - int rowsDeletedFromEntityDeletion = - rollbackRunResult.getRowsDeletedFromEntityDeletion(); - - // since elastic limits how many rows we can access at once, we need to iteratively - // delete - while (aspectRowsToDelete.size() >= ELASTIC_MAX_PAGE_SIZE) { - sleep(ELASTIC_BATCH_DELETE_SLEEP_SEC); - aspectRowsToDelete = - _systemMetadataService.findByRunId( - runId, doHardDelete, 0, ESUtils.MAX_RESULT_SIZE); - log.info( - "{} remaining rows to delete...", stringifyRowCount(aspectRowsToDelete.size())); - log.info("deleting..."); - rollbackRunResult = - _entityService.rollbackRun(aspectRowsToDelete, runId, doHardDelete); - deletedRows.addAll(rollbackRunResult.getRowsRolledBack()); - rowsDeletedFromEntityDeletion += rollbackRunResult.getRowsDeletedFromEntityDeletion(); - } - - // Rollback timeseries aspects - DeleteAspectValuesResult timeseriesRollbackResult = - _timeseriesAspectService.rollbackTimeseriesAspects(runId); - rowsDeletedFromEntityDeletion += timeseriesRollbackResult.getNumDocsDeleted(); - - log.info("finished deleting {} rows", deletedRows.size()); - int aspectsReverted = deletedRows.size() + rowsDeletedFromEntityDeletion; - - final Map> aspectsSplitByIsKeyAspects = - aspectRowsToDelete.stream() - .collect(Collectors.partitioningBy(AspectRowSummary::isKeyAspect)); - - final List keyAspects = aspectsSplitByIsKeyAspects.get(true); - - final long entitiesDeleted = keyAspects.size(); - final long affectedEntities = - deletedRows.stream() - .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) - .keySet() - .size(); - - final AspectRowSummaryArray rowSummaries = - new AspectRowSummaryArray( - aspectRowsToDelete.subList(0, Math.min(100, aspectRowsToDelete.size()))); - - log.info("computing aspects affected by this rollback..."); - // Compute the aspects that exist referencing the key aspects we are deleting - final List affectedAspectsList = - keyAspects.stream() - .map( - (AspectRowSummary urn) -> - _systemMetadataService.findByUrn( - urn.getUrn(), false, 0, ESUtils.MAX_RESULT_SIZE)) - .flatMap(List::stream) - .filter( - row -> - !row.getRunId().equals(runId) - && !row.isKeyAspect() - && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) - .collect(Collectors.toList()); - - long affectedAspects = affectedAspectsList.size(); - long unsafeEntitiesCount = - affectedAspectsList.stream() - .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) - .keySet() - .size(); - - final List unsafeEntityInfos = - affectedAspectsList.stream() - .map(AspectRowSummary::getUrn) - .distinct() - .map( - urn -> { - UnsafeEntityInfo unsafeEntityInfo = new UnsafeEntityInfo(); - unsafeEntityInfo.setUrn(urn); - return unsafeEntityInfo; - }) - // Return at most 1 million rows - .limit(DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE) - .collect(Collectors.toList()); - - log.info("calculation done."); - - updateExecutionRequestStatus(runId, ROLLED_BACK_STATUS); - - return response - .setAspectsAffected(affectedAspects) - .setAspectsReverted(aspectsReverted) - .setEntitiesAffected(affectedEntities) - .setEntitiesDeleted(entitiesDeleted) - .setUnsafeEntitiesCount(unsafeEntitiesCount) - .setUnsafeEntities(new UnsafeEntityInfoArray(unsafeEntityInfos)) - .setAspectRowSummaries(rowSummaries); }, MetricRegistry.name(this.getClass(), "rollback")); } catch (Exception e) { - updateExecutionRequestStatus(runId, ROLLBACK_FAILED_STATUS); + rollbackService.updateExecutionRequestStatus(runId, ROLLBACK_FAILED_STATUS); throw new RuntimeException( String.format("There was an issue rolling back ingestion run with runId %s", runId), e); } } - private String stringifyRowCount(int size) { - if (size < ELASTIC_MAX_PAGE_SIZE) { - return String.valueOf(size); - } else { - return "at least " + size; - } - } - - private void sleep(Integer seconds) { - try { - TimeUnit.SECONDS.sleep(seconds); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - - private void updateExecutionRequestStatus(String runId, String status) { - try { - final Urn executionRequestUrn = - EntityKeyUtils.convertEntityKeyToUrn( - new ExecutionRequestKey().setId(runId), Constants.EXECUTION_REQUEST_ENTITY_NAME); - EnvelopedAspect aspect = - _entityService.getLatestEnvelopedAspect( - executionRequestUrn.getEntityType(), - executionRequestUrn, - Constants.EXECUTION_REQUEST_RESULT_ASPECT_NAME); - if (aspect == null) { - log.warn("Aspect for execution request with runId {} not found", runId); - } else { - final MetadataChangeProposal proposal = new MetadataChangeProposal(); - ExecutionRequestResult requestResult = new ExecutionRequestResult(aspect.getValue().data()); - requestResult.setStatus(status); - proposal.setEntityUrn(executionRequestUrn); - proposal.setEntityType(Constants.EXECUTION_REQUEST_ENTITY_NAME); - proposal.setAspectName(Constants.EXECUTION_REQUEST_RESULT_ASPECT_NAME); - proposal.setAspect(GenericRecordUtils.serializeAspect(requestResult)); - proposal.setChangeType(ChangeType.UPSERT); - - _entityService.ingestProposal( - proposal, - new AuditStamp() - .setActor(UrnUtils.getUrn(Constants.SYSTEM_ACTOR)) - .setTime(System.currentTimeMillis()), - false); - } - } catch (Exception e) { - log.error( - String.format( - "Not able to update execution result aspect with runId %s and new status %s.", - runId, status), - e); - } - } - /** Retrieves the value for an entity that is made up of latest versions of specified aspects. */ @Action(name = "list") @Nonnull diff --git a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java index e3534875c6cd26..d6130e05b77bd3 100644 --- a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java +++ b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java @@ -87,7 +87,7 @@ public void testAsyncDefaultAspects() throws URISyntaxException { .aspect(mcp.getAspect()) .auditStamp(new AuditStamp()) .metadataChangeProposal(mcp) - .build(_entityRegistry, _entityService.getSystemEntityClient()); + .build(_entityService); when(_aspectDao.runInTransactionWithRetry(any(), any(), anyInt())) .thenReturn( List.of( diff --git a/metadata-service/restli-servlet-impl/src/test/java/mock/MockTimeseriesAspectService.java b/metadata-service/restli-servlet-impl/src/test/java/mock/MockTimeseriesAspectService.java index 2a12ecf6866bbe..5187cba0b91510 100644 --- a/metadata-service/restli-servlet-impl/src/test/java/mock/MockTimeseriesAspectService.java +++ b/metadata-service/restli-servlet-impl/src/test/java/mock/MockTimeseriesAspectService.java @@ -7,6 +7,7 @@ import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.timeseries.BatchWriteOperationsOptions; import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.metadata.timeseries.TimeseriesScrollResult; import com.linkedin.timeseries.AggregationSpec; import com.linkedin.timeseries.DeleteAspectValuesResult; import com.linkedin.timeseries.GenericTable; @@ -118,4 +119,18 @@ public void upsertDocument( public List getIndexSizes() { return List.of(); } + + @Nonnull + @Override + public TimeseriesScrollResult scrollAspects( + @Nonnull String entityName, + @Nonnull String aspectName, + @Nullable Filter filter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis) { + return TimeseriesScrollResult.builder().build(); + } } diff --git a/metadata-service/services/build.gradle b/metadata-service/services/build.gradle index c683b0c75f40a8..78d651c05e4d9e 100644 --- a/metadata-service/services/build.gradle +++ b/metadata-service/services/build.gradle @@ -1,6 +1,6 @@ plugins { id 'org.hidetake.swagger.generator' - id 'java' + id 'java-library' } configurations { @@ -14,7 +14,9 @@ dependencies { implementation project(':metadata-events:mxe-avro') implementation project(':metadata-events:mxe-registration') implementation project(':metadata-events:mxe-utils-avro') - implementation project(':metadata-models') + api project(path: ':metadata-models', configuration: 'dataTemplate') + api project(':metadata-models') + implementation project(':metadata-service:restli-client') implementation project(':metadata-service:configuration') diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java index c4216962c134cd..2c1596474fb21e 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java @@ -88,7 +88,7 @@ public static List getAdditionalChanges( public static List getAdditionalChanges( @Nonnull MetadataChangeProposal metadataChangeProposal, - @Nonnull EntityService entityService) { + @Nonnull EntityService entityService) { return getAdditionalChanges(metadataChangeProposal, entityService, false); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java index 71573aa2b10e04..94ab69e895920f 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java @@ -9,11 +9,11 @@ import com.linkedin.entity.Entity; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; -import com.linkedin.entity.client.SystemEntityClient; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.VersionedAspect; import com.linkedin.metadata.aspect.batch.AspectsBatch; import com.linkedin.metadata.aspect.batch.UpsertItem; +import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesResult; import com.linkedin.metadata.models.AspectSpec; @@ -35,7 +35,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -public interface EntityService { +public interface EntityService extends AspectRetriever { /** * Just whether the entity/aspect exists @@ -287,6 +287,8 @@ Pair>> generateDefaultAspectsOnFirstW Set getEntityAspectNames(final String entityName); + @Override + @Nonnull EntityRegistry getEntityRegistry(); RollbackResult deleteAspect( @@ -349,15 +351,5 @@ default boolean exists(@Nonnull Urn urn, boolean includeSoftDelete) { BrowsePathsV2 buildDefaultBrowsePathV2(final @Nonnull Urn urn, boolean useContainerPaths) throws URISyntaxException; - /** - * Allow internal use of the system entity client. Solves recursive dependencies between the - * EntityService and the SystemJavaEntityClient - * - * @param systemEntityClient system entity client - */ - void setSystemEntityClient(SystemEntityClient systemEntityClient); - - SystemEntityClient getSystemEntityClient(); - RecordTemplate getLatestAspect(@Nonnull final Urn urn, @Nonnull final String aspectName); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphService.java index b3e713a906d01d..625353eeb68205 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphService.java @@ -5,6 +5,7 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.search.utils.QueryUtils; import java.net.URISyntaxException; import java.util.ArrayList; @@ -322,4 +323,18 @@ void removeEdgesFromNode( default boolean supportsMultiHop() { return false; } + + @Nonnull + RelatedEntitiesScrollResult scrollRelatedEntities( + @Nullable List sourceTypes, + @Nonnull Filter sourceEntityFilter, + @Nullable List destinationTypes, + @Nonnull Filter destinationEntityFilter, + @Nonnull List relationshipTypes, + @Nonnull RelationshipFilter relationshipFilter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java new file mode 100644 index 00000000000000..0c6f8a0d65d5cf --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java @@ -0,0 +1,31 @@ +package com.linkedin.metadata.graph; + +import com.linkedin.metadata.query.filter.RelationshipDirection; +import javax.annotation.Nonnull; +import lombok.Getter; + +/** Preserves directionality as well as the generic `related` urn concept */ +@Getter +public class RelatedEntities extends RelatedEntity { + /** source Urn * */ + @Nonnull String sourceUrn; + + /** Destination Urn associated with the related entity. */ + @Nonnull String destinationUrn; + + public RelatedEntities( + @Nonnull String relationshipType, + @Nonnull String sourceUrn, + @Nonnull String destinationUrn, + @Nonnull RelationshipDirection relationshipDirection) { + super( + relationshipType, + relationshipDirection == RelationshipDirection.OUTGOING ? destinationUrn : sourceUrn); + this.sourceUrn = sourceUrn; + this.destinationUrn = destinationUrn; + } + + public RelatedEntity asRelatedEntity() { + return new RelatedEntity(relationshipType, urn); + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntitiesScrollResult.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntitiesScrollResult.java new file mode 100644 index 00000000000000..b0b5394ca58083 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntitiesScrollResult.java @@ -0,0 +1,16 @@ +package com.linkedin.metadata.graph; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@AllArgsConstructor +@Data +@Builder +public class RelatedEntitiesScrollResult { + int numResults; + int pageSize; + String scrollId; + List entities; +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java index 189ae09e1b9382..2fec88ad221fd1 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java @@ -161,7 +161,7 @@ AutoCompleteResult autoComplete( * @param field the field name for aggregate * @param requestParams filters to apply before aggregating * @param limit the number of aggregations to return - * @return + * @return a map of the value to the count of documents having the value */ @Nonnull Map aggregateByValue( diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java new file mode 100644 index 00000000000000..59d40b29e7383d --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java @@ -0,0 +1,1107 @@ +package com.linkedin.metadata.service; + +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.Constants.FORMS_ASPECT_NAME; +import static com.linkedin.metadata.Constants.FORM_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTIES_ASPECT_NAME; +import static com.linkedin.metadata.entity.AspectUtils.buildMetadataChangeProposal; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.FieldFormPromptAssociation; +import com.linkedin.common.FieldFormPromptAssociationArray; +import com.linkedin.common.FormAssociation; +import com.linkedin.common.FormAssociationArray; +import com.linkedin.common.FormPromptAssociation; +import com.linkedin.common.FormPromptAssociationArray; +import com.linkedin.common.FormPromptFieldAssociations; +import com.linkedin.common.FormVerificationAssociation; +import com.linkedin.common.FormVerificationAssociationArray; +import com.linkedin.common.Forms; +import com.linkedin.common.Ownership; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.form.FormActorAssignment; +import com.linkedin.form.FormInfo; +import com.linkedin.form.FormPrompt; +import com.linkedin.form.FormType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.authorization.OwnershipUtils; +import com.linkedin.metadata.entity.AspectUtils; +import com.linkedin.metadata.service.util.SearchBasedFormAssignmentRunner; +import com.linkedin.metadata.utils.FormUtils; +import com.linkedin.metadata.utils.SchemaFieldUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import com.linkedin.schema.SchemaField; +import com.linkedin.schema.SchemaMetadata; +import com.linkedin.structured.PrimitivePropertyValueArray; +import com.linkedin.structured.StructuredProperties; +import com.linkedin.structured.StructuredPropertyValueAssignment; +import com.linkedin.structured.StructuredPropertyValueAssignmentArray; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * This class is used to execute CRUD operations around forms and submitting responses to forms and + * their prompts. + * + *

Note that no Authorization is performed within the service. The expectation is that the caller + * has already verified the permissions of the active Actor. + */ +@Slf4j +public class FormService extends BaseService { + private static final int BATCH_FORM_ENTITY_COUNT = 500; + + public FormService( + @Nonnull final EntityClient entityClient, + @Nonnull final Authentication systemAuthentication) { + super(entityClient, systemAuthentication); + } + + /** Batch associated a form to a given set of entities by urn. */ + public void batchAssignFormToEntities( + @Nonnull final List entityUrns, @Nonnull final Urn formUrn) throws Exception { + batchAssignFormToEntities(entityUrns, formUrn, this.systemAuthentication); + } + + /** Batch associated a form to a given set of entities by urn. */ + public void batchAssignFormToEntities( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) + throws Exception { + verifyEntityExists(formUrn, authentication); + verifyEntitiesExist(entityUrns, authentication); + final List changes = + buildAssignFormChanges(entityUrns, formUrn, authentication); + ingestChangeProposals(changes, authentication); + } + + /** Batch remove a form from a given entity by urn. */ + public void batchUnassignFormForEntities( + @Nonnull final List entityUrns, @Nonnull final Urn formUrn) throws Exception { + batchUnassignFormForEntities(entityUrns, formUrn, this.systemAuthentication); + } + + /** Batch remove a form from a given entity by urn. */ + public void batchUnassignFormForEntities( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) + throws Exception { + verifyEntityExists(formUrn, authentication); + verifyEntitiesExist(entityUrns, authentication); + final List changes = + buildUnassignFormChanges(entityUrns, formUrn, authentication); + ingestChangeProposals(changes, authentication); + } + + /** Mark a specific form prompt as incomplete */ + public void batchSetFormPromptIncomplete( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId) + throws Exception { + batchSetFormPromptIncomplete(entityUrns, formUrn, formPromptId, this.systemAuthentication); + } + + /** Mark a specific form prompt as incomplete */ + public void batchSetFormPromptIncomplete( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) + throws Exception { + verifyEntityExists(formUrn, authentication); + verifyEntitiesExist(entityUrns, authentication); + final FormInfo formInfo = getFormInfo(formUrn, authentication); + final List changes = + buildUnsetFormPromptChanges(entityUrns, formUrn, formPromptId, formInfo, authentication); + ingestChangeProposals(changes, authentication); + } + + /** Create a dynamic form assignment for a particular form. */ + public void createDynamicFormAssignment( + @Nonnull final DynamicFormAssignment dynamicFormAssignment, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) + throws RemoteInvocationException { + if (!entityClient.exists(formUrn, authentication)) { + throw new RuntimeException( + String.format("Form %s does not exist. Skipping dynamic form assignment", formUrn)); + } + + try { + this.entityClient.ingestProposal( + AspectUtils.buildMetadataChangeProposal( + formUrn, Constants.DYNAMIC_FORM_ASSIGNMENT_ASPECT_NAME, dynamicFormAssignment), + authentication, + false); + } catch (Exception e) { + throw new RuntimeException("Failed to create form", e); + } + } + + /** Assigns the form to an entity for completion. */ + public void upsertFormAssignmentRunner( + @Nonnull final Urn formUrn, @Nonnull final DynamicFormAssignment formFilters) { + try { + SearchBasedFormAssignmentRunner.assign( + formFilters, formUrn, BATCH_FORM_ENTITY_COUNT, entityClient, systemAuthentication); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to dynamically assign form with urn: %s", formUrn), e); + } + } + + /** Submit a response for a structured property type prompt. */ + public Boolean batchSubmitStructuredPropertyPromptResponse( + @Nonnull final List entityUrns, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) + throws Exception { + entityUrns.forEach( + urnStr -> { + Urn urn = UrnUtils.getUrn(urnStr); + try { + submitStructuredPropertyPromptResponse( + urn, structuredPropertyUrn, values, formUrn, formPromptId, authentication); + } catch (Exception e) { + throw new RuntimeException("Failed to batch submit structured property prompt", e); + } + }); + + return true; + } + + /** Submit a response for a structured property type prompt. */ + public Boolean submitStructuredPropertyPromptResponse( + @Nonnull final Urn entityUrn, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) + throws Exception { + + // First, let's apply the action and add the structured property. + ingestStructuredProperties(entityUrn, structuredPropertyUrn, values, authentication); + + // Then, let's apply the change to the entity's form status. + ingestCompletedFormResponse(entityUrn, formUrn, formPromptId, authentication); + + return true; + } + + /** Submit a response for a field-level structured property type prompt. */ + public Boolean batchSubmitFieldStructuredPropertyPromptResponse( + @Nonnull final List entityUrns, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final String fieldPath, + @Nonnull final Authentication authentication) + throws Exception { + entityUrns.forEach( + urnStr -> { + Urn urn = UrnUtils.getUrn(urnStr); + try { + submitFieldStructuredPropertyPromptResponse( + urn, + structuredPropertyUrn, + values, + formUrn, + formPromptId, + fieldPath, + authentication); + } catch (Exception e) { + throw new RuntimeException( + "Failed to batch submit field structured property prompt", e); + } + }); + + return true; + } + + /** Submit a response for a field-level structured property type prompt. */ + public Boolean submitFieldStructuredPropertyPromptResponse( + @Nonnull final Urn entityUrn, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final String fieldPath, + @Nonnull final Authentication authentication) + throws Exception { + + // First, let's apply the action and add the structured property. + ingestSchemaFieldStructuredProperties( + entityUrn, structuredPropertyUrn, values, fieldPath, authentication); + + // Then, let's apply the change to the entity's form status. + ingestCompletedFieldFormResponse(entityUrn, formUrn, formPromptId, fieldPath, authentication); + + return true; + } + + private void ingestCompletedFieldFormResponse( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final String fieldPath, + @Nonnull final Authentication authentication) + throws Exception { + final Forms forms = getEntityForms(entityUrn, authentication); + final FormAssociation formAssociation = getFormWithUrn(forms, formUrn); + if (formAssociation == null) { + throw new RuntimeException( + String.format("Form %s has not been assigned to entity %s", formUrn, entityUrn)); + } + final FormPromptAssociation formPromptAssociation = + getOrDefaultFormPromptAssociation(formAssociation, formPromptId, authentication); + + // update the prompt association to have this fieldFormPromptAssociation marked as complete + updateFieldPromptToComplete( + formPromptAssociation, fieldPath, UrnUtils.getUrn(authentication.getActor().toUrnStr())); + + // field prompt is complete if all fields in entity's schema metadata are marked complete + if (isFieldPromptComplete(entityUrn, formPromptAssociation, authentication)) { + // if this is complete, the prompt as a whole should be marked as complete + ingestCompletedFormResponse(entityUrn, formUrn, formPromptId, forms, authentication); + } else { + // regardless, ingest forms to save state of this aspect + ingestForms(entityUrn, forms, authentication); + } + } + + private void ingestCompletedFormResponse( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) + throws Exception { + final Forms forms = getEntityForms(entityUrn, authentication); + ingestCompletedFormResponse(entityUrn, formUrn, formPromptId, forms, authentication); + } + + private void ingestCompletedFormResponse( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Forms forms, + @Nonnull final Authentication authentication) + throws Exception { + // Next, get all the information we need to update the forms for the entity. + final FormInfo formInfo = getFormInfo(formUrn, authentication); + final FormAssociation formAssociation = getFormWithUrn(forms, formUrn); + + if (formAssociation == null) { + throw new RuntimeException( + String.format("Form %s has not been assigned to entity %s", formUrn, entityUrn)); + } + + // First, mark the prompt as completed in forms aspect. + updatePromptToComplete(formAssociation, entityUrn, formUrn, formPromptId, authentication); + + // Then, update the completed forms fields based on which prompts remain incomplete. + updateFormCompletion(forms, formAssociation, formInfo); + + // Finally, ingest the newly updated forms aspect. + ingestForms(entityUrn, forms, authentication); + } + + private void ingestSchemaFieldStructuredProperties( + @Nonnull final Urn entityUrn, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final String fieldPath, + @Nonnull final Authentication authentication) + throws Exception { + Urn schemaFieldUrn = SchemaFieldUtils.generateSchemaFieldUrn(entityUrn.toString(), fieldPath); + ingestStructuredProperties(schemaFieldUrn, structuredPropertyUrn, values, authentication); + } + + private void ingestStructuredProperties( + @Nonnull final Urn entityUrn, + @Nonnull final Urn structuredPropertyUrn, + @Nonnull final PrimitivePropertyValueArray values, + @Nonnull final Authentication authentication) + throws Exception { + final EntityResponse response = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(STRUCTURED_PROPERTIES_ASPECT_NAME), + authentication); + + StructuredProperties structuredProperties = new StructuredProperties(); + structuredProperties.setProperties(new StructuredPropertyValueAssignmentArray()); + if (response != null && response.getAspects().containsKey(STRUCTURED_PROPERTIES_ASPECT_NAME)) { + structuredProperties = + new StructuredProperties( + response.getAspects().get(STRUCTURED_PROPERTIES_ASPECT_NAME).getValue().data()); + } + + // Since we upsert assignments for this structuredProperty, + // remove anything from this structured property and add to this list + List filteredAssignments = + structuredProperties.getProperties().stream() + .filter(assignment -> !assignment.getPropertyUrn().equals(structuredPropertyUrn)) + .collect(Collectors.toList()); + + StructuredPropertyValueAssignment assignment = new StructuredPropertyValueAssignment(); + assignment.setValues(values); + assignment.setPropertyUrn(structuredPropertyUrn); + assignment.setCreated( + new AuditStamp() + .setActor(UrnUtils.getUrn(authentication.getActor().toUrnStr())) + .setTime(System.currentTimeMillis())); + assignment.setLastModified( + new AuditStamp() + .setActor(UrnUtils.getUrn(authentication.getActor().toUrnStr())) + .setTime(System.currentTimeMillis())); + filteredAssignments.add(assignment); + + StructuredPropertyValueAssignmentArray assignments = + new StructuredPropertyValueAssignmentArray(filteredAssignments); + structuredProperties.setProperties(assignments); + + final MetadataChangeProposal structuredPropertiesProposal = + AspectUtils.buildMetadataChangeProposal( + entityUrn, STRUCTURED_PROPERTIES_ASPECT_NAME, structuredProperties); + try { + this.entityClient.ingestProposal(structuredPropertiesProposal, authentication, false); + } catch (Exception e) { + throw new RuntimeException("Failed to submit form response", e); + } + } + + private void ingestForms( + @Nonnull final Urn entityUrn, + @Nonnull final Forms forms, + @Nonnull final Authentication authentication) { + try { + ingestChangeProposals( + ImmutableList.of( + AspectUtils.buildMetadataChangeProposal(entityUrn, FORMS_ASPECT_NAME, forms)), + authentication); + } catch (Exception e) { + log.warn(String.format("Failed to ingest forms for entity with urn %s", entityUrn), e); + } + } + + private Forms getEntityForms( + @Nonnull final Urn entityUrn, @Nonnull final Authentication authentication) throws Exception { + final EntityResponse response = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(FORMS_ASPECT_NAME), + authentication); + if (response != null && response.getAspects().containsKey(FORMS_ASPECT_NAME)) { + return new Forms(response.getAspects().get(FORMS_ASPECT_NAME).getValue().data()); + } + // No entity forms found. + throw new RuntimeException( + String.format( + "Entity is missing forms aspect, form is not assigned to entity with urn %s", + entityUrn)); + } + + /** + * Checks schema metadata for an entity and ensures there's a completed field prompt for every + * field. If there is no schema metadata, raise an error. + */ + private boolean isFieldPromptComplete( + @Nonnull final Urn entityUrn, + @Nonnull final FormPromptAssociation formPromptAssociation, + @Nonnull final Authentication authentication) + throws Exception { + final Set completedFieldPaths = + Objects.requireNonNull(formPromptAssociation.getFieldAssociations()) + .getCompletedFieldPrompts() + .stream() + .map(FieldFormPromptAssociation::getFieldPath) + .collect(Collectors.toSet()); + final SchemaMetadata schemaMetadata = getSchemaMetadata(entityUrn, authentication); + final List fieldPaths = + schemaMetadata.getFields().stream() + .map(SchemaField::getFieldPath) + .collect(Collectors.toList()); + + return completedFieldPaths.containsAll(fieldPaths); + } + + /** + * Performs the operation of changing the status of a form field prompt from incomplete to + * complete. + */ + private void updateFieldPromptToComplete( + @Nonnull final FormPromptAssociation formPromptAssociation, + @Nonnull final String fieldPath, + @Nonnull final Urn actor) { + final FieldFormPromptAssociation completedFieldPromptAssociation = + new FieldFormPromptAssociation(); + completedFieldPromptAssociation.setFieldPath(fieldPath); + completedFieldPromptAssociation.setLastModified(createAuditStamp(actor)); + + FormPromptFieldAssociations fieldAssociations = + formPromptAssociation.getFieldAssociations() != null + ? formPromptAssociation.getFieldAssociations() + : new FormPromptFieldAssociations(); + + if (fieldAssociations.getCompletedFieldPrompts() == null) { + fieldAssociations.setCompletedFieldPrompts(new FieldFormPromptAssociationArray()); + } + if (fieldAssociations.getIncompleteFieldPrompts() == null) { + fieldAssociations.setIncompleteFieldPrompts(new FieldFormPromptAssociationArray()); + } + + // add this prompt association to list of completed prompts, removing its previous association + // if it was already in there + FieldFormPromptAssociationArray completedFieldPrompts = + new FieldFormPromptAssociationArray( + fieldAssociations.getCompletedFieldPrompts().stream() + .filter(fieldPrompt -> !fieldPrompt.getFieldPath().equals(fieldPath)) + .collect(Collectors.toList())); + completedFieldPrompts.add(completedFieldPromptAssociation); + fieldAssociations.setCompletedFieldPrompts(completedFieldPrompts); + + // remove this prompt association from list of incomplete prompts + FieldFormPromptAssociationArray incompleteFieldPrompts = new FieldFormPromptAssociationArray(); + fieldAssociations + .getIncompleteFieldPrompts() + .forEach( + incompleteFieldPrompt -> { + if (!incompleteFieldPrompt.getFieldPath().equals(fieldPath)) { + incompleteFieldPrompts.add(incompleteFieldPrompt); + } + }); + fieldAssociations.setIncompleteFieldPrompts(incompleteFieldPrompts); + + formPromptAssociation.setFieldAssociations(fieldAssociations); + } + + /** Performs the operation of changing the status of a form prompt from incomplete to complete. */ + private void updatePromptToComplete( + @Nonnull final FormAssociation formAssociation, + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) { + final FormPromptAssociation formPromptAssociation = + getOrDefaultFormPromptAssociation(formAssociation, formPromptId, authentication); + + // add this prompt association to list of completed prompts, removing its previous association + // if it was already in there + FormPromptAssociationArray completedPrompts = + new FormPromptAssociationArray( + formAssociation.getCompletedPrompts().stream() + .filter(prompt -> !prompt.getId().equals(formPromptId)) + .collect(Collectors.toList())); + completedPrompts.add(formPromptAssociation); + formAssociation.setCompletedPrompts(completedPrompts); + + // remove this prompt association from list of incomplete prompts + FormPromptAssociationArray incompletePrompts = new FormPromptAssociationArray(); + formAssociation + .getIncompletePrompts() + .forEach( + incompletePrompt -> { + if (!incompletePrompt.getId().equals(formPromptId)) { + incompletePrompts.add(incompletePrompt); + } + }); + formAssociation.setIncompletePrompts(incompletePrompts); + } + + /** Performs the operation of changing the status of a form prompt from complete to incomplete. */ + private void updatePromptToIncomplete( + @Nonnull final FormAssociation form, + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId) { + // Remove the prompt from completed. + final List newCompletedPrompts = + form.getCompletedPrompts().stream() + .filter(prompt -> !prompt.getId().equals(formPromptId)) + .collect(Collectors.toList()); + form.setCompletedPrompts(new FormPromptAssociationArray(newCompletedPrompts)); + + // Add the prompt to in-completed. + if (form.getIncompletePrompts().stream() + .anyMatch(prompt -> prompt.getId().equals(formPromptId))) { + log.warn( + String.format( + "Attempting to unset a prompt that is already incomplete. Skipping... Form: %s, Prompt: %s, Entity: %s", + formUrn, formPromptId, entityUrn)); + return; + } + final List newIncompletePrompts = + new ArrayList<>(form.getIncompletePrompts()); + newIncompletePrompts.add( + new FormPromptAssociation().setId(formPromptId).setLastModified(createSystemAuditStamp())); + form.setIncompletePrompts(new FormPromptAssociationArray(newIncompletePrompts)); + } + + private List buildAssignFormChanges( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) { + final List results = new ArrayList<>(); + entityUrns.forEach( + entityUrn -> { + try { + MetadataChangeProposal maybeChange = + buildAssignFormChange(entityUrn, formUrn, authentication); + if (maybeChange != null) { + results.add(maybeChange); + } + } catch (Exception e) { + log.warn( + String.format( + "Failed to retrieve form %s for entity %s. Skipping form assignment", + formUrn, entityUrn), + e); + } + }); + return results; + } + + @Nullable + private MetadataChangeProposal buildAssignFormChange( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) + throws Exception { + + final EntityResponse response = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(FORMS_ASPECT_NAME), + authentication); + + Forms formsAspect = new Forms(); + formsAspect.setIncompleteForms(new FormAssociationArray()); + formsAspect.setCompletedForms(new FormAssociationArray()); + if (response != null && response.getAspects().containsKey(FORMS_ASPECT_NAME)) { + formsAspect = new Forms(response.getAspects().get(FORMS_ASPECT_NAME).getValue().data()); + } + + // if this form is already assigned to this entity, leave it and move on + Optional formAssociation = + Stream.concat( + formsAspect.getCompletedForms().stream(), formsAspect.getIncompleteForms().stream()) + .filter(form -> form.getUrn().equals(formUrn)) + .findAny(); + + if (formAssociation.isPresent()) { + return null; + } + + // add this form to the entity's incomplete form associations. + FormAssociationArray incompleteForms = formsAspect.getIncompleteForms(); + FormAssociation newAssociation = new FormAssociation(); + newAssociation.setUrn(formUrn); + + // set all prompts as incomplete when assigning this form + FormInfo formInfo = getFormInfo(formUrn, authentication); + FormPromptAssociationArray formPromptAssociations = new FormPromptAssociationArray(); + formInfo + .getPrompts() + .forEach( + prompt -> { + FormPromptAssociation association = new FormPromptAssociation(); + association.setId(prompt.getId()); + association.setLastModified(createAuditStamp(authentication)); + formPromptAssociations.add(association); + }); + newAssociation.setIncompletePrompts(formPromptAssociations); + newAssociation.setCompletedPrompts(new FormPromptAssociationArray()); + incompleteForms.add(newAssociation); + formsAspect.setIncompleteForms(incompleteForms); + return buildMetadataChangeProposal(entityUrn, FORMS_ASPECT_NAME, formsAspect); + } + + private List buildUnassignFormChanges( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) { + final List results = new ArrayList<>(); + entityUrns.forEach( + entityUrn -> { + try { + MetadataChangeProposal maybeChange = + buildUnassignFormChange(entityUrn, formUrn, authentication); + if (maybeChange != null) { + results.add(maybeChange); + } + } catch (Exception e) { + log.warn( + String.format( + "Failed to retrieve form %s for entity %s. Skipping form unassignment.", + formUrn, entityUrn), + e); + } + }); + return results; + } + + @Nullable + private MetadataChangeProposal buildUnassignFormChange( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final Authentication authentication) + throws Exception { + final EntityResponse response = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(FORMS_ASPECT_NAME), + authentication); + Forms formsAspect = new Forms(); + formsAspect.setCompletedForms(new FormAssociationArray()); + formsAspect.setIncompleteForms(new FormAssociationArray()); + if (response != null && response.getAspects().containsKey(FORMS_ASPECT_NAME)) { + formsAspect = new Forms(response.getAspects().get(FORMS_ASPECT_NAME).getValue().data()); + } + + List newCompleted = + new ArrayList<>( + new FormAssociationArray( + formsAspect.getCompletedForms().stream() + .filter(form -> !form.getUrn().equals(formUrn)) + .collect(Collectors.toList()))); + List newIncomplete = + new ArrayList<>( + new FormAssociationArray( + formsAspect.getIncompleteForms().stream() + .filter(form -> !form.getUrn().equals(formUrn)) + .collect(Collectors.toList()))); + + if (newCompleted.size() == formsAspect.getCompletedForms().size() + && newIncomplete.size() == formsAspect.getIncompleteForms().size()) { + // No metadata to change. Skip ingestion. + return null; + } + + formsAspect.setCompletedForms(new FormAssociationArray(newCompleted)); + formsAspect.setIncompleteForms(new FormAssociationArray(newIncomplete)); + + return buildMetadataChangeProposal(entityUrn, FORMS_ASPECT_NAME, formsAspect); + } + + private List buildUnsetFormPromptChanges( + @Nonnull final List entityUrns, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final FormInfo formDefinition, + @Nonnull final Authentication authentication) { + final List results = new ArrayList<>(); + entityUrns.forEach( + entityUrn -> { + try { + MetadataChangeProposal maybeChange = + buildUnsetFormPromptChange( + entityUrn, formUrn, formPromptId, formDefinition, authentication); + if (maybeChange != null) { + results.add(maybeChange); + } + } catch (Exception e) { + log.warn( + String.format( + "Failed to retrieve form %s for entity %s. Skipping form unassignment.", + formUrn, entityUrn), + e); + } + }); + return results; + } + + @Nullable + private MetadataChangeProposal buildUnsetFormPromptChange( + @Nonnull final Urn entityUrn, + @Nonnull final Urn formUrn, + @Nonnull final String formPromptId, + @Nonnull final FormInfo formDefinition, + @Nonnull final Authentication authentication) + throws Exception { + + // Retrieve entity forms state + final Forms forms = getEntityForms(entityUrn, authentication); + + // First, find the form with the provided urn. + final FormAssociation formAssociation = getFormWithUrn(forms, formUrn); + + if (formAssociation != null) { + // 1. Find and mark the provided form prompt as incomplete. + updatePromptToIncomplete(formAssociation, entityUrn, formUrn, formPromptId); + + // 2. Update the form's completion status given the incomplete prompt. + updateFormCompletion(forms, formAssociation, formDefinition); + + // 3. Update the form status aspect for the entity. + return buildMetadataChangeProposal(entityUrn, FORMS_ASPECT_NAME, forms); + } else { + // Form not assigned to the entity! Let's warn and do nothing. + log.warn( + String.format( + "Failed to find form with urn %s associated with entity urn %s while attempting to unset form prompt %s. Skipping...", + formUrn, entityUrn, formPromptId)); + } + + return null; + } + + private void updateFormCompletion( + @Nonnull final Forms forms, + @Nonnull final FormAssociation form, + @Nonnull final FormInfo formDefinition) { + + final boolean isFormCompleted = isFormCompleted(form, formDefinition); + + if (isFormCompleted) { + // If the form is complete, we want to add it to completed forms. + + // 1. Remove from incomplete. + forms.setIncompleteForms( + new FormAssociationArray( + forms.getIncompleteForms().stream() + .filter(incompleteForm -> !incompleteForm.getUrn().equals(form.getUrn())) + .collect(Collectors.toList()))); + + // 2. Add to complete (if not already present) + if (forms.getCompletedForms().stream() + .noneMatch(completedForm -> completedForm.getUrn().equals(form.getUrn()))) { + // Not found in completed, let's update it. + List newCompleted = new ArrayList<>(forms.getCompletedForms()); + newCompleted.add(form); + forms.setCompletedForms(new FormAssociationArray(newCompleted)); + } + } else { + // If the form is incomplete, we want to remove it from the completed forms. + // If the form implies verification, we also ensure that the verification status is + // un-applied. + + // 1. Remove from complete. + forms.setCompletedForms( + new FormAssociationArray( + forms.getCompletedForms().stream() + .filter(completedForm -> !completedForm.getUrn().equals(form.getUrn())) + .collect(Collectors.toList()))); + + // 2. Add to incomplete (if not already present) + if (forms.getIncompleteForms().stream() + .noneMatch(incompleteForm -> incompleteForm.getUrn().equals(form.getUrn()))) { + // Not found in incompleted. Let's updated + List newIncomplete = new ArrayList<>(forms.getIncompleteForms()); + newIncomplete.add(form); + forms.setIncompleteForms(new FormAssociationArray(newIncomplete)); + } + + // 3. Remove verification as required. + if (FormType.VERIFICATION.equals(formDefinition.getType())) { + removeFormVerification(form.getUrn(), forms); + } + } + } + + /** + * Returns true if a form is considered completed, false otherwise. This is a function of whether + * all required prompts are marked as completed. + * + *

If none or some required prompts are marked as completed, then the form will be considered + * NOT completed. + * + * @param form the form status, as completed for a specific entity. + * @param formDefinition the form definition, which contains information about which prompts are + * required. + */ + private boolean isFormCompleted( + @Nonnull final FormAssociation form, @Nonnull final FormInfo formDefinition) { + final List requiredPromptsIds = + formDefinition.getPrompts().stream() + .filter(FormPrompt::isRequired) + .map(FormPrompt::getId) + .collect(Collectors.toList()); + + final List completedPromptIds = + form.getCompletedPrompts().stream() + .map(FormPromptAssociation::getId) + .collect(Collectors.toList()); + + // If all required prompts are completed, then the form is completed. + return completedPromptIds.containsAll(requiredPromptsIds); + } + + @Nullable + private FormAssociation getFormWithUrn( + @Nonnull final Forms existingForms, @Nonnull final Urn formUrn) { + // First check in the completed set. + Optional maybeForm = + existingForms.getCompletedForms().stream() + .filter(form -> form.getUrn().equals(formUrn)) + .findFirst(); + if (maybeForm.isPresent()) { + return maybeForm.get(); + } + + // Then check the incomplete set. + maybeForm = + existingForms.getIncompleteForms().stream() + .filter(form -> form.getUrn().equals(formUrn)) + .findFirst(); + if (maybeForm.isPresent()) { + return maybeForm.get(); + } + + // No form found, return null. + return null; + } + + @Nullable + private FormPromptAssociation getFormPromptAssociation( + @Nonnull final FormAssociation formAssociation, @Nonnull final String formPromptId) { + // First check in the completed set. + Optional maybePromptAssociation = + formAssociation.getCompletedPrompts().stream() + .filter(prompt -> prompt.getId().equals(formPromptId)) + .findFirst(); + if (maybePromptAssociation.isPresent()) { + return maybePromptAssociation.get(); + } + + // Then check the incomplete set. + maybePromptAssociation = + formAssociation.getIncompletePrompts().stream() + .filter(prompt -> prompt.getId().equals(formPromptId)) + .findFirst(); + if (maybePromptAssociation.isPresent()) { + return maybePromptAssociation.get(); + } + + // No prompt association found, return null. + return null; + } + + /** + * Gets a form prompt association by the prompt ID. If none exists (could happen as a form was + * changed after assigned or some other reason), then create the association and add it to the + * formAssociation's list of incomplete prompts. + */ + private FormPromptAssociation getOrDefaultFormPromptAssociation( + @Nonnull final FormAssociation formAssociation, + @Nonnull final String formPromptId, + @Nonnull final Authentication authentication) { + final FormPromptAssociation existingPromptAssociation = + getFormPromptAssociation(formAssociation, formPromptId); + final FormPromptAssociation formPromptAssociation = + existingPromptAssociation != null ? existingPromptAssociation : new FormPromptAssociation(); + formPromptAssociation.setId(formPromptId); + formPromptAssociation.setLastModified( + createAuditStamp(UrnUtils.getUrn(authentication.getActor().toUrnStr()))); + if (existingPromptAssociation == null) { + FormPromptAssociationArray incompletePrompts = + new FormPromptAssociationArray(formAssociation.getIncompletePrompts()); + incompletePrompts.add(formPromptAssociation); + formAssociation.setIncompletePrompts(incompletePrompts); + } + return formPromptAssociation; + } + + private void removeFormVerification(@Nonnull final Urn formUrn, @Nonnull final Forms forms) { + if (!forms.hasVerifications()) { + // Nothing to do. + return; + } + + // Remove verification of given urn. + final List newVerifications = + forms.getVerifications().stream() + .filter(verification -> !formUrn.equals(verification.getForm())) + .collect(Collectors.toList()); + + // Update verifications for forms aspect. + forms.setVerifications(new FormVerificationAssociationArray(newVerifications)); + } + + /** + * A form is assigned to a user if either the user or a group the user is in is explicitly set on + * the actors field on a form. Otherwise, if the actors field says that owners are assigned, + * ensure this actor, or a group they're in, is an owner of this entity. + */ + public boolean isFormAssignedToUser( + @Nonnull final Urn formUrn, + @Nonnull final Urn entityUrn, + @Nonnull final Urn actorUrn, + @Nonnull final List groupsForUser, + @Nonnull final Authentication authentication) + throws Exception { + final FormInfo formInfo = getFormInfo(formUrn, authentication); + final FormActorAssignment formActorAssignment = formInfo.getActors(); + if (FormUtils.isFormAssignedToUser(formActorAssignment, actorUrn, groupsForUser)) { + return true; + } + + if (formActorAssignment.isOwners()) { + Ownership entityOwnership = getEntityOwnership(entityUrn, authentication); + return OwnershipUtils.isOwnerOfEntity(entityOwnership, actorUrn, groupsForUser); + } + + return false; + } + + /** + * Adds a new form verification association for an entity for this form on their forms aspect. If + * there was an existing verification association for this form, remove and replace it. First, + * ensure this form is of VERIFICATION type and that this form is in completedForms. + */ + public boolean verifyFormForEntity( + @Nonnull final Urn formUrn, + @Nonnull final Urn entityUrn, + @Nonnull final Authentication authentication) + throws Exception { + final FormInfo formInfo = getFormInfo(formUrn, authentication); + if (!formInfo.getType().equals(FormType.VERIFICATION)) { + throw new UnsupportedOperationException( + String.format("Form %s is not of type VERIFICATION. Cannot verify form.", formUrn)); + } + final Forms formsAspect = getEntityForms(entityUrn, authentication); + if (!isFormInCompletedForms(formUrn, formsAspect)) { + throw new RuntimeException( + String.format( + "Form %s is not in the list of completed forms for this entity. Skipping verification.", + formUrn)); + } + + // Remove any existing verifications for this form to patch a new one + List formVerifications = + formsAspect.getVerifications().stream() + .filter(verification -> !verification.getForm().equals(formUrn)) + .collect(Collectors.toList()); + FormVerificationAssociation newAssociation = new FormVerificationAssociation(); + newAssociation.setForm(formUrn); + newAssociation.setLastModified(createAuditStamp(authentication)); + formVerifications.add(newAssociation); + + formsAspect.setVerifications(new FormVerificationAssociationArray(formVerifications)); + + ingestForms(entityUrn, formsAspect, authentication); + return true; + } + + private boolean isFormInCompletedForms( + @Nonnull final Urn formUrn, @Nonnull final Forms formsAspect) { + return formsAspect.getCompletedForms().stream() + .anyMatch(completedForm -> completedForm.getUrn().equals(formUrn)); + } + + public FormInfo getFormInfo( + @Nonnull final Urn formUrn, @Nonnull final Authentication authentication) + throws URISyntaxException, RemoteInvocationException { + final EntityResponse formInfoResponse = + entityClient.getV2( + formUrn.getEntityType(), + formUrn, + ImmutableSet.of(FORM_INFO_ASPECT_NAME), + authentication); + if (formInfoResponse != null + && formInfoResponse.getAspects().containsKey(FORM_INFO_ASPECT_NAME)) { + return new FormInfo( + formInfoResponse.getAspects().get(FORM_INFO_ASPECT_NAME).getValue().data()); + } else { + throw new RuntimeException(String.format("Form %s does not exist.", formUrn)); + } + } + + private SchemaMetadata getSchemaMetadata( + @Nonnull final Urn entityUrn, @Nonnull final Authentication authentication) + throws URISyntaxException, RemoteInvocationException { + final EntityResponse response = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(SCHEMA_METADATA_ASPECT_NAME), + authentication); + if (response != null && response.getAspects().containsKey(SCHEMA_METADATA_ASPECT_NAME)) { + return new SchemaMetadata( + response.getAspects().get(SCHEMA_METADATA_ASPECT_NAME).getValue().data()); + } else { + throw new RuntimeException( + String.format("Schema metadata does not exist on entity %s.", entityUrn)); + } + } + + private Ownership getEntityOwnership( + @Nonnull final Urn entityUrn, @Nonnull final Authentication authentication) + throws URISyntaxException, RemoteInvocationException { + final EntityResponse entityResponse = + entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + ImmutableSet.of(OWNERSHIP_ASPECT_NAME), + authentication); + if (entityResponse != null && entityResponse.getAspects().containsKey(OWNERSHIP_ASPECT_NAME)) { + return new Ownership( + entityResponse.getAspects().get(OWNERSHIP_ASPECT_NAME).getValue().data()); + } else { + throw new RuntimeException(String.format("Ownership %s does not exist.", entityUrn)); + } + } + + private void verifyEntitiesExist( + @Nonnull final List entityUrns, @Nonnull final Authentication authentication) { + entityUrns.forEach( + entityUrn -> { + try { + verifyEntityExists(entityUrn, authentication); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Issue verifying whether entity exists when assigning form to it. Entity urn: %s", + entityUrn)); + } + }); + } + + private void verifyEntityExists( + @Nonnull final Urn entityUrn, @Nonnull final Authentication authentication) + throws RemoteInvocationException { + if (!entityClient.exists(entityUrn, authentication)) { + throw new RuntimeException( + String.format("Entity %s does not exist. Skipping batch form assignment", entityUrn)); + } + } + + private AuditStamp createSystemAuditStamp() { + return createAuditStamp(UrnUtils.getUrn(SYSTEM_ACTOR)); + } + + private AuditStamp createAuditStamp(@Nonnull final Authentication authentication) { + return createAuditStamp(UrnUtils.getUrn(authentication.getActor().toUrnStr())); + } + + private AuditStamp createAuditStamp(@Nonnull final Urn actor) { + return new AuditStamp().setTime(System.currentTimeMillis()).setActor(actor); + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java new file mode 100644 index 00000000000000..22496b6c078068 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java @@ -0,0 +1,328 @@ +package com.linkedin.metadata.service; + +import static com.linkedin.metadata.Constants.DEFAULT_RUN_ID; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.AuthenticationException; +import com.datahub.authorization.AuthUtil; +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.datahub.authorization.EntitySpec; +import com.datahub.plugins.auth.authorization.Authorizer; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.execution.ExecutionRequestResult; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.RollbackRunResult; +import com.linkedin.metadata.key.ExecutionRequestKey; +import com.linkedin.metadata.run.AspectRowSummary; +import com.linkedin.metadata.run.AspectRowSummaryArray; +import com.linkedin.metadata.run.RollbackResponse; +import com.linkedin.metadata.run.UnsafeEntityInfo; +import com.linkedin.metadata.run.UnsafeEntityInfoArray; +import com.linkedin.metadata.systemmetadata.SystemMetadataService; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.metadata.utils.EntityKeyUtils; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.timeseries.DeleteAspectValuesResult; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** Extracts logic historically in the Restli service which acts across multiple services */ +@Slf4j +@AllArgsConstructor +public class RollbackService { + public static final String ROLLING_BACK_STATUS = "ROLLING_BACK"; + public static final String ROLLED_BACK_STATUS = "ROLLED_BACK"; + public static final String ROLLBACK_FAILED_STATUS = "ROLLBACK_FAILED"; + + public static final int MAX_RESULT_SIZE = 10000; + public static final int ELASTIC_MAX_PAGE_SIZE = 10000; + public static final int DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE = 1000000; + public static final int ELASTIC_BATCH_DELETE_SLEEP_SEC = 5; + + private final EntityService entityService; + private final SystemMetadataService systemMetadataService; + private final TimeseriesAspectService timeseriesAspectService; + private final boolean restApiAuthorizationEnabled; + + public List rollbackTargetAspects(@Nonnull String runId, boolean hardDelete) { + return systemMetadataService.findByRunId(runId, hardDelete, 0, MAX_RESULT_SIZE); + } + + public RollbackResponse rollbackIngestion( + @Nonnull String runId, + boolean dryRun, + boolean hardDelete, + Authorizer authorizer, + @Nonnull Authentication authentication) + throws AuthenticationException { + + if (runId.equals(DEFAULT_RUN_ID)) { + throw new IllegalArgumentException( + String.format( + "%s is a default run-id provided for non labeled ingestion runs. You cannot delete using this reserved run-id", + runId)); + } + + if (!dryRun) { + updateExecutionRequestStatus(runId, ROLLING_BACK_STATUS); + } + + List aspectRowsToDelete = rollbackTargetAspects(runId, hardDelete); + if (!isAuthorized(authorizer, aspectRowsToDelete, authentication)) { + throw new AuthenticationException("User is NOT unauthorized to delete entities."); + } + + log.info("found {} rows to delete...", stringifyRowCount(aspectRowsToDelete.size())); + if (dryRun) { + + final Map> aspectsSplitByIsKeyAspects = + aspectRowsToDelete.stream() + .collect(Collectors.partitioningBy(AspectRowSummary::isKeyAspect)); + + final List keyAspects = aspectsSplitByIsKeyAspects.get(true); + + long entitiesDeleted = keyAspects.size(); + long aspectsReverted = aspectRowsToDelete.size(); + + final long affectedEntities = + aspectRowsToDelete.stream() + .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) + .keySet() + .size(); + + final AspectRowSummaryArray rowSummaries = + new AspectRowSummaryArray( + aspectRowsToDelete.subList(0, Math.min(100, aspectRowsToDelete.size()))); + + // If we are soft deleting, remove key aspects from count of aspects being deleted + if (!hardDelete) { + aspectsReverted -= keyAspects.size(); + rowSummaries.removeIf(AspectRowSummary::isKeyAspect); + } + // Compute the aspects that exist referencing the key aspects we are deleting + final List affectedAspectsList = + keyAspects.stream() + .map( + (AspectRowSummary urn) -> + systemMetadataService.findByUrn(urn.getUrn(), false, 0, MAX_RESULT_SIZE)) + .flatMap(List::stream) + .filter( + row -> + !row.getRunId().equals(runId) + && !row.isKeyAspect() + && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) + .toList(); + + long unsafeEntitiesCount = + affectedAspectsList.stream() + .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) + .keySet() + .size(); + + final List unsafeEntityInfos = + affectedAspectsList.stream() + .map(AspectRowSummary::getUrn) + .distinct() + .map( + urn -> { + UnsafeEntityInfo unsafeEntityInfo = new UnsafeEntityInfo(); + unsafeEntityInfo.setUrn(urn); + return unsafeEntityInfo; + }) + // Return at most 1 million rows + .limit(DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE) + .collect(Collectors.toList()); + + return new RollbackResponse() + .setAspectsReverted(aspectsReverted) + .setEntitiesAffected(affectedEntities) + .setEntitiesDeleted(entitiesDeleted) + .setUnsafeEntitiesCount(unsafeEntitiesCount) + .setUnsafeEntities(new UnsafeEntityInfoArray(unsafeEntityInfos)) + .setAspectRowSummaries(rowSummaries); + } + + RollbackRunResult rollbackRunResult = + entityService.rollbackRun(aspectRowsToDelete, runId, hardDelete); + final List deletedRows = rollbackRunResult.getRowsRolledBack(); + int rowsDeletedFromEntityDeletion = rollbackRunResult.getRowsDeletedFromEntityDeletion(); + + // since elastic limits how many rows we can access at once, we need to iteratively + // delete + while (aspectRowsToDelete.size() >= ELASTIC_MAX_PAGE_SIZE) { + sleep(ELASTIC_BATCH_DELETE_SLEEP_SEC); + aspectRowsToDelete = systemMetadataService.findByRunId(runId, hardDelete, 0, MAX_RESULT_SIZE); + log.info("{} remaining rows to delete...", stringifyRowCount(aspectRowsToDelete.size())); + log.info("deleting..."); + rollbackRunResult = entityService.rollbackRun(aspectRowsToDelete, runId, hardDelete); + deletedRows.addAll(rollbackRunResult.getRowsRolledBack()); + rowsDeletedFromEntityDeletion += rollbackRunResult.getRowsDeletedFromEntityDeletion(); + } + + // Rollback timeseries aspects + DeleteAspectValuesResult timeseriesRollbackResult = + timeseriesAspectService.rollbackTimeseriesAspects(runId); + rowsDeletedFromEntityDeletion += timeseriesRollbackResult.getNumDocsDeleted(); + + log.info("finished deleting {} rows", deletedRows.size()); + int aspectsReverted = deletedRows.size() + rowsDeletedFromEntityDeletion; + + final Map> aspectsSplitByIsKeyAspects = + aspectRowsToDelete.stream() + .collect(Collectors.partitioningBy(AspectRowSummary::isKeyAspect)); + + final List keyAspects = aspectsSplitByIsKeyAspects.get(true); + + final long entitiesDeleted = keyAspects.size(); + final long affectedEntities = + deletedRows.stream() + .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) + .keySet() + .size(); + + final AspectRowSummaryArray rowSummaries = + new AspectRowSummaryArray( + aspectRowsToDelete.subList(0, Math.min(100, aspectRowsToDelete.size()))); + + log.info("computing aspects affected by this rollback..."); + // Compute the aspects that exist referencing the key aspects we are deleting + final List affectedAspectsList = + keyAspects.stream() + .map( + (AspectRowSummary urn) -> + systemMetadataService.findByUrn(urn.getUrn(), false, 0, MAX_RESULT_SIZE)) + .flatMap(List::stream) + .filter( + row -> + !row.getRunId().equals(runId) + && !row.isKeyAspect() + && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) + .toList(); + + long affectedAspects = affectedAspectsList.size(); + long unsafeEntitiesCount = + affectedAspectsList.stream() + .collect(Collectors.groupingBy(AspectRowSummary::getUrn)) + .keySet() + .size(); + + final List unsafeEntityInfos = + affectedAspectsList.stream() + .map(AspectRowSummary::getUrn) + .distinct() + .map( + urn -> { + UnsafeEntityInfo unsafeEntityInfo = new UnsafeEntityInfo(); + unsafeEntityInfo.setUrn(urn); + return unsafeEntityInfo; + }) + // Return at most 1 million rows + .limit(DEFAULT_UNSAFE_ENTITIES_PAGE_SIZE) + .collect(Collectors.toList()); + + log.info("calculation done."); + + updateExecutionRequestStatus(runId, ROLLED_BACK_STATUS); + + return new RollbackResponse() + .setAspectsAffected(affectedAspects) + .setAspectsReverted(aspectsReverted) + .setEntitiesAffected(affectedEntities) + .setEntitiesDeleted(entitiesDeleted) + .setUnsafeEntitiesCount(unsafeEntitiesCount) + .setUnsafeEntities(new UnsafeEntityInfoArray(unsafeEntityInfos)) + .setAspectRowSummaries(rowSummaries); + } + + public void updateExecutionRequestStatus(@Nonnull String runId, @Nonnull String status) { + try { + final Urn executionRequestUrn = + EntityKeyUtils.convertEntityKeyToUrn( + new ExecutionRequestKey().setId(runId), Constants.EXECUTION_REQUEST_ENTITY_NAME); + EnvelopedAspect aspect = + entityService.getLatestEnvelopedAspect( + executionRequestUrn.getEntityType(), + executionRequestUrn, + Constants.EXECUTION_REQUEST_RESULT_ASPECT_NAME); + if (aspect == null) { + log.warn("Aspect for execution request with runId {} not found", runId); + } else { + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + ExecutionRequestResult requestResult = new ExecutionRequestResult(aspect.getValue().data()); + requestResult.setStatus(status); + proposal.setEntityUrn(executionRequestUrn); + proposal.setEntityType(Constants.EXECUTION_REQUEST_ENTITY_NAME); + proposal.setAspectName(Constants.EXECUTION_REQUEST_RESULT_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(requestResult)); + proposal.setChangeType(ChangeType.UPSERT); + + entityService.ingestProposal( + proposal, + new AuditStamp() + .setActor(UrnUtils.getUrn(Constants.SYSTEM_ACTOR)) + .setTime(System.currentTimeMillis()), + false); + } + } catch (Exception e) { + log.error( + String.format( + "Not able to update execution result aspect with runId %s and new status %s.", + runId, status), + e); + } + } + + private boolean isAuthorized( + final Authorizer authorizer, + @Nonnull List rowSummaries, + @Nonnull Authentication authentication) { + DisjunctivePrivilegeGroup orGroup = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.DELETE_ENTITY_PRIVILEGE.getType())))); + + List> resourceSpecs = + rowSummaries.stream() + .map(AspectRowSummary::getUrn) + .map(UrnUtils::getUrn) + .map(urn -> Optional.of(new EntitySpec(urn.getEntityType(), urn.toString()))) + .distinct() + .collect(Collectors.toList()); + + return !restApiAuthorizationEnabled + || AuthUtil.isAuthorizedForResources( + authorizer, authentication.getActor().toUrnStr(), resourceSpecs, orGroup); + } + + private static String stringifyRowCount(int size) { + if (size < ELASTIC_MAX_PAGE_SIZE) { + return String.valueOf(size); + } else { + return "at least " + size; + } + } + + private static void sleep(int seconds) { + try { + TimeUnit.SECONDS.sleep(seconds); + } catch (InterruptedException e) { + log.error("Rollback sleep exception", e); + } + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentManager.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentManager.java new file mode 100644 index 00000000000000..73e3bc130ac9d4 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentManager.java @@ -0,0 +1,94 @@ +package com.linkedin.metadata.service.util; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.search.ScrollResult; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.service.FormService; +import com.linkedin.r2.RemoteInvocationException; +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SearchBasedFormAssignmentManager { + + private static final ImmutableList ENTITY_TYPES = + ImmutableList.of(Constants.DATASET_ENTITY_NAME); + + public static void apply( + DynamicFormAssignment formFilters, + Urn formUrn, + int batchFormEntityCount, + EntityClient entityClient, + Authentication authentication) + throws Exception { + + try { + int totalResults = 0; + int numResults = 0; + String scrollId = null; + FormService formService = new FormService(entityClient, authentication); + + do { + + ScrollResult results = + entityClient.scrollAcrossEntities( + ENTITY_TYPES, + "*", + formFilters.getFilter(), + scrollId, + "5m", + batchFormEntityCount, + null, + authentication); + + if (!results.hasEntities() + || results.getNumEntities() == 0 + || results.getEntities().isEmpty()) { + break; + } + + log.info("Search across entities results: {}.", results); + + if (results.hasEntities()) { + final List entityUrns = + results.getEntities().stream() + .map(SearchEntity::getEntity) + .collect(Collectors.toList()); + + formService.batchAssignFormToEntities(entityUrns, formUrn); + + if (!entityUrns.isEmpty()) { + log.info("Batch assign {} entities to form {}.", entityUrns.size(), formUrn); + } + + numResults = results.getEntities().size(); + totalResults += numResults; + scrollId = results.getScrollId(); + + log.info( + "Starting batch assign forms, count: {} running total: {}, size: {}", + batchFormEntityCount, + totalResults, + results.getEntities().size()); + + } else { + break; + } + } while (scrollId != null); + + log.info("Successfully assigned {} entities to form {}.", totalResults, formUrn); + + } catch (RemoteInvocationException e) { + log.error("Error while assigning form to entities.", e); + throw new RuntimeException(e); + } + } + + private SearchBasedFormAssignmentManager() {} +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentRunner.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentRunner.java new file mode 100644 index 00000000000000..a20f71f550c65d --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/util/SearchBasedFormAssignmentRunner.java @@ -0,0 +1,45 @@ +package com.linkedin.metadata.service.util; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.DynamicFormAssignment; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SearchBasedFormAssignmentRunner { + + public static void assign( + DynamicFormAssignment formFilters, + Urn formUrn, + int batchFormEntityCount, + EntityClient entityClient, + Authentication authentication) { + Runnable runnable = + new Runnable() { + @Override + public void run() { + try { + SearchBasedFormAssignmentManager.apply( + formFilters, formUrn, batchFormEntityCount, entityClient, authentication); + } catch (Exception e) { + log.error( + "SearchBasedFormAssignmentRunner failed to run. " + + "Options: formFilters: {}, " + + "formUrn: {}, " + + "batchFormCount: {}, " + + "entityClient: {}, ", + formFilters, + formUrn, + batchFormEntityCount, + entityClient); + throw new RuntimeException("Form assignment runner error.", e); + } + } + }; + + new Thread(runnable).start(); + } + + private SearchBasedFormAssignmentRunner() {} +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java index 71c4d357ad1eb6..b6bef33df1d7f2 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/shared/ValidationUtils.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.shared; +import com.codahale.metrics.Timer; import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.AbstractArrayTemplate; @@ -19,6 +20,7 @@ import com.linkedin.metadata.search.SearchEntity; import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.utils.metrics.MetricUtils; import java.util.Objects; import java.util.Set; import java.util.function.Function; @@ -33,25 +35,27 @@ public class ValidationUtils { public static SearchResult validateSearchResult( final SearchResult searchResult, @Nonnull final EntityService entityService) { - if (searchResult == null) { - return null; + try (Timer.Context ignored = + MetricUtils.timer(ValidationUtils.class, "validateSearchResult").time()) { + if (searchResult == null) { + return null; + } + Objects.requireNonNull(entityService, "entityService must not be null"); + + SearchResult validatedSearchResult = + new SearchResult() + .setFrom(searchResult.getFrom()) + .setMetadata(searchResult.getMetadata()) + .setPageSize(searchResult.getPageSize()) + .setNumEntities(searchResult.getNumEntities()); + + SearchEntityArray validatedEntities = + validatedUrns(searchResult.getEntities(), SearchEntity::getEntity, entityService, true) + .collect(Collectors.toCollection(SearchEntityArray::new)); + validatedSearchResult.setEntities(validatedEntities); + + return validatedSearchResult; } - Objects.requireNonNull(entityService, "entityService must not be null"); - - SearchResult validatedSearchResult = - new SearchResult() - .setFrom(searchResult.getFrom()) - .setMetadata(searchResult.getMetadata()) - .setPageSize(searchResult.getPageSize()) - .setNumEntities(searchResult.getNumEntities()); - - SearchEntityArray validatedEntities = - validatedUrns(searchResult.getEntities(), SearchEntity::getEntity, entityService, true) - .collect(Collectors.toCollection(SearchEntityArray::new)); - - validatedSearchResult.setEntities(validatedEntities); - - return validatedSearchResult; } public static ScrollResult validateScrollResult( @@ -81,78 +85,85 @@ public static ScrollResult validateScrollResult( public static BrowseResult validateBrowseResult( final BrowseResult browseResult, @Nonnull final EntityService entityService) { - if (browseResult == null) { - return null; + try (Timer.Context ignored = + MetricUtils.timer(ValidationUtils.class, "validateBrowseResult").time()) { + if (browseResult == null) { + return null; + } + Objects.requireNonNull(entityService, "entityService must not be null"); + + BrowseResult validatedBrowseResult = + new BrowseResult() + .setGroups(browseResult.getGroups()) + .setMetadata(browseResult.getMetadata()) + .setFrom(browseResult.getFrom()) + .setPageSize(browseResult.getPageSize()) + .setNumGroups(browseResult.getNumGroups()) + .setNumEntities(browseResult.getNumEntities()) + .setNumElements(browseResult.getNumElements()); + + BrowseResultEntityArray validatedEntities = + validatedUrns(browseResult.getEntities(), BrowseResultEntity::getUrn, entityService, true) + .collect(Collectors.toCollection(BrowseResultEntityArray::new)); + validatedBrowseResult.setEntities(validatedEntities); + + return validatedBrowseResult; } - Objects.requireNonNull(entityService, "entityService must not be null"); - - BrowseResult validatedBrowseResult = - new BrowseResult() - .setGroups(browseResult.getGroups()) - .setMetadata(browseResult.getMetadata()) - .setFrom(browseResult.getFrom()) - .setPageSize(browseResult.getPageSize()) - .setNumGroups(browseResult.getNumGroups()) - .setNumEntities(browseResult.getNumEntities()) - .setNumElements(browseResult.getNumElements()); - - BrowseResultEntityArray validatedEntities = - validatedUrns(browseResult.getEntities(), BrowseResultEntity::getUrn, entityService, true) - .collect(Collectors.toCollection(BrowseResultEntityArray::new)); - - validatedBrowseResult.setEntities(validatedEntities); - - return validatedBrowseResult; } public static ListResult validateListResult( final ListResult listResult, @Nonnull final EntityService entityService) { - if (listResult == null) { - return null; + try (Timer.Context ignored = + MetricUtils.timer(ValidationUtils.class, "validateListResult").time()) { + if (listResult == null) { + return null; + } + Objects.requireNonNull(entityService, "entityService must not be null"); + + ListResult validatedListResult = + new ListResult() + .setStart(listResult.getStart()) + .setCount(listResult.getCount()) + .setTotal(listResult.getTotal()); + + UrnArray validatedEntities = + validatedUrns(listResult.getEntities(), Function.identity(), entityService, true) + .collect(Collectors.toCollection(UrnArray::new)); + validatedListResult.setEntities(validatedEntities); + + return validatedListResult; } - Objects.requireNonNull(entityService, "entityService must not be null"); - - ListResult validatedListResult = - new ListResult() - .setStart(listResult.getStart()) - .setCount(listResult.getCount()) - .setTotal(listResult.getTotal()); - - UrnArray validatedEntities = - validatedUrns(listResult.getEntities(), Function.identity(), entityService, true) - .collect(Collectors.toCollection(UrnArray::new)); - - validatedListResult.setEntities(validatedEntities); - - return validatedListResult; } public static LineageSearchResult validateLineageSearchResult( final LineageSearchResult lineageSearchResult, @Nonnull final EntityService entityService) { - if (lineageSearchResult == null) { - return null; + try (Timer.Context ignored = + MetricUtils.timer(ValidationUtils.class, "validateLineageResult").time()) { + if (lineageSearchResult == null) { + return null; + } + Objects.requireNonNull(entityService, "entityService must not be null"); + + LineageSearchResult validatedLineageSearchResult = + new LineageSearchResult() + .setMetadata(lineageSearchResult.getMetadata()) + .setFrom(lineageSearchResult.getFrom()) + .setPageSize(lineageSearchResult.getPageSize()) + .setNumEntities(lineageSearchResult.getNumEntities()); + + LineageSearchEntityArray validatedEntities = + validatedUrns( + lineageSearchResult.getEntities(), + LineageSearchEntity::getEntity, + entityService, + true) + .collect(Collectors.toCollection(LineageSearchEntityArray::new)); + validatedLineageSearchResult.setEntities(validatedEntities); + + log.debug("Returning validated lineage search results"); + return validatedLineageSearchResult; } - Objects.requireNonNull(entityService, "entityService must not be null"); - - LineageSearchResult validatedLineageSearchResult = - new LineageSearchResult() - .setMetadata(lineageSearchResult.getMetadata()) - .setFrom(lineageSearchResult.getFrom()) - .setPageSize(lineageSearchResult.getPageSize()) - .setNumEntities(lineageSearchResult.getNumEntities()); - - LineageSearchEntityArray validatedEntities = - validatedUrns( - lineageSearchResult.getEntities(), - LineageSearchEntity::getEntity, - entityService, - true) - .collect(Collectors.toCollection(LineageSearchEntityArray::new)); - - validatedLineageSearchResult.setEntities(validatedEntities); - - return validatedLineageSearchResult; } public static EntityLineageResult validateEntityLineageResult( diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/GenericTimeseriesDocument.java b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/GenericTimeseriesDocument.java new file mode 100644 index 00000000000000..1442f099c47032 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/GenericTimeseriesDocument.java @@ -0,0 +1,26 @@ +package com.linkedin.metadata.timeseries; + +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class GenericTimeseriesDocument { + @Nonnull private String urn; + private long timestampMillis; + + @JsonProperty("@timestamp") + private long timestamp; + + @Nonnull private Object event; + @Nullable private String messageId; + @Nullable private Object systemMetadata; + @Nullable private String eventGranularity; + private boolean isExploded; + @Nullable private String runId; + @Nullable private String partition; + @Nullable private Object partitionSpec; +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesAspectService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesAspectService.java index 54480bb700398a..529e8e00ecf570 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesAspectService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesAspectService.java @@ -201,4 +201,15 @@ void upsertDocument( @Nonnull final JsonNode document); List getIndexSizes(); + + @Nonnull + TimeseriesScrollResult scrollAspects( + @Nonnull final String entityName, + @Nonnull final String aspectName, + @Nullable Filter filter, + @Nonnull List sortCriterion, + @Nullable String scrollId, + int count, + @Nullable Long startTimeMillis, + @Nullable Long endTimeMillis); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesScrollResult.java b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesScrollResult.java new file mode 100644 index 00000000000000..200db2dfde8eb3 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/timeseries/TimeseriesScrollResult.java @@ -0,0 +1,18 @@ +package com.linkedin.metadata.timeseries; + +import com.linkedin.metadata.aspect.EnvelopedAspect; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@AllArgsConstructor +@Data +@Builder +public class TimeseriesScrollResult { + int numResults; + int pageSize; + String scrollId; + List events; + List documents; +} diff --git a/metadata-service/servlet/src/main/java/com/datahub/gms/servlet/ConfigSearchExport.java b/metadata-service/servlet/src/main/java/com/datahub/gms/servlet/ConfigSearchExport.java index 970235fc88c875..27aa9ee04cc756 100644 --- a/metadata-service/servlet/src/main/java/com/datahub/gms/servlet/ConfigSearchExport.java +++ b/metadata-service/servlet/src/main/java/com/datahub/gms/servlet/ConfigSearchExport.java @@ -4,7 +4,7 @@ import static com.linkedin.metadata.search.elasticsearch.indexbuilder.SettingsBuilder.KEYWORD_ANALYZER; import com.datahub.gms.util.CSVWriter; -import com.linkedin.datahub.graphql.resolvers.EntityTypeMapper; +import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.config.search.SearchConfiguration; import com.linkedin.metadata.models.EntitySpec; diff --git a/metadata-service/war/src/main/resources/boot/data_types.json b/metadata-service/war/src/main/resources/boot/data_types.json new file mode 100644 index 00000000000000..2d7294e45bd7a5 --- /dev/null +++ b/metadata-service/war/src/main/resources/boot/data_types.json @@ -0,0 +1,42 @@ +[ + { + "urn": "urn:li:dataType:datahub.string", + "info": { + "qualifiedName":"datahub.string", + "displayName": "String", + "description": "A string of characters." + } + }, + { + "urn": "urn:li:dataType:datahub.number", + "info": { + "qualifiedName":"datahub.number", + "displayName": "Number", + "description": "An integer or decimal number." + } + }, + { + "urn": "urn:li:dataType:datahub.urn", + "info": { + "qualifiedName":"datahub.urn", + "displayName": "Urn", + "description": "An unique identifier for a DataHub entity." + } + }, + { + "urn": "urn:li:dataType:datahub.rich_text", + "info": { + "qualifiedName":"datahub.rich_text", + "displayName": "Rich Text", + "description": "An attributed string of characters." + } + }, + { + "urn": "urn:li:dataType:datahub.date", + "info": { + "qualifiedName":"datahub.date", + "displayName": "Date", + "description": "A specific day, without time." + } + } +] diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/OwnershipUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/OwnershipUtils.java new file mode 100644 index 00000000000000..140b64780918db --- /dev/null +++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/OwnershipUtils.java @@ -0,0 +1,20 @@ +package com.linkedin.metadata.authorization; + +import com.linkedin.common.Ownership; +import com.linkedin.common.urn.Urn; +import java.util.List; +import javax.annotation.Nonnull; + +public class OwnershipUtils { + + public static boolean isOwnerOfEntity( + @Nonnull final Ownership entityOwnership, + @Nonnull final Urn actorUrn, + @Nonnull final List groupsForUser) { + return entityOwnership.getOwners().stream() + .anyMatch( + owner -> owner.getOwner().equals(actorUrn) || groupsForUser.contains(owner.getOwner())); + } + + private OwnershipUtils() {} +} diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/AuditStampUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/AuditStampUtils.java index 5f3975b066fde6..6ba311cf166d4e 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/utils/AuditStampUtils.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/AuditStampUtils.java @@ -3,8 +3,11 @@ import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; +import java.net.URISyntaxException; import java.time.Clock; +import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -16,4 +19,11 @@ public static AuditStamp createDefaultAuditStamp() { .setActor(UrnUtils.getUrn(SYSTEM_ACTOR)) .setTime(Clock.systemUTC().millis()); } + + public static AuditStamp createAuditStamp(@Nonnull String actorUrn) throws URISyntaxException { + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setActor(Urn.createFromString(actorUrn)); + auditStamp.setTime(Clock.systemUTC().millis()); + return auditStamp; + } } diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/FormUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/FormUtils.java new file mode 100644 index 00000000000000..ebf2587418daeb --- /dev/null +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/FormUtils.java @@ -0,0 +1,49 @@ +package com.linkedin.metadata.utils; + +import com.linkedin.common.urn.Urn; +import com.linkedin.form.FormActorAssignment; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class FormUtils { + + private FormUtils() {} + + public static boolean isFormAssignedToUser( + @Nonnull final FormActorAssignment parent, + @Nonnull final Urn userUrn, + @Nonnull final List groupUrns) { + // Assigned urn and group urns + final Set assignedUserUrns = + parent.getUsers() != null + ? parent.getUsers().stream().map(Urn::toString).collect(Collectors.toSet()) + : Collections.emptySet(); + + final Set assignedGroupUrns = + parent.getGroups() != null + ? parent.getGroups().stream().map(Urn::toString).collect(Collectors.toSet()) + : Collections.emptySet(); + + // First check whether user is directly assigned. + if (assignedUserUrns.size() > 0) { + boolean isUserAssigned = assignedUserUrns.contains(userUrn.toString()); + if (isUserAssigned) { + return true; + } + } + + // Next check whether the user is assigned indirectly, by group. + if (assignedGroupUrns.size() > 0) { + boolean isUserGroupAssigned = + groupUrns.stream().anyMatch(groupUrn -> assignedGroupUrns.contains(groupUrn.toString())); + if (isUserGroupAssigned) { + return true; + } + } + + return false; + } +} diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java index fc28367e6c7eea..ae061a2d0c0905 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java @@ -1,12 +1,17 @@ package com.linkedin.metadata.utils; import com.datahub.util.RecordUtils; +import com.linkedin.common.urn.Urn; import com.linkedin.data.ByteString; import com.linkedin.data.template.RecordTemplate; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.GenericPayload; import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.stream.Collectors; import javax.annotation.Nonnull; public class GenericRecordUtils { @@ -66,4 +71,20 @@ public static GenericPayload serializePayload(@Nonnull RecordTemplate payload) { genericPayload.setContentType(GenericRecordUtils.JSON); return genericPayload; } + + @Nonnull + public static Map> entityResponseToAspectMap( + Map inputMap) { + return inputMap.entrySet().stream() + .map( + entry -> + Map.entry( + entry.getKey(), + entry.getValue().getAspects().entrySet().stream() + .map( + aspectEntry -> + Map.entry(aspectEntry.getKey(), aspectEntry.getValue().getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } } diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/SchemaFieldUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/SchemaFieldUtils.java new file mode 100644 index 00000000000000..edf959d04a37b3 --- /dev/null +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/SchemaFieldUtils.java @@ -0,0 +1,22 @@ +package com.linkedin.metadata.utils; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.key.SchemaFieldKey; +import javax.annotation.Nonnull; + +public class SchemaFieldUtils { + + private SchemaFieldUtils() {} + + public static Urn generateSchemaFieldUrn( + @Nonnull final String resourceUrn, @Nonnull final String fieldPath) { + // we rely on schemaField fieldPaths to be encoded since we do that on the ingestion side + final String encodedFieldPath = + fieldPath.replaceAll("\\(", "%28").replaceAll("\\)", "%29").replaceAll(",", "%2C"); + final SchemaFieldKey key = + new SchemaFieldKey().setParent(UrnUtils.getUrn(resourceUrn)).setFieldPath(encodedFieldPath); + return EntityKeyUtils.convertEntityKeyToUrn(key, Constants.SCHEMA_FIELD_ENTITY_NAME); + } +} diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/SearchUtil.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/SearchUtil.java index eb58bc509838d5..9df708c6e9fdcd 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/utils/SearchUtil.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/SearchUtil.java @@ -7,14 +7,19 @@ import com.linkedin.metadata.query.filter.Criterion; import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; import com.linkedin.metadata.search.FilterValue; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -142,4 +147,25 @@ public static BoolQueryBuilder filterSoftDeletedByDefault( } return filterQuery; } + + public static SortCriterion sortBy(@Nonnull String field, @Nullable SortOrder direction) { + SortCriterion sortCriterion = new SortCriterion(); + sortCriterion.setField(field); + sortCriterion.setOrder( + com.linkedin.metadata.query.filter.SortOrder.valueOf( + Optional.ofNullable(direction).orElse(SortOrder.ASCENDING).toString())); + return sortCriterion; + } + + public static Filter andFilter(Criterion... criteria) { + Filter filter = new Filter(); + filter.setOr(andCriterion(Arrays.stream(criteria))); + return filter; + } + + public static ConjunctiveCriterionArray andCriterion(Stream criteria) { + return new ConjunctiveCriterionArray( + new ConjunctiveCriterion() + .setAnd(new CriterionArray(criteria.collect(Collectors.toList())))); + } } diff --git a/mock-entity-registry/src/main/java/mock/MockEntityRegistry.java b/mock-entity-registry/src/main/java/mock/MockEntityRegistry.java index a324f9ce0195ba..dfa8c627e0617d 100644 --- a/mock-entity-registry/src/main/java/mock/MockEntityRegistry.java +++ b/mock-entity-registry/src/main/java/mock/MockEntityRegistry.java @@ -1,10 +1,10 @@ package mock; +import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.EventSpec; import com.linkedin.metadata.models.registry.EntityRegistry; -import com.linkedin.metadata.models.registry.template.AspectTemplateEngine; import java.util.Collections; import java.util.HashMap; import java.util.Map; diff --git a/smoke-test/cypress-dev.sh b/smoke-test/cypress-dev.sh index 93f03d36cbd190..b1c6571e1a0658 100755 --- a/smoke-test/cypress-dev.sh +++ b/smoke-test/cypress-dev.sh @@ -15,7 +15,7 @@ python -c 'from tests.cypress.integration_test import ingest_data; ingest_data() cd tests/cypress npm install -source ../../set-cypress-creds.sh +source "$DIR/set-cypress-creds.sh" npx cypress open \ --env "ADMIN_DISPLAYNAME=$CYPRESS_ADMIN_DISPLAYNAME,ADMIN_USERNAME=$CYPRESS_ADMIN_USERNAME,ADMIN_PASSWORD=$CYPRESS_ADMIN_PASSWORD" diff --git a/smoke-test/requests_wrapper/__init__.py b/smoke-test/requests_wrapper/__init__.py index d9956e8434a893..c2f4190e6150db 100644 --- a/smoke-test/requests_wrapper/__init__.py +++ b/smoke-test/requests_wrapper/__init__.py @@ -1,3 +1,4 @@ from .utils_requests_wrapper import CustomSession as Session from .utils_requests_wrapper import get, post from .constants import * +from requests import exceptions diff --git a/smoke-test/tests/cypress/cypress/e2e/siblings/siblings.js b/smoke-test/tests/cypress/cypress/e2e/siblings/siblings.js index 00de08e77a1859..f89b70b7a7d23d 100644 --- a/smoke-test/tests/cypress/cypress/e2e/siblings/siblings.js +++ b/smoke-test/tests/cypress/cypress/e2e/siblings/siblings.js @@ -80,7 +80,7 @@ describe('siblings', () => { cy.login(); cy.visit('/search?page=1&query=raw_orders'); - cy.contains('Showing 1 - 10 of 14 results'); + cy.contains('Showing 1 - 10 of '); cy.get('.test-search-result').should('have.length', 5); cy.get('.test-search-result-sibling-section').should('have.length', 5); diff --git a/smoke-test/tests/structured_properties/__init__.py b/smoke-test/tests/structured_properties/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/smoke-test/tests/structured_properties/click_event.avsc b/smoke-test/tests/structured_properties/click_event.avsc new file mode 100644 index 00000000000000..d959dcbbdeea1f --- /dev/null +++ b/smoke-test/tests/structured_properties/click_event.avsc @@ -0,0 +1,14 @@ +{ + "namespace": "io.datahubproject", + "type": "record", + "name": "ClickEvent", + "fields": [ + { "name": "ip", "type": "string" }, + { "name": "url", "type": "string" }, + { "name": "time", "type": "long" }, + { "name": "referer", "type": ["string", "null"] }, + { "name": "user_agent", "type": ["string", "null"] }, + { "name": "user_id", "type": ["string", "null"] }, + { "name": "session_id", "type": ["string", "null"] } + ] +} diff --git a/smoke-test/tests/structured_properties/test_dataset.yaml b/smoke-test/tests/structured_properties/test_dataset.yaml new file mode 100644 index 00000000000000..2ac1cca6c6dc28 --- /dev/null +++ b/smoke-test/tests/structured_properties/test_dataset.yaml @@ -0,0 +1,19 @@ +- id: user.clicks + platform: hive + # urn: urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD) # use urn instead of id and platform + subtype: table + schema: + file: tests/structured_properties/click_event.avsc + fields: + # - id: ip + - urn: urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD),ip) + structured_properties: + io.acryl.dataManagement.deprecationDate: "2023-01-01" + properties: + retention: 365 + structured_properties: + clusterType: primary + clusterName: gold + projectNames: + - Tracking + - DataHub diff --git a/smoke-test/tests/structured_properties/test_structured_properties.py b/smoke-test/tests/structured_properties/test_structured_properties.py new file mode 100644 index 00000000000000..83994776076b05 --- /dev/null +++ b/smoke-test/tests/structured_properties/test_structured_properties.py @@ -0,0 +1,577 @@ +import logging +import os +from datahub.ingestion.graph.filters import SearchFilterRule +from tests.consistency_utils import wait_for_writes_to_sync +import tempfile +from random import randint +from tests.utilities.file_emitter import FileEmitter +from typing import Iterable, List, Optional, Union + +import pytest +# import tenacity +from datahub.api.entities.dataset.dataset import Dataset +from datahub.api.entities.structuredproperties.structuredproperties import \ + StructuredProperties +from datahub.emitter.mce_builder import make_dataset_urn, make_schema_field_urn +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph +from datahub.metadata.schema_classes import ( + EntityTypeInfoClass, PropertyValueClass, StructuredPropertiesClass, + StructuredPropertyDefinitionClass, StructuredPropertyValueAssignmentClass) +from datahub.specific.dataset import DatasetPatchBuilder +from datahub.utilities.urns.structured_properties_urn import \ + StructuredPropertyUrn +from datahub.utilities.urns.urn import Urn + +from tests.utils import (delete_urns, delete_urns_from_file, get_gms_url, + get_sleep_info, ingest_file_via_rest, + wait_for_writes_to_sync) + +logger = logging.getLogger(__name__) + +start_index = randint(10, 10000) +dataset_urns = [ + make_dataset_urn("snowflake", f"table_foo_{i}") + for i in range(start_index, start_index + 10) +] + +schema_field_urns = [ + make_schema_field_urn(dataset_urn, "column_1") + for dataset_urn in dataset_urns +] + +generated_urns = [d for d in dataset_urns] + [f for f in schema_field_urns] + + +default_namespace = "io.acryl.privacy" + +def create_logical_entity( + entity_name: str, +) -> Iterable[MetadataChangeProposalWrapper]: + mcp = MetadataChangeProposalWrapper( + entityUrn="urn:li:entityType:" + entity_name, + aspect=EntityTypeInfoClass( + qualifiedName="io.datahubproject." + entity_name, + displayName=entity_name, + ), + ) + return [mcp] + + +def create_test_data(filename: str): + file_emitter = FileEmitter(filename) + for mcps in create_logical_entity("dataset"): + file_emitter.emit(mcps) + + file_emitter.close() + wait_for_writes_to_sync() + +sleep_sec, sleep_times = get_sleep_info() + + +@pytest.fixture(scope="module", autouse=False) +def graph() -> DataHubGraph: + graph: DataHubGraph = DataHubGraph( + config=DatahubClientConfig(server=get_gms_url()) + ) + return graph + + +@pytest.fixture(scope="module", autouse=False) +def ingest_cleanup_data(request): + new_file, filename = tempfile.mkstemp() + try: + create_test_data(filename) + print("ingesting structured properties test data") + ingest_file_via_rest(filename) + yield + print("removing structured properties test data") + delete_urns_from_file(filename) + delete_urns(generated_urns) + wait_for_writes_to_sync() + finally: + os.remove(filename) + + +@pytest.mark.dependency() +def test_healthchecks(wait_for_healthchecks): + # Call to wait_for_healthchecks fixture will do the actual functionality. + pass + + +def create_property_definition( + property_name: str, + graph: DataHubGraph, + namespace: str = default_namespace, + value_type: str = "string", + cardinality: str = "SINGLE", + allowed_values: Optional[List[PropertyValueClass]] = None, + entity_types: Optional[List[str]] = None, +): + structured_property_definition = StructuredPropertyDefinitionClass( + qualifiedName=f"{namespace}.{property_name}", + valueType=Urn.make_data_type_urn(value_type), + description="The retention policy for the dataset", + entityTypes=[Urn.make_entity_type_urn(e) for e in entity_types] + if entity_types + else [Urn.make_entity_type_urn("dataset")], + cardinality=cardinality, + allowedValues=allowed_values, + ) + + mcp = MetadataChangeProposalWrapper( + entityUrn=f"urn:li:structuredProperty:{namespace}.{property_name}", + aspect=structured_property_definition, + ) + graph.emit(mcp) + wait_for_writes_to_sync() + + +def attach_property_to_entity( + urn: str, + property_name: str, + property_value: Union[str, float, List[str | float]], + graph: DataHubGraph, + namespace: str = default_namespace +): + if isinstance(property_value, list): + property_values: List[Union[str, float]] = property_value + else: + property_values = [property_value] + + mcp = MetadataChangeProposalWrapper( + entityUrn=urn, + aspect=StructuredPropertiesClass( + properties=[ + StructuredPropertyValueAssignmentClass( + propertyUrn=f"urn:li:structuredProperty:{namespace}.{property_name}", + values=property_values, + ) + ] + ), + ) + graph.emit_mcp(mcp) + wait_for_writes_to_sync() + + +def get_property_from_entity( + urn: str, + property_name: str, + graph: DataHubGraph, +): + structured_properties: Optional[ + StructuredPropertiesClass + ] = graph.get_aspect(urn, StructuredPropertiesClass) + assert structured_properties is not None + for property in structured_properties.properties: + if ( + property.propertyUrn + == f"urn:li:structuredProperty:{property_name}" + ): + return property.values + return None + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), +# wait=tenacity.wait_fixed(sleep_sec), +# ) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_string(ingest_cleanup_data, graph): + property_name = "retentionPolicy" + + create_property_definition(property_name, graph) + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.retentionPolicy") + + attach_property_to_entity( + dataset_urns[0], property_name, ["30d"], graph=graph + ) + + try: + attach_property_to_entity( + dataset_urns[0], property_name, 200030, graph=graph + ) + raise AssertionError( + "Should not be able to attach a number to a string property" + ) + except Exception as e: + if not isinstance(e, AssertionError): + pass + else: + raise e + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), +# wait=tenacity.wait_fixed(sleep_sec), +# ) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_double(ingest_cleanup_data, graph): + property_name = "expiryTime" + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + create_property_definition(property_name, graph, value_type="number") + + attach_property_to_entity( + dataset_urns[0], property_name, 2000034, graph=graph + ) + + try: + attach_property_to_entity( + dataset_urns[0], property_name, "30 days", graph=graph + ) + raise AssertionError( + "Should not be able to attach a string to a number property" + ) + except Exception as e: + if not isinstance(e, AssertionError): + pass + else: + raise e + + try: + attach_property_to_entity( + dataset_urns[0], property_name, [2000034, 2000035], graph=graph + ) + raise AssertionError( + "Should not be able to attach a list to a number property" + ) + except Exception as e: + if not isinstance(e, AssertionError): + pass + else: + raise e + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), +# wait=tenacity.wait_fixed(sleep_sec), +# ) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_double_multiple(ingest_cleanup_data, graph): + property_name = "versions" + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + + create_property_definition( + property_name, graph, value_type="number", cardinality="MULTIPLE" + ) + + attach_property_to_entity( + dataset_urns[0], property_name, [1.0, 2.0], graph=graph + ) + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), +# wait=tenacity.wait_fixed(sleep_sec), +# ) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_string_allowed_values( + ingest_cleanup_data, graph +): + property_name = "enumProperty" + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + + create_property_definition( + property_name, + graph, + value_type="string", + cardinality="MULTIPLE", + allowed_values=[ + PropertyValueClass(value="foo"), + PropertyValueClass(value="bar"), + ], + ) + + attach_property_to_entity( + dataset_urns[0], property_name, ["foo", "bar"], graph=graph + ) + + try: + attach_property_to_entity( + dataset_urns[0], property_name, ["foo", "baz"], graph=graph + ) + raise AssertionError( + "Should not be able to attach a value not in allowed values" + ) + except Exception as e: + if "value: {string=baz} should be one of [" in str(e): + pass + else: + raise e + + +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_definition_evolution( + ingest_cleanup_data, graph +): + property_name = "enumProperty1234" + + create_property_definition( + property_name, + graph, + value_type="string", + cardinality="MULTIPLE", + allowed_values=[ + PropertyValueClass(value="foo"), + PropertyValueClass(value="bar"), + ], + ) + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + + try: + create_property_definition( + property_name, + graph, + value_type="string", + cardinality="SINGLE", + allowed_values=[ + PropertyValueClass(value="foo"), + PropertyValueClass(value="bar"), + ], + ) + raise AssertionError( + "Should not be able to change cardinality from MULTIPLE to SINGLE" + ) + except Exception as e: + if isinstance(e, AssertionError): + raise e + else: + pass + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), +# wait=tenacity.wait_fixed(sleep_sec), +# ) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_structured_property_schema_field(ingest_cleanup_data, graph): + property_name = ( + f"deprecationDate{randint(10, 10000)}" + ) + + create_property_definition( + property_name, + graph, + namespace="io.datahubproject.test", + value_type="date", + entity_types=["schemaField"], + ) + generated_urns.append(f"urn:li:structuredProperty:io.datahubproject.test.{property_name}") + + attach_property_to_entity( + schema_field_urns[0], property_name, "2020-10-01", graph=graph, namespace="io.datahubproject.test" + ) + + assert ( + get_property_from_entity( + schema_field_urns[0], f"io.datahubproject.test.{property_name}", graph=graph + ) + == ["2020-10-01"] + ) + + try: + attach_property_to_entity( + schema_field_urns[0], property_name, 200030, graph=graph, namespace="io.datahubproject.test" + ) + raise AssertionError( + "Should not be able to attach a number to a DATE property" + ) + except Exception as e: + if not isinstance(e, AssertionError): + pass + else: + raise e + + +def test_dataset_yaml_loader(ingest_cleanup_data, graph): + StructuredProperties.create( + "tests/structured_properties/test_structured_properties.yaml" + ) + + for dataset in Dataset.from_yaml( + "tests/structured_properties/test_dataset.yaml" + ): + for mcp in dataset.generate_mcp(): + graph.emit(mcp) + wait_for_writes_to_sync() + + property_name = "io.acryl.dataManagement.deprecationDate" + assert ( + get_property_from_entity( + make_schema_field_urn( + make_dataset_urn("hive", "user.clicks"), "ip" + ), + property_name, + graph=graph, + ) + == ["2023-01-01"] + ) + + dataset = Dataset.from_datahub( + graph=graph, + urn="urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD)", + ) + field_name = "ip" + matching_fields = [ + f + for f in dataset.schema_metadata.fields + if Dataset._simplify_field_path(f.id) == field_name + ] + assert len(matching_fields) == 1 + assert ( + matching_fields[0].structured_properties[ + Urn.make_structured_property_urn( + "io.acryl.dataManagement.deprecationDate" + ) + ] + == ["2023-01-01"] + ) + + +def test_dataset_structured_property_validation( + ingest_cleanup_data, graph, caplog +): + from datahub.api.entities.dataset.dataset import Dataset + + property_name = "replicationSLA" + property_value = 30 + value_type = "number" + + create_property_definition( + property_name=property_name, graph=graph, value_type=value_type + ) + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.replicationSLA") + + attach_property_to_entity( + dataset_urns[0], property_name, [property_value], graph=graph + ) + + assert Dataset.validate_structured_property( + f"{default_namespace}.{property_name}", property_value + ) == ( + f"{default_namespace}.{property_name}", + float(property_value), + ) + + assert ( + Dataset.validate_structured_property("testName", "testValue") is None + ) + + bad_property_value = "2023-09-20" + assert ( + Dataset.validate_structured_property( + property_name, bad_property_value + ) + is None + ) + + +def test_structured_property_search(ingest_cleanup_data, graph: DataHubGraph, caplog): + + def to_es_name(property_name, namespace=default_namespace): + namespace_field = namespace.replace(".", "_") + return f"structuredProperties.{namespace_field}_{property_name}" + + # Attach structured property to entity and to field + field_property_name = f"deprecationDate{randint(10, 10000)}" + + create_property_definition( + namespace="io.datahubproject.test", + property_name=field_property_name, + graph=graph, value_type="date", entity_types=["schemaField"] + ) + generated_urns.append(f"urn:li:structuredProperty:io.datahubproject.test.{field_property_name}") + + attach_property_to_entity( + schema_field_urns[0], field_property_name, "2020-10-01", graph=graph, namespace="io.datahubproject.test" + ) + dataset_property_name = "replicationSLA" + property_value = 30 + value_type = "number" + + create_property_definition(property_name=dataset_property_name, graph=graph, value_type=value_type) + generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{dataset_property_name}") + + attach_property_to_entity(dataset_urns[0], dataset_property_name, [property_value], graph=graph) + + # [] = default entities which includes datasets, does not include fields + entity_urns = list(graph.get_urns_by_filter(extraFilters=[ + { + "field": to_es_name(dataset_property_name), + "negated": "false", + "condition": "EXISTS", + } + ])) + assert len(entity_urns) == 1 + assert entity_urns[0] == dataset_urns[0] + + # Search over schema field specifically + field_structured_prop = graph.get_aspect(entity_urn=schema_field_urns[0], aspect_type=StructuredPropertiesClass) + assert field_structured_prop == StructuredPropertiesClass( + properties=[ + StructuredPropertyValueAssignmentClass( + propertyUrn=f"urn:li:structuredProperty:io.datahubproject.test.{field_property_name}", + values=["2020-10-01"] + ) + ] + ) + + # Search over entities that do not include the field + field_urns = list(graph.get_urns_by_filter(entity_types=["tag"], + extraFilters=[ + { + "field": to_es_name(field_property_name, + namespace="io.datahubproject.test"), + "negated": "false", + "condition": "EXISTS", + } + ])) + assert len(field_urns) == 0 + + # OR the two properties together to return both results + field_urns = list(graph.get_urns_by_filter(entity_types=["dataset", "tag"], + extraFilters=[ + { + "field": to_es_name(dataset_property_name), + "negated": "false", + "condition": "EXISTS", + } + ])) + assert len(field_urns) == 1 + assert dataset_urns[0] in field_urns + + +def test_dataset_structured_property_patch( + ingest_cleanup_data, graph, caplog +): + property_name = "replicationSLA" + property_value = 30 + value_type = "number" + + create_property_definition( + property_name=property_name, + graph=graph, + value_type=value_type + ) + + dataset_patcher: DatasetPatchBuilder = DatasetPatchBuilder( + urn=dataset_urns[0] + ) + + dataset_patcher.set_structured_property(StructuredPropertyUrn.make_structured_property_urn( + f"{default_namespace}.{property_name}"), property_value) + + for mcp in dataset_patcher.build(): + graph.emit(mcp) + wait_for_writes_to_sync() + + dataset = Dataset.from_datahub(graph=graph, urn=dataset_urns[0]) + assert dataset.structured_properties is not None + assert ( + [int(float(k)) for k in dataset.structured_properties[ + StructuredPropertyUrn.make_structured_property_urn( + f"{default_namespace}.{property_name}" + ) + ]] + == [property_value] + ) diff --git a/smoke-test/tests/structured_properties/test_structured_properties.yaml b/smoke-test/tests/structured_properties/test_structured_properties.yaml new file mode 100644 index 00000000000000..569a3d185165d6 --- /dev/null +++ b/smoke-test/tests/structured_properties/test_structured_properties.yaml @@ -0,0 +1,33 @@ +- id: clusterType + type: STRING + display_name: Cluster's type + description: "Test Cluster Type Property" + entity_types: + - dataset +- id: clusterName + type: STRING + display_name: Cluster's name + description: "Test Cluster Name Property" + entity_types: + - dataset +- id: projectNames + type: STRING + cardinality: MULTIPLE + display_name: Project Name + entity_types: + - dataset # or urn:li:logicalEntity:metamodel.datahub.dataset + - dataflow + description: "Test property for project name" + allowed_values: + - value: Tracking + description: test value 1 for project + - value: DataHub + description: test value 2 for project +- id: io.acryl.dataManagement.deprecationDate + type: DATE + display_name: Deprecation Date + entity_types: + - dataset + - dataFlow + - dataJob + - schemaField diff --git a/smoke-test/tests/telemetry/telemetry_test.py b/smoke-test/tests/telemetry/telemetry_test.py index 3127061c9f5061..b7cd6fa0517df7 100644 --- a/smoke-test/tests/telemetry/telemetry_test.py +++ b/smoke-test/tests/telemetry/telemetry_test.py @@ -3,9 +3,19 @@ from datahub.cli.cli_utils import get_aspects_for_entity -def test_no_clientID(): +def test_no_client_id(): client_id_urn = "urn:li:telemetry:clientId" - aspect = ["telemetryClientId"] + aspect = ["clientId"] # this is checking for the removal of the invalid aspect RemoveClientIdAspectStep.java + + res_data = json.dumps( + get_aspects_for_entity(entity_urn=client_id_urn, aspects=aspect, typed=False) + ) + assert res_data == "{}" + + +def test_no_telemetry_client_id(): + client_id_urn = "urn:li:telemetry:clientId" + aspect = ["telemetryClientId"] # telemetry expected to be disabled for tests res_data = json.dumps( get_aspects_for_entity(entity_urn=client_id_urn, aspects=aspect, typed=False) diff --git a/smoke-test/tests/utilities/__init__.py b/smoke-test/tests/utilities/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/smoke-test/tests/utilities/file_emitter.py b/smoke-test/tests/utilities/file_emitter.py new file mode 100644 index 00000000000000..27a91c360af8a8 --- /dev/null +++ b/smoke-test/tests/utilities/file_emitter.py @@ -0,0 +1,21 @@ +from datahub.ingestion.sink.file import FileSink, FileSinkConfig +from datahub.ingestion.api.common import PipelineContext, RecordEnvelope +from datahub.ingestion.api.sink import NoopWriteCallback +import time + + +class FileEmitter: + def __init__(self, filename: str, run_id: str = f"test_{int(time.time()*1000.0)}") -> None: + self.sink: FileSink = FileSink( + ctx=PipelineContext(run_id=run_id), + config=FileSinkConfig(filename=filename), + ) + + def emit(self, event): + self.sink.write_record_async( + record_envelope=RecordEnvelope(record=event, metadata={}), + write_callback=NoopWriteCallback(), + ) + + def close(self): + self.sink.close() \ No newline at end of file From b94d463fe0ad8d3ff12a9b1a752c5da8a0d6fac8 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 22 Jan 2024 16:23:56 -0800 Subject: [PATCH 187/540] feat(ingest/lookml): support complex lookml manifests (#9688) --- metadata-ingestion/setup.py | 2 +- .../ingestion/source/looker/lkml_patched.py | 28 +++++ .../ingestion/source/looker/lookml_source.py | 101 ++++++++---------- .../complex-manifest.lkml | 23 ++++ .../tests/integration/lookml/test_lookml.py | 12 +++ 5 files changed, 109 insertions(+), 57 deletions(-) create mode 100644 metadata-ingestion/src/datahub/ingestion/source/looker/lkml_patched.py create mode 100644 metadata-ingestion/tests/integration/lookml/lkml_manifest_samples/complex-manifest.lkml diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 34e8167a997f6d..1fb570d76120e8 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -149,7 +149,7 @@ # This version of lkml contains a fix for parsing lists in # LookML files with spaces between an item and the following comma. # See https://github.com/joshtemple/lkml/issues/73. - "lkml>=1.3.0b5", + "lkml>=1.3.4", "sql-metadata==2.2.2", *sqllineage_lib, "GitPython>2", diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/lkml_patched.py b/metadata-ingestion/src/datahub/ingestion/source/looker/lkml_patched.py new file mode 100644 index 00000000000000..6506682b8ed8d4 --- /dev/null +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/lkml_patched.py @@ -0,0 +1,28 @@ +import pathlib +from typing import Union + +import lkml +import lkml.simple +import lkml.tree + +# Patch lkml to support the manifest.lkml files. +# We have to patch both locations because lkml uses a immutable tuple +# instead of a list for this type. +lkml.simple.PLURAL_KEYS = ( + *lkml.simple.PLURAL_KEYS, + "local_dependency", + "remote_dependency", + "constant", + "override_constant", +) +lkml.tree.PLURAL_KEYS = lkml.simple.PLURAL_KEYS + + +def load_lkml(path: Union[str, pathlib.Path]) -> dict: + """Loads a LookML file from disk and returns a dictionary.""" + + # Using this method instead of lkml.load directly ensures + # that our patches to lkml are applied. + + with open(path, "r") as file: + return lkml.load(file) diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py index 33079f3fd9ac17..9317605d5b0555 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_source.py @@ -49,6 +49,7 @@ from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.ingestion.source.common.subtypes import DatasetSubTypes from datahub.ingestion.source.git.git_import import GitClone +from datahub.ingestion.source.looker.lkml_patched import load_lkml from datahub.ingestion.source.looker.looker_common import ( CORPUSER_DATAHUB, LookerCommonConfig, @@ -98,13 +99,6 @@ _BASE_PROJECT_NAME = "__BASE" -# Patch lkml to support the local_dependency and remote_dependency keywords. -lkml.simple.PLURAL_KEYS = ( - *lkml.simple.PLURAL_KEYS, - "local_dependency", - "remote_dependency", -) - _EXPLORE_FILE_EXTENSION = ".explore.lkml" _VIEW_FILE_EXTENSION = ".view.lkml" _MODEL_FILE_EXTENSION = ".model.lkml" @@ -384,10 +378,9 @@ def from_looker_dict( ] for included_file in explore_files: try: - with open(included_file, "r") as file: - parsed = lkml.load(file) - included_explores = parsed.get("explores", []) - explores.extend(included_explores) + parsed = load_lkml(included_file) + included_explores = parsed.get("explores", []) + explores.extend(included_explores) except Exception as e: reporter.report_warning( path, f"Failed to load {included_file} due to {e}" @@ -514,24 +507,23 @@ def resolve_includes( f"Will be loading {included_file}, traversed here via {traversal_path}" ) try: - with open(included_file, "r") as file: - parsed = lkml.load(file) - seen_so_far.add(included_file) - if "includes" in parsed: # we have more includes to resolve! - resolved.extend( - LookerModel.resolve_includes( - parsed["includes"], - resolved_project_name, - root_project_name, - base_projects_folder, - included_file, - reporter, - seen_so_far, - traversal_path=traversal_path - + "." - + pathlib.Path(included_file).stem, - ) + parsed = load_lkml(included_file) + seen_so_far.add(included_file) + if "includes" in parsed: # we have more includes to resolve! + resolved.extend( + LookerModel.resolve_includes( + parsed["includes"], + resolved_project_name, + root_project_name, + base_projects_folder, + included_file, + reporter, + seen_so_far, + traversal_path=traversal_path + + "." + + pathlib.Path(included_file).stem, ) + ) except Exception as e: reporter.report_warning( path, f"Failed to load {included_file} due to {e}" @@ -648,21 +640,20 @@ def _load_viewfile( self.reporter.report_failure(path, f"failed to load view file: {e}") return None try: - with open(path, "r") as file: - logger.debug(f"Loading viewfile {path}") - parsed = lkml.load(file) - looker_viewfile = LookerViewFile.from_looker_dict( - absolute_file_path=path, - looker_view_file_dict=parsed, - project_name=project_name, - root_project_name=self._root_project_name, - base_projects_folder=self._base_projects_folder, - raw_file_content=raw_file_content, - reporter=reporter, - ) - logger.debug(f"adding viewfile for path {path} to the cache") - self.viewfile_cache[path] = looker_viewfile - return looker_viewfile + logger.debug(f"Loading viewfile {path}") + parsed = load_lkml(path) + looker_viewfile = LookerViewFile.from_looker_dict( + absolute_file_path=path, + looker_view_file_dict=parsed, + project_name=project_name, + root_project_name=self._root_project_name, + base_projects_folder=self._base_projects_folder, + raw_file_content=raw_file_content, + reporter=reporter, + ) + logger.debug(f"adding viewfile for path {path} to the cache") + self.viewfile_cache[path] = looker_viewfile + return looker_viewfile except Exception as e: self.reporter.report_failure(path, f"failed to load view file: {e}") return None @@ -1498,17 +1489,16 @@ def __init__(self, config: LookMLSourceConfig, ctx: PipelineContext): ) def _load_model(self, path: str) -> LookerModel: - with open(path, "r") as file: - logger.debug(f"Loading model from file {path}") - parsed = lkml.load(file) - looker_model = LookerModel.from_looker_dict( - parsed, - _BASE_PROJECT_NAME, - self.source_config.project_name, - self.base_projects_folder, - path, - self.reporter, - ) + logger.debug(f"Loading model from file {path}") + parsed = load_lkml(path) + looker_model = LookerModel.from_looker_dict( + parsed, + _BASE_PROJECT_NAME, + self.source_config.project_name, + self.base_projects_folder, + path, + self.reporter, + ) return looker_model def _platform_names_have_2_parts(self, platform: str) -> bool: @@ -1797,8 +1787,7 @@ def get_project_name(self, model_name: str) -> str: def get_manifest_if_present(self, folder: pathlib.Path) -> Optional[LookerManifest]: manifest_file = folder / "manifest.lkml" if manifest_file.exists(): - with manifest_file.open() as fp: - manifest_dict = lkml.load(fp) + manifest_dict = load_lkml(manifest_file) manifest = LookerManifest( project_name=manifest_dict.get("project_name"), diff --git a/metadata-ingestion/tests/integration/lookml/lkml_manifest_samples/complex-manifest.lkml b/metadata-ingestion/tests/integration/lookml/lkml_manifest_samples/complex-manifest.lkml new file mode 100644 index 00000000000000..3d2006621dd502 --- /dev/null +++ b/metadata-ingestion/tests/integration/lookml/lkml_manifest_samples/complex-manifest.lkml @@ -0,0 +1,23 @@ +project_name: "complex-manifest-project" + +constant: CONNECTION_NAME { + value: "choose-connection" + export: override_required +} + +constant: other_variable { + value: "other-variable" + export: override_required +} + +local_dependency: { + project: "looker-hub" +} + +remote_dependency: remote-proj-1 { + override_constant: schema_name {value: "mycorp_prod" } + override_constant: choose-connection {value: "snowflake-conn-main"} +} + +remote_dependency: remote-proj-2 { +} diff --git a/metadata-ingestion/tests/integration/lookml/test_lookml.py b/metadata-ingestion/tests/integration/lookml/test_lookml.py index 1ed0d05c842638..7d1e8d053a3817 100644 --- a/metadata-ingestion/tests/integration/lookml/test_lookml.py +++ b/metadata-ingestion/tests/integration/lookml/test_lookml.py @@ -16,6 +16,7 @@ LookerModel, LookerRefinementResolver, LookMLSourceConfig, + load_lkml, ) from datahub.metadata.schema_classes import ( DatasetSnapshotClass, @@ -852,3 +853,14 @@ def test_same_name_views_different_file_path(pytestconfig, tmp_path, mock_time): output_path=tmp_path / mce_out, golden_path=test_resources_dir / mce_out, ) + + +def test_manifest_parser(pytestconfig: pytest.Config) -> None: + # This mainly tests that we're permissive enough that we don't crash when parsing the manifest file. + # We need the test because we monkeypatch the lkml library. + + test_resources_dir = pytestconfig.rootpath / "tests/integration/lookml" + manifest_file = test_resources_dir / "lkml_manifest_samples/complex-manifest.lkml" + + manifest = load_lkml(manifest_file) + assert manifest From 1d5f0f571fcb1c656c6f2c503beb0e7c026fec64 Mon Sep 17 00:00:00 2001 From: Maggie Hays Date: Mon, 22 Jan 2024 18:49:29 -0600 Subject: [PATCH 188/540] docs: Add Case Studies and DataHub Basics to Resources tab (#9687) --- docs-website/docusaurus.config.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs-website/docusaurus.config.js b/docs-website/docusaurus.config.js index 97ef2f857b45d5..22edf749acaede 100644 --- a/docs-website/docusaurus.config.js +++ b/docs-website/docusaurus.config.js @@ -100,6 +100,14 @@ module.exports = { href: "https://www.youtube.com/channel/UC3qFQC5IiwR5fvWEqi_tJ5w", label: "YouTube", }, + { + href: "https://www.youtube.com/playlist?list=PLdCtLs64vZvGCKMQC2dJEZ6cUqWsREbFi", + label: "Case Studies", + }, + { + href: "https://www.youtube.com/playlist?list=PLdCtLs64vZvErAXMiqUYH9e63wyDaMBgg", + label: "DataHub Basics", + }, ], }, { From 720296ea46de85d895603a10710a9d8ab8aac82f Mon Sep 17 00:00:00 2001 From: sleeperdeep <86791232+sleeperdeep@users.noreply.github.com> Date: Tue, 23 Jan 2024 06:25:01 +0200 Subject: [PATCH 189/540] fix(ingestion/mssql): use platform_instance for mssql dataflow/datajob (#9527) Co-authored-by: Harshal Sheth Co-authored-by: sleeperdeep <--global> --- .../ingestion/source/sql/mssql/job_models.py | 26 +- .../ingestion/source/sql/mssql/source.py | 9 +- .../golden_mces_mssql_no_db_to_file.json | 831 +++++++++++++++++- .../golden_mces_mssql_no_db_with_filter.json | 446 +++++++++- .../golden_mces_mssql_to_file.json | 446 +++++++++- ...golden_mces_mssql_with_lower_case_urn.json | 446 +++++++++- 6 files changed, 2111 insertions(+), 93 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/job_models.py b/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/job_models.py index 8aeb5421891aac..8b517747307f84 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/job_models.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/job_models.py @@ -16,7 +16,7 @@ class ProcedureDependency: name: str type: str env: str - server: str + server: Optional[str] source: str = "mssql" @@ -34,7 +34,7 @@ def as_property(self) -> Dict[str, str]: @dataclass class MSSQLJob: db: str - platform_instance: str + platform_instance: Optional[str] name: str env: str source: str = "mssql" @@ -42,7 +42,7 @@ class MSSQLJob: @property def formatted_name(self) -> str: - return f"{self.formatted_platform_instance}.{self.name.replace(',', '-')}" + return self.name.replace(",", "-") @property def full_type(self) -> str: @@ -52,10 +52,6 @@ def full_type(self) -> str: def orchestrator(self) -> str: return self.source - @property - def formatted_platform_instance(self) -> str: - return self.platform_instance.replace(".", "/") - @property def cluster(self) -> str: return f"{self.env}" @@ -64,7 +60,7 @@ def cluster(self) -> str: @dataclass class MSSQLProceduresContainer: db: str - platform_instance: str + platform_instance: Optional[str] name: str env: str source: str = "mssql" @@ -72,16 +68,12 @@ class MSSQLProceduresContainer: @property def formatted_name(self) -> str: - return f"{self.formatted_platform_instance}.{self.name.replace(',', '-')}" + return self.name.replace(",", "-") @property def orchestrator(self) -> str: return self.source - @property - def formatted_platform_instance(self) -> str: - return self.platform_instance.replace(".", "/") - @property def cluster(self) -> str: return f"{self.env}" @@ -149,7 +141,7 @@ def full_type(self) -> str: @property def full_name(self) -> str: - return f"{self.formatted_name}.{self.formatted_name}" + return self.formatted_name @dataclass @@ -172,6 +164,9 @@ def urn(self) -> str: flow_id=self.entity.flow.formatted_name, job_id=self.entity.formatted_name, cluster=self.entity.flow.cluster, + platform_instance=self.entity.flow.platform_instance + if self.entity.flow.platform_instance + else None, ) def add_property( @@ -228,6 +223,9 @@ def urn(self) -> str: orchestrator=self.entity.orchestrator, flow_id=self.entity.formatted_name, cluster=self.entity.cluster, + platform_instance=self.entity.platform_instance + if self.entity.platform_instance + else None, ) @property diff --git a/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/source.py b/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/source.py index 2442df595d9677..56706e6f90d38d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/sql/mssql/source.py @@ -7,7 +7,6 @@ import sqlalchemy.dialects.mssql # This import verifies that the dependencies are available. -import sqlalchemy_pytds # noqa: F401 from pydantic.fields import Field from sqlalchemy import create_engine, inspect from sqlalchemy.engine.base import Connection @@ -132,10 +131,6 @@ def get_sql_alchemy_url( uri = f"{uri}?{urllib.parse.urlencode(self.uri_args)}" return uri - @property - def host(self): - return self.platform_instance or self.host_port.split(":")[0] - @property def db(self): return self.database @@ -369,7 +364,7 @@ def loop_jobs( name=job_name, env=sql_config.env, db=db_name, - platform_instance=sql_config.host, + platform_instance=sql_config.platform_instance, ) data_flow = MSSQLDataFlow(entity=job) yield from self.construct_flow_workunits(data_flow=data_flow) @@ -404,7 +399,7 @@ def loop_stored_procedures( # noqa: C901 name=procedure_flow_name, env=sql_config.env, db=db_name, - platform_instance=sql_config.host, + platform_instance=sql_config.platform_instance, ) data_flow = MSSQLDataFlow(entity=mssql_default_job) with inspector.engine.connect() as conn: diff --git a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_to_file.json b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_to_file.json index 66ef9b097c973e..4c0c1c6512ec77 100644 --- a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_to_file.json +++ b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_to_file.json @@ -88,14 +88,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup" + "name": "Weekly Demo Data Backup" } }, "systemMetadata": { @@ -106,24 +106,24 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { "json": { "customProperties": { - "job_id": "3565ea3e-9a3a-4cb0-acd5-213d740479a0", + "job_id": "0565425f-2083-45d3-bb61-76e0ee5e1117", "job_name": "Weekly Demo Data Backup", "description": "No description available.", - "date_created": "2023-11-27 23:08:29.350000", - "date_modified": "2023-11-27 23:08:29.833000", + "date_created": "2024-01-19 11:45:06.667000", + "date_modified": "2024-01-19 11:45:06.840000", "step_id": "1", "step_name": "Set database to read only", "subsystem": "TSQL", "command": "ALTER DATABASE DemoData SET READ_ONLY" }, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup.localhost.Weekly Demo Data Backup", + "name": "Weekly Demo Data Backup", "type": { "string": "MSSQL_JOB_STEP" } @@ -137,7 +137,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -1932,14 +1932,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.demodata.Foo.stored_procedures" + "name": "demodata.Foo.stored_procedures" } }, "systemMetadata": { @@ -1950,7 +1950,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { @@ -1961,8 +1961,8 @@ "code": "CREATE PROCEDURE [Foo].[Proc.With.SpecialChar] @ID INT\nAS\n SELECT @ID AS ThatDB;\n", "input parameters": "['@ID']", "parameter @ID": "{'type': 'int'}", - "date_created": "2023-11-27 23:08:29.077000", - "date_modified": "2023-11-27 23:08:29.077000" + "date_created": "2024-01-19 11:45:06.590000", + "date_modified": "2024-01-19 11:45:06.590000" }, "externalUrl": "", "name": "demodata.Foo.Proc.With.SpecialChar", @@ -1979,7 +1979,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -4381,7 +4381,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4397,7 +4397,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4413,7 +4413,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4429,7 +4429,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -4442,5 +4442,800 @@ "runId": "mssql-test", "lastRunId": "no-run-id-provided" } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:f1b4c0e379c4b2e2e09a8ecd6c1b6dec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:bad84e08ecf49aee863df68243d8b9d0", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:e48d82445eeacfbe13b431f0bb1826ee", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:884bfecd9e414990a494681293413e8e", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:142ca5fc51b7f44e5e6a424bf1043590", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:1b9d125d390447de36719bfb8dd1f782", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fcd4c8da3739150766f91e7f6c2a3a30", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:2029cab615b3cd82cb87b153957d2e92", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:556e25ccec98892284f017f870ef7809", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.dbo.Products,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "urn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Items,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Persons,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.SalesReason,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:a6bea84fba7b05fb5d12630c8e6306ac", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9f37bb7baa7ded19cc023e9f644a8cf8", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3f157d8292fb473142f19e2250af537f", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:47217386c89d8b94153f6ee31e7e77ec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5eb0d61efa998d1ccd5cbdc6ce4bb4af", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:2816b2cb7f90d3dce64125ba89fb1fa8", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:20d0f0c94e9796ff44ff32d4d0e19084", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3600d2ebb33b25dac607624d7eae7575", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:280f2e3aefacc346d0ce1590ec337c7d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:cba5c3ca7f028fcf749593be369d3c24", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:58c30fa72f213ca7e12fb04f5a7d150f", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9387ddfeb7b57672cabd761ade89c49c", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3a5f70e0e34834d4eeeb4d5a5caf03d0", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,NewData.dbo.ProductsNew,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + }, + { + "id": "urn:li:container:3a5f70e0e34834d4eeeb4d5a5caf03d0", + "urn": "urn:li:container:3a5f70e0e34834d4eeeb4d5a5caf03d0" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:7cc43e5b4e2a7f2f66f1df774d1a0c63", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,NewData.FooNew.ItemsNew,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + }, + { + "id": "urn:li:container:7cc43e5b4e2a7f2f66f1df774d1a0c63", + "urn": "urn:li:container:7cc43e5b4e2a7f2f66f1df774d1a0c63" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,NewData.FooNew.PersonsNew,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + }, + { + "id": "urn:li:container:7cc43e5b4e2a7f2f66f1df774d1a0c63", + "urn": "urn:li:container:7cc43e5b4e2a7f2f66f1df774d1a0c63" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:54727d9fd7deacef27641559125bbc56", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:141b0980dcb08f48544583e47cf48807", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:c6627af82d44de89492e1a9315ae9f4b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59", + "urn": "urn:li:container:9447d283fb4f95ce7474f1db0179bb59" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_with_filter.json b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_with_filter.json index c1984828750eb5..02c357259c3f53 100644 --- a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_with_filter.json +++ b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_no_db_with_filter.json @@ -88,14 +88,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup" + "name": "Weekly Demo Data Backup" } }, "systemMetadata": { @@ -106,24 +106,24 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { "json": { "customProperties": { - "job_id": "1f2f14ba-db84-4fa1-910e-7df71bede642", + "job_id": "0565425f-2083-45d3-bb61-76e0ee5e1117", "job_name": "Weekly Demo Data Backup", "description": "No description available.", - "date_created": "2023-10-27 10:11:55.540000", - "date_modified": "2023-10-27 10:11:55.667000", + "date_created": "2024-01-19 11:45:06.667000", + "date_modified": "2024-01-19 11:45:06.840000", "step_id": "1", "step_name": "Set database to read only", "subsystem": "TSQL", "command": "ALTER DATABASE DemoData SET READ_ONLY" }, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup.localhost.Weekly Demo Data Backup", + "name": "Weekly Demo Data Backup", "type": { "string": "MSSQL_JOB_STEP" } @@ -137,7 +137,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -1932,14 +1932,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.demodata.Foo.stored_procedures" + "name": "demodata.Foo.stored_procedures" } }, "systemMetadata": { @@ -1950,7 +1950,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { @@ -1961,8 +1961,8 @@ "code": "CREATE PROCEDURE [Foo].[Proc.With.SpecialChar] @ID INT\nAS\n SELECT @ID AS ThatDB;\n", "input parameters": "['@ID']", "parameter @ID": "{'type': 'int'}", - "date_created": "2023-10-27 10:11:55.460000", - "date_modified": "2023-10-27 10:11:55.460000" + "date_created": "2024-01-19 11:45:06.590000", + "date_modified": "2024-01-19 11:45:06.590000" }, "externalUrl": "", "name": "demodata.Foo.Proc.With.SpecialChar", @@ -1979,7 +1979,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -2324,7 +2324,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2340,7 +2340,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2356,7 +2356,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2372,7 +2372,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2385,5 +2385,415 @@ "runId": "mssql-test", "lastRunId": "no-run-id-provided" } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:f1b4c0e379c4b2e2e09a8ecd6c1b6dec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:bad84e08ecf49aee863df68243d8b9d0", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:e48d82445eeacfbe13b431f0bb1826ee", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:884bfecd9e414990a494681293413e8e", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:142ca5fc51b7f44e5e6a424bf1043590", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:1b9d125d390447de36719bfb8dd1f782", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fcd4c8da3739150766f91e7f6c2a3a30", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:2029cab615b3cd82cb87b153957d2e92", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:556e25ccec98892284f017f870ef7809", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.dbo.Products,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "urn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Items,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Persons,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.SalesReason,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:a6bea84fba7b05fb5d12630c8e6306ac", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9f37bb7baa7ded19cc023e9f644a8cf8", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3f157d8292fb473142f19e2250af537f", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_to_file.json b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_to_file.json index 9ce3664eff6a1f..02c357259c3f53 100644 --- a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_to_file.json +++ b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_to_file.json @@ -88,14 +88,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup" + "name": "Weekly Demo Data Backup" } }, "systemMetadata": { @@ -106,24 +106,24 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { "json": { "customProperties": { - "job_id": "3b767c17-c921-4331-93d9-eb0e006045a4", + "job_id": "0565425f-2083-45d3-bb61-76e0ee5e1117", "job_name": "Weekly Demo Data Backup", "description": "No description available.", - "date_created": "2023-11-23 11:04:47.927000", - "date_modified": "2023-11-23 11:04:48.090000", + "date_created": "2024-01-19 11:45:06.667000", + "date_modified": "2024-01-19 11:45:06.840000", "step_id": "1", "step_name": "Set database to read only", "subsystem": "TSQL", "command": "ALTER DATABASE DemoData SET READ_ONLY" }, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup.localhost.Weekly Demo Data Backup", + "name": "Weekly Demo Data Backup", "type": { "string": "MSSQL_JOB_STEP" } @@ -137,7 +137,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -1932,14 +1932,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.demodata.Foo.stored_procedures" + "name": "demodata.Foo.stored_procedures" } }, "systemMetadata": { @@ -1950,7 +1950,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { @@ -1961,8 +1961,8 @@ "code": "CREATE PROCEDURE [Foo].[Proc.With.SpecialChar] @ID INT\nAS\n SELECT @ID AS ThatDB;\n", "input parameters": "['@ID']", "parameter @ID": "{'type': 'int'}", - "date_created": "2023-11-23 11:04:47.857000", - "date_modified": "2023-11-23 11:04:47.857000" + "date_created": "2024-01-19 11:45:06.590000", + "date_modified": "2024-01-19 11:45:06.590000" }, "externalUrl": "", "name": "demodata.Foo.Proc.With.SpecialChar", @@ -1979,7 +1979,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -2324,7 +2324,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2340,7 +2340,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2356,7 +2356,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2372,7 +2372,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2385,5 +2385,415 @@ "runId": "mssql-test", "lastRunId": "no-run-id-provided" } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:f1b4c0e379c4b2e2e09a8ecd6c1b6dec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:bad84e08ecf49aee863df68243d8b9d0", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:e48d82445eeacfbe13b431f0bb1826ee", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:884bfecd9e414990a494681293413e8e", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:142ca5fc51b7f44e5e6a424bf1043590", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:1b9d125d390447de36719bfb8dd1f782", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fcd4c8da3739150766f91e7f6c2a3a30", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:2029cab615b3cd82cb87b153957d2e92", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:556e25ccec98892284f017f870ef7809", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.dbo.Products,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "urn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Items,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.Persons,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,DemoData.Foo.SalesReason,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:a6bea84fba7b05fb5d12630c8e6306ac", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9f37bb7baa7ded19cc023e9f644a8cf8", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3f157d8292fb473142f19e2250af537f", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_with_lower_case_urn.json b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_with_lower_case_urn.json index 037a341b7d66e8..ad15c654e44c96 100644 --- a/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_with_lower_case_urn.json +++ b/metadata-ingestion/tests/integration/sql_server/golden_files/golden_mces_mssql_with_lower_case_urn.json @@ -88,14 +88,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup" + "name": "Weekly Demo Data Backup" } }, "systemMetadata": { @@ -106,24 +106,24 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { "json": { "customProperties": { - "job_id": "3b767c17-c921-4331-93d9-eb0e006045a4", + "job_id": "0565425f-2083-45d3-bb61-76e0ee5e1117", "job_name": "Weekly Demo Data Backup", "description": "No description available.", - "date_created": "2023-11-23 11:04:47.927000", - "date_modified": "2023-11-23 11:04:48.090000", + "date_created": "2024-01-19 11:45:06.667000", + "date_modified": "2024-01-19 11:45:06.840000", "step_id": "1", "step_name": "Set database to read only", "subsystem": "TSQL", "command": "ALTER DATABASE DemoData SET READ_ONLY" }, "externalUrl": "", - "name": "localhost.Weekly Demo Data Backup.localhost.Weekly Demo Data Backup", + "name": "Weekly Demo Data Backup", "type": { "string": "MSSQL_JOB_STEP" } @@ -137,7 +137,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -1932,14 +1932,14 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "dataFlowInfo", "aspect": { "json": { "customProperties": {}, "externalUrl": "", - "name": "localhost.demodata.Foo.stored_procedures" + "name": "demodata.Foo.stored_procedures" } }, "systemMetadata": { @@ -1950,7 +1950,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInfo", "aspect": { @@ -1961,8 +1961,8 @@ "code": "CREATE PROCEDURE [Foo].[Proc.With.SpecialChar] @ID INT\nAS\n SELECT @ID AS ThatDB;\n", "input parameters": "['@ID']", "parameter @ID": "{'type': 'int'}", - "date_created": "2023-11-23 11:04:47.857000", - "date_modified": "2023-11-23 11:04:47.857000" + "date_created": "2024-01-19 11:45:06.590000", + "date_modified": "2024-01-19 11:45:06.590000" }, "externalUrl": "", "name": "demodata.Foo.Proc.With.SpecialChar", @@ -1979,7 +1979,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "dataJobInputOutput", "aspect": { @@ -2324,7 +2324,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2340,7 +2340,7 @@ }, { "entityType": "dataFlow", - "entityUrn": "urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD)", + "entityUrn": "urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2356,7 +2356,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.Weekly Demo Data Backup,PROD),localhost.Weekly Demo Data Backup)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,Weekly Demo Data Backup,PROD),Weekly Demo Data Backup)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2372,7 +2372,7 @@ }, { "entityType": "dataJob", - "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,localhost.demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(mssql,demodata.Foo.stored_procedures,PROD),Proc.With.SpecialChar)", "changeType": "UPSERT", "aspectName": "status", "aspect": { @@ -2385,5 +2385,415 @@ "runId": "mssql-test", "lastRunId": "no-run-id-provided" } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:f1b4c0e379c4b2e2e09a8ecd6c1b6dec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:bad84e08ecf49aee863df68243d8b9d0", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:e48d82445eeacfbe13b431f0bb1826ee", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:884bfecd9e414990a494681293413e8e", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:142ca5fc51b7f44e5e6a424bf1043590", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:1b9d125d390447de36719bfb8dd1f782", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fcd4c8da3739150766f91e7f6c2a3a30", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:2029cab615b3cd82cb87b153957d2e92", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:556e25ccec98892284f017f870ef7809", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,demodata.dbo.products,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec", + "urn": "urn:li:container:d41a036a2e6cfa44b834edf7683199ec" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,demodata.foo.items,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,demodata.foo.persons,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:mssql,demodata.foo.salesreason,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + }, + { + "id": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671", + "urn": "urn:li:container:6e5c6d608d0a2dcc4eb03591382e5671" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:a6bea84fba7b05fb5d12630c8e6306ac", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:9f37bb7baa7ded19cc023e9f644a8cf8", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:3f157d8292fb473142f19e2250af537f", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5", + "urn": "urn:li:container:b7062d1c0c650d9de0f7a9a5de00b1b5" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1615443388097, + "runId": "mssql-test", + "lastRunId": "no-run-id-provided" + } } ] \ No newline at end of file From fb6eafbaa0c3c27ff859006357ab0cea8558fb08 Mon Sep 17 00:00:00 2001 From: Kunal-kankriya <127090035+Kunal-kankriya@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:17:44 +0530 Subject: [PATCH 190/540] tests(posts): Verify Edit Post Steps (#9683) --- .../cypress/e2e/settings/homePagePost.js | 116 ++++++++++-------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/settings/homePagePost.js b/smoke-test/tests/cypress/cypress/e2e/settings/homePagePost.js index cb67efe00b4841..843a15d7430af6 100644 --- a/smoke-test/tests/cypress/cypress/e2e/settings/homePagePost.js +++ b/smoke-test/tests/cypress/cypress/e2e/settings/homePagePost.js @@ -1,65 +1,85 @@ -const title = 'Test Link Title' -const url = 'https://www.example.com' -const imagesURL = 'https://www.example.com/images/example-image.jpg' - const homePageRedirection = () => { - cy.visit('/') - cy.waitTextPresent("Welcome back,") -} + cy.visit('/'); + cy.waitTextPresent("Welcome back"); +}; -const addAnnouncement = () => { - cy.get('[id="posts-create-post"]').click({ force: true }); - cy.waitTextPresent('Create new Post') - cy.enterTextInTestId("create-post-title", "Test Announcement Title"); - cy.get('[id="description"]').type("Add Description to post announcement") - cy.get('[data-testid="create-post-button"]').click({ force: true }); - cy.reload() +const addOrEditAnnouncement = (text, title, description, testId) => { + cy.waitTextPresent(text); + cy.get('[data-testid="create-post-title"]').clear().type(title); + cy.get('[id="description"]').clear().type(description); + cy.get(`[data-testid="${testId}-post-button"]`).click({ force: true }); + cy.reload(); homePageRedirection(); - cy.waitTextPresent("Test Announcement Title"); -} +}; -const addLink = (title,url,imagesURL) => { - cy.get('[id="posts-create-post"]').click({ force: true }); - cy.waitTextPresent('Create new Post') - cy.clickOptionWithText('Link'); - cy.enterTextInTestId('create-post-title', title); - cy.enterTextInTestId('create-post-link', url); - cy.enterTextInTestId('create-post-media-location', imagesURL) - cy.get('[data-testid="create-post-button"]').click({ force: true }); - cy.reload() +const addOrEditLink = (text, title, url, imagesURL, testId) => { + cy.waitTextPresent(text); + cy.get('[data-testid="create-post-title"]').clear().type(title); + cy.get('[data-testid="create-post-link"]').clear().type(url); + cy.get('[data-testid="create-post-media-location"]').clear().type(imagesURL); + cy.get(`[data-testid="${testId}-post-button"]`).click({ force: true }); + cy.reload(); homePageRedirection(); - cy.waitTextPresent(title) +}; + +const clickOnNewPost = () =>{ + cy.get('[id="posts-create-post"]').click({ force: true }); } -const deleteFromPostDropdown = () => { - cy.get('[aria-label="more"]').first().click() - cy.clickOptionWithText("Delete"); - cy.clickOptionWithText("Yes"); - cy.reload() - homePageRedirection(); +const clickOnMoreOption = () => { + cy.get('[aria-label="more"]').first().click(); } -describe("Create announcement and link posts", () => { +describe("create announcement and link post", () => { beforeEach(() => { cy.loginWithCredentials(); cy.goToHomePagePostSettings(); }); - it("Create and Verify Announcement Post", () => { - addAnnouncement(); - }) + it("create announcement post and verify", () => { + clickOnNewPost() + addOrEditAnnouncement("Create new Post", "Test Announcement Title", "Add Description to post announcement", "create"); + cy.waitTextPresent("Test Announcement Title"); + }); - it("Delete and Verify Announcement Post", () => { - deleteFromPostDropdown(); - cy.ensureTextNotPresent("Test Announcement Title") - }) + it("edit announced post and verify", () => { + clickOnMoreOption() + cy.clickOptionWithText("Edit"); + addOrEditAnnouncement("Edit Post", "Test Announcement Title Edited", "Decription Edited", "update"); + cy.waitTextPresent("Test Announcement Title Edited"); + }); + + it("delete announced post and verify", () => { + clickOnMoreOption() + cy.clickOptionWithText("Delete"); + cy.clickOptionWithText("Yes"); + cy.reload(); + homePageRedirection(); + cy.ensureTextNotPresent("Test Announcement Title Edited"); + }); + + it("create link post and verify", () => { + clickOnNewPost() + cy.waitTextPresent('Create new Post'); + cy.contains('label', 'Link').click(); + addOrEditLink("Create new Post", "Test Link Title", 'https://www.example.com', 'https://www.example.com/images/example-image.jpg', "create"); + cy.waitTextPresent("Test Link Title"); + }); + + it("edit linked post and verify", () => { + clickOnMoreOption() + cy.clickOptionWithText("Edit"); + addOrEditLink("Edit Post", "Test Link Edited Title", 'https://www.updatedexample.com', 'https://www.updatedexample.com/images/example-image.jpg', "update"); + cy.waitTextPresent("Test Link Edited Title"); + }); - it("Create and Verify Link Post", () => { - addLink(title,url,imagesURL) - }) + it("delete linked post and verify", () => { + clickOnMoreOption() + cy.clickOptionWithText("Delete"); + cy.clickOptionWithText("Yes"); + cy.reload(); + homePageRedirection(); + cy.ensureTextNotPresent("Test Link Edited Title"); + }); +}); - it("Delete and Verify Link Post", () => { - deleteFromPostDropdown(); - cy.ensureTextNotPresent(title); - }) -}) \ No newline at end of file From f14dc4ded2082859ad2c5a1941981354b7e7d19b Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 22 Jan 2024 23:37:23 -0800 Subject: [PATCH 191/540] feat(ingest): improve sql tests + dbt reporting (#9649) --- .../docs/sources/datahub/datahub_recipe.yml | 8 ++++---- .../src/datahub/cli/ingest_cli.py | 2 +- .../ingestion/source/dbt/dbt_common.py | 15 ++++++++++++-- .../datahub/ingestion/source/dbt/dbt_core.py | 2 +- .../unit/sql_parsing/test_sqlglot_lineage.py | 5 ----- .../unit/sql_parsing/test_sqlglot_utils.py | 20 +++++++++++++++++++ 6 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_utils.py diff --git a/metadata-ingestion/docs/sources/datahub/datahub_recipe.yml b/metadata-ingestion/docs/sources/datahub/datahub_recipe.yml index cb7fc97a39b9fb..632828f42014bb 100644 --- a/metadata-ingestion/docs/sources/datahub/datahub_recipe.yml +++ b/metadata-ingestion/docs/sources/datahub/datahub_recipe.yml @@ -1,13 +1,13 @@ pipeline_name: datahub_source_1 datahub_api: - server: "http://localhost:8080" # Migrate data from DataHub instance on localhost:8080 + server: "http://localhost:8080" # Migrate data from DataHub instance on localhost:8080 token: "" source: type: datahub config: include_all_versions: false database_connection: - scheme: "mysql+pymysql" # or "postgresql+psycopg2" for Postgres + scheme: "mysql+pymysql" # or "postgresql+psycopg2" for Postgres host_port: ":" username: "" password: "" @@ -19,12 +19,12 @@ source: enabled: true ignore_old_state: false extractor_config: - set_system_metadata: false # Replicate system metadata + set_system_metadata: false # Replicate system metadata # Here, we write to a DataHub instance # You can also use a different sink, e.g. to write the data to a file instead sink: - type: datahub + type: datahub-rest config: server: "" token: "" diff --git a/metadata-ingestion/src/datahub/cli/ingest_cli.py b/metadata-ingestion/src/datahub/cli/ingest_cli.py index 569a836f3ef5c2..9c55f52497c0ea 100644 --- a/metadata-ingestion/src/datahub/cli/ingest_cli.py +++ b/metadata-ingestion/src/datahub/cli/ingest_cli.py @@ -131,7 +131,7 @@ def run( async def run_pipeline_to_completion(pipeline: Pipeline) -> int: logger.info("Starting metadata ingestion") - with click_spinner.spinner(disable=no_spinner): + with click_spinner.spinner(disable=no_spinner or no_progress): try: pipeline.run() except Exception as e: diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py index 75fba6e9d426bf..985c9118f34220 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_common.py @@ -125,7 +125,9 @@ @dataclass class DBTSourceReport(StaleEntityRemovalSourceReport): - pass + sql_statements_parsed: int = 0 + sql_parser_detach_ctes_failures: int = 0 + sql_parser_skipped_missing_code: int = 0 class EmitDirective(ConfigEnum): @@ -821,6 +823,7 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: ] test_nodes = [test_node for test_node in nodes if test_node.node_type == "test"] + logger.info(f"Creating dbt metadata for {len(nodes)} nodes") yield from self.create_platform_mces( non_test_nodes, additional_custom_props_filtered, @@ -829,6 +832,7 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: self.config.platform_instance, ) + logger.info(f"Updating {self.config.target_platform} metadata") yield from self.create_platform_mces( non_test_nodes, additional_custom_props_filtered, @@ -988,15 +992,22 @@ def _infer_schemas_and_update_cll(self, all_nodes_map: Dict[str, DBTNode]) -> No }, ) except Exception as e: + self.report.sql_parser_detach_ctes_failures += 1 + logger.debug( + f"Failed to detach CTEs from compiled code. {node.dbt_name} will not have column lineage." + ) sql_result = SqlParsingResult.make_from_error(e) else: sql_result = sqlglot_lineage( preprocessed_sql, schema_resolver=schema_resolver ) + self.report.sql_statements_parsed += 1 + else: + self.report.sql_parser_skipped_missing_code += 1 # Save the column lineage. if self.config.include_column_lineage and sql_result: - # We only save the debug info here. We're report errors based on it later, after + # We only save the debug info here. We'll report errors based on it later, after # applying the configured node filters. node.cll_debug_info = sql_result.debug_info diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py index 563b005d7a88d2..6fd3c5ba309f9e 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py @@ -65,7 +65,7 @@ class DBTCoreConfig(DBTCommonConfig): _github_info_deprecated = pydantic_renamed_field("github_info", "git_info") - @validator("aws_connection") + @validator("aws_connection", always=True) def aws_connection_needed_if_s3_uris_present( cls, aws_connection: Optional[AwsConnectionConfig], values: Dict, **kwargs: Any ) -> Optional[AwsConnectionConfig]: diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py index eb1ba06669112f..bbb63ceb1318ea 100644 --- a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py +++ b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py @@ -3,7 +3,6 @@ import pytest from datahub.testing.check_sql_parser_result import assert_sql_result -from datahub.utilities.sqlglot_lineage import _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT RESOURCE_DIR = pathlib.Path(__file__).parent / "goldens" @@ -802,10 +801,6 @@ def test_snowflake_update_hardcoded(): ) -def test_update_from_select(): - assert _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT == {"returning", "this"} - - def test_snowflake_update_from_table(): # Can create these tables with the following SQL: """ diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_utils.py b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_utils.py new file mode 100644 index 00000000000000..b01c512c383cb2 --- /dev/null +++ b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_utils.py @@ -0,0 +1,20 @@ +from datahub.utilities.sqlglot_lineage import ( + _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT, + _get_dialect, + _is_dialect_instance, +) + + +def test_update_from_select(): + assert _UPDATE_ARGS_NOT_SUPPORTED_BY_SELECT == {"returning", "this"} + + +def test_is_dialect_instance(): + snowflake = _get_dialect("snowflake") + + assert _is_dialect_instance(snowflake, "snowflake") + assert not _is_dialect_instance(snowflake, "bigquery") + + redshift = _get_dialect("redshift") + assert _is_dialect_instance(redshift, ["redshift", "snowflake"]) + assert _is_dialect_instance(redshift, ["postgres", "snowflake"]) From 19b76c3307d2990f9fe5ac011546952260b3afd1 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 22 Jan 2024 23:37:53 -0800 Subject: [PATCH 192/540] feat(ingest/sql-parser): test redshift temp tables (#9653) --- .../test_redshift_temp_table_shortcut.json | 47 +++++++++++++++++++ .../unit/sql_parsing/test_sqlglot_lineage.py | 27 +++++++++++ 2 files changed, 74 insertions(+) create mode 100644 metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_temp_table_shortcut.json diff --git a/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_temp_table_shortcut.json b/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_temp_table_shortcut.json new file mode 100644 index 00000000000000..974eddb961d646 --- /dev/null +++ b/metadata-ingestion/tests/unit/sql_parsing/goldens/test_redshift_temp_table_shortcut.json @@ -0,0 +1,47 @@ +{ + "query_type": "CREATE", + "in_tables": [ + "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.other_schema.table1,PROD)" + ], + "out_tables": [ + "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.my_schema.#my_custom_name,PROD)" + ], + "column_lineage": [ + { + "downstream": { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.my_schema.#my_custom_name,PROD)", + "column": "col1", + "column_type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "native_column_type": "INTEGER" + }, + "upstreams": [ + { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.other_schema.table1,PROD)", + "column": "col1" + } + ] + }, + { + "downstream": { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.my_schema.#my_custom_name,PROD)", + "column": "col2", + "column_type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "native_column_type": "INTEGER" + }, + "upstreams": [ + { + "table": "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.other_schema.table1,PROD)", + "column": "col2" + } + ] + } + ] +} \ No newline at end of file diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py index bbb63ceb1318ea..42863ab005f073 100644 --- a/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py +++ b/metadata-ingestion/tests/unit/sql_parsing/test_sqlglot_lineage.py @@ -992,3 +992,30 @@ def test_redshift_materialized_view_auto_refresh(): expected_file=RESOURCE_DIR / "test_redshift_materialized_view_auto_refresh.json", ) + + +def test_redshift_temp_table_shortcut(): + # On redshift, tables starting with # are temporary tables. + assert_sql_result( + """ +CREATE TABLE #my_custom_name +distkey (1) +sortkey (1,2) +AS +WITH cte AS ( +SELECT * +FROM other_schema.table1 +) +SELECT * FROM cte +""", + dialect="redshift", + default_db="my_db", + default_schema="my_schema", + schemas={ + "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.other_schema.table1,PROD)": { + "col1": "INTEGER", + "col2": "INTEGER", + }, + }, + expected_file=RESOURCE_DIR / "test_redshift_temp_table_shortcut.json", + ) From f0a48b6da21536ea7b3f658fa27f3b0716daf5c0 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Tue, 23 Jan 2024 14:06:16 +0000 Subject: [PATCH 193/540] feat(policies): Allow policies to be applied to resources based on tags (#9684) --- .../authorization/EntityFieldType.java | 4 +- .../DefaultEntitySpecResolver.java | 4 +- .../TagFieldResolverProvider.java | 65 ++++++++ .../authorization/PolicyEngineTest.java | 71 +++++++- .../TagFieldResolverProviderTest.java | 154 ++++++++++++++++++ 5 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 metadata-service/auth-impl/src/main/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProvider.java create mode 100644 metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProviderTest.java diff --git a/metadata-auth/auth-api/src/main/java/com/datahub/authorization/EntityFieldType.java b/metadata-auth/auth-api/src/main/java/com/datahub/authorization/EntityFieldType.java index 6b08cdb00e9abc..928876ce71cd56 100644 --- a/metadata-auth/auth-api/src/main/java/com/datahub/authorization/EntityFieldType.java +++ b/metadata-auth/auth-api/src/main/java/com/datahub/authorization/EntityFieldType.java @@ -28,5 +28,7 @@ public enum EntityFieldType { /** Groups of which the entity (only applies to corpUser) is a member */ GROUP_MEMBERSHIP, /** Data platform instance of resource */ - DATA_PLATFORM_INSTANCE + DATA_PLATFORM_INSTANCE, + /** Tags of the entity */ + TAG, } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DefaultEntitySpecResolver.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DefaultEntitySpecResolver.java index c2d9c426933111..653bbecbfa8adf 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DefaultEntitySpecResolver.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DefaultEntitySpecResolver.java @@ -8,6 +8,7 @@ import com.datahub.authorization.fieldresolverprovider.EntityUrnFieldResolverProvider; import com.datahub.authorization.fieldresolverprovider.GroupMembershipFieldResolverProvider; import com.datahub.authorization.fieldresolverprovider.OwnerFieldResolverProvider; +import com.datahub.authorization.fieldresolverprovider.TagFieldResolverProvider; import com.google.common.collect.ImmutableList; import com.linkedin.entity.client.EntityClient; import com.linkedin.util.Pair; @@ -26,7 +27,8 @@ public DefaultEntitySpecResolver(Authentication systemAuthentication, EntityClie new DomainFieldResolverProvider(entityClient, systemAuthentication), new OwnerFieldResolverProvider(entityClient, systemAuthentication), new DataPlatformInstanceFieldResolverProvider(entityClient, systemAuthentication), - new GroupMembershipFieldResolverProvider(entityClient, systemAuthentication)); + new GroupMembershipFieldResolverProvider(entityClient, systemAuthentication), + new TagFieldResolverProvider(entityClient, systemAuthentication)); } @Override diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProvider.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProvider.java new file mode 100644 index 00000000000000..2cfd8032497346 --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProvider.java @@ -0,0 +1,65 @@ +package com.datahub.authorization.fieldresolverprovider; + +import com.datahub.authentication.Authentication; +import com.datahub.authorization.EntityFieldType; +import com.datahub.authorization.EntitySpec; +import com.datahub.authorization.FieldResolver; +import com.linkedin.common.GlobalTags; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** Provides field resolver for owners given entitySpec */ +@Slf4j +@RequiredArgsConstructor +public class TagFieldResolverProvider implements EntityFieldResolverProvider { + + private final EntityClient _entityClient; + private final Authentication _systemAuthentication; + + @Override + public List getFieldTypes() { + return Collections.singletonList(EntityFieldType.TAG); + } + + @Override + public FieldResolver getFieldResolver(EntitySpec entitySpec) { + return FieldResolver.getResolverFromFunction(entitySpec, this::getTags); + } + + private FieldResolver.FieldValue getTags(EntitySpec entitySpec) { + Urn entityUrn = UrnUtils.getUrn(entitySpec.getEntity()); + EnvelopedAspect globalTagsAspect; + try { + EntityResponse response = + _entityClient.getV2( + entityUrn.getEntityType(), + entityUrn, + Collections.singleton(Constants.GLOBAL_TAGS_ASPECT_NAME), + _systemAuthentication); + if (response == null + || !response.getAspects().containsKey(Constants.GLOBAL_TAGS_ASPECT_NAME)) { + return FieldResolver.emptyFieldValue(); + } + globalTagsAspect = response.getAspects().get(Constants.GLOBAL_TAGS_ASPECT_NAME); + } catch (Exception e) { + log.error("Error while retrieving tags aspect for urn {}", entityUrn, e); + return FieldResolver.emptyFieldValue(); + } + GlobalTags globalTags = new GlobalTags(globalTagsAspect.getValue().data()); + return FieldResolver.FieldValue.builder() + .values( + globalTags.getTags().stream() + .map(tag -> tag.getTag().toString()) + .collect(Collectors.toSet())) + .build(); + } +} diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java index c7f06eeba6e85f..8ecfb5a2c27bba 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java @@ -44,6 +44,7 @@ public class PolicyEngineTest { private static final String AUTHORIZED_GROUP = "urn:li:corpGroup:authorizedGroup"; private static final String RESOURCE_URN = "urn:li:dataset:test"; private static final String DOMAIN_URN = "urn:li:domain:domain1"; + private static final String TAG_URN = "urn:li:tag:allowed"; private static final String OWNERSHIP_TYPE_URN = "urn:li:ownershipType:__system__technical_owner"; private static final String OTHER_OWNERSHIP_TYPE_URN = "urn:li:ownershipType:__system__data_steward"; @@ -69,7 +70,8 @@ public void setupTest() throws Exception { AUTHORIZED_PRINCIPAL, Collections.emptySet(), Collections.emptySet(), - Collections.singleton(AUTHORIZED_GROUP)); + Collections.singleton(AUTHORIZED_GROUP), + Collections.emptySet()); unauthorizedUserUrn = Urn.createFromString(UNAUTHORIZED_PRINCIPAL); resolvedUnauthorizedUserSpec = buildEntityResolvers(CORP_USER_ENTITY_NAME, UNAUTHORIZED_PRINCIPAL); @@ -595,6 +597,7 @@ public void testEvaluatePolicyActorFilterUserResourceOwnersMatch() throws Except RESOURCE_URN, ImmutableSet.of(AUTHORIZED_PRINCIPAL), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); // Assert authorized user can edit entity tags, because he is a user owner. PolicyEngine.PolicyEvaluationResult result1 = @@ -653,6 +656,7 @@ public void testEvaluatePolicyActorFilterUserResourceOwnersTypeMatch() throws Ex RESOURCE_URN, ImmutableSet.of(AUTHORIZED_PRINCIPAL), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); PolicyEngine.PolicyEvaluationResult result1 = @@ -712,6 +716,7 @@ public void testEvaluatePolicyActorFilterUserResourceOwnersTypeNoMatch() throws RESOURCE_URN, ImmutableSet.of(AUTHORIZED_PRINCIPAL), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); PolicyEngine.PolicyEvaluationResult result1 = @@ -767,6 +772,7 @@ public void testEvaluatePolicyActorFilterGroupResourceOwnersMatch() throws Excep RESOURCE_URN, ImmutableSet.of(AUTHORIZED_GROUP), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); // Assert authorized user can edit entity tags, because he is a user owner. PolicyEngine.PolicyEvaluationResult result1 = @@ -1037,6 +1043,7 @@ public void testEvaluatePolicyResourceFilterSpecificResourceMatchDomain() throws RESOURCE_URN, Collections.emptySet(), Collections.singleton(DOMAIN_URN), + Collections.emptySet(), Collections.emptySet()); PolicyEngine.PolicyEvaluationResult result = _policyEngine.evaluatePolicy( @@ -1082,6 +1089,7 @@ public void testEvaluatePolicyResourceFilterSpecificResourceNoMatchDomain() thro RESOURCE_URN, Collections.emptySet(), Collections.singleton("urn:li:domain:domain2"), + Collections.emptySet(), Collections.emptySet()); // Domain doesn't match PolicyEngine.PolicyEvaluationResult result = _policyEngine.evaluatePolicy( @@ -1095,6 +1103,52 @@ public void testEvaluatePolicyResourceFilterSpecificResourceNoMatchDomain() thro verify(_entityClient, times(0)).batchGetV2(any(), any(), any(), any()); } + @Test + public void testEvaluatePolicyResourceFilterSpecificResourceMatchTag() throws Exception { + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("VIEW_ENTITY_PAGE")); + dataHubPolicyInfo.setDisplayName("Tag-based policy"); + dataHubPolicyInfo.setDescription("Allow viewing entity pages based on tags"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setResourceOwners(true); + actorFilter.setAllUsers(true); + actorFilter.setAllGroups(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("dataset"), + EntityFieldType.TAG, + Collections.singletonList(TAG_URN)))); + dataHubPolicyInfo.setResources(resourceFilter); + + ResolvedEntitySpec resourceSpec = + buildEntityResolvers( + "dataset", + RESOURCE_URN, + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.singleton(TAG_URN)); + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "VIEW_ENTITY_PAGE", + Optional.of(resourceSpec)); + assertTrue(result.isGranted()); + + // Verify no network calls + verify(_entityClient, times(0)).batchGetV2(any(), any(), any(), any()); + } + @Test public void testGetGrantedPrivileges() throws Exception { // Policy 1, match dataset type and domain @@ -1180,6 +1234,7 @@ public void testGetGrantedPrivileges() throws Exception { RESOURCE_URN, Collections.emptySet(), Collections.singleton(DOMAIN_URN), + Collections.emptySet(), Collections.emptySet()); // Everything matches assertEquals( _policyEngine.getGrantedPrivileges( @@ -1192,6 +1247,7 @@ public void testGetGrantedPrivileges() throws Exception { RESOURCE_URN, Collections.emptySet(), Collections.singleton("urn:li:domain:domain2"), + Collections.emptySet(), Collections.emptySet()); // Domain doesn't match assertEquals( _policyEngine.getGrantedPrivileges( @@ -1204,6 +1260,7 @@ public void testGetGrantedPrivileges() throws Exception { "urn:li:dataset:random", Collections.emptySet(), Collections.singleton(DOMAIN_URN), + Collections.emptySet(), Collections.emptySet()); // Resource doesn't match assertEquals( _policyEngine.getGrantedPrivileges( @@ -1228,6 +1285,7 @@ public void testGetGrantedPrivileges() throws Exception { RESOURCE_URN, Collections.singleton(AUTHORIZED_PRINCIPAL), Collections.singleton(DOMAIN_URN), + Collections.emptySet(), Collections.emptySet()); // Is owner assertEquals( _policyEngine.getGrantedPrivileges( @@ -1240,6 +1298,7 @@ public void testGetGrantedPrivileges() throws Exception { RESOURCE_URN, Collections.singleton(AUTHORIZED_PRINCIPAL), Collections.singleton(DOMAIN_URN), + Collections.emptySet(), Collections.emptySet()); // Resource type doesn't match assertEquals( _policyEngine.getGrantedPrivileges( @@ -1289,6 +1348,7 @@ public void testGetMatchingActorsResourceMatch() throws Exception { RESOURCE_URN, ImmutableSet.of(AUTHORIZED_PRINCIPAL, AUTHORIZED_GROUP), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); PolicyEngine.PolicyActors actors = _policyEngine.getMatchingActors(dataHubPolicyInfo, Optional.of(resourceSpec)); @@ -1406,6 +1466,7 @@ public void testGetMatchingActorsByRoleResourceMatch() throws Exception { RESOURCE_URN, ImmutableSet.of(), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); PolicyEngine.PolicyActors actors = @@ -1506,6 +1567,7 @@ public static ResolvedEntitySpec buildEntityResolvers(String entityType, String entityUrn, Collections.emptySet(), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet()); } @@ -1514,7 +1576,8 @@ public static ResolvedEntitySpec buildEntityResolvers( String entityUrn, Set owners, Set domains, - Set groups) { + Set groups, + Set tags) { return new ResolvedEntitySpec( new EntitySpec(entityType, entityUrn), ImmutableMap.of( @@ -1527,6 +1590,8 @@ public static ResolvedEntitySpec buildEntityResolvers( EntityFieldType.DOMAIN, FieldResolver.getResolverFromValues(domains), EntityFieldType.GROUP_MEMBERSHIP, - FieldResolver.getResolverFromValues(groups))); + FieldResolver.getResolverFromValues(groups), + EntityFieldType.TAG, + FieldResolver.getResolverFromValues(tags))); } } diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProviderTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProviderTest.java new file mode 100644 index 00000000000000..de5ef09cd42512 --- /dev/null +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/fieldresolverprovider/TagFieldResolverProviderTest.java @@ -0,0 +1,154 @@ +package com.datahub.authorization.fieldresolverprovider; + +import static com.linkedin.metadata.Constants.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +import com.datahub.authentication.Authentication; +import com.datahub.authorization.EntityFieldType; +import com.datahub.authorization.EntitySpec; +import com.linkedin.common.GlobalTags; +import com.linkedin.common.TagAssociation; +import com.linkedin.common.TagAssociationArray; +import com.linkedin.common.urn.TagUrn; +import com.linkedin.common.urn.Urn; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.r2.RemoteInvocationException; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Set; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class TagFieldResolverProviderTest { + + private static final String TAG_URN = "urn:li:tag:test"; + private static final String RESOURCE_URN = + "urn:li:dataset:(urn:li:dataPlatform:s3,test-platform-instance.testDataset,PROD)"; + private static final EntitySpec RESOURCE_SPEC = new EntitySpec(DATASET_ENTITY_NAME, RESOURCE_URN); + + @Mock private EntityClient entityClientMock; + @Mock private Authentication systemAuthenticationMock; + + private TagFieldResolverProvider tagFieldResolverProvider; + + @BeforeMethod + public void setup() { + MockitoAnnotations.initMocks(this); + tagFieldResolverProvider = + new TagFieldResolverProvider(entityClientMock, systemAuthenticationMock); + } + + @Test + public void shouldReturnTagType() { + assertEquals(EntityFieldType.TAG, tagFieldResolverProvider.getFieldTypes().get(0)); + } + + @Test + public void shouldReturnEmptyFieldValueWhenResponseIsNull() + throws RemoteInvocationException, URISyntaxException { + when(entityClientMock.getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock))) + .thenReturn(null); + + var result = tagFieldResolverProvider.getFieldResolver(RESOURCE_SPEC); + + assertTrue(result.getFieldValuesFuture().join().getValues().isEmpty()); + verify(entityClientMock, times(1)) + .getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock)); + } + + @Test + public void shouldReturnEmptyFieldValueWhenResourceHasNoTag() + throws RemoteInvocationException, URISyntaxException { + var entityResponseMock = mock(EntityResponse.class); + when(entityResponseMock.getAspects()).thenReturn(new EnvelopedAspectMap()); + when(entityClientMock.getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock))) + .thenReturn(entityResponseMock); + + var result = tagFieldResolverProvider.getFieldResolver(RESOURCE_SPEC); + + assertTrue(result.getFieldValuesFuture().join().getValues().isEmpty()); + verify(entityClientMock, times(1)) + .getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock)); + } + + @Test + public void shouldReturnEmptyFieldValueWhenThereIsAnException() + throws RemoteInvocationException, URISyntaxException { + when(entityClientMock.getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock))) + .thenThrow(new RemoteInvocationException()); + + var result = tagFieldResolverProvider.getFieldResolver(RESOURCE_SPEC); + + assertTrue(result.getFieldValuesFuture().join().getValues().isEmpty()); + verify(entityClientMock, times(1)) + .getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock)); + } + + @Test + public void shouldReturnFieldValueWithTagOfTheResource() + throws RemoteInvocationException, URISyntaxException { + + var tagAssociation = new TagAssociation(); + tagAssociation.setTag(new TagUrn("test")); + var tags = new TagAssociationArray(tagAssociation); + var globalTags = new GlobalTags().setTags(tags); + var entityResponseMock = mock(EntityResponse.class); + var envelopedAspectMap = new EnvelopedAspectMap(); + envelopedAspectMap.put( + GLOBAL_TAGS_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(globalTags.data()))); + when(entityResponseMock.getAspects()).thenReturn(envelopedAspectMap); + when(entityClientMock.getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock))) + .thenReturn(entityResponseMock); + + var result = tagFieldResolverProvider.getFieldResolver(RESOURCE_SPEC); + + assertEquals(Set.of(TAG_URN), result.getFieldValuesFuture().join().getValues()); + verify(entityClientMock, times(1)) + .getV2( + eq(DATASET_ENTITY_NAME), + any(Urn.class), + eq(Collections.singleton(GLOBAL_TAGS_ASPECT_NAME)), + eq(systemAuthenticationMock)); + } +} From 59674b545715f568820d1ee9fe19dbe6a52f7993 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Tue, 23 Jan 2024 20:02:25 +0530 Subject: [PATCH 194/540] feat(role-assignment): add role assignment dropdown in the UI for groups (#9689) --- datahub-web-react/src/app/analytics/event.ts | 8 ++ .../group/AssignRoletoGroupConfirmation.tsx | 67 ++++++++++++ .../src/app/identity/group/GroupList.tsx | 34 +++++- .../src/app/identity/group/GroupListItem.tsx | 18 +++- .../app/identity/group/SelectRoleGroup.tsx | 102 ++++++++++++++++++ .../src/app/identity/group/cacheUtils.ts | 1 + datahub-web-react/src/graphql/group.graphql | 11 ++ .../datahubusage/DataHubUsageEventType.java | 1 + .../cypress/e2e/settings/managing_groups.js | 11 +- 9 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 datahub-web-react/src/app/identity/group/AssignRoletoGroupConfirmation.tsx create mode 100644 datahub-web-react/src/app/identity/group/SelectRoleGroup.tsx diff --git a/datahub-web-react/src/app/analytics/event.ts b/datahub-web-react/src/app/analytics/event.ts index 27340264009336..dd670b35d49e0b 100644 --- a/datahub-web-react/src/app/analytics/event.ts +++ b/datahub-web-react/src/app/analytics/event.ts @@ -48,6 +48,7 @@ export enum EventType { CreateResetCredentialsLinkEvent, DeleteEntityEvent, SelectUserRoleEvent, + SelectGroupRoleEvent, BatchSelectUserRoleEvent, CreatePolicyEvent, UpdatePolicyEvent, @@ -412,6 +413,12 @@ export interface SelectUserRoleEvent extends BaseEvent { userUrn: string; } +export interface SelectGroupRoleEvent extends BaseEvent { + type: EventType.SelectGroupRoleEvent; + roleUrn: string; + groupUrn?: string; +} + export interface BatchSelectUserRoleEvent extends BaseEvent { type: EventType.BatchSelectUserRoleEvent; roleUrn: string; @@ -668,6 +675,7 @@ export type Event = | CreateResetCredentialsLinkEvent | DeleteEntityEvent | SelectUserRoleEvent + | SelectGroupRoleEvent | BatchSelectUserRoleEvent | CreatePolicyEvent | UpdatePolicyEvent diff --git a/datahub-web-react/src/app/identity/group/AssignRoletoGroupConfirmation.tsx b/datahub-web-react/src/app/identity/group/AssignRoletoGroupConfirmation.tsx new file mode 100644 index 00000000000000..f08b607222de66 --- /dev/null +++ b/datahub-web-react/src/app/identity/group/AssignRoletoGroupConfirmation.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { message, Popconfirm } from 'antd'; +import { useBatchAssignRoleMutation } from '../../../graphql/mutations.generated'; +import { DataHubRole } from '../../../types.generated'; +import analytics, { EventType } from '../../analytics'; + +type Props = { + visible: boolean; + roleToAssign: DataHubRole | undefined; + groupName: string; + groupUrn: string; + onClose: () => void; + onConfirm: () => void; +}; + +export default function AssignRoletoGroupConfirmation({ + visible, + roleToAssign, + groupName, + groupUrn, + onClose, + onConfirm, +}: Props) { + const [batchAssignRoleMutation] = useBatchAssignRoleMutation(); + // eslint-disable-next-line + const batchAssignRole = () => { + batchAssignRoleMutation({ + variables: { + input: { + roleUrn: roleToAssign?.urn, + actors: [groupUrn], + }, + }, + }) + .then(({ errors }) => { + if (!errors) { + analytics.event({ + type: EventType.SelectGroupRoleEvent, + roleUrn: roleToAssign?.urn || 'undefined', + groupUrn, + }); + message.success({ + content: roleToAssign + ? `Assigned role ${roleToAssign?.name} to group ${groupName}!` + : `Removed role from user ${groupName}!`, + duration: 2, + }); + onConfirm(); + } + }) + .catch((e) => { + message.destroy(); + message.error({ + content: roleToAssign + ? `Failed to assign role ${roleToAssign?.name} to group ${groupName}: \n ${e.message || ''}` + : `Failed to remove role from group ${groupName}: \n ${e.message || ''}`, + duration: 3, + }); + }); + }; + + const assignRoleText = roleToAssign + ? `Would you like to assign the role ${roleToAssign?.name} to group ${groupName}?` + : `Would you like to remove group ${groupName}'s existing role?`; + + return ; +} diff --git a/datahub-web-react/src/app/identity/group/GroupList.tsx b/datahub-web-react/src/app/identity/group/GroupList.tsx index 788b9eccafc0ac..a8ebbedc2ac6df 100644 --- a/datahub-web-react/src/app/identity/group/GroupList.tsx +++ b/datahub-web-react/src/app/identity/group/GroupList.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { useLocation } from 'react-router'; import * as QueryString from 'query-string'; import { UsergroupAddOutlined } from '@ant-design/icons'; -import { CorpGroup } from '../../../types.generated'; +import { CorpGroup, DataHubRole } from '../../../types.generated'; import { Message } from '../../shared/Message'; import { useListGroupsQuery } from '../../../graphql/group.generated'; import GroupListItem from './GroupListItem'; @@ -16,6 +16,7 @@ import { scrollToTop } from '../../shared/searchUtils'; import { GROUPS_CREATE_GROUP_ID, GROUPS_INTRO_ID } from '../../onboarding/config/GroupsOnboardingConfig'; import { OnboardingTour } from '../../onboarding/OnboardingTour'; import { addGroupToListGroupsCache, DEFAULT_GROUP_LIST_PAGE_SIZE, removeGroupFromListGroupsCache } from './cacheUtils'; +import { useListRolesQuery } from '../../../graphql/role.generated'; const GroupContainer = styled.div` display: flex; @@ -53,7 +54,13 @@ export const GroupList = () => { const pageSize = DEFAULT_GROUP_LIST_PAGE_SIZE; const start = (page - 1) * pageSize; - const { loading, error, data, refetch, client } = useListGroupsQuery({ + const { + loading, + error, + data, + refetch: groupRefetch, + client, + } = useListGroupsQuery({ variables: { input: { start, @@ -76,6 +83,18 @@ export const GroupList = () => { removeGroupFromListGroupsCache(urn, client, page, pageSize); }; + const { data: rolesData } = useListRolesQuery({ + fetchPolicy: 'cache-first', + variables: { + input: { + start: 0, + count: 10, + }, + }, + }); + + const selectRoleOptions = rolesData?.listRoles?.roles?.map((role) => role as DataHubRole) || []; + return ( <> @@ -114,7 +133,12 @@ export const GroupList = () => { }} dataSource={groups} renderItem={(item: any) => ( - handleDelete(item.urn)} group={item as CorpGroup} /> + handleDelete(item.urn)} + group={item as CorpGroup} + selectRoleOptions={selectRoleOptions} + refetch={groupRefetch} + /> )} /> @@ -131,9 +155,9 @@ export const GroupList = () => { {isCreatingGroup && ( setIsCreatingGroup(false)} - onCreate={(group) => { + onCreate={(group: CorpGroup) => { addGroupToListGroupsCache(group, client); - setTimeout(() => refetch(), 3000); + setTimeout(() => groupRefetch(), 3000); }} /> )} diff --git a/datahub-web-react/src/app/identity/group/GroupListItem.tsx b/datahub-web-react/src/app/identity/group/GroupListItem.tsx index 40c5afbbda5efb..74c0a8afb4d02e 100644 --- a/datahub-web-react/src/app/identity/group/GroupListItem.tsx +++ b/datahub-web-react/src/app/identity/group/GroupListItem.tsx @@ -3,16 +3,19 @@ import React from 'react'; import styled from 'styled-components'; import { List, Tag, Tooltip, Typography } from 'antd'; import { Link } from 'react-router-dom'; -import { CorpGroup, EntityType, OriginType } from '../../../types.generated'; +import { CorpGroup, DataHubRole, EntityType, OriginType } from '../../../types.generated'; import CustomAvatar from '../../shared/avatar/CustomAvatar'; import { useEntityRegistry } from '../../useEntityRegistry'; import EntityDropdown from '../../entity/shared/EntityDropdown'; import { EntityMenuItems } from '../../entity/shared/EntityDropdown/EntityDropdown'; import { getElasticCappedTotalValueText } from '../../entity/shared/constants'; +import SelectRoleGroup from './SelectRoleGroup'; type Props = { group: CorpGroup; onDelete?: () => void; + selectRoleOptions: Array; + refetch?: () => void; }; const GroupItemContainer = styled.div` @@ -35,11 +38,16 @@ const GroupItemButtonGroup = styled.div` align-items: center; `; -export default function GroupListItem({ group, onDelete }: Props) { +export default function GroupListItem({ group, onDelete, selectRoleOptions, refetch }: Props) { const entityRegistry = useEntityRegistry(); const displayName = entityRegistry.getDisplayName(EntityType.CorpGroup, group); const isExternalGroup: boolean = group.origin?.type === OriginType.External; const externalGroupType: string = group.origin?.externalType || 'outside DataHub'; + const castedCorpUser = group as any; + const groupRelationships = castedCorpUser?.roles?.relationships; + const userRole = + groupRelationships && groupRelationships.length > 0 && (groupRelationships[0]?.entity as DataHubRole); + const groupRoleUrn = userRole && userRole.urn; return ( @@ -66,6 +74,12 @@ export default function GroupListItem({ group, onDelete }: Props) { )} + ; + refetch?: () => void; +}; + +const RoleSelect = styled(Select)<{ color?: string }>` + min-width: 105px; + ${(props) => (props.color ? ` color: ${props.color};` : '')} +`; + +const RoleIcon = styled.span` + margin-right: 6px; + font-size: 12px; +`; + +export default function SelectRoleGroup({ group, groupRoleUrn, selectRoleOptions, refetch }: Props) { + const client = useApolloClient(); + const rolesMap: Map = new Map(); + selectRoleOptions.forEach((role) => { + rolesMap.set(role.urn, role); + }); + const allSelectRoleOptions = [{ urn: NO_ROLE_URN, name: NO_ROLE_TEXT }, ...selectRoleOptions]; + const selectOptions = allSelectRoleOptions.map((role) => { + return ( + + {mapRoleIcon(role.name)} + {role.name} + + ); + }); + + const defaultRoleUrn = groupRoleUrn || NO_ROLE_URN; + const [currentRoleUrn, setCurrentRoleUrn] = useState(defaultRoleUrn); + const [isViewingAssignRole, setIsViewingAssignRole] = useState(false); + + useEffect(() => { + setCurrentRoleUrn(defaultRoleUrn); + }, [defaultRoleUrn]); + + const onSelectRole = (roleUrn: string) => { + setCurrentRoleUrn(roleUrn); + setIsViewingAssignRole(true); + }; + + const onCancel = () => { + setCurrentRoleUrn(defaultRoleUrn); + setIsViewingAssignRole(false); + }; + + const onConfirm = () => { + setIsViewingAssignRole(false); + setTimeout(() => { + refetch?.(); + clearRoleListCache(client); // Update roles. + }, 3000); + }; + + // wait for available roles to load + if (!selectRoleOptions.length) return null; + + return ( + <> + + + {NO_ROLE_TEXT} + + } + value={currentRoleUrn} + onChange={(e) => onSelectRole(e as string)} + color={currentRoleUrn === NO_ROLE_URN ? ANTD_GRAY[6] : undefined} + > + {selectOptions} + + + + ); +} diff --git a/datahub-web-react/src/app/identity/group/cacheUtils.ts b/datahub-web-react/src/app/identity/group/cacheUtils.ts index d4ecd40a40a975..272b9f841d25c8 100644 --- a/datahub-web-react/src/app/identity/group/cacheUtils.ts +++ b/datahub-web-react/src/app/identity/group/cacheUtils.ts @@ -44,6 +44,7 @@ const createFullGroup = (baseGroup) => { email: null, }, memberCount: null, + roles: null, }; }; diff --git a/datahub-web-react/src/graphql/group.graphql b/datahub-web-react/src/graphql/group.graphql index 1007721e51a4ec..05df34adbf7e9e 100644 --- a/datahub-web-react/src/graphql/group.graphql +++ b/datahub-web-react/src/graphql/group.graphql @@ -195,6 +195,17 @@ query listGroups($input: ListGroupsInput!) { ) { total } + roles: relationships(input: { types: ["IsMemberOfRole"], direction: OUTGOING, start: 0 }) { + relationships { + entity { + ... on DataHubRole { + urn + type + name + } + } + } + } } } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java index 518b5f28a5b992..ea86d2c0c98427 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java @@ -43,6 +43,7 @@ public enum DataHubUsageEventType { CREATE_RESET_CREDENTIALS_LINK_EVENT("CreateResetCredentialsLinkEvent"), DELETE_ENTITY_EVENT("DeleteEntityEvent"), SELECT_USER_ROLE_EVENT("SelectUserRoleEvent"), + SELECT_GROUP_ROLE_EVENT("SelectGroupRoleEvent"), BATCH_SELECT_USER_ROLE_EVENT("BatchSelectUserRoleEvent"), CREATE_POLICY_EVENT("CreatePolicyEvent"), UPDATE_POLICY_EVENT("UpdatePolicyEvent"), diff --git a/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js b/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js index 978a245c3d9e33..8421bd288edf07 100644 --- a/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js +++ b/smoke-test/tests/cypress/cypress/e2e/settings/managing_groups.js @@ -106,10 +106,19 @@ describe("create and manage group", () => { cy.waitTextVisible(username); }); + it("assign role to group ", () => { + cy.loginWithCredentials(); + cy.visit("/settings/identities/groups"); + cy.get(`[href="/group/urn:li:corpGroup:${test_id}"]`).next().click() + cy.get('.ant-select-item-option').contains('Admin').click() + cy.get('button.ant-btn-primary').contains('OK').click(); + cy.get(`[href="/group/urn:li:corpGroup:${test_id}"]`).waitTextVisible('Admin'); + }); + it("remove group", () => { cy.loginWithCredentials(); cy.visit("/settings/identities/groups"); - cy.get(`[href="/group/urn:li:corpGroup:${test_id}"]`).next().click(); + cy.get(`[href="/group/urn:li:corpGroup:${test_id}"]`).openThreeDotDropdown() cy.clickOptionWithText("Delete"); cy.clickOptionWithText("Yes"); cy.waitTextVisible("Deleted Group!"); From 35c4df1e9bb71d348ded50df1edf502bf55099fe Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 23 Jan 2024 08:28:08 -0800 Subject: [PATCH 195/540] feat(ingest/looker): add browse paths for charts (#9639) Co-authored-by: Tamas Nemeth --- metadata-ingestion/src/datahub/emitter/mcp.py | 4 ++-- .../ingestion/source/looker/looker_source.py | 8 +++++++- .../utilities/advanced_thread_executor.py | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/metadata-ingestion/src/datahub/emitter/mcp.py b/metadata-ingestion/src/datahub/emitter/mcp.py index d6aa695665e4ef..47717f3c1ed190 100644 --- a/metadata-ingestion/src/datahub/emitter/mcp.py +++ b/metadata-ingestion/src/datahub/emitter/mcp.py @@ -1,6 +1,6 @@ import dataclasses import json -from typing import TYPE_CHECKING, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union from datahub.emitter.aspect import ASPECT_MAP, JSON_CONTENT_TYPE, TIMESERIES_ASPECT_MAP from datahub.emitter.serialization_helper import post_json_transform, pre_json_transform @@ -100,7 +100,7 @@ def __post_init__(self) -> None: @classmethod def construct_many( - cls, entityUrn: str, aspects: List[Optional[_Aspect]] + cls, entityUrn: str, aspects: Sequence[Optional[_Aspect]] ) -> List["MetadataChangeProposalWrapper"]: return [cls(entityUrn=entityUrn, aspect=aspect) for aspect in aspects if aspect] diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py index 542bf64eb2f499..ab9887c9005710 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/looker/looker_source.py @@ -608,8 +608,14 @@ def _make_chart_metadata_events( else "" }, ) - chart_snapshot.aspects.append(chart_info) + + if dashboard and dashboard.folder_path is not None: + browse_path = BrowsePathsClass( + paths=[f"/looker/{dashboard.folder_path}/{dashboard.title}"] + ) + chart_snapshot.aspects.append(browse_path) + if dashboard is not None: ownership = self.get_ownership(dashboard) if ownership is not None: diff --git a/metadata-ingestion/src/datahub/utilities/advanced_thread_executor.py b/metadata-ingestion/src/datahub/utilities/advanced_thread_executor.py index 6ee47f028b7eb5..2afb6088072fee 100644 --- a/metadata-ingestion/src/datahub/utilities/advanced_thread_executor.py +++ b/metadata-ingestion/src/datahub/utilities/advanced_thread_executor.py @@ -2,6 +2,7 @@ import collections import concurrent.futures +import logging import time from concurrent.futures import Future, ThreadPoolExecutor from threading import BoundedSemaphore @@ -20,6 +21,7 @@ from datahub.ingestion.api.closeable import Closeable +logger = logging.getLogger(__name__) _R = TypeVar("_R") _PARTITION_EXECUTOR_FLUSH_SLEEP_INTERVAL = 0.05 @@ -102,7 +104,19 @@ def _system_done_callback(future: Future) -> None: fn, args, kwargs, user_done_callback = self._pending_by_key[ key ].popleft() - self._submit_nowait(key, fn, args, kwargs, user_done_callback) + + try: + self._submit_nowait(key, fn, args, kwargs, user_done_callback) + except RuntimeError as e: + if self._executor._shutdown: + # If we're in shutdown mode, then we can't submit any more requests. + # That means we'll need to drop requests on the floor, which is to + # be expected in shutdown mode. + # The only reason we'd normally be in shutdown here is during + # Python exit (e.g. KeyboardInterrupt), so this is reasonable. + logger.debug("Dropping request due to shutdown") + else: + raise e else: # If there are no pending requests for this key, mark the key From 4aa26c2a2f071eaff388ec745ad0ad7d7a4853d9 Mon Sep 17 00:00:00 2001 From: Salman-Apptware <101426513+Salman-Apptware@users.noreply.github.com> Date: Wed, 24 Jan 2024 00:50:23 +0530 Subject: [PATCH 196/540] =?UTF-8?q?fix(ui):=20Fix=20error=20message=20when?= =?UTF-8?q?=20attempting=20to=20delete=20home=20page=20posts=20with?= =?UTF-8?q?=E2=80=A6=20(#9667)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/settings/posts/CreatePostModal.tsx | 11 +++++++---- .../src/app/settings/posts/PostItemMenu.tsx | 10 +++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/datahub-web-react/src/app/settings/posts/CreatePostModal.tsx b/datahub-web-react/src/app/settings/posts/CreatePostModal.tsx index 10c4ee880fe857..2a3e2204f23923 100644 --- a/datahub-web-react/src/app/settings/posts/CreatePostModal.tsx +++ b/datahub-web-react/src/app/settings/posts/CreatePostModal.tsx @@ -13,6 +13,7 @@ import { useEnterKeyListener } from '../../shared/useEnterKeyListener'; import { MediaType, PostContentType, PostType } from '../../../types.generated'; import { useCreatePostMutation, useUpdatePostMutation } from '../../../graphql/mutations.generated'; import { PostEntry } from './PostsListColumns'; +import handleGraphQLError from '../../shared/handleGraphQLError'; type Props = { editData: PostEntry; @@ -84,10 +85,12 @@ export default function CreatePostModal({ onClose, onCreate, editData, onEdit }: form.resetFields(); } }) - .catch((e) => { - message.destroy(); - message.error({ content: 'Failed to create Post! An unknown error occured.', duration: 3 }); - console.error('Failed to create Post:', e.message); + .catch((error) => { + handleGraphQLError({ + error, + defaultMessage: 'Failed to create Post! An unexpected error occurred', + permissionMessage: 'Unauthorized to create Post. Please contact your DataHub administrator.', + }); }); onClose(); }; diff --git a/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx b/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx index 3708c04ab1ad33..10e2996c36f69f 100644 --- a/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx +++ b/datahub-web-react/src/app/settings/posts/PostItemMenu.tsx @@ -3,6 +3,7 @@ import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { Dropdown, Menu, message, Modal } from 'antd'; import { MenuIcon } from '../../entity/shared/EntityDropdown/EntityDropdown'; import { useDeletePostMutation } from '../../../graphql/post.generated'; +import handleGraphQLError from '../../shared/handleGraphQLError'; type Props = { urn: string; @@ -26,9 +27,12 @@ export default function PostItemMenu({ title, urn, onDelete, onEdit }: Props) { onDelete?.(); } }) - .catch(() => { - message.destroy(); - message.error({ content: `Failed to delete Post!: An unknown error occurred.`, duration: 3 }); + .catch((error) => { + handleGraphQLError({ + error, + defaultMessage: 'Failed to delete Post! An unexpected error occurred', + permissionMessage: 'Unauthorized to delete Post. Please contact your DataHub administrator.', + }); }); }; From bf538671a2a49c3276ab5b6d9b04282432889c1a Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Tue, 23 Jan 2024 16:30:02 -0500 Subject: [PATCH 197/540] feat(ui) Support auto-render aspects on remaining entity types (#9696) --- datahub-web-react/src/Mocks.tsx | 16 +++++++++++++++- datahub-web-react/src/graphql/chart.graphql | 3 +++ datahub-web-react/src/graphql/container.graphql | 3 +++ datahub-web-react/src/graphql/dashboard.graphql | 3 +++ datahub-web-react/src/graphql/dataFlow.graphql | 3 +++ datahub-web-react/src/graphql/dataJob.graphql | 3 +++ .../src/graphql/dataProduct.graphql | 3 +++ datahub-web-react/src/graphql/dataset.graphql | 8 +------- datahub-web-react/src/graphql/domain.graphql | 3 +++ datahub-web-react/src/graphql/fragments.graphql | 10 ++++++++++ .../src/graphql/glossaryNode.graphql | 3 +++ .../src/graphql/glossaryTerm.graphql | 3 +++ datahub-web-react/src/graphql/group.graphql | 3 +++ datahub-web-react/src/graphql/mlFeature.graphql | 3 +++ .../src/graphql/mlFeatureTable.graphql | 3 +++ datahub-web-react/src/graphql/mlModel.graphql | 3 +++ .../src/graphql/mlModelGroup.graphql | 3 +++ .../src/graphql/mlPrimaryKey.graphql | 3 +++ datahub-web-react/src/graphql/tag.graphql | 3 +++ datahub-web-react/src/graphql/user.graphql | 3 +++ 20 files changed, 77 insertions(+), 8 deletions(-) diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index b32b296af38c58..03d6f4a624c3d1 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -88,6 +88,7 @@ export const user1 = { editableInfo: null, properties: null, editableProperties: null, + autoRenderAspects: [], }; const user2 = { @@ -295,6 +296,7 @@ export const dataset1 = { statsSummary: null, embed: null, browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, + autoRenderAspects: [], }; export const dataset2 = { @@ -390,6 +392,7 @@ export const dataset2 = { statsSummary: null, embed: null, browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, + autoRenderAspects: [], }; export const dataset3 = { @@ -595,7 +598,7 @@ export const dataset3 = { viewProperties: null, autoRenderAspects: [ { - __typename: 'AutoRenderAspect', + __typename: 'RawAspect', aspectName: 'autoRenderAspect', payload: '{ "values": [{ "autoField1": "autoValue1", "autoField2": "autoValue2" }] }', renderSpec: { @@ -962,6 +965,7 @@ export const container1 = { externalUrl: null, __typename: 'ContainerProperties', }, + autoRenderAspects: [], __typename: 'Container', } as Container; @@ -976,6 +980,7 @@ export const container2 = { externalUrl: null, __typename: 'ContainerProperties', }, + autoRenderAspects: [], __typename: 'Container', } as Container; @@ -1023,6 +1028,7 @@ export const glossaryTerm1 = { }, parentNodes: null, deprecation: null, + autoRenderAspects: [], } as GlossaryTerm; const glossaryTerm2 = { @@ -1095,6 +1101,7 @@ const glossaryTerm2 = { __typename: 'EntityRelationshipsResult', }, parentNodes: null, + autoRenderAspects: [], __typename: 'GlossaryTerm', }; @@ -1161,6 +1168,7 @@ const glossaryTerm3 = { __typename: 'GlossaryRelatedTerms', }, deprecation: null, + autoRenderAspects: [], __typename: 'GlossaryTerm', } as GlossaryTerm; @@ -1257,6 +1265,7 @@ export const sampleTag = { description: 'sample tag description', colorHex: 'sample tag color', }, + autoRenderAspects: [], }; export const dataFlow1 = { @@ -1328,6 +1337,7 @@ export const dataFlow1 = { }, domain: null, deprecation: null, + autoRenderAspects: [], } as DataFlow; export const dataJob1 = { @@ -1414,6 +1424,7 @@ export const dataJob1 = { domain: null, status: null, deprecation: null, + autoRenderAspects: [], } as DataJob; export const dataJob2 = { @@ -1483,6 +1494,7 @@ export const dataJob2 = { upstream: null, downstream: null, deprecation: null, + autoRenderAspects: [], } as DataJob; export const dataJob3 = { @@ -1555,6 +1567,7 @@ export const dataJob3 = { downstream: null, status: null, deprecation: null, + autoRenderAspects: [], } as DataJob; export const mlModel = { @@ -1636,6 +1649,7 @@ export const mlModel = { downstream: null, status: null, deprecation: null, + autoRenderAspects: [], } as MlModel; export const dataset1FetchedEntity = { diff --git a/datahub-web-react/src/graphql/chart.graphql b/datahub-web-react/src/graphql/chart.graphql index a4b430720fa3d5..da2dae3bd6d86b 100644 --- a/datahub-web-react/src/graphql/chart.graphql +++ b/datahub-web-react/src/graphql/chart.graphql @@ -103,6 +103,9 @@ query getChart($urn: String!) { subTypes { typeNames } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/container.graphql b/datahub-web-react/src/graphql/container.graphql index 94d2f53ee30a50..e75b26b45aacd9 100644 --- a/datahub-web-react/src/graphql/container.graphql +++ b/datahub-web-react/src/graphql/container.graphql @@ -54,5 +54,8 @@ query getContainer($urn: String!) { status { removed } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/dashboard.graphql b/datahub-web-react/src/graphql/dashboard.graphql index d77f6f5c8107f9..2690de0c507ded 100644 --- a/datahub-web-react/src/graphql/dashboard.graphql +++ b/datahub-web-react/src/graphql/dashboard.graphql @@ -7,6 +7,9 @@ query getDashboard($urn: String!) { datasets: relationships(input: { types: ["Consumes"], direction: OUTGOING, start: 0, count: 100 }) { ...fullRelationshipResults } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/dataFlow.graphql b/datahub-web-react/src/graphql/dataFlow.graphql index 122b35f7b57040..fccec29e082d60 100644 --- a/datahub-web-react/src/graphql/dataFlow.graphql +++ b/datahub-web-react/src/graphql/dataFlow.graphql @@ -102,6 +102,9 @@ query getDataFlow($urn: String!) { } } } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/dataJob.graphql b/datahub-web-react/src/graphql/dataJob.graphql index a41c242a71b8fa..161ea859fc36ae 100644 --- a/datahub-web-react/src/graphql/dataJob.graphql +++ b/datahub-web-react/src/graphql/dataJob.graphql @@ -6,6 +6,9 @@ query getDataJob($urn: String!) { start total } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/dataProduct.graphql b/datahub-web-react/src/graphql/dataProduct.graphql index 464ab7cc121644..4c44639e89d7d0 100644 --- a/datahub-web-react/src/graphql/dataProduct.graphql +++ b/datahub-web-react/src/graphql/dataProduct.graphql @@ -1,6 +1,9 @@ query getDataProduct($urn: String!) { dataProduct(urn: $urn) { ...dataProductFields + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/dataset.graphql b/datahub-web-react/src/graphql/dataset.graphql index 658ce2b47c5676..57c74e0c65d69a 100644 --- a/datahub-web-react/src/graphql/dataset.graphql +++ b/datahub-web-react/src/graphql/dataset.graphql @@ -112,13 +112,7 @@ fragment nonSiblingDatasetFields on Dataset { } ...viewProperties autoRenderAspects: aspects(input: { autoRenderOnly: true }) { - aspectName - payload - renderSpec { - displayType - displayName - key - } + ...autoRenderAspectFields } status { removed diff --git a/datahub-web-react/src/graphql/domain.graphql b/datahub-web-react/src/graphql/domain.graphql index 170a5b5df476ba..76c59ad0ed2ae9 100644 --- a/datahub-web-react/src/graphql/domain.graphql +++ b/datahub-web-react/src/graphql/domain.graphql @@ -27,6 +27,9 @@ query getDomain($urn: String!) { } } } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } ...domainEntitiesFields } } diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql index b77ef9d1ad29c1..bb06ccb90a46df 100644 --- a/datahub-web-react/src/graphql/fragments.graphql +++ b/datahub-web-react/src/graphql/fragments.graphql @@ -1162,3 +1162,13 @@ fragment entityDisplayNameFields on Entity { instanceId } } + +fragment autoRenderAspectFields on RawAspect { + aspectName + payload + renderSpec { + displayType + displayName + key + } +} diff --git a/datahub-web-react/src/graphql/glossaryNode.graphql b/datahub-web-react/src/graphql/glossaryNode.graphql index 9cb01b98b3efb1..4a531eb55248fd 100644 --- a/datahub-web-react/src/graphql/glossaryNode.graphql +++ b/datahub-web-react/src/graphql/glossaryNode.graphql @@ -27,6 +27,9 @@ query getGlossaryNode($urn: String!) { canManageEntity canManageChildren } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } children: relationships( input: { types: ["IsPartOf"] diff --git a/datahub-web-react/src/graphql/glossaryTerm.graphql b/datahub-web-react/src/graphql/glossaryTerm.graphql index f2a311f50fe515..4eb0747e0aeba6 100644 --- a/datahub-web-react/src/graphql/glossaryTerm.graphql +++ b/datahub-web-react/src/graphql/glossaryTerm.graphql @@ -87,6 +87,9 @@ query getGlossaryTerm($urn: String!, $start: Int, $count: Int) { privileges { canManageEntity } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/group.graphql b/datahub-web-react/src/graphql/group.graphql index 05df34adbf7e9e..2b8db6483632df 100644 --- a/datahub-web-react/src/graphql/group.graphql +++ b/datahub-web-react/src/graphql/group.graphql @@ -24,6 +24,9 @@ query getGroup($urn: String!, $membersCount: Int!) { email slack } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } ownership { ...ownershipFields } diff --git a/datahub-web-react/src/graphql/mlFeature.graphql b/datahub-web-react/src/graphql/mlFeature.graphql index f9cd2b66d900ed..9fcb871dc4f495 100644 --- a/datahub-web-react/src/graphql/mlFeature.graphql +++ b/datahub-web-react/src/graphql/mlFeature.graphql @@ -4,5 +4,8 @@ query getMLFeature($urn: String!) { featureTables: relationships(input: { types: ["Contains"], direction: INCOMING, start: 0, count: 100 }) { ...fullRelationshipResults } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/mlFeatureTable.graphql b/datahub-web-react/src/graphql/mlFeatureTable.graphql index 3c52dccf7672ce..4bf3972e722b6f 100644 --- a/datahub-web-react/src/graphql/mlFeatureTable.graphql +++ b/datahub-web-react/src/graphql/mlFeatureTable.graphql @@ -1,5 +1,8 @@ query getMLFeatureTable($urn: String!) { mlFeatureTable(urn: $urn) { ...nonRecursiveMLFeatureTable + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/mlModel.graphql b/datahub-web-react/src/graphql/mlModel.graphql index e5330480039f86..5375485a8a9f62 100644 --- a/datahub-web-react/src/graphql/mlModel.graphql +++ b/datahub-web-react/src/graphql/mlModel.graphql @@ -18,5 +18,8 @@ query getMLModel($urn: String!) { } } } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/mlModelGroup.graphql b/datahub-web-react/src/graphql/mlModelGroup.graphql index 12a1c045861982..57249d543bb86e 100644 --- a/datahub-web-react/src/graphql/mlModelGroup.graphql +++ b/datahub-web-react/src/graphql/mlModelGroup.graphql @@ -21,5 +21,8 @@ query getMLModelGroup($urn: String!) { ) { ...fullRelationshipResults } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/mlPrimaryKey.graphql b/datahub-web-react/src/graphql/mlPrimaryKey.graphql index a70550a44a88d0..2bfceb37ce16b9 100644 --- a/datahub-web-react/src/graphql/mlPrimaryKey.graphql +++ b/datahub-web-react/src/graphql/mlPrimaryKey.graphql @@ -4,5 +4,8 @@ query getMLPrimaryKey($urn: String!) { featureTables: relationships(input: { types: ["KeyedBy"], direction: INCOMING, start: 0, count: 100 }) { ...fullRelationshipResults } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/tag.graphql b/datahub-web-react/src/graphql/tag.graphql index 37aaebc2650322..031d923276bfe3 100644 --- a/datahub-web-react/src/graphql/tag.graphql +++ b/datahub-web-react/src/graphql/tag.graphql @@ -11,6 +11,9 @@ query getTag($urn: String!) { ownership { ...ownershipFields } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } } } diff --git a/datahub-web-react/src/graphql/user.graphql b/datahub-web-react/src/graphql/user.graphql index 4757b8a7e28dd5..48c0d7de8c63ce 100644 --- a/datahub-web-react/src/graphql/user.graphql +++ b/datahub-web-react/src/graphql/user.graphql @@ -27,6 +27,9 @@ query getUser($urn: String!, $groupsCount: Int!) { globalTags { ...globalTagsFields } + autoRenderAspects: aspects(input: { autoRenderOnly: true }) { + ...autoRenderAspectFields + } groups: relationships( input: { types: ["IsMemberOfGroup", "IsMemberOfNativeGroup"] From 1b0ed540c13baee1f75b1a2429d1cd4bebc87030 Mon Sep 17 00:00:00 2001 From: Davi Arnaut Date: Tue, 23 Jan 2024 14:49:39 -0800 Subject: [PATCH 198/540] fix(consumer): wait for internal schema registry (#9699) --- docker/datahub-mae-consumer/start.sh | 3 +++ docker/datahub-mce-consumer/start.sh | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docker/datahub-mae-consumer/start.sh b/docker/datahub-mae-consumer/start.sh index 2af7ce6855d1c5..f839d3646bdc6e 100755 --- a/docker/datahub-mae-consumer/start.sh +++ b/docker/datahub-mae-consumer/start.sh @@ -33,6 +33,9 @@ fi if [[ ${GRAPH_SERVICE_IMPL:-} != elasticsearch ]] && [[ ${SKIP_NEO4J_CHECK:-false} != true ]]; then dockerize_args+=("-wait" "$NEO4J_HOST") fi +if [[ "${KAFKA_SCHEMAREGISTRY_URL:-}" && ${SKIP_SCHEMA_REGISTRY_CHECK:-false} != true ]]; then + dockerize_args+=("-wait" "$KAFKA_SCHEMAREGISTRY_URL") +fi JAVA_TOOL_OPTIONS="${JDK_JAVA_OPTIONS:-}${JAVA_OPTS:+ $JAVA_OPTS}${JMX_OPTS:+ $JMX_OPTS}" if [[ ${ENABLE_OTEL:-false} == true ]]; then diff --git a/docker/datahub-mce-consumer/start.sh b/docker/datahub-mce-consumer/start.sh index ef183d41856aaa..a00127a841188c 100755 --- a/docker/datahub-mce-consumer/start.sh +++ b/docker/datahub-mce-consumer/start.sh @@ -5,6 +5,11 @@ if [[ $SKIP_KAFKA_CHECK != true ]]; then WAIT_FOR_KAFKA=" -wait tcp://$(echo $KAFKA_BOOTSTRAP_SERVER | sed 's/,/ -wait tcp:\/\//g') " fi +WAIT_FOR_SCHEMA_REGISTRY="" +if [[ "$KAFKA_SCHEMAREGISTRY_URL" && $SKIP_SCHEMA_REGISTRY_CHECK != true ]]; then + WAIT_FOR_SCHEMA_REGISTRY="-wait $KAFKA_SCHEMAREGISTRY_URL" +fi + OTEL_AGENT="" if [[ $ENABLE_OTEL == true ]]; then OTEL_AGENT="-javaagent:opentelemetry-javaagent.jar " @@ -17,5 +22,6 @@ fi exec dockerize \ $WAIT_FOR_KAFKA \ + $WAIT_FOR_SCHEMA_REGISTRY \ -timeout 240s \ - java $JAVA_OPTS $JMX_OPTS $OTEL_AGENT $PROMETHEUS_AGENT -jar /datahub/datahub-mce-consumer/bin/mce-consumer-job.jar \ No newline at end of file + java $JAVA_OPTS $JMX_OPTS $OTEL_AGENT $PROMETHEUS_AGENT -jar /datahub/datahub-mce-consumer/bin/mce-consumer-job.jar From 264278861bf7a23e1938d5e8cf16ec78260e9e0a Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:49:23 -0600 Subject: [PATCH 199/540] feat(build): support jdk8 spark lineage (#9697) --- .github/workflows/build-and-test.yml | 4 + build.gradle | 105 +++++++++++++++--- .../metadata/aspect/batch/AspectsBatch.java | 11 +- .../aspect/patch/GenericJsonPatch.java | 3 +- .../aspect/plugins/PluginFactory.java | 11 +- .../plugins/config/PluginConfiguration.java | 9 +- .../plugins/validation/AspectRetriever.java | 6 +- .../models/registry/ConfigEntityRegistry.java | 9 +- .../models/registry/PatchEntityRegistry.java | 6 +- .../config/EntityRegistryLoadResult.java | 9 +- ingestion-scheduler/build.gradle | 5 +- metadata-events/mxe-avro/build.gradle | 8 +- metadata-events/mxe-registration/build.gradle | 5 +- metadata-events/mxe-schemas/build.gradle | 6 +- metadata-events/mxe-utils-avro/build.gradle | 1 + .../java/datahub-client/build.gradle | 45 ++------ metadata-io/build.gradle | 10 +- .../request/AggregationQueryBuilder.java | 17 +-- .../query/request/SearchRequestHandler.java | 18 +-- .../service/UpdateIndicesService.java | 3 +- .../ElasticSearchTimeseriesAspectService.java | 2 +- metadata-jobs/common/build.gradle | 2 +- metadata-models/build.gradle | 8 +- .../main/resources/JavaSpring/model.mustache | 6 +- metadata-service/configuration/build.gradle | 7 +- .../restli-servlet-impl/build.gradle | 2 +- .../EntityRecommendationSource.java | 3 +- .../metadata/service/RollbackService.java | 4 +- metadata-utils/build.gradle | 1 + 29 files changed, 205 insertions(+), 121 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 060d345a6b7d9a..6b7f2b5035c253 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -83,6 +83,10 @@ jobs: ./gradlew :datahub-frontend:build :datahub-web-react:build --parallel env: NODE_OPTIONS: "--max-old-space-size=3072" + - name: Gradle compile (jdk8) for legacy Spark + if: ${{ matrix.command == 'except_metadata_ingestion' && needs.setup.outputs.backend_change == 'true' }} + run: | + ./gradlew -PjavaClassVersionDefault=8 :metadata-integration:java:spark-lineage:compileJava - uses: actions/upload-artifact@v3 if: always() with: diff --git a/build.gradle b/build.gradle index 27455f8592e6fe..ba61d97f0ed6e9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,32 @@ buildscript { - ext.jdkVersion = 17 - ext.javaClassVersion = 11 + ext.jdkVersionDefault = 17 + ext.javaClassVersionDefault = 11 + + ext.jdkVersion = { p -> + // If Spring 6 is present, hard dependency on jdk17 + if (p.configurations.any { it.getDependencies().any{ + (it.getGroup().equals("org.springframework") && it.getVersion().startsWith("6.")) + || (it.getGroup().equals("org.springframework.boot") && it.getVersion().startsWith("3.") && !it.getName().equals("spring-boot-starter-test")) + }}) { + return 17 + } else { + // otherwise we can use the preferred default which can be overridden with a property: -PjdkVersionDefault + return p.hasProperty('jdkVersionDefault') ? Integer.valueOf((String) p.getProperty('jdkVersionDefault')) : ext.jdkVersionDefault + } + } + + ext.javaClassVersion = { p -> + // If Spring 6 is present, hard dependency on jdk17 + if (p.configurations.any { it.getDependencies().any{ + (it.getGroup().equals("org.springframework") && it.getVersion().startsWith("6.")) + || (it.getGroup().equals("org.springframework.boot") && it.getVersion().startsWith("3.") && !it.getName().equals("spring-boot-starter-test")) + }}) { + return 17 + } else { + // otherwise we can use the preferred default which can be overridden with a property: -PjavaClassVersionDefault + return p.hasProperty('javaClassVersionDefault') ? Integer.valueOf((String) p.getProperty('javaClassVersionDefault')) : ext.javaClassVersionDefault + } + } ext.junitJupiterVersion = '5.6.1' // Releases: https://github.com/linkedin/rest.li/blob/master/CHANGELOG.md @@ -217,6 +243,7 @@ project.ext.externalDependency = [ 'springActuator': "org.springframework.boot:spring-boot-starter-actuator:$springBootVersion", 'swaggerAnnotations': 'io.swagger.core.v3:swagger-annotations:2.2.15', 'swaggerCli': 'io.swagger.codegen.v3:swagger-codegen-cli:3.0.46', + 'springBootAutoconfigureJdk11': 'org.springframework.boot:spring-boot-autoconfigure:2.7.18', 'testng': 'org.testng:testng:7.8.0', 'testContainers': 'org.testcontainers:testcontainers:' + testContainersVersion, 'testContainersJunit': 'org.testcontainers:junit-jupiter:' + testContainersVersion, @@ -252,23 +279,27 @@ allprojects { } } - if (project.plugins.hasPlugin('java') + /** + * If making changes to this section also see the sections for pegasus below + * which use project.plugins.hasPlugin('pegasus') + **/ + if (!project.plugins.hasPlugin('pegasus') && (project.plugins.hasPlugin('java') || project.plugins.hasPlugin('java-library') - || project.plugins.hasPlugin('application') - || project.plugins.hasPlugin('pegasus')) { + || project.plugins.hasPlugin('application'))) { java { toolchain { - languageVersion = JavaLanguageVersion.of(jdkVersion) + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) } } compileJava { - options.release = javaClassVersion + options.release = javaClassVersion(project) } + tasks.withType(JavaCompile).configureEach { javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(jdkVersion) + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) } // Puts parameter names into compiled class files, necessary for Spring 6 options.compilerArgs.add("-parameters") @@ -276,24 +307,28 @@ allprojects { tasks.withType(JavaExec).configureEach { javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(jdkVersion) + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) } } + } + + // not duplicated, need to set this outside and inside afterEvaluate + afterEvaluate { + /** + * If making changes to this section also see the sections for pegasus below + * which use project.plugins.hasPlugin('pegasus') + **/ + if (!project.plugins.hasPlugin('pegasus') && (project.plugins.hasPlugin('java') + || project.plugins.hasPlugin('java-library') + || project.plugins.hasPlugin('application'))) { - // not duplicated, need to set this outside and inside afterEvaluate - afterEvaluate { compileJava { - options.release = javaClassVersion - } - tasks.withType(JavaCompile).configureEach { - javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(jdkVersion) - } + options.release = javaClassVersion(project) } tasks.withType(JavaExec).configureEach { javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(jdkVersion) + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) } } } @@ -368,6 +403,30 @@ subprojects { dataTemplateCompile externalDependency.annotationApi // support > jdk8 restClientCompile spec.product.pegasus.restliClient } + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) + } + } + + compileJava { + options.release = javaClassVersion(project) + } + + tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) + } + // Puts parameter names into compiled class files, necessary for Spring 6 + options.compilerArgs.add("-parameters") + } + + tasks.withType(JavaExec).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) + } + } } afterEvaluate { @@ -394,6 +453,16 @@ subprojects { dataTemplateCompile externalDependency.annotationApi // support > jdk8 restClientCompile spec.product.pegasus.restliClient } + + compileJava { + options.release = javaClassVersion(project) + } + + tasks.withType(JavaExec).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(jdkVersion(project)) + } + } } } } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java index 453eddd3ae56c2..806fd47c721ec8 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java @@ -50,22 +50,21 @@ default boolean containsDuplicateAspects() { default Map> getUrnAspectsMap() { return getItems().stream() - .map(aspect -> Map.entry(aspect.getUrn().toString(), aspect.getAspectName())) + .map(aspect -> Pair.of(aspect.getUrn().toString(), aspect.getAspectName())) .collect( Collectors.groupingBy( - Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.toSet()))); + Pair::getKey, Collectors.mapping(Pair::getValue, Collectors.toSet()))); } default Map> getNewUrnAspectsMap( Map> existingMap, List items) { Map> newItemsMap = items.stream() - .map(aspect -> Map.entry(aspect.getUrn().toString(), aspect.getAspectName())) + .map(aspect -> Pair.of(aspect.getUrn().toString(), aspect.getAspectName())) .collect( Collectors.groupingBy( - Map.Entry::getKey, - Collectors.mapping( - Map.Entry::getValue, Collectors.toCollection(HashSet::new)))); + Pair::getKey, + Collectors.mapping(Pair::getValue, Collectors.toCollection(HashSet::new)))); return newItemsMap.entrySet().stream() .filter( diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java index c73ccbb2d93e33..484603b9c1f859 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/GenericJsonPatch.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.fge.jsonpatch.JsonPatch; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.Map; import javax.annotation.Nonnull; @@ -24,7 +25,7 @@ public class GenericJsonPatch { @Nonnull public Map> getArrayPrimaryKeys() { - return arrayPrimaryKeys == null ? Map.of() : arrayPrimaryKeys; + return arrayPrimaryKeys == null ? Collections.emptyMap() : arrayPrimaryKeys; } @JsonIgnore diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java index aec0a4cfa07065..5f35cb0447e484 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/PluginFactory.java @@ -12,6 +12,7 @@ import io.github.classgraph.ClassInfo; import io.github.classgraph.MethodInfo; import io.github.classgraph.ScanResult; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -40,7 +41,7 @@ public static PluginFactory withCustomClasspath( } public static PluginFactory withConfig(@Nullable PluginConfiguration pluginConfiguration) { - return PluginFactory.withCustomClasspath(pluginConfiguration, List.of()); + return PluginFactory.withCustomClasspath(pluginConfiguration, Collections.emptyList()); } public static PluginFactory empty() { @@ -180,7 +181,7 @@ public EntityRegistryLoadResult.PluginLoadResult getPluginLoadResult() { private List buildAspectPayloadValidators( @Nullable PluginConfiguration pluginConfiguration) { return pluginConfiguration == null - ? List.of() + ? Collections.emptyList() : applyDisable( build( AspectPayloadValidator.class, @@ -190,7 +191,7 @@ private List buildAspectPayloadValidators( private List buildMutationHooks(@Nullable PluginConfiguration pluginConfiguration) { return pluginConfiguration == null - ? List.of() + ? Collections.emptyList() : applyDisable( build(MutationHook.class, pluginConfiguration.getMutationHooks(), HOOK_PACKAGES)); } @@ -198,7 +199,7 @@ private List buildMutationHooks(@Nullable PluginConfiguration plug private List buildMCLSideEffects( @Nullable PluginConfiguration pluginConfiguration) { return pluginConfiguration == null - ? List.of() + ? Collections.emptyList() : applyDisable( build(MCLSideEffect.class, pluginConfiguration.getMclSideEffects(), HOOK_PACKAGES)); } @@ -206,7 +207,7 @@ private List buildMCLSideEffects( private List buildMCPSideEffects( @Nullable PluginConfiguration pluginConfiguration) { return pluginConfiguration == null - ? List.of() + ? Collections.emptyList() : applyDisable( build(MCPSideEffect.class, pluginConfiguration.getMcpSideEffects(), HOOK_PACKAGES)); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/config/PluginConfiguration.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/config/PluginConfiguration.java index a4d0678c130f3b..a2caab7be5f80b 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/config/PluginConfiguration.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/config/PluginConfiguration.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.plugins.config; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -11,10 +12,10 @@ @AllArgsConstructor @NoArgsConstructor public class PluginConfiguration { - private List aspectPayloadValidators = List.of(); - private List mutationHooks = List.of(); - private List mclSideEffects = List.of(); - private List mcpSideEffects = List.of(); + private List aspectPayloadValidators = Collections.emptyList(); + private List mutationHooks = Collections.emptyList(); + private List mclSideEffects = Collections.emptyList(); + private List mcpSideEffects = Collections.emptyList(); public static PluginConfiguration EMPTY = new PluginConfiguration(); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java index 00a20b3131c2a2..11cd2352025efe 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectRetriever.java @@ -1,10 +1,12 @@ package com.linkedin.metadata.aspect.plugins.validation; +import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.entity.Aspect; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.r2.RemoteInvocationException; import java.net.URISyntaxException; +import java.util.Collections; import java.util.Map; import java.util.Set; import javax.annotation.Nonnull; @@ -15,8 +17,8 @@ public interface AspectRetriever { @Nullable default Aspect getLatestAspectObject(@Nonnull final Urn urn, @Nonnull final String aspectName) throws RemoteInvocationException, URISyntaxException { - return getLatestAspectObjects(Set.of(urn), Set.of(aspectName)) - .getOrDefault(urn, Map.of()) + return getLatestAspectObjects(ImmutableSet.of(urn), ImmutableSet.of(aspectName)) + .getOrDefault(urn, Collections.emptyMap()) .get(aspectName); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java index bd9a6b6c9e5898..41043995a3b77e 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java @@ -28,11 +28,13 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -67,7 +69,10 @@ public class ConfigEntityRegistry implements EntityRegistry { public ConfigEntityRegistry(Pair configFileClassPathPair) throws IOException { this( DataSchemaFactory.withCustomClasspath(configFileClassPathPair.getSecond()), - DataSchemaFactory.getClassLoader(configFileClassPathPair.getSecond()).stream().toList(), + DataSchemaFactory.getClassLoader(configFileClassPathPair.getSecond()) + .map(Stream::of) + .orElse(Stream.empty()) + .collect(Collectors.toList()), configFileClassPathPair.getFirst()); } @@ -112,7 +117,7 @@ private static Pair getFileAndClassPath(String entityRegistryRoot) } public ConfigEntityRegistry(InputStream configFileInputStream) { - this(DataSchemaFactory.getInstance(), List.of(), configFileInputStream); + this(DataSchemaFactory.getInstance(), Collections.emptyList(), configFileInputStream); } public ConfigEntityRegistry( diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java index 35bfe935423f04..b82b905c500042 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -93,7 +94,10 @@ public PatchEntityRegistry( throws IOException, EntityRegistryException { this( DataSchemaFactory.withCustomClasspath(configFileClassPathPair.getSecond()), - DataSchemaFactory.getClassLoader(configFileClassPathPair.getSecond()).stream().toList(), + DataSchemaFactory.getClassLoader(configFileClassPathPair.getSecond()) + .map(Stream::of) + .orElse(Stream.empty()) + .collect(Collectors.toList()), configFileClassPathPair.getFirst(), registryName, registryVersion); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/config/EntityRegistryLoadResult.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/config/EntityRegistryLoadResult.java index 076387909326bd..12a29a7e1757a7 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/config/EntityRegistryLoadResult.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/config/EntityRegistryLoadResult.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.models.registry.config; +import java.util.Collections; import java.util.Set; import lombok.Builder; import lombok.Data; @@ -23,9 +24,9 @@ public static class PluginLoadResult { private int mcpSideEffectCount; private int mclSideEffectCount; - @Builder.Default private Set validatorClasses = Set.of(); - @Builder.Default private Set mutationHookClasses = Set.of(); - @Builder.Default private Set mcpSideEffectClasses = Set.of(); - @Builder.Default private Set mclSideEffectClasses = Set.of(); + @Builder.Default private Set validatorClasses = Collections.emptySet(); + @Builder.Default private Set mutationHookClasses = Collections.emptySet(); + @Builder.Default private Set mcpSideEffectClasses = Collections.emptySet(); + @Builder.Default private Set mclSideEffectClasses = Collections.emptySet(); } } diff --git a/ingestion-scheduler/build.gradle b/ingestion-scheduler/build.gradle index dc9887406b8b4f..9505ec57aa858c 100644 --- a/ingestion-scheduler/build.gradle +++ b/ingestion-scheduler/build.gradle @@ -1,4 +1,6 @@ -apply plugin: 'java' +plugins { + id 'java' +} dependencies { implementation project(path: ':metadata-models') @@ -7,6 +9,7 @@ dependencies { implementation project(':metadata-service:configuration') implementation externalDependency.slf4jApi + implementation externalDependency.springContext compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok diff --git a/metadata-events/mxe-avro/build.gradle b/metadata-events/mxe-avro/build.gradle index 3aebc6bb1004d6..58e82aff464d51 100644 --- a/metadata-events/mxe-avro/build.gradle +++ b/metadata-events/mxe-avro/build.gradle @@ -1,10 +1,12 @@ +plugins { + id 'java-library' + id 'io.acryl.gradle.plugin.avro' +} + configurations { avsc } -apply plugin: 'io.acryl.gradle.plugin.avro' -apply plugin: 'java-library' - dependencies { api externalDependency.avro implementation(externalDependency.avroCompiler) { diff --git a/metadata-events/mxe-registration/build.gradle b/metadata-events/mxe-registration/build.gradle index 2842dd935c7ee7..d4b4d446996fa6 100644 --- a/metadata-events/mxe-registration/build.gradle +++ b/metadata-events/mxe-registration/build.gradle @@ -1,4 +1,7 @@ -apply plugin: 'java' +plugins { + id 'java' + id 'pegasus' +} configurations { avroOriginal diff --git a/metadata-events/mxe-schemas/build.gradle b/metadata-events/mxe-schemas/build.gradle index 8dc8b71bd1cd83..ab0ea8b649e9d4 100644 --- a/metadata-events/mxe-schemas/build.gradle +++ b/metadata-events/mxe-schemas/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'java-library' -apply plugin: 'pegasus' +plugins { + id 'java-library' + id 'pegasus' +} dependencies { dataModel project(path: ':li-utils', configuration: 'dataTemplate') diff --git a/metadata-events/mxe-utils-avro/build.gradle b/metadata-events/mxe-utils-avro/build.gradle index 98bfb9127b2094..860ced6af25813 100644 --- a/metadata-events/mxe-utils-avro/build.gradle +++ b/metadata-events/mxe-utils-avro/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java-library' + id 'pegasus' } dependencies { diff --git a/metadata-integration/java/datahub-client/build.gradle b/metadata-integration/java/datahub-client/build.gradle index 8e05b7ef8f5d68..873943fd437813 100644 --- a/metadata-integration/java/datahub-client/build.gradle +++ b/metadata-integration/java/datahub-client/build.gradle @@ -1,13 +1,13 @@ plugins { id("com.palantir.git-version") apply false + id 'java' + id 'com.github.johnrengelman.shadow' + id 'jacoco' + id 'signing' + id 'io.codearte.nexus-staging' + id 'maven-publish' } -apply plugin: 'java' -apply plugin: 'com.github.johnrengelman.shadow' -apply plugin: 'jacoco' -apply plugin: 'signing' -apply plugin: 'io.codearte.nexus-staging' -apply plugin: 'maven-publish' -apply plugin: 'org.hidetake.swagger.generator' + apply from: "../versioning.gradle" import org.apache.tools.ant.filters.ReplaceTokens @@ -28,10 +28,7 @@ dependencies { compileOnly externalDependency.httpAsyncClient implementation externalDependency.jacksonDataBind - implementation externalDependency.javaxValidation runtimeOnly externalDependency.jna - implementation externalDependency.springContext - implementation externalDependency.swaggerAnnotations implementation externalDependency.slf4jApi compileOnly externalDependency.lombok @@ -45,8 +42,6 @@ dependencies { testImplementation externalDependency.testContainers testImplementation externalDependency.httpAsyncClient testRuntimeOnly externalDependency.logbackClassic - - swaggerCodegen externalDependency.swaggerCli } task copyAvroSchemas { @@ -80,7 +75,7 @@ shadowJar { // preventing java multi-release JAR leakage // https://github.com/johnrengelman/shadow/issues/729 exclude('module-info.class', 'META-INF/versions/**', - '**/LICENSE', '**/LICENSE*.txt', '**/NOTICE', '**/NOTICE.txt', 'licenses/**', 'log4j2.xml', 'log4j.xml') + '**/LICENSE', '**/LICENSE*.txt', '**/NOTICE', '**/NOTICE.txt', 'licenses/**', 'log4j2.*', 'log4j.*') mergeServiceFiles() // we relocate namespaces manually, because we want to know exactly which libs we are exposing and why // we can move to automatic relocation using ConfigureShadowRelocation after we get to a good place on these first @@ -209,28 +204,4 @@ nexusStaging { //required only for projects registered in Sonatype after 2021-02-24 username = System.getenv("NEXUS_USERNAME") password = System.getenv("NEXUS_PASSWORD") -} - -tasks.register('generateOpenApiPojos', GenerateSwaggerCode) { - it.setInputFile(file("${project(':metadata-models').projectDir}/src/generatedJsonSchema/combined/open-api.yaml")) - it.setOutputDir(file("$projectDir/generated")) - it.setLanguage("spring") - it.setComponents(['models']) - it.setTemplateDir(file("$projectDir/src/main/resources/JavaSpring")) - it.setAdditionalProperties(["group-id" : "io.datahubproject", - "dateLibrary" : "java8", - "java8" : "true", - "modelPropertyNaming": "original", - "modelPackage" : "io.datahubproject.openapi.generated"] as Map) - - dependsOn ':metadata-models:generateJsonSchema' -} - -compileJava.dependsOn generateOpenApiPojos -processResources.dependsOn generateOpenApiPojos -sourceSets.main.java.srcDir "${generateOpenApiPojos.outputDir}/src/main/java" -sourceSets.main.resources.srcDir "${generateOpenApiPojos.outputDir}/src/main/resources" - -clean { - project.delete("$projectDir/generated") } \ No newline at end of file diff --git a/metadata-io/build.gradle b/metadata-io/build.gradle index 568b99acdf8943..f96517d93fca6a 100644 --- a/metadata-io/build.gradle +++ b/metadata-io/build.gradle @@ -1,5 +1,7 @@ -apply plugin: 'java-library' -apply plugin: 'org.hidetake.swagger.generator' +plugins { + id 'java-library' + id 'pegasus' +} configurations { enhance @@ -46,8 +48,8 @@ dependencies { implementation externalDependency.ebeanDdl implementation externalDependency.opentelemetryAnnotations implementation externalDependency.resilience4j - api externalDependency.springContext - implementation externalDependency.swaggerAnnotations + // Newer Spring libraries require JDK17 classes, allow for JDK11 + compileOnly externalDependency.springBootAutoconfigureJdk11 implementation(externalDependency.mixpanel) { exclude group: 'org.json', module: 'json' } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java index 0f22b75b69f107..bdc0332b040df9 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java @@ -106,13 +106,16 @@ private AggregationBuilder facetToAggregationBuilder(final String inputFacet) { if (facet.contains(AGGREGATION_SPECIAL_TYPE_DELIMITER)) { List specialTypeFields = List.of(facet.split(AGGREGATION_SPECIAL_TYPE_DELIMITER)); switch (specialTypeFields.get(0)) { - case MISSING_SPECIAL_TYPE -> aggBuilder = - INDEX_VIRTUAL_FIELD.equalsIgnoreCase(specialTypeFields.get(1)) - ? AggregationBuilders.missing(inputFacet).field(getAggregationField("_index")) - : AggregationBuilders.missing(inputFacet) - .field(getAggregationField(specialTypeFields.get(1))); - default -> throw new UnsupportedOperationException( - "Unknown special type: " + specialTypeFields.get(0)); + case MISSING_SPECIAL_TYPE: + aggBuilder = + INDEX_VIRTUAL_FIELD.equalsIgnoreCase(specialTypeFields.get(1)) + ? AggregationBuilders.missing(inputFacet).field(getAggregationField("_index")) + : AggregationBuilders.missing(inputFacet) + .field(getAggregationField(specialTypeFields.get(1))); + break; + default: + throw new UnsupportedOperationException( + "Unknown special type: " + specialTypeFields.get(0)); } } else { aggBuilder = diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java index 05fa6f45fcb30e..c5a5ade216bf7c 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java @@ -648,10 +648,12 @@ public static Map extractAggregationsFromResponse( if (aggregation == null) { return Collections.emptyMap(); } - if (aggregation instanceof ParsedTerms terms) { - return extractTermAggregations(terms, aggregationName.equals("_entityType")); - } else if (aggregation instanceof ParsedMissing missing) { - return Collections.singletonMap(missing.getName(), missing.getDocCount()); + if (aggregation instanceof ParsedTerms) { + return extractTermAggregations( + (ParsedTerms) aggregation, aggregationName.equals("_entityType")); + } else if (aggregation instanceof ParsedMissing) { + return Collections.singletonMap( + aggregation.getName(), ((ParsedMissing) aggregation).getDocCount()); } throw new UnsupportedOperationException( "Unsupported aggregation type: " + aggregation.getClass().getName()); @@ -669,10 +671,10 @@ private static Map recursivelyAddNestedSubAggs(@Nullable Aggregati if (aggs != null) { for (Map.Entry entry : aggs.getAsMap().entrySet()) { - if (entry.getValue() instanceof ParsedTerms terms) { - recurseTermsAgg(terms, aggResult, false); - } else if (entry.getValue() instanceof ParsedMissing missing) { - recurseMissingAgg(missing, aggResult); + if (entry.getValue() instanceof ParsedTerms) { + recurseTermsAgg((ParsedTerms) entry.getValue(), aggResult, false); + } else if (entry.getValue() instanceof ParsedMissing) { + recurseMissingAgg((ParsedMissing) entry.getValue(), aggResult); } else { throw new UnsupportedOperationException( "Unsupported aggregation type: " + entry.getValue().getClass().getName()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java index 1f39a3947c47a3..ee2d794471f6be 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java @@ -129,7 +129,8 @@ public void handleChangeEvent(@Nonnull final MetadataChangeLog event) { .stream() .flatMap(mclSideEffect -> mclSideEffect.apply(List.of(batch), aspectRetriever)); - for (MCLBatchItem mclBatchItem : Stream.concat(Stream.of(batch), sideEffects).toList()) { + for (MCLBatchItem mclBatchItem : + Stream.concat(Stream.of(batch), sideEffects).collect(Collectors.toList())) { MetadataChangeLog hookEvent = mclBatchItem.getMetadataChangeLog(); if (UPDATE_CHANGE_TYPES.contains(hookEvent.getChangeType())) { handleUpdateChangeEvent(mclBatchItem); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java index 71ffd603c999f6..a2b36b7d8ddb87 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java @@ -526,7 +526,7 @@ public TimeseriesScrollResult scrollAspects( List> resultPairs = Arrays.stream(response.getHits().getHits()) .map(ElasticSearchTimeseriesAspectService::toEnvAspectGenericDocument) - .toList(); + .collect(Collectors.toList()); return TimeseriesScrollResult.builder() .numResults(totalCount) diff --git a/metadata-jobs/common/build.gradle b/metadata-jobs/common/build.gradle index bdc3b7a44a98af..b0a3a6827b7297 100644 --- a/metadata-jobs/common/build.gradle +++ b/metadata-jobs/common/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'java' + id 'java-library' } dependencies { diff --git a/metadata-models/build.gradle b/metadata-models/build.gradle index 86f404adb7fef3..179e1eac177ac8 100644 --- a/metadata-models/build.gradle +++ b/metadata-models/build.gradle @@ -18,10 +18,14 @@ dependencies { api project(path: ':li-utils', configuration: "dataTemplate") dataModel project(':li-utils') + // Newer Spring libraries require JDK17 classes, allow for JDK11 + compileOnly externalDependency.springBootAutoconfigureJdk11 + compileOnly externalDependency.annotationApi + compileOnly externalDependency.javaxValidation + compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok - compileOnly externalDependency.swaggerAnnotations - compileOnly externalDependency.springBootStarterValidation + api externalDependency.swaggerAnnotations compileOnly externalDependency.jacksonCore compileOnly externalDependency.jacksonDataBind diff --git a/metadata-models/src/main/resources/JavaSpring/model.mustache b/metadata-models/src/main/resources/JavaSpring/model.mustache index 72da42612777c0..a048f249a6b3de 100644 --- a/metadata-models/src/main/resources/JavaSpring/model.mustache +++ b/metadata-models/src/main/resources/JavaSpring/model.mustache @@ -9,9 +9,9 @@ import java.io.Serializable; {{/serializableModel}} {{#useBeanValidation}} import org.springframework.validation.annotation.Validated; -import jakarta.validation.Valid; +import javax.validation.Valid; import com.fasterxml.jackson.annotation.JsonInclude; -import jakarta.validation.constraints.*; +import javax.validation.constraints.*; {{/useBeanValidation}} {{#jackson}} {{#withXml}} @@ -20,7 +20,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; {{/withXml}} {{/jackson}} {{#withXml}} -import jakarta.xml.bind.annotation.*; +import javax.xml.bind.annotation.*; {{/withXml}} {{/x-is-composed-model}} diff --git a/metadata-service/configuration/build.gradle b/metadata-service/configuration/build.gradle index 80cf6541261c23..f912e2ac01f0b6 100644 --- a/metadata-service/configuration/build.gradle +++ b/metadata-service/configuration/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'java' + id 'java-library' } apply from: "../../gradle/versioning/versioning.gradle" @@ -7,8 +7,9 @@ dependencies { implementation externalDependency.jacksonDataBind implementation externalDependency.slf4jApi - implementation externalDependency.springCore - implementation externalDependency.springBeans + + // Newer Spring libraries require JDK17 classes, allow for JDK11 + compileOnly externalDependency.springBootAutoconfigureJdk11 compileOnly externalDependency.lombok diff --git a/metadata-service/restli-servlet-impl/build.gradle b/metadata-service/restli-servlet-impl/build.gradle index ec5b645ee233c7..8d21bdd489505b 100644 --- a/metadata-service/restli-servlet-impl/build.gradle +++ b/metadata-service/restli-servlet-impl/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'java' + id 'java-library' id 'pegasus' } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java index 546c2856c28ac3..0a29ebfe46415a 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntityRecommendationSource.java @@ -8,6 +8,7 @@ import com.linkedin.metadata.recommendation.RecommendationParams; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -29,7 +30,7 @@ default Stream buildContent( entityUrns.stream() .map(UrnUtils::getUrn) .filter(urn -> getSupportedEntityTypes().contains(urn.getEntityType())) - .toList(); + .collect(Collectors.toList()); Set existingNonRemoved = entityService.exists(entities, false); return entities.stream().filter(existingNonRemoved::contains).map(this::buildContent); diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java index 22496b6c078068..666fe23a93187f 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/RollbackService.java @@ -126,7 +126,7 @@ public RollbackResponse rollbackIngestion( !row.getRunId().equals(runId) && !row.isKeyAspect() && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) - .toList(); + .collect(Collectors.toList()); long unsafeEntitiesCount = affectedAspectsList.stream() @@ -212,7 +212,7 @@ public RollbackResponse rollbackIngestion( !row.getRunId().equals(runId) && !row.isKeyAspect() && !row.getAspectName().equals(Constants.STATUS_ASPECT_NAME)) - .toList(); + .collect(Collectors.toList()); long affectedAspects = affectedAspectsList.size(); long unsafeEntitiesCount = diff --git a/metadata-utils/build.gradle b/metadata-utils/build.gradle index 3d65675219624d..919d93c5f9fe19 100644 --- a/metadata-utils/build.gradle +++ b/metadata-utils/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java-library' + id 'pegasus' } dependencies { From f627fc459dd7181203392036779e12cdd3e3881f Mon Sep 17 00:00:00 2001 From: Davi Arnaut Date: Tue, 23 Jan 2024 22:34:18 -0800 Subject: [PATCH 200/540] feat(ingest): add ODBC library and tools to base image (#9701) --- docker/datahub-ingestion-base/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/datahub-ingestion-base/Dockerfile b/docker/datahub-ingestion-base/Dockerfile index 558a5afe2c2cf9..0bf0d2f88af738 100644 --- a/docker/datahub-ingestion-base/Dockerfile +++ b/docker/datahub-ingestion-base/Dockerfile @@ -48,6 +48,8 @@ RUN apt-get update && apt-get install -y -qq \ zip \ unzip \ ldap-utils \ + unixodbc \ + libodbc2 \ && python -m pip install --no-cache --upgrade pip wheel setuptools \ && rm -rf /var/lib/apt/lists/* /var/cache/apk/* From c158d2b9ecd42365994a9eaa1b959fdcb2749d26 Mon Sep 17 00:00:00 2001 From: muzzacode <109360468+muzzacode@users.noreply.github.com> Date: Wed, 24 Jan 2024 12:07:16 +0530 Subject: [PATCH 201/540] feat (resolver): Add new endpoint for editing secrets (#9665) --- .../datahub/graphql/GmsGraphQLEngine.java | 3 + .../ingest/secret/CreateSecretResolver.java | 19 ++-- .../ingest/secret/UpdateSecretResolver.java | 82 ++++++++++++++++ .../mapper/DataHubSecretValueMapper.java | 55 +++++++++++ .../src/main/resources/ingestion.graphql | 30 ++++++ .../secret/UpdateSecretResolverTest.java | 98 +++++++++++++++++++ .../src/graphql/ingestion.graphql | 4 + .../managed_ingestion_test.py | 23 ++++- 8 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ingest/secret/mapper/DataHubSecretValueMapper.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolverTest.java diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 4819510d340180..4b5bbdb6e15ec9 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -170,6 +170,7 @@ import com.linkedin.datahub.graphql.resolvers.ingest.secret.DeleteSecretResolver; import com.linkedin.datahub.graphql.resolvers.ingest.secret.GetSecretValuesResolver; import com.linkedin.datahub.graphql.resolvers.ingest.secret.ListSecretsResolver; +import com.linkedin.datahub.graphql.resolvers.ingest.secret.UpdateSecretResolver; import com.linkedin.datahub.graphql.resolvers.ingest.source.DeleteIngestionSourceResolver; import com.linkedin.datahub.graphql.resolvers.ingest.source.GetIngestionSourceResolver; import com.linkedin.datahub.graphql.resolvers.ingest.source.ListIngestionSourcesResolver; @@ -1086,6 +1087,8 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "createSecret", new CreateSecretResolver(this.entityClient, this.secretService)) .dataFetcher("deleteSecret", new DeleteSecretResolver(this.entityClient)) + .dataFetcher( + "updateSecret", new UpdateSecretResolver(this.entityClient, this.secretService)) .dataFetcher( "createAccessToken", new CreateAccessTokenResolver(this.statefulTokenService)) .dataFetcher( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java index 577780e53ce862..f5e7cf4d69ce8e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/CreateSecretResolver.java @@ -6,11 +6,11 @@ import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.UrnUtils; -import com.linkedin.data.template.SetMode; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.CreateSecretInput; import com.linkedin.datahub.graphql.resolvers.ingest.IngestionAuthUtils; +import com.linkedin.datahub.graphql.types.ingest.secret.mapper.DataHubSecretValueMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.key.DataHubSecretKey; import com.linkedin.metadata.secret.SecretService; @@ -58,14 +58,15 @@ public CompletableFuture get(final DataFetchingEnvironment environment) } // Create the secret value. - final DataHubSecretValue value = new DataHubSecretValue(); - value.setName(input.getName()); - value.setValue(_secretService.encrypt(input.getValue())); - value.setDescription(input.getDescription(), SetMode.IGNORE_NULL); - value.setCreated( - new AuditStamp() - .setActor(UrnUtils.getUrn(context.getActorUrn())) - .setTime(System.currentTimeMillis())); + final DataHubSecretValue value = + DataHubSecretValueMapper.map( + null, + input.getName(), + _secretService.encrypt(input.getValue()), + input.getDescription(), + new AuditStamp() + .setActor(UrnUtils.getUrn(context.getActorUrn())) + .setTime(System.currentTimeMillis())); final MetadataChangeProposal proposal = buildMetadataChangeProposalWithKey( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolver.java new file mode 100644 index 00000000000000..20a685265b5453 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolver.java @@ -0,0 +1,82 @@ +package com.linkedin.datahub.graphql.resolvers.ingest.secret; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.buildMetadataChangeProposalWithUrn; +import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateSecretInput; +import com.linkedin.datahub.graphql.resolvers.ingest.IngestionAuthUtils; +import com.linkedin.datahub.graphql.types.ingest.secret.mapper.DataHubSecretValueMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.secret.SecretService; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.secret.DataHubSecretValue; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Creates an encrypted DataHub secret. Uses AES symmetric encryption / decryption. Requires the + * MANAGE_SECRETS privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class UpdateSecretResolver implements DataFetcher> { + private final EntityClient entityClient; + private final SecretService secretService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final UpdateSecretInput input = + bindArgument(environment.getArgument("input"), UpdateSecretInput.class); + final Urn secretUrn = Urn.createFromString(input.getUrn()); + return CompletableFuture.supplyAsync( + () -> { + if (IngestionAuthUtils.canManageSecrets(context)) { + + try { + EntityResponse response = + entityClient.getV2( + secretUrn.getEntityType(), + secretUrn, + Set.of(SECRET_VALUE_ASPECT_NAME), + context.getAuthentication()); + if (!entityClient.exists(secretUrn, context.getAuthentication()) + || response == null) { + throw new IllegalArgumentException( + String.format("Secret for urn %s doesn't exists!", secretUrn)); + } + + DataHubSecretValue updatedVal = + DataHubSecretValueMapper.map( + response, + input.getName(), + secretService.encrypt(input.getValue()), + input.getDescription(), + null); + + final MetadataChangeProposal proposal = + buildMetadataChangeProposalWithUrn( + secretUrn, SECRET_VALUE_ASPECT_NAME, updatedVal); + return entityClient.ingestProposal(proposal, context.getAuthentication(), false); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Failed to update a secret with urn %s and name %s", + secretUrn, input.getName()), + e); + } + } + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ingest/secret/mapper/DataHubSecretValueMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ingest/secret/mapper/DataHubSecretValueMapper.java new file mode 100644 index 00000000000000..2c5e84dad28c21 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/ingest/secret/mapper/DataHubSecretValueMapper.java @@ -0,0 +1,55 @@ +package com.linkedin.datahub.graphql.types.ingest.secret.mapper; + +import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; + +import com.linkedin.common.AuditStamp; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.data.template.SetMode; +import com.linkedin.entity.EntityResponse; +import com.linkedin.secret.DataHubSecretValue; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema. + * + *

To be replaced by auto-generated mappers implementations + */ +public class DataHubSecretValueMapper { + + public static final DataHubSecretValueMapper INSTANCE = new DataHubSecretValueMapper(); + + public static DataHubSecretValue map( + EntityResponse fromSecret, + @Nonnull final String name, + @Nonnull final String value, + String description, + AuditStamp auditStamp) { + return INSTANCE.apply(fromSecret, name, value, description, auditStamp); + } + + public DataHubSecretValue apply( + EntityResponse existingSecret, + @Nonnull final String name, + @Nonnull final String value, + String description, + AuditStamp auditStamp) { + final DataHubSecretValue result; + if (Objects.nonNull(existingSecret)) { + result = + new DataHubSecretValue( + existingSecret.getAspects().get(SECRET_VALUE_ASPECT_NAME).getValue().data()); + } else { + result = new DataHubSecretValue(); + } + + result.setName(name); + result.setValue(value); + result.setDescription(description, SetMode.IGNORE_NULL); + if (Objects.nonNull(auditStamp)) { + result.setCreated(auditStamp); + } + + return result; + } +} diff --git a/datahub-graphql-core/src/main/resources/ingestion.graphql b/datahub-graphql-core/src/main/resources/ingestion.graphql index 21f9fb2633119b..d65343c0a16d2b 100644 --- a/datahub-graphql-core/src/main/resources/ingestion.graphql +++ b/datahub-graphql-core/src/main/resources/ingestion.graphql @@ -36,6 +36,11 @@ extend type Mutation { """ createSecret(input: CreateSecretInput!): String + """ + Update a Secret + """ + updateSecret(input: UpdateSecretInput!): String + """ Delete a Secret """ @@ -560,6 +565,31 @@ input CreateSecretInput { description: String } +""" +Input arguments for updating a Secret +""" +input UpdateSecretInput { + """ + The primary key of the Secret to update + """ + urn: String! + + """ + The name of the secret for reference in ingestion recipes + """ + name: String! + + """ + The value of the secret, to be encrypted and stored + """ + value: String! + + """ + An optional description for the secret + """ + description: String +} + """ Input arguments for retrieving the plaintext values of a set of secrets """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolverTest.java new file mode 100644 index 00000000000000..73d228d6002665 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/secret/UpdateSecretResolverTest.java @@ -0,0 +1,98 @@ +package com.linkedin.datahub.graphql.resolvers.ingest.secret; + +import static com.linkedin.datahub.graphql.resolvers.ingest.IngestTestUtils.getMockAllowContext; +import static com.linkedin.datahub.graphql.resolvers.ingest.IngestTestUtils.getMockDenyContext; +import static com.linkedin.metadata.Constants.SECRET_VALUE_ASPECT_NAME; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.when; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateSecretInput; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.secret.SecretService; +import com.linkedin.secret.DataHubSecretValue; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class UpdateSecretResolverTest { + + private static final Urn TEST_URN = UrnUtils.getUrn("urn:li:secret:secret-id"); + + private static final UpdateSecretInput TEST_INPUT = + new UpdateSecretInput(TEST_URN.toString(), "MY_SECRET", "mysecretvalue", "dummy"); + + private DataFetchingEnvironment mockEnv; + private EntityClient mockClient; + private SecretService mockSecretService; + private UpdateSecretResolver resolver; + + @BeforeMethod + public void before() { + mockClient = Mockito.mock(EntityClient.class); + mockSecretService = Mockito.mock(SecretService.class); + + resolver = new UpdateSecretResolver(mockClient, mockSecretService); + } + + private DataHubSecretValue createSecretAspect() { + DataHubSecretValue secretAspect = new DataHubSecretValue(); + secretAspect.setValue("encryptedvalue.updated"); + secretAspect.setName(TEST_INPUT.getName() + ".updated"); + secretAspect.setDescription(TEST_INPUT.getDescription() + ".updated"); + secretAspect.setCreated( + new AuditStamp().setActor(UrnUtils.getUrn("urn:li:corpuser:test")).setTime(0L)); + return secretAspect; + } + + @Test + public void testGetSuccess() throws Exception { + // with valid context + QueryContext mockContext = getMockAllowContext(); + mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when(mockClient.exists(any(), any())).thenReturn(true); + Mockito.when(mockSecretService.encrypt(any())).thenReturn("encrypted_value"); + final EntityResponse entityResponse = new EntityResponse(); + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + aspectMap.put( + SECRET_VALUE_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(createSecretAspect().data()))); + entityResponse.setAspects(aspectMap); + + when(mockClient.getV2(any(), any(), any(), any())).thenReturn(entityResponse); + + // Invoke the resolver + resolver.get(mockEnv).join(); + Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(any(), any(), anyBoolean()); + } + + @Test( + description = "validate if nothing provided throws Exception", + expectedExceptions = {AuthorizationException.class, CompletionException.class}) + public void testGetUnauthorized() throws Exception { + // Execute resolver + QueryContext mockContext = getMockDenyContext(); + mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + resolver.get(mockEnv).join(); + Mockito.verify(mockClient, Mockito.times(0)) + .ingestProposal(any(), any(Authentication.class), anyBoolean()); + } +} diff --git a/datahub-web-react/src/graphql/ingestion.graphql b/datahub-web-react/src/graphql/ingestion.graphql index 1767fe34bfef08..4d6f090b993569 100644 --- a/datahub-web-react/src/graphql/ingestion.graphql +++ b/datahub-web-react/src/graphql/ingestion.graphql @@ -145,6 +145,10 @@ mutation createSecret($input: CreateSecretInput!) { createSecret(input: $input) } +mutation updateSecret($input: UpdateSecretInput!) { + updateSecret(input: $input) +} + mutation deleteSecret($urn: String!) { deleteSecret(urn: $urn) } diff --git a/smoke-test/tests/managed-ingestion/managed_ingestion_test.py b/smoke-test/tests/managed-ingestion/managed_ingestion_test.py index b5e408731334e1..6d95f731f32b1d 100644 --- a/smoke-test/tests/managed-ingestion/managed_ingestion_test.py +++ b/smoke-test/tests/managed-ingestion/managed_ingestion_test.py @@ -260,6 +260,27 @@ def test_create_list_get_remove_secret(frontend_session): # Get new count of secrets _ensure_secret_increased(frontend_session, before_count) + # Update existing secret + json_q = { + "query": """mutation updateSecret($input: UpdateSecretInput!) {\n + updateSecret(input: $input) + }""", + "variables": {"input": {"urn": secret_urn, "name": "SMOKE_TEST", "value": "mytestvalue.updated"}}, + } + + response = frontend_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=json_q + ) + response.raise_for_status() + res_data = response.json() + + assert res_data + assert res_data["data"] + assert res_data["data"]["updateSecret"] is not None + assert "errors" not in res_data + + secret_urn = res_data["data"]["updateSecret"] + # Get the secret value back json_q = { "query": """query getSecretValues($input: GetSecretValuesInput!) {\n @@ -285,7 +306,7 @@ def test_create_list_get_remove_secret(frontend_session): secret_values = res_data["data"]["getSecretValues"] secret_value = [x for x in secret_values if x["name"] == "SMOKE_TEST"][0] - assert secret_value["value"] == "mytestvalue" + assert secret_value["value"] == "mytestvalue.updated" # Now cleanup and remove the secret json_q = { From c4dec931a370a92e115c5213f013620bfe4f2d58 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Wed, 24 Jan 2024 12:14:14 +0530 Subject: [PATCH 202/540] feat(ingest): handling for const in json schema (#9694) Co-authored-by: Harshal Sheth --- .../datahub/ingestion/extractor/json_schema_util.py | 10 ++++++---- .../tests/unit/schema/test_json_schema_util.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/extractor/json_schema_util.py b/metadata-ingestion/src/datahub/ingestion/extractor/json_schema_util.py index 360ddf1129154b..52d2e4a8f56e3b 100644 --- a/metadata-ingestion/src/datahub/ingestion/extractor/json_schema_util.py +++ b/metadata-ingestion/src/datahub/ingestion/extractor/json_schema_util.py @@ -316,10 +316,12 @@ def _get_discriminated_type_from_schema(schema: Dict) -> str: @staticmethod def _get_description_from_any_schema(schema: Dict) -> str: - # we do a redundant `if description in schema` check to guard against the scenario that schema is not a dictionary - description = ( - (schema.get("description") or "") if "description" in schema else "" - ) + description = "" + if "description" in schema: + description = str(schema.get("description")) + elif "const" in schema: + schema_const = schema.get("const") + description = f"Const value: {schema_const}" if JsonSchemaTranslator._INJECT_DEFAULTS_INTO_DESCRIPTION: default = schema.get("default") if default is not None: diff --git a/metadata-ingestion/tests/unit/schema/test_json_schema_util.py b/metadata-ingestion/tests/unit/schema/test_json_schema_util.py index 2635363ed8d2e5..5e095fc0df8dce 100644 --- a/metadata-ingestion/tests/unit/schema/test_json_schema_util.py +++ b/metadata-ingestion/tests/unit/schema/test_json_schema_util.py @@ -725,6 +725,19 @@ def test_non_str_enums(): assert fields[0].description == 'One of: "baz", 1, null' +def test_const_description_pulled_correctly(): + schema = { + "$id": "test", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"bar": {"type": "string", "const": "not_defined"}}, + } + + fields = list(JsonSchemaTranslator.get_fields_from_schema(schema)) + expected_field_paths: List[str] = ["[version=2.0].[type=object].[type=string].bar"] + assert_field_paths_match(fields, expected_field_paths) + assert fields[0].description == "Const value: not_defined" + + def test_anyof_with_properties(): # We expect the event / timestamp fields to be included in both branches of the anyOf. From 2f36817e95f9853e0a0302888136a150fe8a8889 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Tue, 23 Jan 2024 22:58:46 -0800 Subject: [PATCH 203/540] refactor(ingest): simplify adding aspects to MCEs in transformers (#9686) --- .../datahub/api/entities/dataset/dataset.py | 2 +- metadata-ingestion/src/datahub/entrypoints.py | 13 ---- .../ingestion/transformer/base_transformer.py | 62 +++++++++---------- .../transformer/extract_dataset_tags.py | 2 +- 4 files changed, 32 insertions(+), 47 deletions(-) diff --git a/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py b/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py index 3b4a5fbfbb0614..a1498a6ca961e9 100644 --- a/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py +++ b/metadata-ingestion/src/datahub/api/entities/dataset/dataset.py @@ -147,7 +147,7 @@ def platform_urn(self) -> str: return make_data_platform_urn(self.platform) else: assert self.urn is not None # validator should have filled this in - dataset_urn = DatasetUrn.create_from_string(self.urn) + dataset_urn = DatasetUrn.from_string(self.urn) return str(dataset_urn.get_data_platform_urn()) @validator("urn", pre=True, always=True) diff --git a/metadata-ingestion/src/datahub/entrypoints.py b/metadata-ingestion/src/datahub/entrypoints.py index 4989f984badcc8..1bf090a2e514ec 100644 --- a/metadata-ingestion/src/datahub/entrypoints.py +++ b/metadata-ingestion/src/datahub/entrypoints.py @@ -62,13 +62,6 @@ default=None, help="Enable debug logging.", ) -@click.option( - "--debug-vars/--no-debug-vars", - type=bool, - is_flag=True, - default=False, - help="Show variable values in stack traces. Implies --debug. While we try to avoid printing sensitive information like passwords, this may still happen.", -) @click.version_option( version=datahub_package.nice_version_name(), prog_name=datahub_package.__package_name__, @@ -76,13 +69,7 @@ def datahub( debug: bool, log_file: Optional[str], - debug_vars: bool, ) -> None: - if debug_vars: - # debug_vars implies debug. This option isn't actually used here, but instead - # read directly from the command line arguments in the main entrypoint. - debug = True - debug = debug or get_boolean_env_variable("DATAHUB_DEBUG", False) # Note that we're purposely leaking the context manager here. diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py index 254b3d084f2be2..e8e25a061a665e 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py @@ -77,7 +77,7 @@ def __init__(self): mixedin = mixedin or isinstance(self, mixin) if not mixedin: assert ( - "Class does not implement one of required traits {self.allowed_mixins}" + f"Class does not implement one of required traits {self.allowed_mixins}" ) def _should_process( @@ -135,38 +135,37 @@ def _transform_or_record_mce( if mce.proposedSnapshot: self._record_mce(mce) if isinstance(self, SingleAspectTransformer): - aspect_type = ASPECT_MAP.get(self.aspect_name()) - if aspect_type: - # if we find a type corresponding to the aspect name we look for it in the mce - old_aspect = ( - builder.get_aspect_if_available( + aspect_type = ASPECT_MAP[self.aspect_name()] + + # If we find a type corresponding to the aspect name we look for it in the mce + # It's possible that the aspect is supported by the entity but not in the MCE + # snapshot union. In those cases, we just want to record the urn as seen. + supports_aspect = builder.can_add_aspect(mce, aspect_type) + if supports_aspect: + old_aspect = builder.get_aspect_if_available( + mce, + aspect_type, + ) + if old_aspect is not None: + # TRICKY: If the aspect is not present in the MCE, it might still show up in a + # subsequent MCP. As such, we _only_ mark the urn as processed if we actually + # find the aspect already in the MCE. + + transformed_aspect = self.transform_aspect( + entity_urn=mce.proposedSnapshot.urn, + aspect_name=self.aspect_name(), + aspect=old_aspect, + ) + + # If transformed_aspect is None, this will remove the aspect. + builder.set_aspect( mce, - aspect_type, + aspect_type=aspect_type, + aspect=transformed_aspect, ) - if builder.can_add_aspect(mce, aspect_type) - else None - ) - if old_aspect: - if isinstance(self, LegacyMCETransformer): - # use the transform_one pathway to transform this MCE - envelope.record = self.transform_one(mce) - else: - transformed_aspect = self.transform_aspect( - entity_urn=mce.proposedSnapshot.urn, - aspect_name=self.aspect_name(), - aspect=old_aspect, - ) - builder.set_aspect( - mce, - aspect_type=aspect_type, - aspect=transformed_aspect, - ) - envelope.record = mce + + envelope.record = mce self._mark_processed(mce.proposedSnapshot.urn) - else: - log.warning( - f"Could not locate a snapshot aspect type for aspect {self.aspect_name()}. This can lead to silent drops of messages in transformers." - ) elif isinstance(self, LegacyMCETransformer): # we pass down the full MCE envelope.record = self.transform_one(mce) @@ -202,7 +201,6 @@ def _transform_or_record_mcpw( def _handle_end_of_stream( self, envelope: RecordEnvelope ) -> Iterable[RecordEnvelope]: - if not isinstance(self, SingleAspectTransformer) and not isinstance( self, LegacyMCETransformer ): @@ -265,7 +263,7 @@ def transform( else None, ) if transformed_aspect: - structured_urn = Urn.create_from_string(urn) + structured_urn = Urn.from_string(urn) mcp: MetadataChangeProposalWrapper = ( MetadataChangeProposalWrapper( diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/extract_dataset_tags.py b/metadata-ingestion/src/datahub/ingestion/transformer/extract_dataset_tags.py index 25b18f0806fd63..4b64d38a9b42fa 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/extract_dataset_tags.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/extract_dataset_tags.py @@ -34,7 +34,7 @@ def create(cls, config_dict: dict, ctx: PipelineContext) -> "ExtractDatasetTags" def _get_tags_to_add(self, entity_urn: str) -> List[TagAssociationClass]: if self.config.extract_tags_from == ExtractTagsOption.URN: - urn = DatasetUrn.create_from_string(entity_urn) + urn = DatasetUrn.from_string(entity_urn) match = re.search(self.config.extract_tags_regex, urn.get_dataset_name()) if match: captured_group = match.group(1) From 7ae88d97ab9b35a4356848ec94c4b885f8f5ae80 Mon Sep 17 00:00:00 2001 From: Dimitri <36767102+dim-ops@users.noreply.github.com> Date: Wed, 24 Jan 2024 07:59:28 +0100 Subject: [PATCH 204/540] docs(openapi): improve description fields (#9690) --- .../src/datahub/ingestion/source/openapi.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/openapi.py b/metadata-ingestion/src/datahub/ingestion/source/openapi.py index ad62ef7362aebd..1b3a6dc4bee58c 100755 --- a/metadata-ingestion/src/datahub/ingestion/source/openapi.py +++ b/metadata-ingestion/src/datahub/ingestion/source/openapi.py @@ -46,12 +46,20 @@ class OpenApiConfig(ConfigModel): - name: str = Field(description="") - url: str = Field(description="") - swagger_file: str = Field(description="") - ignore_endpoints: list = Field(default=[], description="") - username: str = Field(default="", description="") - password: str = Field(default="", description="") + name: str = Field(description="Name of ingestion.") + url: str = Field(description="Endpoint URL. e.g. https://example.com") + swagger_file: str = Field( + description="Route for access to the swagger file. e.g. openapi.json" + ) + ignore_endpoints: list = Field( + default=[], description="List of endpoints to ignore during ingestion." + ) + username: str = Field( + default="", description="Username used for basic HTTP authentication." + ) + password: str = Field( + default="", description="Password used for basic HTTP authentication." + ) proxies: Optional[dict] = Field( default=None, description="Eg. " @@ -59,9 +67,16 @@ class OpenApiConfig(ConfigModel): "If authentication is required, add it to the proxy url directly e.g. " "`http://user:pass@10.10.1.10:3128/`.", ) - forced_examples: dict = Field(default={}, description="") - token: Optional[str] = Field(default=None, description="") - get_token: dict = Field(default={}, description="") + forced_examples: dict = Field( + default={}, + description="If no example is provided for a route, it is possible to create one using forced_example.", + ) + token: Optional[str] = Field( + default=None, description="Token for endpoint authentication." + ) + get_token: dict = Field( + default={}, description="Retrieving a token from the endpoint." + ) def get_swagger(self) -> Dict: if self.get_token or self.token is not None: From c80383dd1a92a6fd5da935f5cd315e854292bbfc Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Wed, 24 Jan 2024 21:45:00 +0000 Subject: [PATCH 205/540] feat(docs): Add documentation on Incident Change Event (#9709) --- .../datahub-api/entity-events-api.md | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/managed-datahub/datahub-api/entity-events-api.md b/docs/managed-datahub/datahub-api/entity-events-api.md index 07fa2522494528..23499904d5505a 100644 --- a/docs/managed-datahub/datahub-api/entity-events-api.md +++ b/docs/managed-datahub/datahub-api/entity-events-api.md @@ -563,7 +563,7 @@ This event is emitted when an Assertion has been run has succeeded on DataHub. "parameters": { "runResult": "SUCCESS", "runId": "123", - "aserteeUrn": "urn:li:dataset:def" + "asserteeUrn": "urn:li:dataset:def" }, "auditStamp": { "actor": "urn:li:corpuser:jdoe", @@ -808,4 +808,36 @@ These are the common parameters for all parameters. "time": 1649953100653 } } -``` \ No newline at end of file +``` + +### Incident Change Event + +This event is emitted when an Incident has been created or it's status changes. + +#### Header + +
CategoryOperationEntity Types
INCIDENTACTIVE, RESOLVEDincident
+ +#### Parameters + +| Name | Type | Description | Optional | +|--------------| ------ |---------------------------------------------------| -------- | +| entities | String | The list of entities associated with the incident | False | + +#### Sample Event + +``` +{ + "entityUrn": "urn:li:incident:16ff200a-0ac5-4a7d-bbab-d4bdb4f831f9", + "entityType": "incident", + "category": "INCIDENT", + "operation": "ACTIVE", + "parameters": { + "entities": "[urn:li:dataset:abc, urn:li:dataset:abc2]", + }, + "auditStamp": { + "actor": "urn:li:corpuser:jdoe", + "time": 1649953100653 + } +} +``` From 9b051e38d6bd9f62ea42ecce1cfbfdf686d9b0e9 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 24 Jan 2024 14:29:41 -0800 Subject: [PATCH 206/540] feat(ingest/dbt): support aws config without region (#9650) Co-authored-by: Tamas Nemeth --- .../ingestion/source/aws/aws_common.py | 4 +-- .../datahub/ingestion/source/aws/sagemaker.py | 4 +-- .../datahub/ingestion/source/dbt/dbt_core.py | 2 +- .../tests/unit/test_dbt_source.py | 33 +++++++++++++++---- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py b/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py index 421991a0966c3a..95ca10045f1bb9 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/aws/aws_common.py @@ -34,7 +34,7 @@ class AwsAssumeRoleConfig(PermissiveConfigModel): def assume_role( role: AwsAssumeRoleConfig, - aws_region: str, + aws_region: Optional[str], credentials: Optional[dict] = None, ) -> dict: credentials = credentials or {} @@ -93,7 +93,7 @@ class AwsConnectionConfig(ConfigModel): default=None, description="Named AWS profile to use. Only used if access key / secret are unset. If not set the default will be used", ) - aws_region: str = Field(description="AWS region code.") + aws_region: Optional[str] = Field(None, description="AWS region code.") aws_endpoint_url: Optional[str] = Field( default=None, diff --git a/metadata-ingestion/src/datahub/ingestion/source/aws/sagemaker.py b/metadata-ingestion/src/datahub/ingestion/source/aws/sagemaker.py index 6f6e8bbc05661d..e335174eeb003c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/aws/sagemaker.py +++ b/metadata-ingestion/src/datahub/ingestion/source/aws/sagemaker.py @@ -82,7 +82,7 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: env=self.env, report=self.report, job_type_filter=self.source_config.extract_jobs, - aws_region=self.source_config.aws_region, + aws_region=self.sagemaker_client.meta.region_name, ) yield from job_processor.get_workunits() @@ -98,7 +98,7 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: model_image_to_jobs=model_image_to_jobs, model_name_to_jobs=model_name_to_jobs, lineage=lineage, - aws_region=self.source_config.aws_region, + aws_region=self.sagemaker_client.meta.region_name, ) yield from model_processor.get_workunits() diff --git a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py index 6fd3c5ba309f9e..a2f96264b7f643 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py +++ b/metadata-ingestion/src/datahub/ingestion/source/dbt/dbt_core.py @@ -81,7 +81,7 @@ def aws_connection_needed_if_s3_uris_present( if (values.get(f) or "").startswith("s3://") ] - if uri_containing_fields and not aws_connection: + if uri_containing_fields and aws_connection is None: raise ValueError( f"Please provide aws_connection configuration, since s3 uris have been provided in fields {uri_containing_fields}" ) diff --git a/metadata-ingestion/tests/unit/test_dbt_source.py b/metadata-ingestion/tests/unit/test_dbt_source.py index 0fbe9ecbcc43cc..737cf6aca33cc3 100644 --- a/metadata-ingestion/tests/unit/test_dbt_source.py +++ b/metadata-ingestion/tests/unit/test_dbt_source.py @@ -1,6 +1,7 @@ from typing import Dict, List, Union from unittest import mock +import pytest from pydantic import ValidationError from datahub.emitter import mce_builder @@ -180,14 +181,12 @@ def test_dbt_entity_emission_configuration(): "target_platform": "dummy_platform", "entities_enabled": {"models": "Only", "seeds": "Only"}, } - try: + with pytest.raises( + ValidationError, + match="Cannot have more than 1 type of entity emission set to ONLY", + ): DBTCoreConfig.parse_obj(config_dict) - except ValidationError as ve: - assert len(ve.errors()) == 1 - assert ( - "Cannot have more than 1 type of entity emission set to ONLY" - in ve.errors()[0]["msg"] - ) + # valid config config_dict = { "manifest_path": "dummy_path", @@ -198,6 +197,26 @@ def test_dbt_entity_emission_configuration(): DBTCoreConfig.parse_obj(config_dict) +def test_dbt_s3_config(): + # test missing aws config + config_dict: dict = { + "manifest_path": "s3://dummy_path", + "catalog_path": "s3://dummy_path", + "target_platform": "dummy_platform", + } + with pytest.raises(ValidationError, match="provide aws_connection"): + DBTCoreConfig.parse_obj(config_dict) + + # valid config + config_dict = { + "manifest_path": "s3://dummy_path", + "catalog_path": "s3://dummy_path", + "target_platform": "dummy_platform", + "aws_connection": {}, + } + DBTCoreConfig.parse_obj(config_dict) + + def test_default_convert_column_urns_to_lowercase(): config_dict = { "manifest_path": "dummy_path", From d6a30a74a7877bb22f9cb1c00d22afde8b492a66 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Wed, 24 Jan 2024 16:30:40 -0600 Subject: [PATCH 207/540] fix(test): improve cypress tests (#9711) --- smoke-test/tests/cypress/cypress/e2e/glossary/glossary.js | 6 +++--- .../cypress/cypress/e2e/mutations/managed_ingestion.js | 2 +- smoke-test/tests/cypress/cypress/support/commands.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/glossary/glossary.js b/smoke-test/tests/cypress/cypress/e2e/glossary/glossary.js index dbc4e1db729435..b0e24d5346feab 100644 --- a/smoke-test/tests/cypress/cypress/e2e/glossary/glossary.js +++ b/smoke-test/tests/cypress/cypress/e2e/glossary/glossary.js @@ -1,6 +1,6 @@ const urn = "urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)"; const datasetName = "cypress_logging_events"; -const glossaryTerm = "CypressGlosssaryTerm"; +const glossaryTerm = "CypressGlossaryTerm"; const glossaryTermGroup = "CypressGlossaryGroup"; describe("glossary", () => { @@ -8,9 +8,9 @@ describe("glossary", () => { cy.loginWithCredentials(); cy.goToGlossaryList(); cy.clickOptionWithText("Add Term"); - cy.addViaModal(glossaryTerm, "Create Glossary Term", glossaryTerm); + cy.addViaModal(glossaryTerm, "Create Glossary Term", glossaryTerm, "glossary-entity-modal-create-button"); cy.clickOptionWithText("Add Term Group"); - cy.addViaModal(glossaryTermGroup, "Create Term Group", glossaryTermGroup); + cy.addViaModal(glossaryTermGroup, "Create Term Group", glossaryTermGroup, "glossary-entity-modal-create-button"); cy.addTermToDataset(urn, datasetName, glossaryTerm); cy.waitTextVisible(glossaryTerm) cy.goToGlossaryList(); diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/managed_ingestion.js b/smoke-test/tests/cypress/cypress/e2e/mutations/managed_ingestion.js index 05f94c94bfe2a2..c355aaabc336ab 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/managed_ingestion.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/managed_ingestion.js @@ -26,7 +26,7 @@ describe("run managed ingestion", () => { cy.enterTextInTestId('source-name-input', testName) cy.clickOptionWithText("Advanced") cy.enterTextInTestId('cli-version-input', cli_version) - cy.clickOptionWithText("Save & Run") + cy.clickOptionWithTextToScrollintoView("Save & Run") cy.waitTextVisible(testName) cy.contains(testName).parent().within(() => { diff --git a/smoke-test/tests/cypress/cypress/support/commands.js b/smoke-test/tests/cypress/cypress/support/commands.js index ba5600b79f5f65..f32512aff45fac 100644 --- a/smoke-test/tests/cypress/cypress/support/commands.js +++ b/smoke-test/tests/cypress/cypress/support/commands.js @@ -183,10 +183,10 @@ Cypress.Commands.add("addViaFormModal", (text, modelHeader) => { cy.get(".ant-modal-footer > button:nth-child(2)").click(); }); -Cypress.Commands.add("addViaModal", (text, modelHeader,value) => { +Cypress.Commands.add("addViaModal", (text, modelHeader, value, dataTestId) => { cy.waitTextVisible(modelHeader); cy.get(".ant-input-affix-wrapper > input[type='text']").first().type(text); - cy.get(".ant-modal-footer > button:nth-child(2)").click(); + cy.get('[data-testid="' + dataTestId + '"]').click(); cy.contains(value).should('be.visible'); }); From 9d8e2b9067781f0eabb53362609b3a19e5d5adfb Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Wed, 24 Jan 2024 14:38:25 -0800 Subject: [PATCH 208/540] feat(ingest/tableau): map trino_jdbc platform type (#9708) --- .../src/datahub/ingestion/source/tableau_common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py b/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py index a2f460feca3888..121b2e257a6bac 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py @@ -533,6 +533,9 @@ def get_platform(connection_type: str) -> str: platform = "mssql" elif connection_type in ("athena"): platform = "athena" + elif connection_type.endswith("_jdbc"): + # e.g. convert trino_jdbc -> trino + platform = connection_type[: -len("_jdbc")] else: platform = connection_type return platform From 23277f8dc4cabc5252c8eafed58ed75a3b62e27d Mon Sep 17 00:00:00 2001 From: Davi Arnaut Date: Wed, 24 Jan 2024 17:36:30 -0800 Subject: [PATCH 209/540] fix(oidc settings): effective JWS algorithm setting (#9712) --- datahub-frontend/app/auth/AuthUtils.java | 3 + .../app/auth/sso/oidc/OidcConfigs.java | 4 +- datahub-frontend/play.gradle | 3 + .../test/security/OidcConfigurationTest.java | 24 +++++ .../linkedin/settings/global/OidcSettings.pdl | 9 +- .../auth-servlet-impl/build.gradle | 8 ++ .../authentication/AuthServiceController.java | 6 +- .../AuthServiceControllerTest.java | 96 +++++++++++++++++++ .../AuthServiceTestConfiguration.java | 32 +++++++ 9 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceControllerTest.java create mode 100644 metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceTestConfiguration.java diff --git a/datahub-frontend/app/auth/AuthUtils.java b/datahub-frontend/app/auth/AuthUtils.java index 84488a43f253e1..51bb784c61b3b1 100644 --- a/datahub-frontend/app/auth/AuthUtils.java +++ b/datahub-frontend/app/auth/AuthUtils.java @@ -76,6 +76,9 @@ public class AuthUtils { public static final String USE_NONCE = "useNonce"; public static final String READ_TIMEOUT = "readTimeout"; public static final String EXTRACT_JWT_ACCESS_TOKEN_CLAIMS = "extractJwtAccessTokenClaims"; + // Retained for backwards compatibility + public static final String PREFERRED_JWS_ALGORITHM = "preferredJwsAlgorithm"; + public static final String PREFERRED_JWS_ALGORITHM_2 = "preferredJwsAlgorithm2"; /** * Determines whether the inbound request should be forward to downstream Metadata Service. Today, diff --git a/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java b/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java index bf3384527af11f..5de4eba9cb6798 100644 --- a/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java +++ b/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java @@ -226,8 +226,8 @@ public Builder from(final com.typesafe.config.Config configs, final String ssoSe extractJwtAccessTokenClaims = Optional.of(jsonNode.get(EXTRACT_JWT_ACCESS_TOKEN_CLAIMS).asBoolean()); } - if (jsonNode.has(OIDC_PREFERRED_JWS_ALGORITHM)) { - preferredJwsAlgorithm = Optional.of(jsonNode.get(OIDC_PREFERRED_JWS_ALGORITHM).asText()); + if (jsonNode.has(PREFERRED_JWS_ALGORITHM_2)) { + preferredJwsAlgorithm = Optional.of(jsonNode.get(PREFERRED_JWS_ALGORITHM_2).asText()); } else { preferredJwsAlgorithm = Optional.ofNullable(getOptional(configs, OIDC_PREFERRED_JWS_ALGORITHM, null)); diff --git a/datahub-frontend/play.gradle b/datahub-frontend/play.gradle index 1e3a2767852d6b..9bd77e5279a91e 100644 --- a/datahub-frontend/play.gradle +++ b/datahub-frontend/play.gradle @@ -101,6 +101,9 @@ play { test { useJUnitPlatform() + testLogging.showStandardStreams = true + testLogging.exceptionFormat = 'full' + def playJava17CompatibleJvmArgs = [ "--add-opens=java.base/java.lang=ALL-UNNAMED", //"--add-opens=java.base/java.lang.invoke=ALL-UNNAMED", diff --git a/datahub-frontend/test/security/OidcConfigurationTest.java b/datahub-frontend/test/security/OidcConfigurationTest.java index c1147ae936b3ac..8226d4e74cc21a 100644 --- a/datahub-frontend/test/security/OidcConfigurationTest.java +++ b/datahub-frontend/test/security/OidcConfigurationTest.java @@ -1,5 +1,6 @@ package security; +import static auth.AuthUtils.*; import static auth.sso.oidc.OidcConfigs.*; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -24,6 +25,7 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.pac4j.oidc.client.OidcClient; +import org.json.JSONObject; public class OidcConfigurationTest { @@ -317,4 +319,26 @@ public void readTimeoutPropagation() { OidcProvider oidcProvider = new OidcProvider(oidcConfigs); assertEquals(10000, ((OidcClient) oidcProvider.client()).getConfiguration().getReadTimeout()); } + + @Test + public void readPreferredJwsAlgorithmPropagationFromConfig() { + final String SSO_SETTINGS_JSON_STR = new JSONObject().put(PREFERRED_JWS_ALGORITHM, "HS256").toString(); + CONFIG.withValue(OIDC_PREFERRED_JWS_ALGORITHM, ConfigValueFactory.fromAnyRef("RS256")); + OidcConfigs.Builder oidcConfigsBuilder = new OidcConfigs.Builder(); + oidcConfigsBuilder.from(CONFIG, SSO_SETTINGS_JSON_STR); + OidcConfigs oidcConfigs = new OidcConfigs(oidcConfigsBuilder); + OidcProvider oidcProvider = new OidcProvider(oidcConfigs); + assertEquals("RS256", ((OidcClient) oidcProvider.client()).getConfiguration().getPreferredJwsAlgorithm().toString()); + } + + @Test + public void readPreferredJwsAlgorithmPropagationFromJSON() { + final String SSO_SETTINGS_JSON_STR = new JSONObject().put(PREFERRED_JWS_ALGORITHM, "Unused").put(PREFERRED_JWS_ALGORITHM_2, "HS256").toString(); + CONFIG.withValue(OIDC_PREFERRED_JWS_ALGORITHM, ConfigValueFactory.fromAnyRef("RS256")); + OidcConfigs.Builder oidcConfigsBuilder = new OidcConfigs.Builder(); + oidcConfigsBuilder.from(CONFIG, SSO_SETTINGS_JSON_STR); + OidcConfigs oidcConfigs = new OidcConfigs(oidcConfigsBuilder); + OidcProvider oidcProvider = new OidcProvider(oidcConfigs); + assertEquals("HS256", ((OidcClient) oidcProvider.client()).getConfiguration().getPreferredJwsAlgorithm().toString()); + } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/settings/global/OidcSettings.pdl b/metadata-models/src/main/pegasus/com/linkedin/settings/global/OidcSettings.pdl index d5b23c28cb2279..f925505c8e54f1 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/settings/global/OidcSettings.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/settings/global/OidcSettings.pdl @@ -90,7 +90,12 @@ record OidcSettings { extractJwtAccessTokenClaims: optional boolean /** - * ADVANCED. Which jws algorithm to use. + * ADVANCED. Which jws algorithm to use. Unused. */ preferredJwsAlgorithm: optional string -} \ No newline at end of file + + /** + * ADVANCED. Which jws algorithm to use. + */ + preferredJwsAlgorithm2: optional string +} diff --git a/metadata-service/auth-servlet-impl/build.gradle b/metadata-service/auth-servlet-impl/build.gradle index b8310bbd4ebc01..29e452472358b8 100644 --- a/metadata-service/auth-servlet-impl/build.gradle +++ b/metadata-service/auth-servlet-impl/build.gradle @@ -18,4 +18,12 @@ dependencies { compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok + + testImplementation externalDependency.testng + testImplementation externalDependency.springBootTest +} + +test { + testLogging.showStandardStreams = true + testLogging.exceptionFormat = 'full' } diff --git a/metadata-service/auth-servlet-impl/src/main/java/com/datahub/auth/authentication/AuthServiceController.java b/metadata-service/auth-servlet-impl/src/main/java/com/datahub/auth/authentication/AuthServiceController.java index 430ed2d236219e..fc283b7e986bbf 100644 --- a/metadata-service/auth-servlet-impl/src/main/java/com/datahub/auth/authentication/AuthServiceController.java +++ b/metadata-service/auth-servlet-impl/src/main/java/com/datahub/auth/authentication/AuthServiceController.java @@ -72,7 +72,9 @@ public class AuthServiceController { private static final String USE_NONCE = "useNonce"; private static final String READ_TIMEOUT = "readTimeout"; private static final String EXTRACT_JWT_ACCESS_TOKEN_CLAIMS = "extractJwtAccessTokenClaims"; + // Retained for backwards compatibility private static final String PREFERRED_JWS_ALGORITHM = "preferredJwsAlgorithm"; + private static final String PREFERRED_JWS_ALGORITHM_2 = "preferredJwsAlgorithm2"; @Inject StatelessTokenService _statelessTokenService; @@ -514,8 +516,8 @@ private void buildOidcSettingsResponse(JSONObject json, final OidcSettings oidcS if (oidcSettings.hasExtractJwtAccessTokenClaims()) { json.put(EXTRACT_JWT_ACCESS_TOKEN_CLAIMS, oidcSettings.isExtractJwtAccessTokenClaims()); } - if (oidcSettings.hasPreferredJwsAlgorithm()) { - json.put(PREFERRED_JWS_ALGORITHM, oidcSettings.getPreferredJwsAlgorithm()); + if (oidcSettings.hasPreferredJwsAlgorithm2()) { + json.put(PREFERRED_JWS_ALGORITHM, oidcSettings.getPreferredJwsAlgorithm2()); } } } diff --git a/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceControllerTest.java b/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceControllerTest.java new file mode 100644 index 00000000000000..bb305ae16900c5 --- /dev/null +++ b/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceControllerTest.java @@ -0,0 +1,96 @@ +package com.datahub.auth.authentication; + +import static com.linkedin.metadata.Constants.GLOBAL_SETTINGS_INFO_ASPECT_NAME; +import static com.linkedin.metadata.Constants.GLOBAL_SETTINGS_URN; +import static org.mockito.Mockito.when; +import static org.testng.Assert.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.data.schema.annotation.PathSpecBasedSchemaAnnotationVisitor; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.settings.global.GlobalSettingsInfo; +import com.linkedin.settings.global.OidcSettings; +import com.linkedin.settings.global.SsoSettings; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.springframework.web.servlet.DispatcherServlet; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +@SpringBootTest(classes = {DispatcherServlet.class}) +@ComponentScan(basePackages = {"com.datahub.auth.authentication"}) +@Import({AuthServiceTestConfiguration.class}) +public class AuthServiceControllerTest extends AbstractTestNGSpringContextTests { + @BeforeTest + public void disableAssert() { + PathSpecBasedSchemaAnnotationVisitor.class + .getClassLoader() + .setClassAssertionStatus(PathSpecBasedSchemaAnnotationVisitor.class.getName(), false); + } + + @Autowired private AuthServiceController authServiceController; + @Autowired private EntityService mockEntityService; + + private final String PREFERRED_JWS_ALGORITHM = "preferredJwsAlgorithm"; + + @Test + public void initTest() { + assertNotNull(authServiceController); + assertNotNull(mockEntityService); + } + + @Test + public void oldPreferredJwsAlgorithmIsNotReturned() throws IOException { + OidcSettings mockOidcSettings = + new OidcSettings() + .setEnabled(true) + .setClientId("1") + .setClientSecret("2") + .setDiscoveryUri("http://localhost") + .setPreferredJwsAlgorithm("test"); + SsoSettings mockSsoSettings = + new SsoSettings().setBaseUrl("http://localhost").setOidcSettings(mockOidcSettings); + GlobalSettingsInfo mockGlobalSettingsInfo = new GlobalSettingsInfo().setSso(mockSsoSettings); + + when(mockEntityService.getLatestAspect(GLOBAL_SETTINGS_URN, GLOBAL_SETTINGS_INFO_ASPECT_NAME)) + .thenReturn(mockGlobalSettingsInfo); + + ResponseEntity httpResponse = authServiceController.getSsoSettings(null).join(); + assertEquals(httpResponse.getStatusCode(), HttpStatus.OK); + + JsonNode jsonNode = new ObjectMapper().readTree(httpResponse.getBody()); + assertFalse(jsonNode.has(PREFERRED_JWS_ALGORITHM)); + } + + @Test + public void newPreferredJwsAlgorithmIsReturned() throws IOException { + OidcSettings mockOidcSettings = + new OidcSettings() + .setEnabled(true) + .setClientId("1") + .setClientSecret("2") + .setDiscoveryUri("http://localhost") + .setPreferredJwsAlgorithm("jws1") + .setPreferredJwsAlgorithm2("jws2"); + SsoSettings mockSsoSettings = + new SsoSettings().setBaseUrl("http://localhost").setOidcSettings(mockOidcSettings); + GlobalSettingsInfo mockGlobalSettingsInfo = new GlobalSettingsInfo().setSso(mockSsoSettings); + + when(mockEntityService.getLatestAspect(GLOBAL_SETTINGS_URN, GLOBAL_SETTINGS_INFO_ASPECT_NAME)) + .thenReturn(mockGlobalSettingsInfo); + + ResponseEntity httpResponse = authServiceController.getSsoSettings(null).join(); + assertEquals(httpResponse.getStatusCode(), HttpStatus.OK); + + JsonNode jsonNode = new ObjectMapper().readTree(httpResponse.getBody()); + assertTrue(jsonNode.has(PREFERRED_JWS_ALGORITHM)); + assertEquals(jsonNode.get(PREFERRED_JWS_ALGORITHM).asText(), "jws2"); + } +} diff --git a/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceTestConfiguration.java b/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceTestConfiguration.java new file mode 100644 index 00000000000000..428f14e67d1370 --- /dev/null +++ b/metadata-service/auth-servlet-impl/src/test/java/com/datahub/auth/authentication/AuthServiceTestConfiguration.java @@ -0,0 +1,32 @@ +package com.datahub.auth.authentication; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.invite.InviteTokenService; +import com.datahub.authentication.token.StatelessTokenService; +import com.datahub.authentication.user.NativeUserService; +import com.datahub.telemetry.TrackingService; +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.secret.SecretService; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; + +@TestConfiguration +public class AuthServiceTestConfiguration { + @MockBean StatelessTokenService _statelessTokenService; + + @MockBean Authentication _systemAuthentication; + + @MockBean(name = "configurationProvider") + ConfigurationProvider _configProvider; + + @MockBean NativeUserService _nativeUserService; + + @MockBean EntityService _entityService; + + @MockBean SecretService _secretService; + + @MockBean InviteTokenService _inviteTokenService; + + @MockBean TrackingService _trackingService; +} From 53c7790f9aa56eeb6695d3fbf602b3b84a7283e4 Mon Sep 17 00:00:00 2001 From: Andrew Sikowitz Date: Thu, 25 Jan 2024 01:36:59 -0800 Subject: [PATCH 210/540] feat(ingest/metabase): Use new sql parser; reduce error reporting levels (#9714) --- .../src/datahub_airflow_plugin/_extractors.py | 4 +- .../src/datahub/ingestion/source/metabase.py | 100 ++++++++---------- .../powerbi/m_query/native_sql_parser.py | 4 +- .../ingestion/source/redshift/lineage.py | 4 +- .../src/datahub/ingestion/source/tableau.py | 11 +- .../src/datahub/utilities/sqlglot_lineage.py | 40 +++---- .../metabase/metabase_mces_golden.json | 32 ++++-- .../integration/metabase/setup/card.json | 2 +- .../integration/metabase/setup/card_1.json | 4 +- .../metabase/setup/dashboard_1.json | 4 +- .../tableau/test_tableau_ingest.py | 10 +- 11 files changed, 108 insertions(+), 107 deletions(-) diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_extractors.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_extractors.py index f84b7b56f61191..32bbe884816361 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_extractors.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_extractors.py @@ -199,8 +199,8 @@ def _sql_extractor_extract(self: "SqlExtractor") -> TaskMetadata: platform=platform, platform_instance=None, env=builder.DEFAULT_ENV, - database=default_database, - schema=default_schema, + default_db=default_database, + default_schema=default_schema, ) self.log.debug(f"Got sql lineage {sql_parsing_result}") diff --git a/metadata-ingestion/src/datahub/ingestion/source/metabase.py b/metadata-ingestion/src/datahub/ingestion/source/metabase.py index 9f09a4322bb5df..af41a74f311f64 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/metabase.py +++ b/metadata-ingestion/src/datahub/ingestion/source/metabase.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timezone from functools import lru_cache from typing import Dict, Iterable, List, Optional, Tuple, Union @@ -7,7 +8,6 @@ import requests from pydantic import Field, validator from requests.models import HTTPError -from sqllineage.runner import LineageRunner import datahub.emitter.mce_builder as builder from datahub.configuration.source_common import DatasetLineageProviderConfigBase @@ -42,6 +42,9 @@ OwnershipTypeClass, ) from datahub.utilities import config_clean +from datahub.utilities.sqlglot_lineage import create_lineage_sql_parsed_result + +logger = logging.getLogger(__name__) DATASOURCE_URN_RECURSION_LIMIT = 5 @@ -225,7 +228,7 @@ def construct_dashboard_from_api_data( dashboard_response.raise_for_status() dashboard_details = dashboard_response.json() except HTTPError as http_error: - self.report.report_failure( + self.report.report_warning( key=f"metabase-dashboard-{dashboard_id}", reason=f"Unable to retrieve dashboard. " f"Reason: {str(http_error)}", ) @@ -293,7 +296,7 @@ def _get_ownership(self, creator_id: int) -> Optional[OwnershipClass]: ) return None # For cases when the error is not 404 but something else - self.report.report_failure( + self.report.report_warning( key=f"metabase-user-{creator_id}", reason=f"Unable to retrieve User info. " f"Reason: {str(http_error)}", ) @@ -348,7 +351,7 @@ def get_card_details_by_id(self, card_id: Union[int, str]) -> dict: card_response.raise_for_status() return card_response.json() except HTTPError as http_error: - self.report.report_failure( + self.report.report_warning( key=f"metabase-card-{card_id}", reason=f"Unable to retrieve Card info. " f"Reason: {str(http_error)}", ) @@ -357,7 +360,7 @@ def get_card_details_by_id(self, card_id: Union[int, str]) -> dict: def construct_card_from_api_data(self, card_data: dict) -> Optional[ChartSnapshot]: card_id = card_data.get("id") if card_id is None: - self.report.report_failure( + self.report.report_warning( key="metabase-card", reason=f"Unable to get Card id from card data {str(card_data)}", ) @@ -365,7 +368,7 @@ def construct_card_from_api_data(self, card_data: dict) -> Optional[ChartSnapsho card_details = self.get_card_details_by_id(card_id) if not card_details: - self.report.report_failure( + self.report.report_warning( key=f"metabase-card-{card_id}", reason="Unable to construct Card due to empty card details", ) @@ -482,7 +485,7 @@ def get_datasource_urn( self, card_details: dict, recursion_depth: int = 0 ) -> Optional[List]: if recursion_depth > DATASOURCE_URN_RECURSION_LIMIT: - self.report.report_failure( + self.report.report_warning( key=f"metabase-card-{card_details.get('id')}", reason="Unable to retrieve Card info. Reason: source table recursion depth exceeded", ) @@ -496,14 +499,13 @@ def get_datasource_urn( platform_instance, ) = self.get_datasource_from_id(datasource_id) if not platform: - self.report.report_failure( + self.report.report_warning( key=f"metabase-datasource-{datasource_id}", reason=f"Unable to detect platform for database id {datasource_id}", ) return None query_type = card_details.get("dataset_query", {}).get("type", {}) - source_tables = set() if query_type == "query": source_table_id = ( @@ -525,57 +527,40 @@ def get_datasource_urn( # the question is built directly from table in DB schema_name, table_name = self.get_source_table_from_id(source_table_id) if table_name: - source_tables.add( - f"{database_name + '.' if database_name else ''}{schema_name + '.' if schema_name else ''}{table_name}" - ) - else: - try: - raw_query = ( - card_details.get("dataset_query", {}) - .get("native", {}) - .get("query", "") - ) - parser = LineageRunner(raw_query) - - for table in parser.source_tables: - sources = str(table).split(".") - - source_db = sources[-3] if len(sources) > 2 else database_name - source_schema, source_table = sources[-2], sources[-1] - if source_schema == "": - source_schema = ( - database_schema - if database_schema is not None - else str(self.config.default_schema) + name_components = [database_name, schema_name, table_name] + return [ + builder.make_dataset_urn_with_platform_instance( + platform=platform, + name=".".join([v for v in name_components if v]), + platform_instance=platform_instance, + env=self.config.env, ) - - source_tables.add( - f"{source_db + '.' if source_db else ''}{source_schema}.{source_table}" - ) - except Exception as e: - self.report.report_failure( - key="metabase-query", - reason=f"Unable to retrieve lineage from query. " - f"Query: {raw_query} " - f"Reason: {str(e)} ", - ) - return None - - if platform == "snowflake": - source_tables = set(i.lower() for i in source_tables) - - # Create dataset URNs - dataset_urn = [ - builder.make_dataset_urn_with_platform_instance( + ] + else: + raw_query = ( + card_details.get("dataset_query", {}).get("native", {}).get("query", "") + ) + result = create_lineage_sql_parsed_result( + query=raw_query, + default_db=database_name, + default_schema=database_schema or self.config.default_schema, platform=platform, - name=name, platform_instance=platform_instance, env=self.config.env, + graph=self.ctx.graph, ) - for name in source_tables - ] + if result.debug_info.table_error: + logger.info( + f"Failed to parse lineage from query {raw_query}: " + f"{result.debug_info.table_error}" + ) + self.report.report_warning( + key="metabase-query", + reason=f"Unable to retrieve lineage from query: {raw_query}", + ) + return result.in_tables - return dataset_urn + return None @lru_cache(maxsize=None) def get_source_table_from_id( @@ -592,10 +577,9 @@ def get_source_table_from_id( return schema, name except HTTPError as http_error: - self.report.report_failure( + self.report.report_warning( key=f"metabase-table-{table_id}", - reason=f"Unable to retrieve source table. " - f"Reason: {str(http_error)}", + reason=f"Unable to retrieve source table. Reason: {str(http_error)}", ) return None, None @@ -641,7 +625,7 @@ def get_datasource_from_id( dataset_response.raise_for_status() dataset_json = dataset_response.json() except HTTPError as http_error: - self.report.report_failure( + self.report.report_warning( key=f"metabase-datasource-{datasource_id}", reason=f"Unable to retrieve Datasource. " f"Reason: {str(http_error)}", ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/native_sql_parser.py b/metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/native_sql_parser.py index 0afa8e7ff4564e..56c9a4abe18ad5 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/native_sql_parser.py +++ b/metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/native_sql_parser.py @@ -69,8 +69,8 @@ def parse_custom_sql( return sqlglot_l.create_lineage_sql_parsed_result( query=sql_query, - schema=schema, - database=database, + default_schema=schema, + default_db=database, platform=platform, platform_instance=platform_instance, env=env, diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py index 8135e1d44c1021..3efef58737c6e9 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py @@ -167,8 +167,8 @@ def _get_sources_from_query( query=query, platform=LineageDatasetPlatform.REDSHIFT.value, platform_instance=self.config.platform_instance, - database=db_name, - schema=str(self.config.default_schema), + default_db=db_name, + default_schema=str(self.config.default_schema), graph=self.context.graph, env=self.config.env, ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau.py index 46694dfcc47d16..acdece14a6440e 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau.py @@ -32,7 +32,6 @@ from urllib3 import Retry import datahub.emitter.mce_builder as builder -import datahub.utilities.sqlglot_lineage as sqlglot_l from datahub.configuration.common import ( AllowDenyPattern, ConfigModel, @@ -144,7 +143,11 @@ ViewPropertiesClass, ) from datahub.utilities import config_clean -from datahub.utilities.sqlglot_lineage import ColumnLineageInfo, SqlParsingResult +from datahub.utilities.sqlglot_lineage import ( + ColumnLineageInfo, + SqlParsingResult, + create_lineage_sql_parsed_result, +) from datahub.utilities.urns.dataset_urn import DatasetUrn logger: logging.Logger = logging.getLogger(__name__) @@ -1617,9 +1620,9 @@ def parse_custom_sql( f"Overridden info upstream_db={upstream_db}, platform_instance={platform_instance}, platform={platform}" ) - return sqlglot_l.create_lineage_sql_parsed_result( + return create_lineage_sql_parsed_result( query=query, - database=upstream_db, + default_db=upstream_db, platform=platform, platform_instance=platform_instance, env=env, diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index 46ca17609f3ea7..abe4f826737778 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -1280,35 +1280,35 @@ def replace_cte_refs(node: sqlglot.exp.Expression) -> sqlglot.exp.Expression: def create_lineage_sql_parsed_result( query: str, - database: Optional[str], + default_db: Optional[str], platform: str, platform_instance: Optional[str], env: str, - schema: Optional[str] = None, + default_schema: Optional[str] = None, graph: Optional[DataHubGraph] = None, ) -> SqlParsingResult: - needs_close = False - try: - if graph: - schema_resolver = graph._make_schema_resolver( - platform=platform, - platform_instance=platform_instance, - env=env, - ) - else: - needs_close = True - schema_resolver = SchemaResolver( - platform=platform, - platform_instance=platform_instance, - env=env, - graph=None, - ) + if graph: + needs_close = False + schema_resolver = graph._make_schema_resolver( + platform=platform, + platform_instance=platform_instance, + env=env, + ) + else: + needs_close = True + schema_resolver = SchemaResolver( + platform=platform, + platform_instance=platform_instance, + env=env, + graph=None, + ) + try: return sqlglot_lineage( query, schema_resolver=schema_resolver, - default_db=database, - default_schema=schema, + default_db=default_db, + default_schema=default_schema, ) except Exception as e: return SqlParsingResult.make_from_error(e) diff --git a/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json b/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json index 0ba6afbd04fc9b..9b143348fdf603 100644 --- a/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json +++ b/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json @@ -25,6 +25,9 @@ }, "chartUrl": "http://localhost:3000/card/1", "inputs": [ + { + "string": "urn:li:dataset:(urn:li:dataPlatform:bigquery,acryl-data.public.customer,PROD)" + }, { "string": "urn:li:dataset:(urn:li:dataPlatform:bigquery,acryl-data.public.payment,PROD)" } @@ -34,7 +37,7 @@ }, { "com.linkedin.pegasus2avro.chart.ChartQuery": { - "rawQuery": "SELECT\\n\\tcustomer.customer_id,\\n\\tfirst_name,\\n\\tlast_name,\\n\\tamount,\\n\\tpayment_date,\\n\\trental_id\\nFROM\\n\\tcustomer\\nINNER JOIN payment \\n ON payment.customer_id = customer.customer_id\\nORDER BY payment_date", + "rawQuery": "SELECT\n\tcustomer.customer_id,\n\tfirst_name,\n\tlast_name,\n\tamount,\n\tpayment_date,\n\trental_id\nFROM\n\tcustomer\nINNER JOIN payment \n ON payment.customer_id = customer.customer_id\nORDER BY payment_date", "type": "SQL" } }, @@ -57,7 +60,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -112,7 +116,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -141,6 +146,9 @@ }, "chartUrl": "http://localhost:3000/card/3", "inputs": [ + { + "string": "urn:li:dataset:(urn:li:dataPlatform:bigquery,acryl-data.public.customer,PROD)" + }, { "string": "urn:li:dataset:(urn:li:dataPlatform:bigquery,acryl-data.public.payment,PROD)" } @@ -167,7 +175,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -217,7 +226,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -232,7 +242,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -247,7 +258,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -262,7 +274,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } }, { @@ -277,7 +290,8 @@ }, "systemMetadata": { "lastObserved": 1636614000000, - "runId": "metabase-test" + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/metabase/setup/card.json b/metadata-ingestion/tests/integration/metabase/setup/card.json index 83bff66e6c9f3e..7ded73d02ad7d5 100644 --- a/metadata-ingestion/tests/integration/metabase/setup/card.json +++ b/metadata-ingestion/tests/integration/metabase/setup/card.json @@ -172,7 +172,7 @@ "dataset_query": { "type": "native", "native": { - "query": "SELECT\\n\\tcustomer.customer_id,\\n\\tfirst_name,\\n\\tlast_name,\\n\\tamount,\\n\\tpayment_date,\\n\\trental_id\\nFROM\\n\\tcustomer\\nINNER JOIN payment \\n ON payment.customer_id = customer.customer_id\\nORDER BY payment_date", + "query": "SELECT\n\tcustomer.customer_id,\n\tfirst_name,\n\tlast_name,\n\tamount,\n\tpayment_date,\n\trental_id\nFROM\n\tcustomer\nINNER JOIN payment \n ON payment.customer_id = customer.customer_id\nORDER BY payment_date", "template-tags": {} }, "database": 2 diff --git a/metadata-ingestion/tests/integration/metabase/setup/card_1.json b/metadata-ingestion/tests/integration/metabase/setup/card_1.json index 01e35c5b308446..66c46a72997d0c 100644 --- a/metadata-ingestion/tests/integration/metabase/setup/card_1.json +++ b/metadata-ingestion/tests/integration/metabase/setup/card_1.json @@ -177,7 +177,7 @@ "dataset_query": { "type": "native", "native": { - "query": "SELECT\\n\\tcustomer.customer_id,\\n\\tfirst_name,\\n\\tlast_name,\\n\\tamount,\\n\\tpayment_date,\\n\\trental_id\\nFROM\\n\\tcustomer\\nINNER JOIN payment \\n ON payment.customer_id = customer.customer_id\\nORDER BY payment_date", + "query": "SELECT\n\tcustomer.customer_id,\n\tfirst_name,\n\tlast_name,\n\tamount,\n\tpayment_date,\n\trental_id\nFROM\n\tcustomer\nINNER JOIN payment \n ON payment.customer_id = customer.customer_id\nORDER BY payment_date", "template-tags": {} }, "database": 2 @@ -198,4 +198,4 @@ "collection": null, "created_at": "2021-12-13T17:46:32.77", "public_uuid": null -} \ No newline at end of file +} diff --git a/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json b/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json index 0b232cd220045c..288087a67da6dd 100644 --- a/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json +++ b/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json @@ -171,7 +171,7 @@ "dataset_query": { "type": "native", "native": { - "query": "SELECT\\n\\tcustomer.customer_id,\\n\\tfirst_name,\\n\\tlast_name,\\n\\tamount,\\n\\tpayment_date,\\n\\trental_id\\nFROM\\n\\tcustomer\\nINNER JOIN payment \\n ON payment.customer_id = customer.customer_id\\nORDER BY payment_date", + "query": "SELECT\n\tcustomer.customer_id,\n\tfirst_name,\n\tlast_name,\n\tamount,\n\tpayment_date,\n\trental_id\nFROM\n\tcustomer\nINNER JOIN payment \n ON payment.customer_id = customer.customer_id\nORDER BY payment_date", "template-tags": {} }, "database": 2 @@ -330,4 +330,4 @@ "created_at": "2021-12-13T17:46:48.185", "public_uuid": null, "points_of_interest": null -} \ No newline at end of file +} diff --git a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py index 90fa71013338da..474228e9c9fc49 100644 --- a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py +++ b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py @@ -812,16 +812,16 @@ def test_tableau_unsupported_csql(mock_datahub_graph): database_override_map={"production database": "prod"} ) - with mock.patch("datahub.ingestion.source.tableau.sqlglot_l") as sqlglot_lineage: - - sqlglot_lineage.create_lineage_sql_parsed_result.return_value = SqlParsingResult( # type:ignore + with mock.patch( + "datahub.ingestion.source.tableau.create_lineage_sql_parsed_result", + return_value=SqlParsingResult( in_tables=[ "urn:li:dataset:(urn:li:dataPlatform:bigquery,my_bigquery_project.invent_dw.userdetail,PROD)" ], out_tables=[], column_lineage=None, - ) - + ), + ): source = TableauSource(config=config, ctx=context) lineage = source._create_lineage_from_unsupported_csql( From f83a2fab4415bd31f88cae1e05384282ab4d955c Mon Sep 17 00:00:00 2001 From: Shubham Jagtap <132359390+shubhamjagtap639@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:48:41 +0530 Subject: [PATCH 211/540] fix(ingestion/bigquery): Table-view-snapshot Lineage Bug fix (#9579) Co-authored-by: Aseem Bansal Co-authored-by: Harshal Sheth --- .../ingestion/source/bigquery_v2/bigquery.py | 133 ++++++++++++++++-- .../source/bigquery_v2/bigquery_config.py | 9 ++ .../source/bigquery_v2/bigquery_report.py | 3 + .../source/bigquery_v2/bigquery_schema.py | 82 ++++++++++- .../ingestion/source/bigquery_v2/lineage.py | 72 ++++++++-- .../ingestion/source/bigquery_v2/queries.py | 56 ++++++++ .../ingestion/source/common/subtypes.py | 1 + .../tests/unit/test_bigquery_source.py | 91 +++++++++++- 8 files changed, 416 insertions(+), 31 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py index 3704eae96aece0..b8bc07b9a35597 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery.py @@ -47,6 +47,7 @@ BigqueryProject, BigQuerySchemaApi, BigqueryTable, + BigqueryTableSnapshot, BigqueryView, ) from datahub.ingestion.source.bigquery_v2.common import ( @@ -234,7 +235,7 @@ def __init__(self, ctx: PipelineContext, config: BigQueryV2Config): run_id=self.ctx.run_id, ) - # For database, schema, tables, views, etc + # For database, schema, tables, views, snapshots etc self.lineage_extractor = BigqueryLineageExtractor( config, self.report, @@ -282,8 +283,12 @@ def __init__(self, ctx: PipelineContext, config: BigQueryV2Config): # Maps project -> view_ref, so we can find all views in a project self.view_refs_by_project: Dict[str, Set[str]] = defaultdict(set) + # Maps project -> snapshot_ref, so we can find all snapshots in a project + self.snapshot_refs_by_project: Dict[str, Set[str]] = defaultdict(set) # Maps view ref -> actual sql self.view_definitions: FileBackedDict[str] = FileBackedDict() + # Maps snapshot ref -> Snapshot + self.snapshots_by_ref: FileBackedDict[BigqueryTableSnapshot] = FileBackedDict() self.add_config_to_report() atexit.register(cleanup, config) @@ -303,6 +308,10 @@ def connectivity_test(client: bigquery.Client) -> CapabilityReport: else: return CapabilityReport(capable=True) + @property + def store_table_refs(self): + return self.config.include_table_lineage or self.config.include_usage_statistics + @staticmethod def metadata_read_capability_test( project_ids: List[str], config: BigQueryV2Config @@ -453,6 +462,7 @@ def _init_schema_resolver(self) -> SchemaResolver: self.config.include_schema_metadata and self.config.include_tables and self.config.include_views + and self.config.include_table_snapshots ) if schema_resolution_required and not schema_ingestion_enabled: @@ -567,6 +577,8 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: self.sql_parser_schema_resolver, self.view_refs_by_project, self.view_definitions, + self.snapshot_refs_by_project, + self.snapshots_by_ref, self.table_refs, ) @@ -603,6 +615,7 @@ def _process_project( ) -> Iterable[MetadataWorkUnit]: db_tables: Dict[str, List[BigqueryTable]] = {} db_views: Dict[str, List[BigqueryView]] = {} + db_snapshots: Dict[str, List[BigqueryTableSnapshot]] = {} project_id = bigquery_project.id try: @@ -651,9 +664,9 @@ def _process_project( self.report.report_dropped(f"{bigquery_dataset.name}.*") continue try: - # db_tables and db_views are populated in the this method + # db_tables, db_views, and db_snapshots are populated in the this method yield from self._process_schema( - project_id, bigquery_dataset, db_tables, db_views + project_id, bigquery_dataset, db_tables, db_views, db_snapshots ) except Exception as e: @@ -684,6 +697,7 @@ def _process_schema( bigquery_dataset: BigqueryDataset, db_tables: Dict[str, List[BigqueryTable]], db_views: Dict[str, List[BigqueryView]], + db_snapshots: Dict[str, List[BigqueryTableSnapshot]], ) -> Iterable[MetadataWorkUnit]: dataset_name = bigquery_dataset.name @@ -692,7 +706,11 @@ def _process_schema( ) columns = None - if self.config.include_tables or self.config.include_views: + if ( + self.config.include_tables + or self.config.include_views + or self.config.include_table_snapshots + ): columns = self.bigquery_data_dictionary.get_columns_for_dataset( project_id=project_id, dataset_name=dataset_name, @@ -713,7 +731,7 @@ def _process_schema( project_id=project_id, dataset_name=dataset_name, ) - elif self.config.include_table_lineage or self.config.include_usage_statistics: + elif self.store_table_refs: # Need table_refs to calculate lineage and usage for table_item in self.bigquery_data_dictionary.list_tables( dataset_name, project_id @@ -738,7 +756,10 @@ def _process_schema( if self.config.include_views: db_views[dataset_name] = list( self.bigquery_data_dictionary.get_views_for_dataset( - project_id, dataset_name, self.config.is_profiling_enabled() + project_id, + dataset_name, + self.config.is_profiling_enabled(), + self.report, ) ) @@ -751,6 +772,25 @@ def _process_schema( dataset_name=dataset_name, ) + if self.config.include_table_snapshots: + db_snapshots[dataset_name] = list( + self.bigquery_data_dictionary.get_snapshots_for_dataset( + project_id, + dataset_name, + self.config.is_profiling_enabled(), + self.report, + ) + ) + + for snapshot in db_snapshots[dataset_name]: + snapshot_columns = columns.get(snapshot.name, []) if columns else [] + yield from self._process_snapshot( + snapshot=snapshot, + columns=snapshot_columns, + project_id=project_id, + dataset_name=dataset_name, + ) + # This method is used to generate the ignore list for datatypes the profiler doesn't support we have to do it here # because the profiler doesn't have access to columns def generate_profile_ignore_list(self, columns: List[BigqueryColumn]) -> List[str]: @@ -778,7 +818,7 @@ def _process_table( self.report.report_dropped(table_identifier.raw_table_name()) return - if self.config.include_table_lineage or self.config.include_usage_statistics: + if self.store_table_refs: self.table_refs.add( str(BigQueryTableRef(table_identifier).get_sanitized_table_ref()) ) @@ -827,7 +867,7 @@ def _process_view( self.report.report_dropped(table_identifier.raw_table_name()) return - if self.config.include_table_lineage or self.config.include_usage_statistics: + if self.store_table_refs: table_ref = str( BigQueryTableRef(table_identifier).get_sanitized_table_ref() ) @@ -849,6 +889,48 @@ def _process_view( dataset_name=dataset_name, ) + def _process_snapshot( + self, + snapshot: BigqueryTableSnapshot, + columns: List[BigqueryColumn], + project_id: str, + dataset_name: str, + ) -> Iterable[MetadataWorkUnit]: + table_identifier = BigqueryTableIdentifier( + project_id, dataset_name, snapshot.name + ) + + self.report.snapshots_scanned += 1 + + if not self.config.table_snapshot_pattern.allowed( + table_identifier.raw_table_name() + ): + self.report.report_dropped(table_identifier.raw_table_name()) + return + + snapshot.columns = columns + snapshot.column_count = len(columns) + if not snapshot.column_count: + logger.warning( + f"Snapshot doesn't have any column or unable to get columns for table: {table_identifier}" + ) + + if self.store_table_refs: + table_ref = str( + BigQueryTableRef(table_identifier).get_sanitized_table_ref() + ) + self.table_refs.add(table_ref) + if snapshot.base_table_identifier: + self.snapshot_refs_by_project[project_id].add(table_ref) + self.snapshots_by_ref[table_ref] = snapshot + + yield from self.gen_snapshot_dataset_workunits( + table=snapshot, + columns=columns, + project_id=project_id, + dataset_name=dataset_name, + ) + def gen_table_dataset_workunits( self, table: BigqueryTable, @@ -933,9 +1015,34 @@ def gen_view_dataset_workunits( aspect=view_properties_aspect, ).as_workunit() + def gen_snapshot_dataset_workunits( + self, + table: BigqueryTableSnapshot, + columns: List[BigqueryColumn], + project_id: str, + dataset_name: str, + ) -> Iterable[MetadataWorkUnit]: + custom_properties: Dict[str, str] = {} + if table.ddl: + custom_properties["snapshot_ddl"] = table.ddl + if table.snapshot_time: + custom_properties["snapshot_time"] = str(table.snapshot_time) + if table.size_in_bytes: + custom_properties["size_in_bytes"] = str(table.size_in_bytes) + if table.rows_count: + custom_properties["rows_count"] = str(table.rows_count) + yield from self.gen_dataset_workunits( + table=table, + columns=columns, + project_id=project_id, + dataset_name=dataset_name, + sub_types=[DatasetSubTypes.BIGQUERY_TABLE_SNAPSHOT], + custom_properties=custom_properties, + ) + def gen_dataset_workunits( self, - table: Union[BigqueryTable, BigqueryView], + table: Union[BigqueryTable, BigqueryView, BigqueryTableSnapshot], columns: List[BigqueryColumn], project_id: str, dataset_name: str, @@ -1041,6 +1148,9 @@ def gen_schema_fields(self, columns: List[BigqueryColumn]) -> List[SchemaField]: # TODO: Refractor this such that # converter = HiveColumnToAvroConverter(struct_type_separator=" "); # converter.get_schema_fields_for_hive_column(...) + original_struct_type_separator = ( + HiveColumnToAvroConverter._STRUCT_TYPE_SEPARATOR + ) HiveColumnToAvroConverter._STRUCT_TYPE_SEPARATOR = " " _COMPLEX_TYPE = re.compile("^(struct|array)") last_id = -1 @@ -1101,12 +1211,15 @@ def gen_schema_fields(self, columns: List[BigqueryColumn]) -> List[SchemaField]: ) schema_fields.append(field) last_id = col.ordinal_position + HiveColumnToAvroConverter._STRUCT_TYPE_SEPARATOR = ( + original_struct_type_separator + ) return schema_fields def gen_schema_metadata( self, dataset_urn: str, - table: Union[BigqueryTable, BigqueryView], + table: Union[BigqueryTable, BigqueryView, BigqueryTableSnapshot], columns: List[BigqueryColumn], dataset_name: str, ) -> MetadataWorkUnit: diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py index bb14295bc38a83..2f4978d49e6870 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_config.py @@ -148,6 +148,15 @@ class BigQueryV2Config( " because the project id is represented as the top-level container.", ) + include_table_snapshots: Optional[bool] = Field( + default=True, description="Whether table snapshots should be ingested." + ) + + table_snapshot_pattern: AllowDenyPattern = Field( + default=AllowDenyPattern.allow_all(), + description="Regex patterns for table snapshots to filter in ingestion. Specify regex to match the entire snapshot name in database.schema.snapshot format. e.g. to match all snapshots starting with customer in Customer database and public schema, use the regex 'Customer.public.customer.*'", + ) + debug_include_full_payloads: bool = Field( default=False, description="Include full payload into events. It is only for debugging and internal use.", diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py index 69913b383af874..ad7b86219e7c13 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_report.py @@ -25,6 +25,7 @@ class BigQuerySchemaApiPerfReport(Report): get_tables_for_dataset: PerfTimer = field(default_factory=PerfTimer) list_tables: PerfTimer = field(default_factory=PerfTimer) get_views_for_dataset: PerfTimer = field(default_factory=PerfTimer) + get_snapshots_for_dataset: PerfTimer = field(default_factory=PerfTimer) @dataclass @@ -119,6 +120,8 @@ class BigQueryV2Report(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowR num_usage_query_hash_collisions: int = 0 num_operational_stats_workunits_emitted: int = 0 + snapshots_scanned: int = 0 + num_view_definitions_parsed: int = 0 num_view_definitions_failed_parsing: int = 0 num_view_definitions_failed_column_parsing: int = 0 diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_schema.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_schema.py index 7edc8656360bb8..d918782691c778 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_schema.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/bigquery_schema.py @@ -106,6 +106,14 @@ class BigqueryView(BaseView): materialized: bool = False +@dataclass +class BigqueryTableSnapshot(BaseTable): + # Upstream table identifier + base_table_identifier: Optional[BigqueryTableIdentifier] = None + snapshot_time: Optional[datetime] = None + columns: List[BigqueryColumn] = field(default_factory=list) + + @dataclass class BigqueryDataset: name: str @@ -116,6 +124,7 @@ class BigqueryDataset: comment: Optional[str] = None tables: List[BigqueryTable] = field(default_factory=list) views: List[BigqueryView] = field(default_factory=list) + snapshots: List[BigqueryTableSnapshot] = field(default_factory=list) columns: List[BigqueryColumn] = field(default_factory=list) @@ -289,10 +298,11 @@ def get_views_for_dataset( project_id: str, dataset_name: str, has_data_read: bool, - report: Optional[BigQueryV2Report] = None, + report: BigQueryV2Report, ) -> Iterator[BigqueryView]: with self.report.get_views_for_dataset as current_timer: if has_data_read: + # If profiling is enabled cur = self.get_query_result( BigqueryQuery.views_for_dataset.format( project_id=project_id, dataset_name=dataset_name @@ -315,11 +325,10 @@ def get_views_for_dataset( f"Error while processing view {view_name}", exc_info=True, ) - if report: - report.report_warning( - "metadata-extraction", - f"Failed to get view {view_name}: {e}", - ) + report.report_warning( + "metadata-extraction", + f"Failed to get view {view_name}: {e}", + ) @staticmethod def _make_bigquery_view(view: bigquery.Row) -> BigqueryView: @@ -334,6 +343,8 @@ def _make_bigquery_view(view: bigquery.Row) -> BigqueryView: comment=view.comment, view_definition=view.view_definition, materialized=view.table_type == BigqueryTableType.MATERIALIZED_VIEW, + size_in_bytes=view.get("size_bytes"), + rows_count=view.get("row_count"), ) def get_columns_for_dataset( @@ -429,3 +440,62 @@ def get_columns_for_table( last_seen_table = column.table_name return columns + + def get_snapshots_for_dataset( + self, + project_id: str, + dataset_name: str, + has_data_read: bool, + report: BigQueryV2Report, + ) -> Iterator[BigqueryTableSnapshot]: + with self.report.get_snapshots_for_dataset as current_timer: + if has_data_read: + # If profiling is enabled + cur = self.get_query_result( + BigqueryQuery.snapshots_for_dataset.format( + project_id=project_id, dataset_name=dataset_name + ), + ) + else: + cur = self.get_query_result( + BigqueryQuery.snapshots_for_dataset_without_data_read.format( + project_id=project_id, dataset_name=dataset_name + ), + ) + + for table in cur: + try: + with current_timer.pause(): + yield BigQuerySchemaApi._make_bigquery_table_snapshot(table) + except Exception as e: + snapshot_name = f"{project_id}.{dataset_name}.{table.table_name}" + logger.warning( + f"Error while processing view {snapshot_name}", + exc_info=True, + ) + report.report_warning( + "metadata-extraction", + f"Failed to get view {snapshot_name}: {e}", + ) + + @staticmethod + def _make_bigquery_table_snapshot(snapshot: bigquery.Row) -> BigqueryTableSnapshot: + return BigqueryTableSnapshot( + name=snapshot.table_name, + created=snapshot.created, + last_altered=datetime.fromtimestamp( + snapshot.get("last_altered") / 1000, tz=timezone.utc + ) + if snapshot.get("last_altered") is not None + else snapshot.created, + comment=snapshot.comment, + ddl=snapshot.ddl, + snapshot_time=snapshot.snapshot_time, + size_in_bytes=snapshot.get("size_bytes"), + rows_count=snapshot.get("row_count"), + base_table_identifier=BigqueryTableIdentifier( + project_id=snapshot.base_table_catalog, + dataset=snapshot.base_table_schema, + table=snapshot.base_table_name, + ), + ) diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py index b44b06feb95af2..7db36867b4e69d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/lineage.py @@ -37,7 +37,10 @@ ) from datahub.ingestion.source.bigquery_v2.bigquery_config import BigQueryV2Config from datahub.ingestion.source.bigquery_v2.bigquery_report import BigQueryV2Report -from datahub.ingestion.source.bigquery_v2.bigquery_schema import BigQuerySchemaApi +from datahub.ingestion.source.bigquery_v2.bigquery_schema import ( + BigQuerySchemaApi, + BigqueryTableSnapshot, +) from datahub.ingestion.source.bigquery_v2.common import BQ_DATETIME_FORMAT from datahub.ingestion.source.bigquery_v2.queries import ( BQ_FILTER_RULE_TEMPLATE_V2_LINEAGE, @@ -198,6 +201,28 @@ def make_lineage_edges_from_parsing_result( return list(table_edges.values()) +def make_lineage_edge_for_snapshot( + snapshot: BigqueryTableSnapshot, +) -> Optional[LineageEdge]: + if snapshot.base_table_identifier: + base_table_name = str( + BigQueryTableRef.from_bigquery_table(snapshot.base_table_identifier) + ) + return LineageEdge( + table=base_table_name, + column_mapping=frozenset( + LineageEdgeColumnMapping( + out_column=column.field_path, + in_columns=frozenset([column.field_path]), + ) + for column in snapshot.columns + ), + auditStamp=datetime.now(timezone.utc), + type=DatasetLineageTypeClass.TRANSFORMED, + ) + return None + + class BigqueryLineageExtractor: def __init__( self, @@ -256,27 +281,35 @@ def get_lineage_workunits( sql_parser_schema_resolver: SchemaResolver, view_refs_by_project: Dict[str, Set[str]], view_definitions: FileBackedDict[str], + snapshot_refs_by_project: Dict[str, Set[str]], + snapshots_by_ref: FileBackedDict[BigqueryTableSnapshot], table_refs: Set[str], ) -> Iterable[MetadataWorkUnit]: if not self._should_ingest_lineage(): return - views_skip_audit_log_lineage: Set[str] = set() - if self.config.lineage_parse_view_ddl: - view_lineage: Dict[str, Set[LineageEdge]] = {} - for project in projects: + datasets_skip_audit_log_lineage: Set[str] = set() + dataset_lineage: Dict[str, Set[LineageEdge]] = {} + for project in projects: + self.populate_snapshot_lineage( + dataset_lineage, + snapshot_refs_by_project[project], + snapshots_by_ref, + ) + + if self.config.lineage_parse_view_ddl: self.populate_view_lineage_with_sql_parsing( - view_lineage, + dataset_lineage, view_refs_by_project[project], view_definitions, sql_parser_schema_resolver, project, ) - views_skip_audit_log_lineage.update(view_lineage.keys()) - for lineage_key in view_lineage.keys(): - yield from self.gen_lineage_workunits_for_table( - view_lineage, BigQueryTableRef.from_string_name(lineage_key) - ) + datasets_skip_audit_log_lineage.update(dataset_lineage.keys()) + for lineage_key in dataset_lineage.keys(): + yield from self.gen_lineage_workunits_for_table( + dataset_lineage, BigQueryTableRef.from_string_name(lineage_key) + ) if self.config.use_exported_bigquery_audit_metadata: projects = ["*"] # project_id not used when using exported metadata @@ -286,7 +319,7 @@ def get_lineage_workunits( yield from self.generate_lineage( project, sql_parser_schema_resolver, - views_skip_audit_log_lineage, + datasets_skip_audit_log_lineage, table_refs, ) @@ -300,7 +333,7 @@ def generate_lineage( self, project_id: str, sql_parser_schema_resolver: SchemaResolver, - views_skip_audit_log_lineage: Set[str], + datasets_skip_audit_log_lineage: Set[str], table_refs: Set[str], ) -> Iterable[MetadataWorkUnit]: logger.info(f"Generate lineage for {project_id}") @@ -338,7 +371,7 @@ def generate_lineage( # as they may contain indirectly referenced tables. if ( lineage_key not in table_refs - or lineage_key in views_skip_audit_log_lineage + or lineage_key in datasets_skip_audit_log_lineage ): continue @@ -387,6 +420,17 @@ def populate_view_lineage_with_sql_parsing( ) ) + def populate_snapshot_lineage( + self, + snapshot_lineage: Dict[str, Set[LineageEdge]], + snapshot_refs: Set[str], + snapshots_by_ref: FileBackedDict[BigqueryTableSnapshot], + ) -> None: + for snapshot in snapshot_refs: + lineage_edge = make_lineage_edge_for_snapshot(snapshots_by_ref[snapshot]) + if lineage_edge: + snapshot_lineage[snapshot] = {lineage_edge} + def gen_lineage_workunits_for_table( self, lineage: Dict[str, Set[LineageEdge]], table_ref: BigQueryTableRef ) -> Iterable[MetadataWorkUnit]: diff --git a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/queries.py b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/queries.py index 67fcc33cdf2182..86971fce36a53d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/queries.py +++ b/metadata-ingestion/src/datahub/ingestion/source/bigquery_v2/queries.py @@ -157,6 +157,62 @@ class BigqueryQuery: table_name ASC """ + snapshots_for_dataset: str = f""" +SELECT + t.table_catalog as table_catalog, + t.table_schema as table_schema, + t.table_name as table_name, + t.table_type as table_type, + t.creation_time as created, + t.is_insertable_into, + t.ddl, + t.snapshot_time_ms as snapshot_time, + t.base_table_catalog, + t.base_table_schema, + t.base_table_name, + ts.last_modified_time as last_altered, + tos.OPTION_VALUE as comment, + ts.row_count, + ts.size_bytes +FROM + `{{project_id}}`.`{{dataset_name}}`.INFORMATION_SCHEMA.TABLES t + join `{{project_id}}`.`{{dataset_name}}`.__TABLES__ as ts on ts.table_id = t.TABLE_NAME + left join `{{project_id}}`.`{{dataset_name}}`.INFORMATION_SCHEMA.TABLE_OPTIONS as tos on t.table_schema = tos.table_schema + and t.TABLE_NAME = tos.TABLE_NAME + and tos.OPTION_NAME = "description" +WHERE + table_type = '{BigqueryTableType.SNAPSHOT}' +order by + table_schema ASC, + table_name ASC +""" + + snapshots_for_dataset_without_data_read: str = f""" +SELECT + t.table_catalog as table_catalog, + t.table_schema as table_schema, + t.table_name as table_name, + t.table_type as table_type, + t.creation_time as created, + t.is_insertable_into, + t.ddl, + t.snapshot_time_ms as snapshot_time, + t.base_table_catalog, + t.base_table_schema, + t.base_table_name, + tos.OPTION_VALUE as comment, +FROM + `{{project_id}}`.`{{dataset_name}}`.INFORMATION_SCHEMA.TABLES t + left join `{{project_id}}`.`{{dataset_name}}`.INFORMATION_SCHEMA.TABLE_OPTIONS as tos on t.table_schema = tos.table_schema + and t.TABLE_NAME = tos.TABLE_NAME + and tos.OPTION_NAME = "description" +WHERE + table_type = '{BigqueryTableType.SNAPSHOT}' +order by + table_schema ASC, + table_name ASC +""" + columns_for_dataset: str = """ select c.table_catalog as table_catalog, diff --git a/metadata-ingestion/src/datahub/ingestion/source/common/subtypes.py b/metadata-ingestion/src/datahub/ingestion/source/common/subtypes.py index 741b4789bef216..3296a8fb293544 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/common/subtypes.py +++ b/metadata-ingestion/src/datahub/ingestion/source/common/subtypes.py @@ -15,6 +15,7 @@ class DatasetSubTypes(str, Enum): SALESFORCE_CUSTOM_OBJECT = "Custom Object" SALESFORCE_STANDARD_OBJECT = "Object" POWERBI_DATASET_TABLE = "PowerBI Dataset Table" + BIGQUERY_TABLE_SNAPSHOT = "Bigquery Table Snapshot" # TODO: Create separate entity... NOTEBOOK = "Notebook" diff --git a/metadata-ingestion/tests/unit/test_bigquery_source.py b/metadata-ingestion/tests/unit/test_bigquery_source.py index 3cdb73d77d0a1b..42d65fdf02683f 100644 --- a/metadata-ingestion/tests/unit/test_bigquery_source.py +++ b/metadata-ingestion/tests/unit/test_bigquery_source.py @@ -11,6 +11,7 @@ from google.cloud.bigquery.table import Row, TableListItem from datahub.configuration.common import AllowDenyPattern +from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.source.bigquery_v2.bigquery import BigqueryV2Source from datahub.ingestion.source.bigquery_v2.bigquery_audit import ( @@ -27,6 +28,7 @@ BigqueryDataset, BigqueryProject, BigQuerySchemaApi, + BigqueryTableSnapshot, BigqueryView, ) from datahub.ingestion.source.bigquery_v2.lineage import ( @@ -34,7 +36,10 @@ LineageEdgeColumnMapping, ) from datahub.metadata.com.linkedin.pegasus2avro.dataset import ViewProperties -from datahub.metadata.schema_classes import MetadataChangeProposalClass +from datahub.metadata.schema_classes import ( + DatasetPropertiesClass, + MetadataChangeProposalClass, +) def test_bigquery_uri(): @@ -769,6 +774,7 @@ def test_get_views_for_dataset( project_id="test-project", dataset_name="test-dataset", has_data_read=False, + report=BigQueryV2Report(), ) assert list(views) == [bigquery_view_1, bigquery_view_2] @@ -810,6 +816,89 @@ def test_gen_view_dataset_workunits( ) +@pytest.fixture +def bigquery_snapshot() -> BigqueryTableSnapshot: + now = datetime.now(tz=timezone.utc) + return BigqueryTableSnapshot( + name="table-snapshot", + created=now - timedelta(days=10), + last_altered=now - timedelta(hours=1), + comment="comment1", + ddl="CREATE SNAPSHOT TABLE 1", + size_in_bytes=None, + rows_count=None, + snapshot_time=now - timedelta(days=10), + base_table_identifier=BigqueryTableIdentifier( + project_id="test-project", + dataset="test-dataset", + table="test-table", + ), + ) + + +@patch.object(BigQuerySchemaApi, "get_query_result") +@patch.object(BigQueryV2Config, "get_bigquery_client") +def test_get_snapshots_for_dataset( + get_bq_client_mock: Mock, + query_mock: Mock, + bigquery_snapshot: BigqueryTableSnapshot, +) -> None: + client_mock = MagicMock() + get_bq_client_mock.return_value = client_mock + assert bigquery_snapshot.last_altered + assert bigquery_snapshot.base_table_identifier + row1 = create_row( + dict( + table_name=bigquery_snapshot.name, + created=bigquery_snapshot.created, + last_altered=bigquery_snapshot.last_altered.timestamp() * 1000, + comment=bigquery_snapshot.comment, + ddl=bigquery_snapshot.ddl, + snapshot_time=bigquery_snapshot.snapshot_time, + table_type="SNAPSHOT", + base_table_catalog=bigquery_snapshot.base_table_identifier.project_id, + base_table_schema=bigquery_snapshot.base_table_identifier.dataset, + base_table_name=bigquery_snapshot.base_table_identifier.table, + ) + ) + query_mock.return_value = [row1] + bigquery_data_dictionary = BigQuerySchemaApi( + BigQueryV2Report().schema_api_perf, client_mock + ) + + snapshots = bigquery_data_dictionary.get_snapshots_for_dataset( + project_id="test-project", + dataset_name="test-dataset", + has_data_read=False, + report=BigQueryV2Report(), + ) + assert list(snapshots) == [bigquery_snapshot] + + +@patch.object(BigQueryV2Config, "get_bigquery_client") +def test_gen_snapshot_dataset_workunits(get_bq_client_mock, bigquery_snapshot): + project_id = "test-project" + dataset_name = "test-dataset" + config = BigQueryV2Config.parse_obj( + { + "project_id": project_id, + } + ) + source: BigqueryV2Source = BigqueryV2Source( + config=config, ctx=PipelineContext(run_id="test") + ) + + gen = source.gen_snapshot_dataset_workunits( + bigquery_snapshot, [], project_id, dataset_name + ) + mcp = cast(MetadataChangeProposalWrapper, list(gen)[2].metadata) + dataset_properties = cast(DatasetPropertiesClass, mcp.aspect) + assert dataset_properties.customProperties["snapshot_ddl"] == bigquery_snapshot.ddl + assert dataset_properties.customProperties["snapshot_time"] == str( + bigquery_snapshot.snapshot_time + ) + + @pytest.mark.parametrize( "table_name, expected_table_prefix, expected_shard", [ From a78c6899a2cde4277d854b264feef313f929531d Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Thu, 25 Jan 2024 10:12:01 -0500 Subject: [PATCH 212/540] feat(ui) Add structured properties support in the UI (#9695) --- datahub-web-react/src/Mocks.tsx | 5 + .../components/SchemaDescriptionField.tsx | 14 +- .../components/legacy/DescriptionModal.tsx | 2 +- .../shared/components/styled/EntityIcon.tsx | 24 ++ .../containers/profile/EntityProfile.tsx | 29 +-- .../profile/sidebar/EntitySidebar.tsx | 4 +- .../profile/sidebar/ProfileSidebar.tsx | 77 +++++++ .../shared/tabs/Dataset/Schema/SchemaTab.tsx | 10 +- .../tabs/Dataset/Schema/SchemaTable.tsx | 103 ++++++--- .../Schema/components/ChildCountLabel.tsx | 32 +++ .../Schema/components/PropertiesColumn.tsx | 30 +++ .../Schema/components/PropertyTypeLabel.tsx | 39 ++++ .../SchemaFieldDrawer/DrawerHeader.tsx | 106 +++++++++ .../SchemaFieldDrawer/FieldDescription.tsx | 115 ++++++++++ .../SchemaFieldDrawer/FieldHeader.tsx | 60 +++++ .../SchemaFieldDrawer/FieldProperties.tsx | 70 ++++++ .../SchemaFieldDrawer/FieldTags.tsx | 33 +++ .../SchemaFieldDrawer/FieldTerms.tsx | 34 +++ .../SchemaFieldDrawer/FieldUsageStats.tsx | 59 +++++ .../SchemaFieldDrawer/SchemaFieldDrawer.tsx | 83 +++++++ .../SchemaFieldDrawer/components.ts | 12 + .../Schema/utils/useDescriptionRenderer.tsx | 2 +- .../Schema/utils/useTagsAndTermsRenderer.tsx | 38 ++-- .../Schema/utils/useUsageStatsRenderer.tsx | 2 +- .../components/editor/Editor.tsx | 5 +- .../tabs/Properties/CardinalityLabel.tsx | 43 ++++ .../shared/tabs/Properties/NameColumn.tsx | 87 +++++++ .../shared/tabs/Properties/PropertiesTab.tsx | 91 +++++--- .../Properties/StructuredPropertyTooltip.tsx | 31 +++ .../Properties/StructuredPropertyValue.tsx | 69 ++++++ .../shared/tabs/Properties/TabHeader.tsx | 32 +++ .../shared/tabs/Properties/ValuesColumn.tsx | 24 ++ .../tabs/Properties/__tests__/utils.test.ts | 87 +++++++ .../entity/shared/tabs/Properties/types.ts | 25 ++ .../Properties/useStructuredProperties.tsx | 215 ++++++++++++++++++ .../useUpdateExpandedRowsFromFilter.ts | 23 ++ .../entity/shared/tabs/Properties/utils.ts | 68 ++++++ .../src/app/entity/shared/types.ts | 2 + .../src/app/entity/shared/utils.ts | 12 +- .../src/graphql/fragments.graphql | 77 +++++++ .../tests/cypress/cypress/e2e/login/login.js | 4 +- .../e2e/mutations/edit_documentation.js | 9 +- .../cypress/e2e/mutations/mutations.js | 19 +- .../cypress/e2e/schema_blame/schema_blame.js | 2 + .../tests/cypress/cypress/support/commands.js | 4 + 45 files changed, 1772 insertions(+), 140 deletions(-) create mode 100644 datahub-web-react/src/app/entity/shared/components/styled/EntityIcon.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/ProfileSidebar.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ChildCountLabel.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertyTypeLabel.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/DrawerHeader.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldDescription.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldHeader.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTags.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTerms.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldUsageStats.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components.ts create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/CardinalityLabel.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/NameColumn.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyTooltip.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/ValuesColumn.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/__tests__/utils.test.ts create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/useUpdateExpandedRowsFromFilter.ts create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/utils.ts diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 03d6f4a624c3d1..9f339bb7db548a 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -297,6 +297,7 @@ export const dataset1 = { embed: null, browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, autoRenderAspects: [], + structuredProperties: null, }; export const dataset2 = { @@ -393,6 +394,7 @@ export const dataset2 = { embed: null, browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, autoRenderAspects: [], + structuredProperties: null, }; export const dataset3 = { @@ -626,6 +628,7 @@ export const dataset3 = { dataProduct: null, lastProfile: null, lastOperation: null, + structuredProperties: null, } as Dataset; export const dataset3WithSchema = { @@ -650,6 +653,7 @@ export const dataset3WithSchema = { globalTags: null, glossaryTerms: null, label: 'hi', + schemaFieldEntity: null, }, { __typename: 'SchemaField', @@ -665,6 +669,7 @@ export const dataset3WithSchema = { globalTags: null, glossaryTerms: null, label: 'hi', + schemaFieldEntity: null, }, ], hash: '', diff --git a/datahub-web-react/src/app/entity/dataset/profile/schema/components/SchemaDescriptionField.tsx b/datahub-web-react/src/app/entity/dataset/profile/schema/components/SchemaDescriptionField.tsx index 1d4f155f797e06..2cd4cbd6dcb6ca 100644 --- a/datahub-web-react/src/app/entity/dataset/profile/schema/components/SchemaDescriptionField.tsx +++ b/datahub-web-react/src/app/entity/dataset/profile/schema/components/SchemaDescriptionField.tsx @@ -86,6 +86,7 @@ type Props = { description: string, ) => Promise, Record> | void>; isEdited?: boolean; + isReadOnly?: boolean; }; const ABBREVIATED_LIMIT = 80; @@ -97,10 +98,11 @@ export default function DescriptionField({ onUpdate, isEdited = false, original, + isReadOnly, }: Props) { const [showAddModal, setShowAddModal] = useState(false); const overLimit = removeMarkdown(description).length > 80; - const isSchemaEditable = React.useContext(SchemaEditableContext); + const isSchemaEditable = React.useContext(SchemaEditableContext) && !isReadOnly; const onCloseModal = () => setShowAddModal(false); const { urn, entityType } = useEntityData(); @@ -140,11 +142,12 @@ export default function DescriptionField({ {expanded || !overLimit ? ( <> {!!description && } - {!!description && ( + {!!description && (EditButton || overLimit) && ( {overLimit && ( { + onClick={(e) => { + e.stopPropagation(); handleExpanded(false); }} > @@ -162,7 +165,8 @@ export default function DescriptionField({ readMore={ <> { + onClick={(e) => { + e.stopPropagation(); handleExpanded(true); }} > @@ -177,7 +181,7 @@ export default function DescriptionField({ )} - {isSchemaEditable && isEdited && (edited)} + {isEdited && (edited)} {showAddModal && (

- + {!isAddDesc && description && original && ( Original:}> diff --git a/datahub-web-react/src/app/entity/shared/components/styled/EntityIcon.tsx b/datahub-web-react/src/app/entity/shared/components/styled/EntityIcon.tsx new file mode 100644 index 00000000000000..bd001b51d53ceb --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/EntityIcon.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useEntityRegistry } from '../../../../useEntityRegistry'; +import { PlatformIcon } from '../../../../search/filters/utils'; +import { Entity } from '../../../../../types.generated'; +import { IconStyleType } from '../../../Entity'; +import { ANTD_GRAY } from '../../constants'; + +interface Props { + entity: Entity; + size?: number; +} + +export default function EntityIcon({ entity, size = 14 }: Props) { + const entityRegistry = useEntityRegistry(); + const genericEntityProps = entityRegistry.getGenericEntityProperties(entity.type, entity); + const logoUrl = genericEntityProps?.platform?.properties?.logoUrl; + const icon = logoUrl ? ( + + ) : ( + entityRegistry.getIcon(entity.type, size, IconStyleType.ACCENT, ANTD_GRAY[9]) + ); + + return <>{icon}; +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx index d7b7a4da804ef4..a781c732c9de63 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx @@ -30,7 +30,6 @@ import LineageExplorer from '../../../../lineage/LineageExplorer'; import CompactContext from '../../../../shared/CompactContext'; import DynamicTab from '../../tabs/Entity/weaklyTypedAspects/DynamicTab'; import analytics, { EventType } from '../../../../analytics'; -import { ProfileSidebarResizer } from './sidebar/ProfileSidebarResizer'; import { EntityMenuItems } from '../../EntityDropdown/EntityDropdown'; import { useIsSeparateSiblingsMode } from '../../siblingUtils'; import { EntityActionItem } from '../../entity/EntityActions'; @@ -45,6 +44,7 @@ import { } from '../../../../onboarding/config/LineageGraphOnboardingConfig'; import { useAppConfig } from '../../../../useAppConfig'; import { useUpdateDomainEntityDataOnChange } from '../../../../domain/utils'; +import ProfileSidebar from './sidebar/ProfileSidebar'; type Props = { urn: string; @@ -75,8 +75,6 @@ type Props = { isNameEditable?: boolean; }; -const MAX_SIDEBAR_WIDTH = 800; -const MIN_SIDEBAR_WIDTH = 200; const MAX_COMPACT_WIDTH = 490 - 24 * 2; const ContentContainer = styled.div` @@ -85,6 +83,7 @@ const ContentContainer = styled.div` min-height: 100%; flex: 1; min-width: 0; + overflow: hidden; `; const HeaderAndTabs = styled.div` @@ -113,15 +112,6 @@ const HeaderAndTabsFlex = styled.div` -webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75); } `; -const Sidebar = styled.div<{ $width: number }>` - max-height: 100%; - overflow: auto; - width: ${(props) => props.$width}px; - min-width: ${(props) => props.$width}px; - padding-left: 20px; - padding-right: 20px; - padding-bottom: 20px; -`; const Header = styled.div` border-bottom: 1px solid ${ANTD_GRAY[4.5]}; @@ -145,7 +135,7 @@ const defaultTabDisplayConfig = { enabled: (_, _1) => true, }; -const defaultSidebarSection = { +export const DEFAULT_SIDEBAR_SECTION = { visible: (_, _1) => true, }; @@ -176,11 +166,10 @@ export const EntityProfile = ({ const sortedTabs = sortEntityProfileTabs(appConfig.config, entityType, tabsWithDefaults); const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({ ...sidebarSection, - display: { ...defaultSidebarSection, ...sidebarSection.display }, + display: { ...DEFAULT_SIDEBAR_SECTION, ...sidebarSection.display }, })); const [shouldRefetchEmbeddedListSearch, setShouldRefetchEmbeddedListSearch] = useState(false); - const [sidebarWidth, setSidebarWidth] = useState(window.innerWidth * 0.25); const entityStepIds: string[] = getOnboardingStepIdsForEntityType(entityType); const lineageGraphStepIds: string[] = [LINEAGE_GRAPH_INTRO_ID, LINEAGE_GRAPH_TIME_FILTER_ID]; const stepIds = isLineageMode ? lineageGraphStepIds : entityStepIds; @@ -344,15 +333,7 @@ export const EntityProfile = ({ - - setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH)) - } - initialSize={sidebarWidth} - /> - - - + )} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntitySidebar.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntitySidebar.tsx index fbece870706f58..a8d1dceb71ec92 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntitySidebar.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntitySidebar.tsx @@ -36,14 +36,16 @@ const LastIngestedSection = styled.div` type Props = { sidebarSections: EntitySidebarSection[]; + topSection?: EntitySidebarSection; }; -export const EntitySidebar = ({ sidebarSections }: Props) => { +export const EntitySidebar = ({ sidebarSections, topSection }: Props) => { const { entityData } = useEntityData(); const baseEntity = useBaseEntity(); return ( <> + {topSection && } {entityData?.lastIngested && ( diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/ProfileSidebar.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/ProfileSidebar.tsx new file mode 100644 index 00000000000000..b5e6737c16641d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/ProfileSidebar.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { ProfileSidebarResizer } from './ProfileSidebarResizer'; +import { EntitySidebar } from './EntitySidebar'; +import { EntitySidebarSection } from '../../../types'; + +export const MAX_SIDEBAR_WIDTH = 800; +export const MIN_SIDEBAR_WIDTH = 200; + +const Sidebar = styled.div<{ $width: number; backgroundColor?: string }>` + max-height: 100%; + position: relative; + width: ${(props) => props.$width}px; + min-width: ${(props) => props.$width}px; + ${(props) => props.backgroundColor && `background-color: ${props.backgroundColor};`} +`; + +const ScrollWrapper = styled.div` + overflow: auto; + max-height: 100%; + padding: 0 20px 20px 20px; +`; + +const DEFAULT_SIDEBAR_SECTION = { + visible: (_, _1) => true, +}; + +interface Props { + sidebarSections: EntitySidebarSection[]; + backgroundColor?: string; + topSection?: EntitySidebarSection; + alignLeft?: boolean; +} + +export default function ProfileSidebar({ sidebarSections, backgroundColor, topSection, alignLeft }: Props) { + const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({ + ...sidebarSection, + display: { ...DEFAULT_SIDEBAR_SECTION, ...sidebarSection.display }, + })); + + const [sidebarWidth, setSidebarWidth] = useState(window.innerWidth * 0.25); + + if (alignLeft) { + return ( + <> + + + + + + + setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH)) + } + initialSize={sidebarWidth} + isSidebarOnLeft + /> + + ); + } + + return ( + <> + + setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH)) + } + initialSize={sidebarWidth} + /> + + + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx index 75027e17b6d0c1..28dc3ba5c6ce54 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx @@ -76,6 +76,14 @@ export const SchemaTab = ({ properties }: { properties?: any }) => { [schemaMetadata], ); + const hasProperties = useMemo( + () => + entityWithSchema?.schemaMetadata?.fields.some( + (schemaField) => !!schemaField.schemaFieldEntity?.structuredProperties?.properties?.length, + ), + [entityWithSchema], + ); + const [showKeySchema, setShowKeySchema] = useState(false); const [showSchemaAuditView, setShowSchemaAuditView] = useState(false); @@ -190,13 +198,13 @@ export const SchemaTab = ({ properties }: { properties?: any }) => { diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx index 41b92aea93b5ad..bd092e86b3584a 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTable.tsx @@ -21,9 +21,10 @@ import { StyledTable } from '../../../components/styled/StyledTable'; import { SchemaRow } from './components/SchemaRow'; import { FkContext } from './utils/selectedFkContext'; import useSchemaBlameRenderer from './utils/useSchemaBlameRenderer'; -import { ANTD_GRAY } from '../../../constants'; -import MenuColumn from './components/MenuColumn'; +import { ANTD_GRAY, ANTD_GRAY_V2 } from '../../../constants'; import translateFieldPath from '../../../../dataset/profile/schema/utils/translateFieldPath'; +import PropertiesColumn from './components/PropertiesColumn'; +import SchemaFieldDrawer from './components/SchemaFieldDrawer/SchemaFieldDrawer'; const TableContainer = styled.div` overflow: inherit; @@ -41,18 +42,36 @@ const TableContainer = styled.div` padding-bottom: 600px; vertical-align: top; } + + &&& .ant-table-cell { + background-color: inherit; + cursor: pointer; + } + + &&& tbody > tr:hover > td { + background-color: ${ANTD_GRAY_V2[2]}; + } + + &&& .expanded-row { + background-color: ${(props) => props.theme.styles['highlight-color']} !important; + + td { + background-color: ${(props) => props.theme.styles['highlight-color']} !important; + } + } `; export type Props = { rows: Array; schemaMetadata: SchemaMetadata | undefined | null; editableSchemaMetadata?: EditableSchemaMetadata | null; - editMode?: boolean; usageStats?: UsageQueryResult | null; schemaFieldBlameList?: Array | null; showSchemaAuditView: boolean; expandedRowsFromFilter?: Set; filterText?: string; + hasProperties?: boolean; + inputFields?: SchemaField[]; }; const EMPTY_SET: Set = new Set(); @@ -63,56 +82,46 @@ export default function SchemaTable({ schemaMetadata, editableSchemaMetadata, usageStats, - editMode = true, schemaFieldBlameList, showSchemaAuditView, expandedRowsFromFilter = EMPTY_SET, filterText = '', + hasProperties, + inputFields, }: Props): JSX.Element { const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]); const [tableHeight, setTableHeight] = useState(0); - const [tagHoveredIndex, setTagHoveredIndex] = useState(undefined); - const [selectedFkFieldPath, setSelectedFkFieldPath] = - useState(null); + const [selectedFkFieldPath, setSelectedFkFieldPath] = useState(null); + const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState(null); + + const schemaFields = schemaMetadata ? schemaMetadata.fields : inputFields; const descriptionRender = useDescriptionRenderer(editableSchemaMetadata); const usageStatsRenderer = useUsageStatsRenderer(usageStats); const tagRenderer = useTagsAndTermsRenderer( editableSchemaMetadata, - tagHoveredIndex, - setTagHoveredIndex, { showTags: true, showTerms: false, }, filterText, + false, ); const termRenderer = useTagsAndTermsRenderer( editableSchemaMetadata, - tagHoveredIndex, - setTagHoveredIndex, { showTags: false, showTerms: true, }, filterText, + false, ); const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText); const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList); - const onTagTermCell = (record: SchemaField) => ({ - onMouseEnter: () => { - if (editMode) { - setTagHoveredIndex(record.fieldPath); - } - }, - onMouseLeave: () => { - if (editMode) { - setTagHoveredIndex(undefined); - } - }, - }); - const fieldColumn = { width: '22%', title: 'Field', @@ -139,7 +148,6 @@ export default function SchemaTable({ dataIndex: 'globalTags', key: 'tag', render: tagRenderer, - onCell: onTagTermCell, }; const termColumn = { @@ -148,7 +156,6 @@ export default function SchemaTable({ dataIndex: 'globalTags', key: 'tag', render: termRenderer, - onCell: onTagTermCell, }; const blameColumn = { @@ -184,16 +191,20 @@ export default function SchemaTable({ sorter: (sourceA, sourceB) => getCount(sourceA.fieldPath) - getCount(sourceB.fieldPath), }; - const menuColumn = { - width: '5%', - title: '', + const propertiesColumn = { + width: '13%', + title: 'Properties', dataIndex: '', key: 'menu', - render: (field: SchemaField) => , + render: (field: SchemaField) => , }; let allColumns: ColumnsType = [fieldColumn, descriptionColumn, tagColumn, termColumn]; + if (hasProperties) { + allColumns = [...allColumns, propertiesColumn]; + } + if (hasUsageStats) { allColumns = [...allColumns, usageColumn]; } @@ -202,8 +213,6 @@ export default function SchemaTable({ allColumns = [...allColumns, blameColumn]; } - allColumns = [...allColumns, menuColumn]; - const [expandedRows, setExpandedRows] = useState>(new Set()); useEffect(() => { @@ -224,9 +233,15 @@ export default function SchemaTable({ setTableHeight(dimensions.height - TABLE_HEADER_HEIGHT)}> - record.fieldPath === selectedFkFieldPath?.fieldPath ? 'open-fk-row' : '' - } + rowClassName={(record) => { + if (record.fieldPath === selectedFkFieldPath?.fieldPath) { + return 'open-fk-row'; + } + if (expandedDrawerFieldPath === record.fieldPath) { + return 'expanded-row'; + } + return ''; + }} columns={allColumns} dataSource={rows} rowKey="fieldPath" @@ -250,9 +265,27 @@ export default function SchemaTable({ indentSize: 0, }} pagination={false} + onRow={(record) => ({ + onClick: () => { + setExpandedDrawerFieldPath( + expandedDrawerFieldPath === record.fieldPath ? null : record.fieldPath, + ); + }, + style: { + backgroundColor: expandedDrawerFieldPath === record.fieldPath ? `` : 'white', + }, + })} /> + {!!schemaFields && ( + + )} ); } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ChildCountLabel.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ChildCountLabel.tsx new file mode 100644 index 00000000000000..44bd48620649af --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ChildCountLabel.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Badge } from 'antd'; +import styled from 'styled-components'; + +import { ANTD_GRAY_V2 } from '../../../../constants'; + +type Props = { + count: number; +}; + +const ChildCountBadge = styled(Badge)` + margin-left: 10px; + margin-top: 16px; + margin-bottom: 16px; + &&& .ant-badge-count { + background-color: ${ANTD_GRAY_V2[1]}; + color: ${ANTD_GRAY_V2[8]}; + box-shadow: 0 2px 1px -1px ${ANTD_GRAY_V2[6]}; + border-radius: 4px 4px 4px 4px; + font-size: 12px; + font-weight: 500; + height: 22px; + font-family: 'Manrope'; + } +`; + +export default function ChildCountLabel({ count }: Props) { + const propertyString = count > 1 ? ' properties' : ' property'; + + // eslint-disable-next-line + return ; +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx new file mode 100644 index 00000000000000..b74de3e94e5544 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertiesColumn.tsx @@ -0,0 +1,30 @@ +import { ControlOutlined } from '@ant-design/icons'; +import React from 'react'; +import styled from 'styled-components'; +import { SchemaField } from '../../../../../../../types.generated'; + +const ColumnWrapper = styled.div` + font-size: 14px; +`; + +const StyledIcon = styled(ControlOutlined)` + margin-right: 4px; +`; + +interface Props { + field: SchemaField; +} + +export default function PropertiesColumn({ field }: Props) { + const { schemaFieldEntity } = field; + const numProperties = schemaFieldEntity?.structuredProperties?.properties?.length; + + if (!schemaFieldEntity || !numProperties) return null; + + return ( + + + {numProperties} {numProperties === 1 ? 'property' : 'properties'} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertyTypeLabel.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertyTypeLabel.tsx new file mode 100644 index 00000000000000..366fc4762b2106 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PropertyTypeLabel.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Badge } from 'antd'; +import styled from 'styled-components'; +import { capitalizeFirstLetterOnly } from '../../../../../../shared/textUtil'; +import { DataTypeEntity, SchemaFieldDataType } from '../../../../../../../types.generated'; +import { truncate } from '../../../../utils'; +import { ANTD_GRAY, ANTD_GRAY_V2 } from '../../../../constants'; +import { TypeData } from '../../../Properties/types'; + +type Props = { + type: TypeData; + dataType?: DataTypeEntity; +}; + +export const PropertyTypeBadge = styled(Badge)` + margin: 4px 0 4px 8px; + &&& .ant-badge-count { + background-color: ${ANTD_GRAY[1]}; + color: ${ANTD_GRAY_V2[8]}; + border: 1px solid ${ANTD_GRAY_V2[6]}; + font-size: 12px; + font-weight: 500; + height: 22px; + font-family: 'Manrope'; + } +`; + +export default function PropertyTypeLabel({ type, dataType }: Props) { + // if unable to match type to DataHub, display native type info by default + const { nativeDataType } = type; + const nativeFallback = type.type === SchemaFieldDataType.Null; + + const typeText = + dataType?.info.displayName || + dataType?.info.type || + (nativeFallback ? truncate(250, nativeDataType) : type.type); + + return ; +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/DrawerHeader.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/DrawerHeader.tsx new file mode 100644 index 00000000000000..13f8ec869126d9 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/DrawerHeader.tsx @@ -0,0 +1,106 @@ +import { CaretLeftOutlined, CaretRightOutlined, CloseOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY_V2 } from '../../../../../constants'; +import { SchemaField } from '../../../../../../../../types.generated'; +import { pluralize } from '../../../../../../../shared/textUtil'; + +const HeaderWrapper = styled.div` + border-bottom: 1px solid ${ANTD_GRAY_V2[4]}; + display: flex; + justify-content: space-between; + padding: 8px 16px; +`; + +const StyledButton = styled(Button)` + font-size: 12px; + padding: 0; + height: 26px; + width: 26px; + display: flex; + align-items: center; + justify-content: center; + + svg { + height: 10px; + width: 10px; + } +`; + +const FieldIndexText = styled.span` + font-size: 14px; + color: ${ANTD_GRAY_V2[8]}; + margin: 0 8px; +`; + +const ButtonsWrapper = styled.div` + display: flex; + align-items: center; +`; + +interface Props { + schemaFields?: SchemaField[]; + expandedFieldIndex?: number; + setExpandedDrawerFieldPath: (fieldPath: string | null) => void; +} + +export default function DrawerHeader({ schemaFields = [], expandedFieldIndex = 0, setExpandedDrawerFieldPath }: Props) { + function showNextField() { + if (expandedFieldIndex !== undefined && expandedFieldIndex !== -1) { + if (expandedFieldIndex === schemaFields.length - 1) { + const newField = schemaFields[0]; + setExpandedDrawerFieldPath(newField.fieldPath); + } else { + const newField = schemaFields[expandedFieldIndex + 1]; + const { fieldPath } = newField; + setExpandedDrawerFieldPath(fieldPath); + } + } + } + + function showPreviousField() { + if (expandedFieldIndex !== undefined && expandedFieldIndex !== -1) { + if (expandedFieldIndex === 0) { + const newField = schemaFields[schemaFields.length - 1]; + setExpandedDrawerFieldPath(newField.fieldPath); + } else { + const newField = schemaFields[expandedFieldIndex - 1]; + setExpandedDrawerFieldPath(newField.fieldPath); + } + } + } + + function handleArrowKeys(event: KeyboardEvent) { + if (event.code === 'ArrowUp' || event.code === 'ArrowLeft') { + showPreviousField(); + } else if (event.code === 'ArrowDown' || event.code === 'ArrowRight') { + showNextField(); + } + } + + useEffect(() => { + document.addEventListener('keydown', handleArrowKeys); + + return () => document.removeEventListener('keydown', handleArrowKeys); + }); + + return ( + + + + + + + {expandedFieldIndex + 1} of {schemaFields.length} {pluralize(schemaFields.length, 'field')} + + + + + + setExpandedDrawerFieldPath(null)}> + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldDescription.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldDescription.tsx new file mode 100644 index 00000000000000..410d2801d51c87 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldDescription.tsx @@ -0,0 +1,115 @@ +import { EditOutlined } from '@ant-design/icons'; +import { Button, message } from 'antd'; +import DOMPurify from 'dompurify'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { SectionHeader, StyledDivider } from './components'; +import UpdateDescriptionModal from '../../../../../components/legacy/DescriptionModal'; +import { EditableSchemaFieldInfo, SchemaField, SubResourceType } from '../../../../../../../../types.generated'; +import DescriptionSection from '../../../../../containers/profile/sidebar/AboutSection/DescriptionSection'; +import { useEntityData, useMutationUrn, useRefetch } from '../../../../../EntityContext'; +import { useSchemaRefetch } from '../../SchemaContext'; +import { useUpdateDescriptionMutation } from '../../../../../../../../graphql/mutations.generated'; +import analytics, { EntityActionType, EventType } from '../../../../../../../analytics'; +import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext'; + +const DescriptionWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const EditIcon = styled(Button)` + border: none; + box-shadow: none; + height: 20px; + width: 20px; +`; + +interface Props { + expandedField: SchemaField; + editableFieldInfo?: EditableSchemaFieldInfo; +} + +export default function FieldDescription({ expandedField, editableFieldInfo }: Props) { + const isSchemaEditable = React.useContext(SchemaEditableContext); + const urn = useMutationUrn(); + const refetch = useRefetch(); + const schemaRefetch = useSchemaRefetch(); + const [updateDescription] = useUpdateDescriptionMutation(); + const [isModalVisible, setIsModalVisible] = useState(false); + const { entityType } = useEntityData(); + + const sendAnalytics = () => { + analytics.event({ + type: EventType.EntityActionEvent, + actionType: EntityActionType.UpdateSchemaDescription, + entityType, + entityUrn: urn, + }); + }; + + const refresh: any = () => { + refetch?.(); + schemaRefetch?.(); + }; + + const onSuccessfulMutation = () => { + refresh(); + sendAnalytics(); + message.destroy(); + message.success({ content: 'Updated!', duration: 2 }); + }; + + const onFailMutation = (e) => { + message.destroy(); + if (e instanceof Error) message.error({ content: `Proposal Failed! \n ${e.message || ''}`, duration: 2 }); + }; + + const generateMutationVariables = (updatedDescription: string) => ({ + variables: { + input: { + description: DOMPurify.sanitize(updatedDescription), + resourceUrn: urn, + subResource: expandedField.fieldPath, + subResourceType: SubResourceType.DatasetField, + }, + }, + }); + + const displayedDescription = editableFieldInfo?.description || expandedField.description; + + return ( + <> + +
+ Description + +
+ {isSchemaEditable && ( + setIsModalVisible(true)} + icon={} + /> + )} + {isModalVisible && ( + setIsModalVisible(false)} + onSubmit={(updatedDescription: string) => { + message.loading({ content: 'Updating...' }); + updateDescription(generateMutationVariables(updatedDescription)) + .then(onSuccessfulMutation) + .catch(onFailMutation); + setIsModalVisible(false); + }} + isAddDesc={!displayedDescription} + /> + )} +
+ + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldHeader.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldHeader.tsx new file mode 100644 index 00000000000000..7b06ff43393ef4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldHeader.tsx @@ -0,0 +1,60 @@ +import { Typography } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import translateFieldPath from '../../../../../../dataset/profile/schema/utils/translateFieldPath'; +import TypeLabel from '../TypeLabel'; +import PrimaryKeyLabel from '../PrimaryKeyLabel'; +import PartitioningKeyLabel from '../PartitioningKeyLabel'; +import NullableLabel from '../NullableLabel'; +import MenuColumn from '../MenuColumn'; +import { ANTD_GRAY_V2 } from '../../../../../constants'; +import { SchemaField } from '../../../../../../../../types.generated'; + +const FieldHeaderWrapper = styled.div` + padding: 16px; + display: flex; + justify-content: space-between; + border-bottom: 1px solid ${ANTD_GRAY_V2[4]}; +`; + +const FieldName = styled(Typography.Text)` + font-size: 16px; + font-family: 'Roboto Mono', monospace; +`; + +const TypesSection = styled.div` + margin-left: -4px; + margin-top: 8px; +`; + +const NameTypesWrapper = styled.div` + overflow: hidden; +`; + +const MenuWrapper = styled.div` + margin-right: 5px; +`; + +interface Props { + expandedField: SchemaField; +} + +export default function FieldHeader({ expandedField }: Props) { + const displayName = translateFieldPath(expandedField.fieldPath || ''); + return ( + + + {displayName} + + + {expandedField.isPartOfKey && } + {expandedField.isPartitioningKey && } + {expandedField.nullable && } + + + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx new file mode 100644 index 00000000000000..8c88cdce95f06f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import styled from 'styled-components'; +import { SchemaField, StdDataType } from '../../../../../../../../types.generated'; +import { SectionHeader, StyledDivider } from './components'; +import { mapStructuredPropertyValues } from '../../../../Properties/useStructuredProperties'; +import StructuredPropertyValue from '../../../../Properties/StructuredPropertyValue'; + +const PropertyTitle = styled.div` + font-size: 14px; + font-weight: 700; + margin-bottom: 4px; +`; + +const PropertyWrapper = styled.div` + margin-bottom: 12px; +`; + +const PropertiesWrapper = styled.div` + padding-left: 16px; +`; + +const StyledList = styled.ul` + padding-left: 24px; +`; + +interface Props { + expandedField: SchemaField; +} + +export default function FieldProperties({ expandedField }: Props) { + const { schemaFieldEntity } = expandedField; + + if (!schemaFieldEntity?.structuredProperties?.properties?.length) return null; + + return ( + <> + Properties + + {schemaFieldEntity.structuredProperties.properties.map((structuredProp) => { + const isRichText = + structuredProp.structuredProperty.definition.valueType?.info.type === StdDataType.RichText; + const valuesData = mapStructuredPropertyValues(structuredProp); + const hasMultipleValues = valuesData.length > 1; + + return ( + + {structuredProp.structuredProperty.definition.displayName} + {hasMultipleValues ? ( + + {valuesData.map((value) => ( +
  • + +
  • + ))} +
    + ) : ( + <> + {valuesData.map((value) => ( + + ))} + + )} +
    + ); + })} +
    + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTags.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTags.tsx new file mode 100644 index 00000000000000..c071506d3ad797 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTags.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { EditableSchemaMetadata, GlobalTags, SchemaField } from '../../../../../../../../types.generated'; +import useTagsAndTermsRenderer from '../../utils/useTagsAndTermsRenderer'; +import { SectionHeader, StyledDivider } from './components'; +import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext'; + +interface Props { + expandedField: SchemaField; + editableSchemaMetadata?: EditableSchemaMetadata | null; +} + +export default function FieldTags({ expandedField, editableSchemaMetadata }: Props) { + const isSchemaEditable = React.useContext(SchemaEditableContext); + const tagRenderer = useTagsAndTermsRenderer( + editableSchemaMetadata, + { + showTags: true, + showTerms: false, + }, + '', + isSchemaEditable, + ); + + return ( + <> + Tags +
    + {tagRenderer(expandedField.globalTags as GlobalTags, expandedField)} +
    + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTerms.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTerms.tsx new file mode 100644 index 00000000000000..94349836539a6a --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldTerms.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { EditableSchemaMetadata, GlobalTags, SchemaField } from '../../../../../../../../types.generated'; +import useTagsAndTermsRenderer from '../../utils/useTagsAndTermsRenderer'; +import { SectionHeader, StyledDivider } from './components'; +import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext'; + +interface Props { + expandedField: SchemaField; + editableSchemaMetadata?: EditableSchemaMetadata | null; +} + +export default function FieldTerms({ expandedField, editableSchemaMetadata }: Props) { + const isSchemaEditable = React.useContext(SchemaEditableContext); + const termRenderer = useTagsAndTermsRenderer( + editableSchemaMetadata, + { + showTags: false, + showTerms: true, + }, + '', + isSchemaEditable, + ); + + return ( + <> + Glossary Terms + {/* pass in globalTags since this is a shared component, tags will not be shown or used */} +
    + {termRenderer(expandedField.globalTags as GlobalTags, expandedField)} +
    + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldUsageStats.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldUsageStats.tsx new file mode 100644 index 00000000000000..2f7288904b2df9 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldUsageStats.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { GetDatasetQuery } from '../../../../../../../../graphql/dataset.generated'; +import { useBaseEntity } from '../../../../../EntityContext'; +import { ANTD_GRAY_V2 } from '../../../../../constants'; +import { SectionHeader, StyledDivider } from './components'; +import { pathMatchesNewPath } from '../../../../../../dataset/profile/schema/utils/utils'; +import { UsageBar } from '../../utils/useUsageStatsRenderer'; +import { SchemaField } from '../../../../../../../../types.generated'; + +const USAGE_BAR_MAX_WIDTH = 100; + +const UsageBarWrapper = styled.div` + display: flex; + align-items: center; +`; + +const UsageBarBackground = styled.div` + background-color: ${ANTD_GRAY_V2[3]}; + border-radius: 2px; + height: 4px; + width: ${USAGE_BAR_MAX_WIDTH}px; +`; + +const UsageTextWrapper = styled.span` + margin-left: 8px; +`; + +interface Props { + expandedField: SchemaField; +} + +export default function FieldUsageStats({ expandedField }: Props) { + const baseEntity = useBaseEntity(); + const usageStats = baseEntity?.dataset?.usageStats; + const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]); + const maxFieldUsageCount = useMemo( + () => Math.max(...(usageStats?.aggregations?.fields?.map((field) => field?.count || 0) || [])), + [usageStats], + ); + const relevantUsageStats = usageStats?.aggregations?.fields?.find((fieldStats) => + pathMatchesNewPath(fieldStats?.fieldName, expandedField.fieldPath), + ); + + if (!hasUsageStats || !relevantUsageStats) return null; + + return ( + <> + Usage + + + + + {relevantUsageStats.count || 0} queries / month + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx new file mode 100644 index 00000000000000..7a5366f04e9837 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/SchemaFieldDrawer.tsx @@ -0,0 +1,83 @@ +import { Drawer } from 'antd'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import DrawerHeader from './DrawerHeader'; +import FieldHeader from './FieldHeader'; +import FieldDescription from './FieldDescription'; +import { EditableSchemaMetadata, SchemaField } from '../../../../../../../../types.generated'; +import { pathMatchesNewPath } from '../../../../../../dataset/profile/schema/utils/utils'; +import FieldUsageStats from './FieldUsageStats'; +import FieldTags from './FieldTags'; +import FieldTerms from './FieldTerms'; +import FieldProperties from './FieldProperties'; + +const StyledDrawer = styled(Drawer)` + position: absolute; + + &&& .ant-drawer-body { + padding: 0; + } + + &&& .ant-drawer-content-wrapper { + border-left: 3px solid ${(props) => props.theme.styles['primary-color']}; + } +`; + +const MetadataSections = styled.div` + padding: 16px 24px; +`; + +interface Props { + schemaFields: SchemaField[]; + editableSchemaMetadata?: EditableSchemaMetadata | null; + expandedDrawerFieldPath: string | null; + setExpandedDrawerFieldPath: (fieldPath: string | null) => void; +} + +export default function SchemaFieldDrawer({ + schemaFields, + editableSchemaMetadata, + expandedDrawerFieldPath, + setExpandedDrawerFieldPath, +}: Props) { + const expandedFieldIndex = useMemo( + () => schemaFields.findIndex((row) => row.fieldPath === expandedDrawerFieldPath), + [expandedDrawerFieldPath, schemaFields], + ); + const expandedField = + expandedFieldIndex !== undefined && expandedFieldIndex !== -1 ? schemaFields[expandedFieldIndex] : undefined; + const editableFieldInfo = editableSchemaMetadata?.editableSchemaFieldInfo.find((candidateEditableFieldInfo) => + pathMatchesNewPath(candidateEditableFieldInfo.fieldPath, expandedField?.fieldPath), + ); + + return ( + setExpandedDrawerFieldPath(null)} + getContainer={() => document.getElementById('entity-profile-sidebar') as HTMLElement} + contentWrapperStyle={{ width: '100%', boxShadow: 'none' }} + mask={false} + maskClosable={false} + placement="right" + closable={false} + > + + {expandedField && ( + <> + + + + + + + + + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components.ts new file mode 100644 index 00000000000000..0348336d649b56 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components.ts @@ -0,0 +1,12 @@ +import { Divider } from 'antd'; +import styled from 'styled-components'; + +export const SectionHeader = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; +`; + +export const StyledDivider = styled(Divider)` + margin: 12px 0; +`; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useDescriptionRenderer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useDescriptionRenderer.tsx index d80143f4bb82c8..5f2b5d23771c07 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useDescriptionRenderer.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useDescriptionRenderer.tsx @@ -48,8 +48,8 @@ export default function useDescriptionRenderer(editableSchemaMetadata: EditableS }, }).then(refresh) } + isReadOnly /> ); }; } -// diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useTagsAndTermsRenderer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useTagsAndTermsRenderer.tsx index a57344e5733b4e..207deb31d7ab7b 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useTagsAndTermsRenderer.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useTagsAndTermsRenderer.tsx @@ -2,15 +2,14 @@ import React from 'react'; import { EditableSchemaMetadata, EntityType, GlobalTags, SchemaField } from '../../../../../../../types.generated'; import TagTermGroup from '../../../../../../shared/tags/TagTermGroup'; import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/utils'; -import { useMutationUrn, useRefetch } from '../../../../EntityContext'; import { useSchemaRefetch } from '../SchemaContext'; +import { useMutationUrn, useRefetch } from '../../../../EntityContext'; export default function useTagsAndTermsRenderer( editableSchemaMetadata: EditableSchemaMetadata | null | undefined, - tagHoveredIndex: string | undefined, - setTagHoveredIndex: (index: string | undefined) => void, options: { showTags: boolean; showTerms: boolean }, filterText: string, + canEdit: boolean, ) { const urn = useMutationUrn(); const refetch = useRefetch(); @@ -27,24 +26,21 @@ export default function useTagsAndTermsRenderer( ); return ( -
    - setTagHoveredIndex(undefined)} - entityUrn={urn} - entityType={EntityType.Dataset} - entitySubresource={record.fieldPath} - highlightText={filterText} - refetch={refresh} - /> -
    + ); }; return tagAndTermRender; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useUsageStatsRenderer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useUsageStatsRenderer.tsx index 393783c4ca7871..e6b58eeb376f9d 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useUsageStatsRenderer.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/useUsageStatsRenderer.tsx @@ -7,7 +7,7 @@ import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/ const USAGE_BAR_MAX_WIDTH = 50; -const UsageBar = styled.div<{ width: number }>` +export const UsageBar = styled.div<{ width: number }>` width: ${(props) => props.width}px; height: 4px; background-color: ${geekblue[3]}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/Editor.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/Editor.tsx index bd2e410fb30d90..db56c092c8ccdf 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/Editor.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/editor/Editor.tsx @@ -40,10 +40,11 @@ type EditorProps = { onChange?: (md: string) => void; className?: string; doNotFocus?: boolean; + dataTestId?: string; }; export const Editor = forwardRef((props: EditorProps, ref) => { - const { content, readOnly, onChange, className } = props; + const { content, readOnly, onChange, className, dataTestId } = props; const { manager, state, getContext } = useRemirror({ extensions: () => [ new BlockquoteExtension(), @@ -98,7 +99,7 @@ export const Editor = forwardRef((props: EditorProps, ref) => { }, [readOnly, content]); return ( - + {!readOnly && ( diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/CardinalityLabel.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/CardinalityLabel.tsx new file mode 100644 index 00000000000000..14d3b2166554a9 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/CardinalityLabel.tsx @@ -0,0 +1,43 @@ +import { Tooltip } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { PropertyCardinality, StructuredPropertyEntity } from '../../../../../types.generated'; +import { PropertyTypeBadge } from '../Dataset/Schema/components/PropertyTypeLabel'; +import { getStructuredPropertyValue } from '../../utils'; + +const Header = styled.div` + font-size: 10px; +`; + +const List = styled.ul` + padding: 0 24px; + max-height: 500px; + overflow: auto; +`; + +interface Props { + structuredProperty: StructuredPropertyEntity; +} + +export default function CardinalityLabel({ structuredProperty }: Props) { + const labelText = + structuredProperty.definition.cardinality === PropertyCardinality.Single ? 'Single-Select' : 'Multi-Select'; + + return ( + +
    Property Options
    + + {structuredProperty.definition.allowedValues?.map((value) => ( +
  • {getStructuredPropertyValue(value.value)}
  • + ))} +
    + + } + > + +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/NameColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/NameColumn.tsx new file mode 100644 index 00000000000000..3b718c1ec30edd --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/NameColumn.tsx @@ -0,0 +1,87 @@ +import { Tooltip, Typography } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import Highlight from 'react-highlighter'; +import { PropertyRow } from './types'; +import ChildCountLabel from '../Dataset/Schema/components/ChildCountLabel'; +import PropertyTypeLabel from '../Dataset/Schema/components/PropertyTypeLabel'; +import StructuredPropertyTooltip from './StructuredPropertyTooltip'; +import CardinalityLabel from './CardinalityLabel'; + +const ParentNameText = styled(Typography.Text)` + color: #373d44; + font-size: 16px; + font-family: Manrope; + font-weight: 600; + line-height: 20px; + word-wrap: break-word; + padding-left: 16px; + display: flex; + align-items: center; +`; + +const ChildNameText = styled(Typography.Text)` + align-self: stretch; + color: #373d44; + font-size: 14px; + font-family: Manrope; + font-weight: 500; + line-height: 18px; + word-wrap: break-word; + padding-left: 16px; + display: flex; + align-items: center; +`; + +const NameLabelWrapper = styled.span` + display: inline-flex; + align-items: center; + flex-wrap: wrap; +`; + +interface Props { + propertyRow: PropertyRow; + filterText?: string; +} + +export default function NameColumn({ propertyRow, filterText }: Props) { + const { structuredProperty } = propertyRow; + return ( + <> + {propertyRow.children ? ( + + + {propertyRow.displayName} + + {propertyRow.childrenCount ? : } + + ) : ( + + + ) : ( + '' + ) + } + > + + {propertyRow.displayName} + + + {propertyRow.type ? ( + + ) : ( + + )} + {structuredProperty?.definition.allowedValues && ( + + )} + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx index 277096e1c09cbd..01d1145877e3b0 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/PropertiesTab.tsx @@ -1,52 +1,79 @@ -import React from 'react'; -import { Typography } from 'antd'; import styled from 'styled-components'; - -import { ANTD_GRAY } from '../../constants'; -import { StyledTable } from '../../components/styled/StyledTable'; +import React, { useState } from 'react'; +import ExpandIcon from '../Dataset/Schema/components/ExpandIcon'; +import { StyledTable as Table } from '../../components/styled/StyledTable'; import { useEntityData } from '../../EntityContext'; +import { PropertyRow } from './types'; +import useStructuredProperties from './useStructuredProperties'; +import { getFilteredCustomProperties, mapCustomPropertiesToPropertyRows } from './utils'; +import ValuesColumn from './ValuesColumn'; +import NameColumn from './NameColumn'; +import TabHeader from './TabHeader'; +import useUpdateExpandedRowsFromFilter from './useUpdateExpandedRowsFromFilter'; +import { useEntityRegistry } from '../../../../useEntityRegistry'; -const NameText = styled(Typography.Text)` - font-family: 'Roboto Mono', monospace; - font-weight: 600; - font-size: 12px; - color: ${ANTD_GRAY[9]}; -`; - -const ValueText = styled(Typography.Text)` - font-family: 'Roboto Mono', monospace; - font-weight: 400; - font-size: 12px; - color: ${ANTD_GRAY[8]}; -`; +const StyledTable = styled(Table)` + &&& .ant-table-cell-with-append { + padding: 4px; + } +` as typeof Table; export const PropertiesTab = () => { + const [filterText, setFilterText] = useState(''); const { entityData } = useEntityData(); + const entityRegistry = useEntityRegistry(); const propertyTableColumns = [ { - width: 210, + width: '40%', title: 'Name', - dataIndex: 'key', - sorter: (a, b) => a?.key.localeCompare(b?.key || '') || 0, defaultSortOrder: 'ascend', - render: (name: string) => {name}, + render: (propertyRow: PropertyRow) => , }, { title: 'Value', - dataIndex: 'value', - render: (value: string) => {value}, + render: (propertyRow: PropertyRow) => , }, ]; + const { structuredPropertyRows, expandedRowsFromFilter } = useStructuredProperties(entityRegistry, filterText); + const customProperties = getFilteredCustomProperties(filterText, entityData) || []; + const customPropertyRows = mapCustomPropertiesToPropertyRows(customProperties); + const dataSource: PropertyRow[] = structuredPropertyRows.concat(customPropertyRows); + + const [expandedRows, setExpandedRows] = useState>(new Set()); + + useUpdateExpandedRowsFromFilter({ expandedRowsFromFilter, setExpandedRows }); + return ( - + <> + + { + if (expanded) { + setExpandedRows((previousRows) => new Set(previousRows.add(record.qualifiedName))); + } else { + setExpandedRows((previousRows) => { + previousRows.delete(record.qualifiedName); + return new Set(previousRows); + }); + } + }, + indentSize: 0, + }} + /> + ); }; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyTooltip.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyTooltip.tsx new file mode 100644 index 00000000000000..be0f443ce01b2f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyTooltip.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components'; +import { StructuredPropertyEntity } from '../../../../../types.generated'; + +const ContentWrapper = styled.div` + font-size: 12px; +`; + +const Header = styled.div` + font-size: 10px; +`; + +const Description = styled.div` + padding-left: 16px; +`; + +interface Props { + structuredProperty: StructuredPropertyEntity; +} + +export default function StructuredPropertyTooltip({ structuredProperty }: Props) { + return ( + +
    Structured Property
    +
    {structuredProperty.definition.displayName || structuredProperty.definition.qualifiedName}
    + {structuredProperty.definition.description && ( + {structuredProperty.definition.description} + )} +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx new file mode 100644 index 00000000000000..a8b4e6607b25ea --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx @@ -0,0 +1,69 @@ +import Icon from '@ant-design/icons/lib/components/Icon'; +import React from 'react'; +import Highlight from 'react-highlighter'; +import { Typography } from 'antd'; +import styled from 'styled-components'; +import { ValueColumnData } from './types'; +import { ANTD_GRAY } from '../../constants'; +import { useEntityRegistry } from '../../../../useEntityRegistry'; +import ExternalLink from '../../../../../images/link-out.svg?react'; +import MarkdownViewer, { MarkdownView } from '../../components/legacy/MarkdownViewer'; +import EntityIcon from '../../components/styled/EntityIcon'; + +const ValueText = styled(Typography.Text)` + font-family: 'Manrope'; + font-weight: 400; + font-size: 14px; + color: ${ANTD_GRAY[9]}; + display: block; + + ${MarkdownView} { + font-size: 14px; + } +`; + +const StyledIcon = styled(Icon)` + margin-left: 6px; +`; + +const IconWrapper = styled.span` + margin-right: 4px; +`; + +interface Props { + value: ValueColumnData; + isRichText?: boolean; + filterText?: string; +} + +export default function StructuredPropertyValue({ value, isRichText, filterText }: Props) { + const entityRegistry = useEntityRegistry(); + + return ( + + {value.entity ? ( + <> + + + + {entityRegistry.getDisplayName(value.entity.type, value.entity)} + + + + + ) : ( + <> + {isRichText ? ( + + ) : ( + {value.value?.toString()} + )} + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx new file mode 100644 index 00000000000000..9e0b4992d9c786 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx @@ -0,0 +1,32 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Input } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY } from '../../constants'; + +const StyledInput = styled(Input)` + border-radius: 70px; + max-width: 300px; +`; + +const TableHeader = styled.div` + padding: 8px 16px; + border-bottom: 1px solid ${ANTD_GRAY[4.5]}; +`; + +interface Props { + setFilterText: (text: string) => void; +} + +export default function TabHeader({ setFilterText }: Props) { + return ( + + setFilterText(e.target.value)} + allowClear + prefix={} + /> + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/ValuesColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/ValuesColumn.tsx new file mode 100644 index 00000000000000..b050e06f96de85 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/ValuesColumn.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { PropertyRow } from './types'; +import { StdDataType } from '../../../../../types.generated'; +import StructuredPropertyValue from './StructuredPropertyValue'; + +interface Props { + propertyRow: PropertyRow; + filterText?: string; +} + +export default function ValuesColumn({ propertyRow, filterText }: Props) { + const { values } = propertyRow; + const isRichText = propertyRow.dataType?.info.type === StdDataType.RichText; + + return ( + <> + {values ? ( + values.map((v) => ) + ) : ( + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/__tests__/utils.test.ts b/datahub-web-react/src/app/entity/shared/tabs/Properties/__tests__/utils.test.ts new file mode 100644 index 00000000000000..512510732d716c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/__tests__/utils.test.ts @@ -0,0 +1,87 @@ +import { getTestEntityRegistry } from '../../../../../../utils/test-utils/TestPageContainer'; +import { PropertyRow } from '../types'; +import { filterStructuredProperties } from '../utils'; + +describe('filterSchemaRows', () => { + const testEntityRegistry = getTestEntityRegistry(); + const rows = [ + { + displayName: 'Has PII', + qualifiedName: 'io.acryl.ads.data_protection.has_pii', + values: [{ value: 'yes', entity: null }], + }, + { + displayName: 'Discovery Date Utc', + qualifiedName: 'io.acryl.ads.change_management.discovery_date_utc', + values: [{ value: '2023-10-31', entity: null }], + }, + { + displayName: 'Link Data Location', + qualifiedName: 'io.acryl.ads.context.data_location', + values: [{ value: 'New York City', entity: null }], + }, + { + displayName: 'Number Prop', + qualifiedName: 'io.acryl.ads.number', + values: [{ value: 100, entity: null }], + }, + ] as PropertyRow[]; + + it('should properly filter structured properties based on field name', () => { + const filterText = 'has pi'; + const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties( + testEntityRegistry, + rows, + filterText, + ); + + expect(filteredRows).toMatchObject([ + { + displayName: 'Has PII', + qualifiedName: 'io.acryl.ads.data_protection.has_pii', + values: [{ value: 'yes', entity: null }], + }, + ]); + expect(expandedRowsFromFilter).toMatchObject( + new Set(['io', 'io.acryl', 'io.acryl.ads', 'io.acryl.ads.data_protection']), + ); + }); + + it('should properly filter structured properties based on field value', () => { + const filterText = 'new york'; + const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties( + testEntityRegistry, + rows, + filterText, + ); + + expect(filteredRows).toMatchObject([ + { + displayName: 'Link Data Location', + qualifiedName: 'io.acryl.ads.context.data_location', + values: [{ value: 'New York City', entity: null }], + }, + ]); + expect(expandedRowsFromFilter).toMatchObject( + new Set(['io', 'io.acryl', 'io.acryl.ads', 'io.acryl.ads.context']), + ); + }); + + it('should properly filter structured properties based on field value even for numbers', () => { + const filterText = '100'; + const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties( + testEntityRegistry, + rows, + filterText, + ); + + expect(filteredRows).toMatchObject([ + { + displayName: 'Number Prop', + qualifiedName: 'io.acryl.ads.number', + values: [{ value: 100, entity: null }], + }, + ]); + expect(expandedRowsFromFilter).toMatchObject(new Set(['io', 'io.acryl', 'io.acryl.ads'])); + }); +}); diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts b/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts new file mode 100644 index 00000000000000..b93ba886d5a642 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts @@ -0,0 +1,25 @@ +import { DataTypeEntity, Entity, StructuredPropertyEntity } from '../../../../../types.generated'; + +export interface ValueColumnData { + value: string | number | null; + entity: Entity | null; +} + +export interface TypeData { + type: string; + nativeDataType: string; +} + +export interface PropertyRow { + displayName: string; + qualifiedName: string; + values?: ValueColumnData[]; + children?: PropertyRow[]; + childrenCount?: number; + parent?: PropertyRow; + depth?: number; + type?: TypeData; + dataType?: DataTypeEntity; + isParentRow?: boolean; + structuredProperty?: StructuredPropertyEntity; +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx new file mode 100644 index 00000000000000..5600d7c3e8498e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx @@ -0,0 +1,215 @@ +import { PropertyValue, StructuredPropertiesEntry } from '../../../../../types.generated'; +import EntityRegistry from '../../../EntityRegistry'; +import { useEntityData } from '../../EntityContext'; +import { GenericEntityProperties } from '../../types'; +import { getStructuredPropertyValue } from '../../utils'; +import { PropertyRow } from './types'; +import { filterStructuredProperties } from './utils'; + +const typeNameToType = { + StringValue: { type: 'string', nativeDataType: 'text' }, + NumberValue: { type: 'number', nativeDataType: 'float' }, +}; + +export function mapStructuredPropertyValues(structuredPropertiesEntry: StructuredPropertiesEntry) { + return structuredPropertiesEntry.values + .filter((value) => !!value) + .map((value) => ({ + value: getStructuredPropertyValue(value as PropertyValue), + entity: + structuredPropertiesEntry.valueEntities?.find( + (entity) => entity?.urn === getStructuredPropertyValue(value as PropertyValue), + ) || null, + })); +} + +// map the properties map into a list of PropertyRow objects to render in a table +function getStructuredPropertyRows(entityData?: GenericEntityProperties | null) { + const structuredPropertyRows: PropertyRow[] = []; + + entityData?.structuredProperties?.properties?.forEach((structuredPropertiesEntry) => { + const { displayName, qualifiedName } = structuredPropertiesEntry.structuredProperty.definition; + structuredPropertyRows.push({ + displayName: displayName || qualifiedName, + qualifiedName, + values: mapStructuredPropertyValues(structuredPropertiesEntry), + dataType: structuredPropertiesEntry.structuredProperty.definition.valueType, + structuredProperty: structuredPropertiesEntry.structuredProperty, + type: + structuredPropertiesEntry.values[0] && structuredPropertiesEntry.values[0].__typename + ? { + type: typeNameToType[structuredPropertiesEntry.values[0].__typename].type, + nativeDataType: typeNameToType[structuredPropertiesEntry.values[0].__typename].nativeDataType, + } + : undefined, + }); + }); + + return structuredPropertyRows; +} + +export function findAllSubstrings(s: string): Array { + const substrings: Array = []; + + for (let i = 0; i < s.length; i++) { + if (s[i] === '.') { + substrings.push(s.substring(0, i)); + } + } + substrings.push(s); + return substrings; +} + +export function createParentPropertyRow(displayName: string, qualifiedName: string): PropertyRow { + return { + displayName, + qualifiedName, + isParentRow: true, + }; +} + +export function identifyAndAddParentRows(rows?: Array): Array { + /** + * This function takes in an array of PropertyRow objects and determines which rows are parents. These parents need + * to be extracted in order to organize the rows into a properly nested structure later on. The final product returned + * is a list of parent rows, without values or children assigned. + */ + const qualifiedNames: Array = []; + + // Get list of fqns + if (rows) { + rows.forEach((row) => { + qualifiedNames.push(row.qualifiedName); + }); + } + + const finalParents: PropertyRow[] = []; + const finalParentNames = new Set(); + + // Loop through list of fqns and find all substrings. + // e.g. a.b.c.d becomes a, a.b, a.b.c, a.b.c.d + qualifiedNames.forEach((fqn) => { + let previousCount: number | null = null; + let previousParentName = ''; + + const substrings = findAllSubstrings(fqn); + + // Loop through substrings and count how many other fqns have that substring in them. Use this to determine + // if a property should be nested. If the count is equal then we should not nest, because there's no split + // that would tell us to nest. If the count is not equal, we should nest the child properties. + for (let index = 0; index < substrings.length; index++) { + const token = substrings[index]; + const currentCount = qualifiedNames.filter((name) => name.startsWith(token)).length; + + // If we're at the beginning of the path and there is no nesting, break + if (index === 0 && currentCount === 1) { + break; + } + + // Add previous fqn, or,previousParentName, if we have found a viable parent path + if (previousCount !== null && previousCount !== currentCount) { + if (!finalParentNames.has(previousParentName)) { + const parent: PropertyRow = createParentPropertyRow(previousParentName, previousParentName); + parent.childrenCount = previousCount; + finalParentNames.add(previousParentName); + finalParents.push(parent); + } + } + + previousCount = currentCount; + previousParentName = token; + } + }); + + return finalParents; +} + +export function groupByParentProperty(rows?: Array): Array { + /** + * This function takes in an array of PropertyRow objects, representing parent and child properties. Parent properties + * will not have values, but child properties will. It organizes the rows into the parent and child structure and + * returns a list of PropertyRow objects representing it. + */ + const outputRows: Array = []; + const outputRowByPath = {}; + + if (rows) { + // Iterate through all rows + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + let parentRow: null | PropertyRow = null; + const row = { children: undefined, ...rows[rowIndex], depth: 0 }; + + // Iterate through a row's characters, and split the row's path into tokens + // e.g. a, b, c for the example a.b.c + for (let j = rowIndex - 1; j >= 0; j--) { + const rowTokens = row.qualifiedName.split('.'); + let parentPath: null | string = null; + let previousParentPath = rowTokens.slice(0, rowTokens.length - 1).join('.'); + + // Iterate through a row's path backwards, and check if the previous row's path has been seen. If it has, + // populate parentRow. If not, move on to the next path token. + // e.g. for a.b.c.d, first evaluate a.b.c to see if it has been seen. If it hasn't, move to a.b + for ( + let lastParentTokenIndex = rowTokens.length - 2; + lastParentTokenIndex >= 0; + --lastParentTokenIndex + ) { + const lastParentToken: string = rowTokens[lastParentTokenIndex]; + if (lastParentToken && Object.keys(outputRowByPath).includes(previousParentPath)) { + parentPath = rowTokens.slice(0, lastParentTokenIndex + 1).join('.'); + break; + } + previousParentPath = rowTokens.slice(0, lastParentTokenIndex).join('.'); + } + + if (parentPath && rows[j].qualifiedName === parentPath) { + parentRow = outputRowByPath[rows[j].qualifiedName]; + break; + } + } + + // If the parent row exists in the ouput, add the current row as a child. If not, add the current row + // to the final output. + if (parentRow) { + row.depth = (parentRow.depth || 0) + 1; + row.parent = parentRow; + if (row.isParentRow) { + row.displayName = row.displayName.replace(`${parentRow.displayName}.`, ''); + } + parentRow.children = [...(parentRow.children || []), row]; + } else { + outputRows.push(row); + } + outputRowByPath[row.qualifiedName] = row; + } + } + return outputRows; +} + +export default function useStructuredProperties(entityRegistry: EntityRegistry, filterText?: string) { + const { entityData } = useEntityData(); + + let structuredPropertyRowsRaw = getStructuredPropertyRows(entityData); + const parentRows = identifyAndAddParentRows(structuredPropertyRowsRaw); + + structuredPropertyRowsRaw = [...structuredPropertyRowsRaw, ...parentRows]; + + const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties( + entityRegistry, + structuredPropertyRowsRaw, + filterText, + ); + + // Sort by fqn before nesting algorithm + const copy = [...filteredRows].sort((a, b) => { + return a.qualifiedName.localeCompare(b.qualifiedName); + }); + + // group properties by path + const structuredPropertyRows = groupByParentProperty(copy); + + return { + structuredPropertyRows, + expandedRowsFromFilter: expandedRowsFromFilter as Set, + }; +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/useUpdateExpandedRowsFromFilter.ts b/datahub-web-react/src/app/entity/shared/tabs/Properties/useUpdateExpandedRowsFromFilter.ts new file mode 100644 index 00000000000000..0dbe762c537db4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/useUpdateExpandedRowsFromFilter.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { isEqual } from 'lodash'; +import usePrevious from '../../../../shared/usePrevious'; + +interface Props { + expandedRowsFromFilter: Set; + setExpandedRows: React.Dispatch>>; +} + +export default function useUpdateExpandedRowsFromFilter({ expandedRowsFromFilter, setExpandedRows }: Props) { + const previousExpandedRowsFromFilter = usePrevious(expandedRowsFromFilter); + + useEffect(() => { + if (!isEqual(expandedRowsFromFilter, previousExpandedRowsFromFilter)) { + setExpandedRows((previousRows) => { + const finalRowsSet = new Set(); + expandedRowsFromFilter.forEach((row) => finalRowsSet.add(row)); + previousRows.forEach((row) => finalRowsSet.add(row)); + return finalRowsSet as Set; + }); + } + }, [expandedRowsFromFilter, previousExpandedRowsFromFilter, setExpandedRows]); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/utils.ts b/datahub-web-react/src/app/entity/shared/tabs/Properties/utils.ts new file mode 100644 index 00000000000000..91870e2e37e07d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/utils.ts @@ -0,0 +1,68 @@ +import { CustomPropertiesEntry } from '../../../../../types.generated'; +import EntityRegistry from '../../../EntityRegistry'; +import { GenericEntityProperties } from '../../types'; +import { PropertyRow, ValueColumnData } from './types'; + +export function mapCustomPropertiesToPropertyRows(customProperties: CustomPropertiesEntry[]) { + return (customProperties?.map((customProp) => ({ + displayName: customProp.key, + values: [{ value: customProp.value || '' }], + type: { type: 'string', nativeDataType: 'string' }, + })) || []) as PropertyRow[]; +} + +function matchesName(name: string, filterText: string) { + return name.toLocaleLowerCase().includes(filterText.toLocaleLowerCase()); +} + +function matchesAnyFromValues(values: ValueColumnData[], filterText: string, entityRegistry: EntityRegistry) { + return values.some( + (value) => + matchesName(value.value?.toString() || '', filterText) || + matchesName(value.entity ? entityRegistry.getDisplayName(value.entity.type, value.entity) : '', filterText), + ); +} + +export function getFilteredCustomProperties(filterText: string, entityData?: GenericEntityProperties | null) { + return entityData?.customProperties?.filter( + (property) => matchesName(property.key, filterText) || matchesName(property.value || '', filterText), + ); +} + +export function filterStructuredProperties( + entityRegistry: EntityRegistry, + propertyRows: PropertyRow[], + filterText?: string, +) { + if (!propertyRows) return { filteredRows: [], expandedRowsFromFilter: new Set() }; + if (!filterText) return { filteredRows: propertyRows, expandedRowsFromFilter: new Set() }; + const formattedFilterText = filterText.toLocaleLowerCase(); + + const finalQualifiedNames = new Set(); + const expandedRowsFromFilter = new Set(); + + propertyRows.forEach((row) => { + // if we match on the qualified name (maybe from a parent) do not filter out + if (matchesName(row.qualifiedName, formattedFilterText)) { + finalQualifiedNames.add(row.qualifiedName); + } + // if we match specifically on this property (not just its parent), add and expand all parents + if ( + matchesName(row.displayName, formattedFilterText) || + matchesAnyFromValues(row.values || [], formattedFilterText, entityRegistry) + ) { + finalQualifiedNames.add(row.qualifiedName); + + const splitFieldPath = row.qualifiedName.split('.'); + splitFieldPath.reduce((previous, current) => { + finalQualifiedNames.add(previous); + expandedRowsFromFilter.add(previous); + return `${previous}.${current}`; + }); + } + }); + + const filteredRows = propertyRows.filter((row) => finalQualifiedNames.has(row.qualifiedName)); + + return { filteredRows, expandedRowsFromFilter }; +} diff --git a/datahub-web-react/src/app/entity/shared/types.ts b/datahub-web-react/src/app/entity/shared/types.ts index d4e3965cd66f5f..47cad4a69096de 100644 --- a/datahub-web-react/src/app/entity/shared/types.ts +++ b/datahub-web-react/src/app/entity/shared/types.ts @@ -38,6 +38,7 @@ import { BrowsePathV2, DataJobInputOutput, ParentDomainsResult, + StructuredProperties, } from '../../../types.generated'; import { FetchedEntity } from '../../lineage/types'; @@ -84,6 +85,7 @@ export type GenericEntityProperties = { platform?: Maybe; dataPlatformInstance?: Maybe; customProperties?: Maybe; + structuredProperties?: Maybe; institutionalMemory?: Maybe; schemaMetadata?: Maybe; externalUrl?: Maybe; diff --git a/datahub-web-react/src/app/entity/shared/utils.ts b/datahub-web-react/src/app/entity/shared/utils.ts index a158cc9b7c119b..217aaaaf9dde85 100644 --- a/datahub-web-react/src/app/entity/shared/utils.ts +++ b/datahub-web-react/src/app/entity/shared/utils.ts @@ -1,6 +1,6 @@ import { Maybe } from 'graphql/jsutils/Maybe'; -import { Entity, EntityType, EntityRelationshipsResult, DataProduct } from '../../../types.generated'; +import { Entity, EntityType, EntityRelationshipsResult, DataProduct, PropertyValue } from '../../../types.generated'; import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import { GenericEntityProperties } from './types'; @@ -130,3 +130,13 @@ export function getDataProduct(dataProductResult: Maybe { it('logs in', () => { cy.visit('/'); - cy.get('input[data-testid=username]').type(Cypress.env('ADMIN_USERNAME')); - cy.get('input[data-testid=password]').type(Cypress.env('ADMIN_PASSWORD')); + cy.get('input[data-testid=username]').type('datahub'); + cy.get('input[data-testid=password]').type('datahub'); cy.contains('Sign In').click(); cy.contains('Welcome back, ' + Cypress.env('ADMIN_DISPLAYNAME')); }); diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js b/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js index 5f9758a35ca0ea..c6d2b205250e07 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/edit_documentation.js @@ -78,17 +78,18 @@ describe("edit documentation and link to dataset", () => { cy.visit( "/dataset/urn:li:dataset:(urn:li:dataPlatform:hive,SampleCypressHiveDataset,PROD)/Schema" ); - cy.get("tbody [data-icon='edit']").first().click({ force: true }); + cy.clickOptionWithText("field_foo"); + cy.clickOptionWithTestId("edit-field-description"); cy.waitTextVisible("Update description"); cy.waitTextVisible("Foo field description has changed"); - cy.focused().clear().wait(1000); + cy.getWithTestId("description-editor").clear().wait(1000); cy.focused().type(documentation_edited); cy.clickOptionWithTestId("description-modal-update-button"); cy.waitTextVisible("Updated!"); cy.waitTextVisible(documentation_edited); cy.waitTextVisible("(edited)"); - cy.get("tbody [data-icon='edit']").first().click({ force: true }); - cy.focused().clear().wait(1000); + cy.clickOptionWithTestId("edit-field-description"); + cy.getWithTestId("description-editor").clear().wait(1000); cy.focused().type("Foo field description has changed"); cy.clickOptionWithTestId("description-modal-update-button"); cy.waitTextVisible("Updated!"); diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/mutations.js b/smoke-test/tests/cypress/cypress/e2e/mutations/mutations.js index 1baa33901724f8..7f8a4e4f8f335c 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/mutations.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/mutations.js @@ -77,7 +77,7 @@ describe("mutations", () => { cy.login(); cy.viewport(2000, 800); cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events"); - cy.mouseover('[data-testid="schema-field-event_name-tags"]'); + cy.clickOptionWithText("event_name"); cy.get('[data-testid="schema-field-event_name-tags"]').within(() => cy.contains("Add Tag").click() ); @@ -116,7 +116,8 @@ describe("mutations", () => { // verify dataset shows up in search now cy.contains("of 1 result").click(); cy.contains("cypress_logging_events").click(); - cy.get('[data-testid="tag-CypressTestAddTag2"]').within(() => + cy.clickOptionWithText("event_name"); + cy.get('[data-testid="schema-field-event_name-tags"]').within(() => cy .get("span[aria-label=close]") .trigger("mouseover", { force: true }) @@ -134,10 +135,7 @@ describe("mutations", () => { // make space for the glossary term column cy.viewport(2000, 800); cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events"); - cy.get('[data-testid="schema-field-event_name-terms"]').trigger( - "mouseover", - { force: true } - ); + cy.clickOptionWithText("event_name"); cy.get('[data-testid="schema-field-event_name-terms"]').within(() => cy.contains("Add Term").click({ force: true }) ); @@ -146,9 +144,12 @@ describe("mutations", () => { cy.contains("CypressTerm"); - cy.get( - 'a[href="/glossaryTerm/urn:li:glossaryTerm:CypressNode.CypressTerm"]' - ).within(() => cy.get("span[aria-label=close]").click({ force: true })); + cy.get('[data-testid="schema-field-event_name-terms"]').within(() => + cy + .get("span[aria-label=close]") + .trigger("mouseover", { force: true }) + .click({ force: true }) + ); cy.contains("Yes").click({ force: true }); cy.contains("CypressTerm").should("not.exist"); diff --git a/smoke-test/tests/cypress/cypress/e2e/schema_blame/schema_blame.js b/smoke-test/tests/cypress/cypress/e2e/schema_blame/schema_blame.js index 6e282b52496369..1ce1fbe900172a 100644 --- a/smoke-test/tests/cypress/cypress/e2e/schema_blame/schema_blame.js +++ b/smoke-test/tests/cypress/cypress/e2e/schema_blame/schema_blame.js @@ -14,6 +14,7 @@ describe('schema blame', () => { cy.contains('field_bar').should('not.exist'); cy.contains('Foo field description has changed'); cy.contains('Baz field description'); + cy.clickOptionWithText("field_foo"); cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy'); // Make sure the schema blame is accurate @@ -41,6 +42,7 @@ describe('schema blame', () => { cy.contains('field_baz').should('not.exist'); cy.contains('Foo field description'); cy.contains('Bar field description'); + cy.clickOptionWithText("field_foo"); cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy').should('not.exist'); // Make sure the schema blame is accurate diff --git a/smoke-test/tests/cypress/cypress/support/commands.js b/smoke-test/tests/cypress/cypress/support/commands.js index f32512aff45fac..51b06a24c19211 100644 --- a/smoke-test/tests/cypress/cypress/support/commands.js +++ b/smoke-test/tests/cypress/cypress/support/commands.js @@ -218,6 +218,10 @@ Cypress.Commands.add( 'multiSelect', (within_data_id , text) => { cy.clickOptionWithText(text); }); +Cypress.Commands.add("getWithTestId", (id) => { + return cy.get(selectorWithtestId(id)); +}); + Cypress.Commands.add("enterTextInTestId", (id, text) => { cy.get(selectorWithtestId(id)).type(text); }) From caf6ebe3b7a7ebaafd2b5252763171f4dfbeb754 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Thu, 25 Jan 2024 10:40:22 -0800 Subject: [PATCH 213/540] docs(): Updating docs for assertions to correct databricks assertions support (#9713) Co-authored-by: John Joyce --- .../observe/custom-sql-assertions.md | 2 +- .../observe/freshness-assertions.md | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/managed-datahub/observe/custom-sql-assertions.md b/docs/managed-datahub/observe/custom-sql-assertions.md index 11e9aa807b6167..581b5426881347 100644 --- a/docs/managed-datahub/observe/custom-sql-assertions.md +++ b/docs/managed-datahub/observe/custom-sql-assertions.md @@ -117,7 +117,7 @@ The **Assertion Description**: This is a human-readable description of the Asser ### Prerequisites 1. **Permissions**: To create or delete Custom SQL Assertions for a specific entity on DataHub, you'll need to be granted the - `Edit Assertions` and `Edit Monitors` privileges for the entity. This is granted to Entity owners by default. + `Edit Assertions`, `Edit Monitors`, **and the additional `Edit SQL Assertion Monitors`** privileges for the entity. This is granted to Entity owners by default. 2. **Data Platform Connection**: In order to create a Custom SQL Assertion, you'll need to have an **Ingestion Source** configured to your Data Platform: Snowflake, BigQuery, or Redshift under the **Integrations** tab. diff --git a/docs/managed-datahub/observe/freshness-assertions.md b/docs/managed-datahub/observe/freshness-assertions.md index 416db6a65343eb..9704f475b15873 100644 --- a/docs/managed-datahub/observe/freshness-assertions.md +++ b/docs/managed-datahub/observe/freshness-assertions.md @@ -107,12 +107,14 @@ Change Source types vary by the platform, but generally fall into these categori - **Audit Log** (Default): A metadata API or Table that is exposed by the Data Warehouse which contains captures information about the operations that have been performed to each Table. It is usually efficient to check, but some useful operations are not - fully supported across all major Warehouse platforms. + fully supported across all major Warehouse platforms. Note that for Databricks, [this option](https://docs.databricks.com/en/delta/history.html) + is only available for tables stored in Delta format. - **Information Schema**: A system Table that is exposed by the Data Warehouse which contains live information about the Databases and Tables stored inside the Data Warehouse. It is usually efficient to check, but lacks detailed information about the _type_ - of change that was last made to a specific table (e.g. the operation itself - INSERT, UPDATE, DELETE, number of impacted rows, etc) - + of change that was last made to a specific table (e.g. the operation itself - INSERT, UPDATE, DELETE, number of impacted rows, etc). + Note that for Databricks, [this option](https://docs.databricks.com/en/delta/table-details.html) is only available for tables stored in Delta format. + - **Last Modified Column**: A Date or Timestamp column that represents the last time that a specific _row_ was touched or updated. Adding a Last Modified Column to each warehouse Table is a pattern is often used for existing use cases around change management. If this change source is used, a query will be issued to the Table to search for rows that have been modified within a specific @@ -128,8 +130,11 @@ Change Source types vary by the platform, but generally fall into these categori This relies on Operations being reported to DataHub, either via ingestion or via use of the DataHub APIs (see [Report Operation via API](#reporting-operations-via-api)). Note if you have not configured an ingestion source through DataHub, then this may be the only option available. By default, any operation type found will be considered a valid change. Use the **Operation Types** dropdown when selecting this option to specify which operation types should be considered valid changes. You may choose from one of DataHub's standard Operation Types, or specify a "Custom" Operation Type by typing in the name of the Operation Type. - Using either of the column value approaches (**Last Modified Column** or **High Watermark Column**) to determine whether a Table has changed can be useful because it can be customized to determine whether specific types of important changes have been made to a given Table. - Because it does not involve system warehouse tables, it is also easily portable across Data Warehouse and Data Lake providers. + - **File Metadata** (Databricks Only): A column that is exposed by Databricks for both Unity Catalog and Hive Metastore based tables + which includes information about the last time that a file for the table was changed. Read more about it [here](https://docs.databricks.com/en/ingestion/file-metadata-column.html). + + Using either of the column value approaches (**Last Modified Column** or **High Watermark Column**) to determine whether a Table has changed can be useful because it can be customized to determine whether specific types of changes have been made to a given Table. + And because this type of assertion does not involve system warehouse tables, they are easily portable across Data Warehouse and Data Lake providers. Freshness Assertions also have an off switch: they can be started or stopped at any time with the click of button. From d292b35f2340e227a49ad872a156d7b2f15fb9a9 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:41:51 -0600 Subject: [PATCH 214/540] test(spark-lineage): minor tweaks (#9717) --- .github/workflows/spark-smoke-test.yml | 18 +++++++++++++++++- docker/build.gradle | 17 ++++++++++++----- .../datahub/spark/TestSparkJobsLineage.java | 9 +++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/spark-smoke-test.yml b/.github/workflows/spark-smoke-test.yml index e463e15243ee3e..87fa3c85fc581b 100644 --- a/.github/workflows/spark-smoke-test.yml +++ b/.github/workflows/spark-smoke-test.yml @@ -42,8 +42,12 @@ jobs: cache: "pip" - name: Install dependencies run: ./metadata-ingestion/scripts/install_deps.sh + - name: Disk Check + run: df -h . && docker images - name: Remove images run: docker image prune -a -f || true + - name: Disk Check + run: df -h . && docker images - name: Smoke test run: | ./gradlew :metadata-integration:java:spark-lineage:integrationTest \ @@ -54,12 +58,24 @@ jobs: -x :datahub-web-react:yarnBuild \ -x :datahub-web-react:distZip \ -x :datahub-web-react:jar + - name: store logs + if: failure() + run: | + docker ps -a + docker logs datahub-gms >& gms-${{ matrix.test_strategy }}.log || true + docker logs datahub-actions >& actions-${{ matrix.test_strategy }}.log || true + docker logs broker >& broker-${{ matrix.test_strategy }}.log || true + docker logs mysql >& mysql-${{ matrix.test_strategy }}.log || true + docker logs elasticsearch >& elasticsearch-${{ matrix.test_strategy }}.log || true + docker logs datahub-frontend-react >& frontend-${{ matrix.test_strategy }}.log || true - name: Upload logs uses: actions/upload-artifact@v3 if: failure() with: name: docker logs - path: "docker/build/container-logs/*.log" + path: | + "**/build/container-logs/*.log" + "*.log" - uses: actions/upload-artifact@v3 if: always() with: diff --git a/docker/build.gradle b/docker/build.gradle index cc95e12f26f767..b14739104a9f1e 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -8,15 +8,17 @@ import com.avast.gradle.dockercompose.tasks.ComposeDownForced apply from: "../gradle/versioning/versioning.gradle" ext { - quickstart_modules = [ + backend_profile_modules = [ ':docker:elasticsearch-setup', ':docker:mysql-setup', ':docker:kafka-setup', ':datahub-upgrade', + ':metadata-service:war', + ] + quickstart_modules = backend_profile_modules + [ ':metadata-jobs:mce-consumer-job', ':metadata-jobs:mae-consumer-job', - ':metadata-service:war', - ':datahub-frontend', + ':datahub-frontend' ] debug_modules = quickstart_modules - [':metadata-jobs:mce-consumer-job', @@ -90,9 +92,14 @@ dockerCompose { removeVolumes = false } + /** + * The smallest disk footprint required for Spark integration tests + * + * No frontend, mae, mce, or other services + */ quickstartSlim { isRequiredBy(tasks.named('quickstartSlim')) - composeAdditionalArgs = ['--profile', 'quickstart-consumers'] + composeAdditionalArgs = ['--profile', 'quickstart-backend'] environment.put 'DATAHUB_VERSION', "v${version}" environment.put "DATAHUB_ACTIONS_IMAGE", "acryldata/datahub-ingestion" @@ -132,7 +139,7 @@ tasks.getByName('quickstartComposeUp').dependsOn( tasks.getByName('quickstartPgComposeUp').dependsOn( pg_quickstart_modules.collect { it + ':dockerTag' }) tasks.getByName('quickstartSlimComposeUp').dependsOn( - ([':docker:datahub-ingestion'] + quickstart_modules) + ([':docker:datahub-ingestion'] + backend_profile_modules) .collect { it + ':dockerTag' }) tasks.getByName('quickstartDebugComposeUp').dependsOn( debug_modules.collect { it + ':dockerTagDebug' } diff --git a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java index fa896814d16f62..a4eb035b0abce5 100644 --- a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java +++ b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java @@ -136,6 +136,7 @@ public static void resetBaseExpectations() { .respond(HttpResponse.response().withStatusCode(200)); } + @BeforeClass public static void init() { mockServer = startClientAndServer(GMS_PORT); resetBaseExpectations(); @@ -219,8 +220,12 @@ private static void clear() { @AfterClass public static void tearDown() throws Exception { - spark.stop(); - mockServer.stop(); + if (spark != null) { + spark.stop(); + } + if (mockServer != null) { + mockServer.stop(); + } } private static void check(List expected, List actual) { From acec2a7159bc7fcc7ad37a3709b3c68d5d26536e Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Thu, 25 Jan 2024 13:04:50 -0600 Subject: [PATCH 215/540] feat(search): support filtering on count type searchable fields for equality (#9700) --- .../linkedin/metadata/models/EntitySpec.java | 15 ++ .../metadata/models/EntitySpecBuilder.java | 4 +- .../models/registry/ConfigEntityRegistry.java | 2 +- .../models/registry/MergedEntityRegistry.java | 21 +-- .../models/registry/PatchEntityRegistry.java | 28 ++- .../registry/SnapshotEntityRegistry.java | 2 +- .../models/EntitySpecBuilderTest.java | 16 +- .../elasticsearch/query/ESBrowseDAO.java | 18 +- .../elasticsearch/query/ESSearchDAO.java | 15 +- .../request/AutocompleteRequestHandler.java | 19 +- .../query/request/SearchRequestHandler.java | 23 ++- .../metadata/search/utils/ESUtils.java | 172 +++++++++++++----- .../ElasticSearchTimeseriesAspectService.java | 45 +++-- .../elastic/query/ESAggregatedStatsDAO.java | 4 +- .../search/fixtures/GoldenTestBase.java | 40 +++- .../indexbuilder/MappingsBuilderTest.java | 3 +- .../request/SearchRequestHandlerTest.java | 2 +- .../metadata/search/utils/ESUtilsTest.java | 31 ++-- .../TimeseriesAspectServiceTestBase.java | 59 ++++++ .../test/search/SearchTestUtils.java | 18 ++ .../long_tail/datasetindex_v2.json.gz | Bin 183656 -> 183668 bytes .../com/datahub/test/TestEntityInfo.pdl | 6 + 22 files changed, 430 insertions(+), 113 deletions(-) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java index e4c9dd55a3b4ae..fac08c7e206463 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java @@ -3,8 +3,11 @@ import com.linkedin.data.schema.RecordDataSchema; import com.linkedin.data.schema.TyperefDataSchema; import com.linkedin.metadata.models.annotation.EntityAnnotation; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** A specification of a DataHub Entity */ @@ -36,6 +39,18 @@ default List getSearchableFieldSpecs() { .collect(Collectors.toList()); } + default Map> getSearchableFieldSpecMap() { + return getSearchableFieldSpecs().stream() + .collect( + Collectors.toMap( + searchableFieldSpec -> searchableFieldSpec.getSearchableAnnotation().getFieldName(), + searchableFieldSpec -> new HashSet<>(Collections.singleton(searchableFieldSpec)), + (set1, set2) -> { + set1.addAll(set2); + return set1; + })); + } + default List getSearchScoreFieldSpecs() { return getAspectSpecs().stream() .map(AspectSpec::getSearchScoreFieldSpecs) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpecBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpecBuilder.java index 580134f5668718..54f2206798da0d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpecBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpecBuilder.java @@ -248,9 +248,9 @@ public AspectSpec buildAspectSpec( // Extract SearchScore Field Specs final SearchScoreFieldSpecExtractor searchScoreFieldSpecExtractor = new SearchScoreFieldSpecExtractor(); - final DataSchemaRichContextTraverser searcScoreFieldSpecTraverser = + final DataSchemaRichContextTraverser searchScoreFieldSpecTraverser = new DataSchemaRichContextTraverser(searchScoreFieldSpecExtractor); - searcScoreFieldSpecTraverser.traverse(processedSearchScoreResult.getResultSchema()); + searchScoreFieldSpecTraverser.traverse(processedSearchScoreResult.getResultSchema()); final SchemaAnnotationProcessor.SchemaAnnotationProcessResult processedRelationshipResult = SchemaAnnotationProcessor.process( diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java index 41043995a3b77e..9aed29ab8595e7 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/ConfigEntityRegistry.java @@ -91,7 +91,7 @@ private static Pair getFileAndClassPath(String entityRegistryRoot) .filter(Files::isRegularFile) .filter(f -> f.endsWith("entity-registry.yml") || f.endsWith("entity-registry.yaml")) .collect(Collectors.toList()); - if (yamlFiles.size() == 0) { + if (yamlFiles.isEmpty()) { throw new EntityRegistryException( String.format( "Did not find an entity registry (entity_registry.yaml/yml) under %s", diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java index 650a1cd41066e9..0dcd0420d4df82 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/MergedEntityRegistry.java @@ -58,7 +58,7 @@ private void validateEntitySpec(EntitySpec entitySpec, final ValidationResult va validationResult.setValid(false); validationResult .getValidationFailures() - .add(String.format("Key aspect is missing in entity {}", entitySpec.getName())); + .add(String.format("Key aspect is missing in entity %s", entitySpec.getName())); } } @@ -86,7 +86,7 @@ public MergedEntityRegistry apply(EntityRegistry patchEntityRegistry) } // Merge Event Specs - if (patchEntityRegistry.getEventSpecs().size() > 0) { + if (!patchEntityRegistry.getEventSpecs().isEmpty()) { eventNameToSpec.putAll(patchEntityRegistry.getEventSpecs()); } // TODO: Validate that the entity registries don't have conflicts among each other @@ -116,19 +116,18 @@ private void checkMergeable( if (existingEntitySpec != null) { existingEntitySpec .getAspectSpecMap() - .entrySet() .forEach( - aspectSpecEntry -> { - if (newEntitySpec.hasAspect(aspectSpecEntry.getKey())) { + (key, value) -> { + if (newEntitySpec.hasAspect(key)) { CompatibilityResult result = CompatibilityChecker.checkCompatibility( - aspectSpecEntry.getValue().getPegasusSchema(), - newEntitySpec.getAspectSpec(aspectSpecEntry.getKey()).getPegasusSchema(), + value.getPegasusSchema(), + newEntitySpec.getAspectSpec(key).getPegasusSchema(), new CompatibilityOptions()); if (result.isError()) { log.error( "{} schema is not compatible with previous schema due to {}", - aspectSpecEntry.getKey(), + key, result.getMessages()); // we want to continue processing all aspects to collect all failures validationResult.setValid(false); @@ -137,11 +136,11 @@ private void checkMergeable( .add( String.format( "%s schema is not compatible with previous schema due to %s", - aspectSpecEntry.getKey(), result.getMessages())); + key, result.getMessages())); } else { log.info( "{} schema is compatible with previous schema due to {}", - aspectSpecEntry.getKey(), + key, result.getMessages()); } } @@ -222,7 +221,7 @@ public PluginFactory getPluginFactory() { @Setter @Getter - private class ValidationResult { + private static class ValidationResult { boolean valid = true; List validationFailures = new ArrayList<>(); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java index b82b905c500042..b4fc4193e7263b 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PatchEntityRegistry.java @@ -71,19 +71,17 @@ public class PatchEntityRegistry implements EntityRegistry { @Override public String toString() { StringBuilder sb = new StringBuilder("PatchEntityRegistry[" + "identifier=" + identifier + ';'); - entityNameToSpec.entrySet().stream() - .forEach( - entry -> - sb.append("[entityName=") - .append(entry.getKey()) - .append(";aspects=[") - .append( - entry.getValue().getAspectSpecs().stream() - .map(spec -> spec.getName()) - .collect(Collectors.joining(","))) - .append("]]")); - eventNameToSpec.entrySet().stream() - .forEach(entry -> sb.append("[eventName=").append(entry.getKey()).append("]")); + entityNameToSpec.forEach( + (key1, value1) -> + sb.append("[entityName=") + .append(key1) + .append(";aspects=[") + .append( + value1.getAspectSpecs().stream() + .map(AspectSpec::getName) + .collect(Collectors.joining(","))) + .append("]]")); + eventNameToSpec.forEach((key, value) -> sb.append("[eventName=").append(key).append("]")); return sb.toString(); } @@ -119,7 +117,7 @@ private static Pair getFileAndClassPath(String entityRegistryRoot) .filter(Files::isRegularFile) .filter(f -> f.endsWith("entity-registry.yml") || f.endsWith("entity-registry.yaml")) .collect(Collectors.toList()); - if (yamlFiles.size() == 0) { + if (yamlFiles.isEmpty()) { throw new EntityRegistryException( String.format( "Did not find an entity registry (entity-registry.yaml/yml) under %s", @@ -175,7 +173,7 @@ private PatchEntityRegistry( entities = OBJECT_MAPPER.readValue(configFileStream, Entities.class); this.pluginFactory = PluginFactory.withCustomClasspath(entities.getPlugins(), classLoaders); } catch (IOException e) { - e.printStackTrace(); + log.error("Unable to read Patch configuration.", e); throw new IllegalArgumentException( String.format( "Error while reading config file in path %s: %s", configFileStream, e.getMessage())); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java index 8fefa2fe00ae86..22aeddb6ac65fd 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java @@ -120,7 +120,7 @@ public AspectTemplateEngine getAspectTemplateEngine() { } @Override - public EventSpec getEventSpec(final String ignored) { + public EventSpec getEventSpec(@Nonnull final String ignored) { return null; } diff --git a/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java b/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java index d9cf8fd2603a8f..8b043569dd16a2 100644 --- a/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java +++ b/entity-registry/src/test/java/com/linkedin/metadata/models/EntitySpecBuilderTest.java @@ -189,7 +189,7 @@ private void validateTestEntityInfo(final AspectSpec testEntityInfo) { testEntityInfo.getPegasusSchema().getFullName()); // Assert on Searchable Fields - assertEquals(testEntityInfo.getSearchableFieldSpecs().size(), 11); + assertEquals(testEntityInfo.getSearchableFieldSpecs().size(), 12); assertEquals( "customProperties", testEntityInfo @@ -340,6 +340,20 @@ private void validateTestEntityInfo(final AspectSpec testEntityInfo) { .get(new PathSpec("doubleField").toString()) .getSearchableAnnotation() .getFieldType()); + assertEquals( + "removed", + testEntityInfo + .getSearchableFieldSpecMap() + .get(new PathSpec("removed").toString()) + .getSearchableAnnotation() + .getFieldName()); + assertEquals( + SearchableAnnotation.FieldType.BOOLEAN, + testEntityInfo + .getSearchableFieldSpecMap() + .get(new PathSpec("removed").toString()) + .getSearchableAnnotation() + .getFieldType()); // Assert on Relationship Fields assertEquals(4, testEntityInfo.getRelationshipFieldSpecs().size()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java index 3c71a2dfd91809..d610ea4b4e028a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java @@ -19,6 +19,7 @@ import com.linkedin.metadata.config.search.SearchConfiguration; import com.linkedin.metadata.config.search.custom.CustomSearchConfiguration; import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.elasticsearch.query.request.SearchRequestHandler; @@ -33,6 +34,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -554,7 +556,8 @@ private QueryBuilder buildQueryStringV2( queryBuilder.filter(QueryBuilders.rangeQuery(BROWSE_PATH_V2_DEPTH).gt(browseDepthVal)); - queryBuilder.filter(SearchRequestHandler.getFilterQuery(filter)); + queryBuilder.filter( + SearchRequestHandler.getFilterQuery(filter, entitySpec.getSearchableFieldSpecMap())); return queryBuilder; } @@ -580,7 +583,18 @@ private QueryBuilder buildQueryStringBrowseAcrossEntities( queryBuilder.filter(QueryBuilders.rangeQuery(BROWSE_PATH_V2_DEPTH).gt(browseDepthVal)); - queryBuilder.filter(SearchRequestHandler.getFilterQuery(filter)); + Map> searchableFields = + entitySpecs.stream() + .flatMap(entitySpec -> entitySpec.getSearchableFieldSpecMap().entrySet().stream()) + .collect( + Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (set1, set2) -> { + set1.addAll(set2); + return set1; + })); + queryBuilder.filter(SearchRequestHandler.getFilterQuery(filter, searchableFields)); return queryBuilder; } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java index 0eb44edfb11dea..1ec90ed6f61e29 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java @@ -78,7 +78,8 @@ public long docCount(@Nonnull String entityName) { EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); CountRequest countRequest = new CountRequest(indexConvention.getIndexName(entitySpec)) - .query(SearchRequestHandler.getFilterQuery(null)); + .query( + SearchRequestHandler.getFilterQuery(null, entitySpec.getSearchableFieldSpecMap())); try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "docCount").time()) { return client.count(countRequest, RequestOptions.DEFAULT).getCount(); } catch (IOException e) { @@ -315,9 +316,17 @@ public Map aggregateByValue( @Nonnull String field, @Nullable Filter requestParams, int limit) { + List entitySpecs; + if (entityNames == null || entityNames.isEmpty()) { + entitySpecs = new ArrayList<>(entityRegistry.getEntitySpecs().values()); + } else { + entitySpecs = + entityNames.stream().map(entityRegistry::getEntitySpec).collect(Collectors.toList()); + } final SearchRequest searchRequest = - SearchRequestHandler.getAggregationRequest( - field, transformFilterForEntities(requestParams, indexConvention), limit); + SearchRequestHandler.getBuilder(entitySpecs, searchConfiguration, customSearchConfiguration) + .getAggregationRequest( + field, transformFilterForEntities(requestParams, indexConvention), limit); if (entityNames == null) { String indexName = indexConvention.getAllEntityIndicesPattern(); searchRequest.indices(indexName); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java index cdcdae2f3d311d..333d9602734d25 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java @@ -14,6 +14,7 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.utils.ESUtils; import java.net.URISyntaxException; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -40,19 +41,33 @@ public class AutocompleteRequestHandler { private final List _defaultAutocompleteFields; + private final Map> searchableFields; private static final Map AUTOCOMPLETE_QUERY_BUILDER_BY_ENTITY_NAME = new ConcurrentHashMap<>(); public AutocompleteRequestHandler(@Nonnull EntitySpec entitySpec) { + List fieldSpecs = entitySpec.getSearchableFieldSpecs(); _defaultAutocompleteFields = Stream.concat( - entitySpec.getSearchableFieldSpecs().stream() + fieldSpecs.stream() .map(SearchableFieldSpec::getSearchableAnnotation) .filter(SearchableAnnotation::isEnableAutocomplete) .map(SearchableAnnotation::getFieldName), Stream.of("urn")) .collect(Collectors.toList()); + searchableFields = + fieldSpecs.stream() + .collect( + Collectors.toMap( + searchableFieldSpec -> + searchableFieldSpec.getSearchableAnnotation().getFieldName(), + searchableFieldSpec -> + new HashSet<>(Collections.singleton(searchableFieldSpec)), + (set1, set2) -> { + set1.addAll(set2); + return set1; + })); } public static AutocompleteRequestHandler getBuilder(@Nonnull EntitySpec entitySpec) { @@ -66,7 +81,7 @@ public SearchRequest getSearchRequest( SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); searchSourceBuilder.size(limit); searchSourceBuilder.query(getQuery(input, field)); - searchSourceBuilder.postFilter(ESUtils.buildFilterQuery(filter, false)); + searchSourceBuilder.postFilter(ESUtils.buildFilterQuery(filter, false, searchableFields)); searchSourceBuilder.highlighter(getHighlights(field)); searchRequest.source(searchSourceBuilder); return searchRequest; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java index c5a5ade216bf7c..e6ee909c80dae4 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java @@ -97,6 +97,7 @@ public class SearchRequestHandler { private final SearchConfiguration _configs; private final SearchQueryBuilder _searchQueryBuilder; private final AggregationQueryBuilder _aggregationQueryBuilder; + private final Map> searchableFields; private SearchRequestHandler( @Nonnull EntitySpec entitySpec, @@ -121,6 +122,17 @@ private SearchRequestHandler( _searchQueryBuilder = new SearchQueryBuilder(configs, customSearchConfiguration); _aggregationQueryBuilder = new AggregationQueryBuilder(configs, annotations); _configs = configs; + searchableFields = + _entitySpecs.stream() + .flatMap(entitySpec -> entitySpec.getSearchableFieldSpecMap().entrySet().stream()) + .collect( + Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (set1, set2) -> { + set1.addAll(set2); + return set1; + })); } public static SearchRequestHandler getBuilder( @@ -169,8 +181,13 @@ private BinaryOperator mapMerger() { }; } - public static BoolQueryBuilder getFilterQuery(@Nullable Filter filter) { - BoolQueryBuilder filterQuery = ESUtils.buildFilterQuery(filter, false); + public BoolQueryBuilder getFilterQuery(@Nullable Filter filter) { + return getFilterQuery(filter, searchableFields); + } + + public static BoolQueryBuilder getFilterQuery( + @Nullable Filter filter, Map> searchableFields) { + BoolQueryBuilder filterQuery = ESUtils.buildFilterQuery(filter, false, searchableFields); return filterSoftDeletedByDefault(filter, filterQuery); } @@ -354,7 +371,7 @@ public SearchRequest getFilterRequest( * @return {@link SearchRequest} that contains the aggregation query */ @Nonnull - public static SearchRequest getAggregationRequest( + public SearchRequest getAggregationRequest( @Nonnull String field, @Nullable Filter filter, int limit) { SearchRequest searchRequest = new SearchRequest(); BoolQueryBuilder filterQuery = getFilterQuery(filter); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java index aa854149de43ae..77a67f100895c8 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java @@ -7,7 +7,6 @@ import static com.linkedin.metadata.search.utils.SearchUtils.isUrn; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.StructuredPropertyUtils; @@ -18,11 +17,13 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -32,6 +33,7 @@ import org.opensearch.index.query.BoolQueryBuilder; import org.opensearch.index.query.QueryBuilder; import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.query.RangeQueryBuilder; import org.opensearch.search.builder.PointInTimeBuilder; import org.opensearch.search.builder.SearchSourceBuilder; import org.opensearch.search.sort.FieldSortBuilder; @@ -76,6 +78,13 @@ public class ESUtils { SearchableAnnotation.FieldType.BROWSE_PATH_V2, SearchableAnnotation.FieldType.URN, SearchableAnnotation.FieldType.URN_PARTIAL); + + public static final Set RANGE_QUERY_CONDITIONS = + Set.of( + Condition.GREATER_THAN, + Condition.GREATER_THAN_OR_EQUAL_TO, + Condition.LESS_THAN, + Condition.LESS_THAN_OR_EQUAL_TO); public static final String ENTITY_NAME_FIELD = "_entityName"; public static final String NAME_SUGGESTION = "nameSuggestion"; @@ -100,9 +109,6 @@ public class ESUtils { } }; - // TODO - This has been expanded for has* in another branch - public static final Set BOOLEAN_FIELDS = ImmutableSet.of("removed"); - /* * Refer to https://www.elastic.co/guide/en/elasticsearch/reference/current/regexp-syntax.html for list of reserved * characters in an Elasticsearch regular expression. @@ -123,7 +129,10 @@ private ESUtils() {} * @return built filter query */ @Nonnull - public static BoolQueryBuilder buildFilterQuery(@Nullable Filter filter, boolean isTimeseries) { + public static BoolQueryBuilder buildFilterQuery( + @Nullable Filter filter, + boolean isTimeseries, + final Map> searchableFields) { BoolQueryBuilder finalQueryBuilder = QueryBuilders.boolQuery(); if (filter == null) { return finalQueryBuilder; @@ -134,7 +143,8 @@ public static BoolQueryBuilder buildFilterQuery(@Nullable Filter filter, boolean .getOr() .forEach( or -> - finalQueryBuilder.should(ESUtils.buildConjunctiveFilterQuery(or, isTimeseries))); + finalQueryBuilder.should( + ESUtils.buildConjunctiveFilterQuery(or, isTimeseries, searchableFields))); } else if (filter.getCriteria() != null) { // Otherwise, build boolean query from the deprecated "criteria" field. log.warn("Received query Filter with a deprecated field 'criteria'. Use 'or' instead."); @@ -146,7 +156,8 @@ public static BoolQueryBuilder buildFilterQuery(@Nullable Filter filter, boolean if (!criterion.getValue().trim().isEmpty() || criterion.hasValues() || criterion.getCondition() == Condition.IS_NULL) { - andQueryBuilder.must(getQueryBuilderFromCriterion(criterion, isTimeseries)); + andQueryBuilder.must( + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); } }); finalQueryBuilder.should(andQueryBuilder); @@ -156,7 +167,9 @@ public static BoolQueryBuilder buildFilterQuery(@Nullable Filter filter, boolean @Nonnull public static BoolQueryBuilder buildConjunctiveFilterQuery( - @Nonnull ConjunctiveCriterion conjunctiveCriterion, boolean isTimeseries) { + @Nonnull ConjunctiveCriterion conjunctiveCriterion, + boolean isTimeseries, + Map> searchableFields) { final BoolQueryBuilder andQueryBuilder = new BoolQueryBuilder(); conjunctiveCriterion .getAnd() @@ -167,9 +180,11 @@ public static BoolQueryBuilder buildConjunctiveFilterQuery( || criterion.hasValues()) { if (!criterion.isNegated()) { // `filter` instead of `must` (enables caching and bypasses scoring) - andQueryBuilder.filter(getQueryBuilderFromCriterion(criterion, isTimeseries)); + andQueryBuilder.filter( + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); } else { - andQueryBuilder.mustNot(getQueryBuilderFromCriterion(criterion, isTimeseries)); + andQueryBuilder.mustNot( + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); } } }); @@ -205,7 +220,9 @@ public static BoolQueryBuilder buildConjunctiveFilterQuery( */ @Nonnull public static QueryBuilder getQueryBuilderFromCriterion( - @Nonnull final Criterion criterion, boolean isTimeseries) { + @Nonnull final Criterion criterion, + boolean isTimeseries, + final Map> searchableFields) { final String fieldName = toFacetField(criterion.getField()); if (fieldName.startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD)) { criterion.setField(fieldName); @@ -224,10 +241,10 @@ public static QueryBuilder getQueryBuilderFromCriterion( if (maybeFieldToExpand.isPresent()) { return getQueryBuilderFromCriterionForFieldToExpand( - maybeFieldToExpand.get(), criterion, isTimeseries); + maybeFieldToExpand.get(), criterion, isTimeseries, searchableFields); } - return getQueryBuilderFromCriterionForSingleField(criterion, isTimeseries); + return getQueryBuilderFromCriterionForSingleField(criterion, isTimeseries, searchableFields); } public static String getElasticTypeForFieldType(SearchableAnnotation.FieldType fieldType) { @@ -378,7 +395,7 @@ public static String toFacetField(@Nonnull final String filterField) { @Nonnull public static String toKeywordField( - @Nonnull final String filterField, @Nonnull final boolean skipKeywordSuffix) { + @Nonnull final String filterField, final boolean skipKeywordSuffix) { return skipKeywordSuffix || KEYWORD_FIELDS.contains(filterField) || PATH_HIERARCHY_FIELDS.contains(filterField) @@ -428,7 +445,8 @@ public static void setSearchAfter( private static QueryBuilder getQueryBuilderFromCriterionForFieldToExpand( @Nonnull final List fields, @Nonnull final Criterion criterion, - final boolean isTimeseries) { + final boolean isTimeseries, + final Map> searchableFields) { final BoolQueryBuilder orQueryBuilder = new BoolQueryBuilder(); for (String field : fields) { Criterion criterionToQuery = new Criterion(); @@ -442,14 +460,17 @@ private static QueryBuilder getQueryBuilderFromCriterionForFieldToExpand( } criterionToQuery.setField(toKeywordField(field, isTimeseries)); orQueryBuilder.should( - getQueryBuilderFromCriterionForSingleField(criterionToQuery, isTimeseries)); + getQueryBuilderFromCriterionForSingleField( + criterionToQuery, isTimeseries, searchableFields)); } return orQueryBuilder; } @Nonnull private static QueryBuilder getQueryBuilderFromCriterionForSingleField( - @Nonnull Criterion criterion, @Nonnull boolean isTimeseries) { + @Nonnull Criterion criterion, + boolean isTimeseries, + final Map> searchableFields) { final Condition condition = criterion.getCondition(); final String fieldName = toFacetField(criterion.getField()); @@ -463,24 +484,11 @@ private static QueryBuilder getQueryBuilderFromCriterionForSingleField( .queryName(fieldName); } else if (criterion.hasValues() || criterion.hasValue()) { if (condition == Condition.EQUAL) { - return buildEqualsConditionFromCriterion(fieldName, criterion, isTimeseries); - // TODO: Support multi-match on the following operators (using new 'values' field) - } else if (condition == Condition.GREATER_THAN) { - return QueryBuilders.rangeQuery(criterion.getField()) - .gt(criterion.getValue().trim()) - .queryName(fieldName); - } else if (condition == Condition.GREATER_THAN_OR_EQUAL_TO) { - return QueryBuilders.rangeQuery(criterion.getField()) - .gte(criterion.getValue().trim()) - .queryName(fieldName); - } else if (condition == Condition.LESS_THAN) { - return QueryBuilders.rangeQuery(criterion.getField()) - .lt(criterion.getValue().trim()) - .queryName(fieldName); - } else if (condition == Condition.LESS_THAN_OR_EQUAL_TO) { - return QueryBuilders.rangeQuery(criterion.getField()) - .lte(criterion.getValue().trim()) - .queryName(fieldName); + return buildEqualsConditionFromCriterion( + fieldName, criterion, isTimeseries, searchableFields); + } else if (RANGE_QUERY_CONDITIONS.contains(condition)) { + return buildRangeQueryFromCriterion( + criterion, fieldName, searchableFields, condition, isTimeseries); } else if (condition == Condition.CONTAIN) { return QueryBuilders.wildcardQuery( toKeywordField(criterion.getField(), isTimeseries), @@ -504,13 +512,15 @@ private static QueryBuilder getQueryBuilderFromCriterionForSingleField( private static QueryBuilder buildEqualsConditionFromCriterion( @Nonnull final String fieldName, @Nonnull final Criterion criterion, - final boolean isTimeseries) { + final boolean isTimeseries, + final Map> searchableFields) { /* * If the newer 'values' field of Criterion.pdl is set, then we * handle using the following code to allow multi-match. */ if (!criterion.getValues().isEmpty()) { - return buildEqualsConditionFromCriterionWithValues(fieldName, criterion, isTimeseries); + return buildEqualsConditionFromCriterionWithValues( + fieldName, criterion, isTimeseries, searchableFields); } /* * Otherwise, we are likely using the deprecated 'value' field. @@ -526,21 +536,95 @@ private static QueryBuilder buildEqualsConditionFromCriterion( private static QueryBuilder buildEqualsConditionFromCriterionWithValues( @Nonnull final String fieldName, @Nonnull final Criterion criterion, - final boolean isTimeseries) { - if (BOOLEAN_FIELDS.contains(fieldName) && criterion.getValues().size() == 1) { - // Handle special-cased Boolean fields. - // here we special case boolean fields we recognize the names of and hard-cast - // the first provided value to a boolean to do the comparison. - // Ideally, we should detect the type of the field from the entity-registry in order - // to determine how to cast. + final boolean isTimeseries, + final Map> searchableFields) { + Set fieldTypes = getFieldTypes(searchableFields, fieldName); + if (fieldTypes.size() > 1) { + log.warn( + "Multiple field types for field name {}, determining best fit for set: {}", + fieldName, + fieldTypes); + } + if (fieldTypes.contains(BOOLEAN_FIELD_TYPE) && criterion.getValues().size() == 1) { return QueryBuilders.termQuery(fieldName, Boolean.parseBoolean(criterion.getValues().get(0))) .queryName(fieldName); + } else if (fieldTypes.contains(LONG_FIELD_TYPE) || fieldTypes.contains(DATE_FIELD_TYPE)) { + List longValues = + criterion.getValues().stream().map(Long::parseLong).collect(Collectors.toList()); + return QueryBuilders.termsQuery(fieldName, longValues).queryName(fieldName); + } else if (fieldTypes.contains(DOUBLE_FIELD_TYPE)) { + List doubleValues = + criterion.getValues().stream().map(Double::parseDouble).collect(Collectors.toList()); + return QueryBuilders.termsQuery(fieldName, doubleValues).queryName(fieldName); } return QueryBuilders.termsQuery( toKeywordField(criterion.getField(), isTimeseries), criterion.getValues()) .queryName(fieldName); } + private static Set getFieldTypes( + Map> searchableFields, String fieldName) { + Set fieldSpecs = + searchableFields.getOrDefault(fieldName, Collections.emptySet()); + Set fieldTypes = + fieldSpecs.stream() + .map(SearchableFieldSpec::getSearchableAnnotation) + .map(SearchableAnnotation::getFieldType) + .map(ESUtils::getElasticTypeForFieldType) + .collect(Collectors.toSet()); + if (fieldTypes.size() > 1) { + log.warn( + "Multiple field types for field name {}, determining best fit for set: {}", + fieldName, + fieldTypes); + } + return fieldTypes; + } + + private static RangeQueryBuilder buildRangeQueryFromCriterion( + Criterion criterion, + String fieldName, + Map> searchableFields, + Condition condition, + boolean isTimeseries) { + Set fieldTypes = getFieldTypes(searchableFields, fieldName); + + // Determine criterion value, range query only accepts single value so take first value in + // values if multiple + String criterionValueString; + if (!criterion.getValues().isEmpty()) { + criterionValueString = criterion.getValues().get(0).trim(); + } else { + criterionValueString = criterion.getValue().trim(); + } + Object criterionValue; + String documentFieldName; + if (fieldTypes.contains(BOOLEAN_FIELD_TYPE)) { + criterionValue = Boolean.parseBoolean(criterionValueString); + documentFieldName = criterion.getField(); + } else if (fieldTypes.contains(LONG_FIELD_TYPE) || fieldTypes.contains(DATE_FIELD_TYPE)) { + criterionValue = Long.parseLong(criterionValueString); + documentFieldName = criterion.getField(); + } else if (fieldTypes.contains(DOUBLE_FIELD_TYPE)) { + criterionValue = Double.parseDouble(criterionValueString); + documentFieldName = criterion.getField(); + } else { + criterionValue = criterionValueString; + documentFieldName = toKeywordField(criterion.getField(), isTimeseries); + } + + // Set up QueryBuilder based on condition + if (condition == Condition.GREATER_THAN) { + return QueryBuilders.rangeQuery(documentFieldName).gt(criterionValue).queryName(fieldName); + } else if (condition == Condition.GREATER_THAN_OR_EQUAL_TO) { + return QueryBuilders.rangeQuery(documentFieldName).gte(criterionValue).queryName(fieldName); + } else if (condition == Condition.LESS_THAN) { + return QueryBuilders.rangeQuery(documentFieldName).lt(criterionValue).queryName(fieldName); + } else /*if (condition == Condition.LESS_THAN_OR_EQUAL_TO)*/ { + return QueryBuilders.rangeQuery(documentFieldName).lte(criterionValue).queryName(fieldName); + } + } + /** * Builds an instance of {@link QueryBuilder} representing an EQUALS condition which was created * using the deprecated 'value' field of Criterion.pdl model. diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java index a2b36b7d8ddb87..6cf8e92d61929f 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java @@ -14,6 +14,7 @@ import com.linkedin.metadata.aspect.EnvelopedAspect; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.Criterion; @@ -290,7 +291,12 @@ public long countByFilter( @Nullable final Filter filter) { final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); final BoolQueryBuilder filterQueryBuilder = - QueryBuilders.boolQuery().must(ESUtils.buildFilterQuery(filter, true)); + QueryBuilders.boolQuery() + .must( + ESUtils.buildFilterQuery( + filter, + true, + _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap())); CountRequest countRequest = new CountRequest(); countRequest.query(filterQueryBuilder); countRequest.indices(indexName); @@ -313,8 +319,10 @@ public List getAspectValues( @Nullable final Integer limit, @Nullable final Filter filter, @Nullable final SortCriterion sort) { + Map> searchableFields = + _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap(); final BoolQueryBuilder filterQueryBuilder = - QueryBuilders.boolQuery().must(ESUtils.buildFilterQuery(filter, true)); + QueryBuilders.boolQuery().must(ESUtils.buildFilterQuery(filter, true, searchableFields)); filterQueryBuilder.must(QueryBuilders.matchQuery("urn", urn.toString())); // NOTE: We are interested only in the un-exploded rows as only they carry the `event` payload. filterQueryBuilder.mustNot(QueryBuilders.termQuery(MappingsBuilder.IS_EXPLODED_FIELD, true)); @@ -324,7 +332,8 @@ public List getAspectValues( .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) .setValue(startTimeMillis.toString()); - filterQueryBuilder.must(ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true)); + filterQueryBuilder.must( + ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFields)); } if (endTimeMillis != null) { Criterion endTimeCriterion = @@ -332,7 +341,8 @@ public List getAspectValues( .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) .setValue(endTimeMillis.toString()); - filterQueryBuilder.must(ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true)); + filterQueryBuilder.must( + ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFields)); } final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); searchSourceBuilder.query(filterQueryBuilder); @@ -400,7 +410,9 @@ public GenericTable getAggregatedStats( public DeleteAspectValuesResult deleteAspectValues( @Nonnull String entityName, @Nonnull String aspectName, @Nonnull Filter filter) { final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); - final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery(filter, true); + final BoolQueryBuilder filterQueryBuilder = + ESUtils.buildFilterQuery( + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); final Optional result = _bulkProcessor @@ -426,7 +438,9 @@ public String deleteAspectValuesAsync( @Nonnull Filter filter, @Nonnull BatchWriteOperationsOptions options) { final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); - final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery(filter, true); + final BoolQueryBuilder filterQueryBuilder = + ESUtils.buildFilterQuery( + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); final int batchSize = options.getBatchSize() > 0 ? options.getBatchSize() : DEFAULT_LIMIT; TimeValue timeout = options.getTimeoutSeconds() > 0 @@ -450,7 +464,9 @@ public String reindexAsync( @Nonnull Filter filter, @Nonnull BatchWriteOperationsOptions options) { final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); - final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery(filter, true); + final BoolQueryBuilder filterQueryBuilder = + ESUtils.buildFilterQuery( + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); try { return this.reindexAsync(indexName, filterQueryBuilder, options); } catch (Exception e) { @@ -498,8 +514,11 @@ public TimeseriesScrollResult scrollAspects( int count, @Nullable Long startTimeMillis, @Nullable Long endTimeMillis) { + + Map> searchableFields = + _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap(); final BoolQueryBuilder filterQueryBuilder = - QueryBuilders.boolQuery().filter(ESUtils.buildFilterQuery(filter, true)); + QueryBuilders.boolQuery().filter(ESUtils.buildFilterQuery(filter, true, searchableFields)); if (startTimeMillis != null) { Criterion startTimeCriterion = @@ -507,7 +526,8 @@ public TimeseriesScrollResult scrollAspects( .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) .setValue(startTimeMillis.toString()); - filterQueryBuilder.filter(ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true)); + filterQueryBuilder.filter( + ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFields)); } if (endTimeMillis != null) { Criterion endTimeCriterion = @@ -515,7 +535,8 @@ public TimeseriesScrollResult scrollAspects( .setField(MappingsBuilder.TIMESTAMP_MILLIS_FIELD) .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) .setValue(endTimeMillis.toString()); - filterQueryBuilder.filter(ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true)); + filterQueryBuilder.filter( + ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFields)); } SearchResponse response = @@ -537,7 +558,7 @@ public TimeseriesScrollResult scrollAspects( } private SearchResponse executeScrollSearchQuery( - @Nonnull final String entityNname, + @Nonnull final String entityName, @Nonnull final String aspectName, @Nonnull final QueryBuilder query, @Nonnull List sortCriterion, @@ -560,7 +581,7 @@ private SearchResponse executeScrollSearchQuery( searchRequest.source(searchSourceBuilder); ESUtils.setSearchAfter(searchSourceBuilder, sort, null, null); - searchRequest.indices(_indexConvention.getTimeseriesAspectIndexName(entityNname, aspectName)); + searchRequest.indices(_indexConvention.getTimeseriesAspectIndexName(entityName, aspectName)); try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "scrollAspects_search").time()) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java index 539e5dfbaa1d04..f8b2cd85523576 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java @@ -377,7 +377,9 @@ public GenericTable getAggregatedStats( @Nullable GroupingBucket[] groupingBuckets) { // Setup the filter query builder using the input filter provided. - final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery(filter, true); + final BoolQueryBuilder filterQueryBuilder = + ESUtils.buildFilterQuery( + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); AspectSpec aspectSpec = getTimeseriesAspectSpec(entityName, aspectName); // Build and attach the grouping aggregations diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java index d2aef982750bda..4c125065deb4da 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/GoldenTestBase.java @@ -1,18 +1,27 @@ package com.linkedin.metadata.search.fixtures; +import static com.linkedin.metadata.Constants.*; import static io.datahubproject.test.search.SearchTestUtils.searchAcrossCustomEntities; import static io.datahubproject.test.search.SearchTestUtils.searchAcrossEntities; -import static org.testng.Assert.assertTrue; +import static org.testng.Assert.*; import static org.testng.AssertJUnit.assertNotNull; +import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.StringArray; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.MatchedFieldArray; import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.search.SearchService; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -169,6 +178,35 @@ public void testNameMatchCustomerOrders() { assertTrue(firstResultScore > secondResultScore); } + @Test + public void testFilterOnCountField() { + assertNotNull(getSearchService()); + Filter filter = + new Filter() + .setOr( + new ConjunctiveCriterionArray( + new ConjunctiveCriterion() + .setAnd( + new CriterionArray( + ImmutableList.of( + new Criterion() + .setField("rowCount") + .setValue("") + .setValues(new StringArray(ImmutableList.of("68")))))))); + SearchResult searchResult = + searchAcrossEntities( + getSearchService(), + "*", + SEARCHABLE_LONGTAIL_ENTITIES, + filter, + Collections.singletonList(DATASET_ENTITY_NAME)); + assertFalse(searchResult.getEntities().isEmpty()); + Urn firstResultUrn = searchResult.getEntities().get(0).getEntity(); + assertEquals( + firstResultUrn.toString(), + "urn:li:dataset:(urn:li:dataPlatform:dbt,long_tail_companions.analytics.dogs_in_movies,PROD)"); + } + /* Tests that should pass but do not yet can be added below here, with the following annotation: @Test(enabled = false) diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java index 6df31b35fecde2..8d504c562c99cc 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/indexbuilder/MappingsBuilderTest.java @@ -21,7 +21,7 @@ public void testMappingsBuilder() { Map result = MappingsBuilder.getMappings(TestEntitySpecBuilder.getSpec()); assertEquals(result.size(), 1); Map properties = (Map) result.get("properties"); - assertEquals(properties.size(), 20); + assertEquals(properties.size(), 21); assertEquals( properties.get("urn"), ImmutableMap.of( @@ -52,6 +52,7 @@ public void testMappingsBuilder() { assertEquals(properties.get("runId"), ImmutableMap.of("type", "keyword")); assertTrue(properties.containsKey("browsePaths")); assertTrue(properties.containsKey("browsePathV2")); + assertTrue(properties.containsKey("removed")); // KEYWORD Map keyPart3Field = (Map) properties.get("keyPart3"); assertEquals(keyPart3Field.get("type"), "keyword"); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java index daf2ac58002e06..02c9ea800f0af3 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/SearchRequestHandlerTest.java @@ -614,7 +614,7 @@ public void testBrowsePathQueryFilter() { Filter filter = new Filter(); filter.setOr(conjunctiveCriterionArray); - BoolQueryBuilder test = SearchRequestHandler.getFilterQuery(filter); + BoolQueryBuilder test = SearchRequestHandler.getFilterQuery(filter, new HashMap<>()); assertEquals(test.should().size(), 1); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java index 980b82194536e6..838df98fdce9c6 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/ESUtilsTest.java @@ -4,6 +4,7 @@ import com.linkedin.data.template.StringArray; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.Criterion; +import java.util.HashMap; import org.opensearch.index.query.QueryBuilder; import org.testng.Assert; import org.testng.annotations.Test; @@ -21,7 +22,8 @@ public void testGetQueryBuilderFromCriterionEqualsValues() { .setCondition(Condition.EQUAL) .setValues(new StringArray(ImmutableList.of("value1"))); - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"terms\" : {\n" @@ -40,7 +42,7 @@ public void testGetQueryBuilderFromCriterionEqualsValues() { .setCondition(Condition.EQUAL) .setValues(new StringArray(ImmutableList.of("value1", "value2"))); - result = ESUtils.getQueryBuilderFromCriterion(multiValueCriterion, false); + result = ESUtils.getQueryBuilderFromCriterion(multiValueCriterion, false, new HashMap<>()); expected = "{\n" + " \"terms\" : {\n" @@ -60,7 +62,7 @@ public void testGetQueryBuilderFromCriterionEqualsValues() { .setCondition(Condition.EQUAL) .setValues(new StringArray(ImmutableList.of("value1", "value2"))); - result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true, new HashMap<>()); expected = "{\n" + " \"terms\" : {\n" @@ -80,7 +82,8 @@ public void testGetQueryBuilderFromCriterionExists() { final Criterion singleValueCriterion = new Criterion().setField("myTestField").setCondition(Condition.EXISTS); - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"bool\" : {\n" @@ -103,7 +106,7 @@ public void testGetQueryBuilderFromCriterionExists() { final Criterion timeseriesField = new Criterion().setField("myTestField").setCondition(Condition.EXISTS); - result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true, new HashMap<>()); expected = "{\n" + " \"bool\" : {\n" @@ -128,7 +131,8 @@ public void testGetQueryBuilderFromCriterionIsNull() { final Criterion singleValueCriterion = new Criterion().setField("myTestField").setCondition(Condition.IS_NULL); - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"bool\" : {\n" @@ -151,7 +155,7 @@ public void testGetQueryBuilderFromCriterionIsNull() { final Criterion timeseriesField = new Criterion().setField("myTestField").setCondition(Condition.IS_NULL); - result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true, new HashMap<>()); expected = "{\n" + " \"bool\" : {\n" @@ -182,7 +186,8 @@ public void testGetQueryBuilderFromCriterionFieldToExpand() { .setValues(new StringArray(ImmutableList.of("value1"))); // Ensure that the query is expanded! - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"bool\" : {\n" @@ -220,7 +225,7 @@ public void testGetQueryBuilderFromCriterionFieldToExpand() { .setValues(new StringArray(ImmutableList.of("value1", "value2"))); // Ensure that the query is expanded without keyword. - result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true, new HashMap<>()); expected = "{\n" + " \"bool\" : {\n" @@ -262,7 +267,8 @@ public void testGetQueryBuilderFromStructPropEqualsValue() { .setCondition(Condition.EQUAL) .setValues(new StringArray(ImmutableList.of("value1"))); - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"terms\" : {\n" @@ -281,7 +287,8 @@ public void testGetQueryBuilderFromStructPropExists() { final Criterion singleValueCriterion = new Criterion().setField("structuredProperties.ab.fgh.ten").setCondition(Condition.EXISTS); - QueryBuilder result = ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false); + QueryBuilder result = + ESUtils.getQueryBuilderFromCriterion(singleValueCriterion, false, new HashMap<>()); String expected = "{\n" + " \"bool\" : {\n" @@ -304,7 +311,7 @@ public void testGetQueryBuilderFromStructPropExists() { final Criterion timeseriesField = new Criterion().setField("myTestField").setCondition(Condition.EXISTS); - result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true); + result = ESUtils.getQueryBuilderFromCriterion(timeseriesField, true, new HashMap<>()); expected = "{\n" + " \"bool\" : {\n" diff --git a/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceTestBase.java index 8d7701f6d174f8..23ca4a4a4247e1 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/timeseries/search/TimeseriesAspectServiceTestBase.java @@ -485,6 +485,65 @@ public void testGetAggregatedStatsLatestStatForDay1() { _testEntityProfiles.get(_startTime + 23 * TIME_INCREMENT).getStat().toString()))); } + @Test( + groups = {"getAggregatedStats"}, + dependsOnGroups = {"upsert"}) + public void testGetAggregatedStatsLatestStatForDay1WithValues() { + // Filter is only on the urn + Criterion hasUrnCriterion = + new Criterion().setField("urn").setCondition(Condition.EQUAL).setValue(TEST_URN.toString()); + Criterion startTimeCriterion = + new Criterion() + .setField(ES_FIELD_TIMESTAMP) + .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) + .setValues(new StringArray(_startTime.toString())) + .setValue(""); + Criterion endTimeCriterion = + new Criterion() + .setField(ES_FIELD_TIMESTAMP) + .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) + .setValues(new StringArray(String.valueOf(_startTime + 23 * TIME_INCREMENT))) + .setValue(""); + + Filter filter = + QueryUtils.getFilterFromCriteria( + ImmutableList.of(hasUrnCriterion, startTimeCriterion, endTimeCriterion)); + + // Aggregate on latest stat value + AggregationSpec latestStatAggregationSpec = + new AggregationSpec().setAggregationType(AggregationType.LATEST).setFieldPath("stat"); + + // Grouping bucket is only timestamp filed. + GroupingBucket timestampBucket = + new GroupingBucket() + .setKey(ES_FIELD_TIMESTAMP) + .setType(GroupingBucketType.DATE_GROUPING_BUCKET) + .setTimeWindowSize(new TimeWindowSize().setMultiple(1).setUnit(CalendarInterval.DAY)); + + GenericTable resultTable = + _elasticSearchTimeseriesAspectService.getAggregatedStats( + ENTITY_NAME, + ASPECT_NAME, + new AggregationSpec[] {latestStatAggregationSpec}, + filter, + new GroupingBucket[] {timestampBucket}); + // Validate column names + assertEquals( + resultTable.getColumnNames(), + new StringArray(ES_FIELD_TIMESTAMP, "latest_" + ES_FIELD_STAT)); + // Validate column types + assertEquals(resultTable.getColumnTypes(), new StringArray("long", "long")); + // Validate rows + assertNotNull(resultTable.getRows()); + assertEquals(resultTable.getRows().size(), 1); + assertEquals( + resultTable.getRows(), + new StringArrayArray( + new StringArray( + _startTime.toString(), + _testEntityProfiles.get(_startTime + 23 * TIME_INCREMENT).getStat().toString()))); + } + @Test( groups = {"getAggregatedStats"}, dependsOnGroups = {"upsert"}) diff --git a/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java b/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java index a22a774065852f..f3689f9b5d04ae 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java +++ b/metadata-io/src/test/java/io/datahubproject/test/search/SearchTestUtils.java @@ -15,6 +15,7 @@ import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.metadata.graph.LineageDirection; import com.linkedin.metadata.query.SearchFlags; +import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.LineageSearchResult; import com.linkedin.metadata.search.LineageSearchService; import com.linkedin.metadata.search.ScrollResult; @@ -70,6 +71,23 @@ public static SearchResult searchAcrossEntities( facets); } + public static SearchResult searchAcrossEntities( + SearchService searchService, + String query, + @Nullable List facets, + Filter filter, + List entityNames) { + return searchService.searchAcrossEntities( + entityNames, + query, + filter, + null, + 0, + 100, + new SearchFlags().setFulltext(true).setSkipCache(true), + facets); + } + public static SearchResult searchAcrossCustomEntities( SearchService searchService, String query, List searchableEntities) { return searchService.searchAcrossEntities( diff --git a/metadata-io/src/test/resources/elasticsearch/long_tail/datasetindex_v2.json.gz b/metadata-io/src/test/resources/elasticsearch/long_tail/datasetindex_v2.json.gz index dd48fe240cdf2f2047a500840288c525bbde3e22..5a412ff4b14e0f0a4e4a3e0e371e83223091369e 100644 GIT binary patch delta 149868 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%0Dg@x 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 delta 149903 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 bdAQgC)UKaZQItwwoWjy zZQHiZiIWK?#>Ac&U!Ldv*1GSy_x|bA-RE@wI6rFDuG+P$o)cNLd*nqA#X@J{*UvuV z$;3VU=ow|0yr<2P5j3*nrY$a0kHqZ4=yPY{;Rv~2@-Wvh=$4YdBa>)wZNukL-#151 zTr|9Katt05ecW9oyw2Z4ptJ|I0n!wjne-w{s$7U4;GbUCJS3n`$!wp%ok0D2?I&M8 zVgoVGe1x5nbO)gie_AS!AWWTmm*wXNoX1Q%c|%F7{bIPC?AF`AP1G&NsCMCaIWoTa z!UUYaTi_PHYxvFQX`q85(g-@5{KGrTfS#VyC9+7Y|i} zk-+OM_6U82nf#Il;s3-*QWzb^$3oHv zSlvD*_i=V^W=yY6pl|=OKph&bD-QE+C%6$t5M#G zr$Q?!AbW~&7W{RO`evw|K$-0oQNcfNdc_~36!2_0G_c?WFHaJ%#}wE9p>G3*N7WL8Fff2#FjMN zIUN(FF30t4*?*HM1hD;b)3&OZw9RIQ-wsp1=W^zIRm3d-y-} zTBV9l4`2OX-pvR>LNU;SH(VoLQGJ6U%_8&>ZfubzK|Vow{AUhAnh>CDXo!C;kX?h? z!_GoM$|_Eul$K-Eyp>xmK+e0%2~oX&_gzG+ihV9TE;9P#dBDqWk}dQTEAVmfbe60( z$*m)&T9hWDbJJl@%7bssqr6OSCSN))7lvDq?+82_cs#1RDhD9mq7N_dI$$N@gpJ32 za8KYo`W!LsCp=D9wV4rOulNzK+xJLmmqu41;uPZF){ti?)H4E^6z$Ltn5F|Z(P#rj zqGsoUx=LUtWaSnwX;TWGf=+&hw+IX&^F8=O{W|hQ_^Mx2+J?fXb!_aYlgh(0)*O%J zcRLpW`2F{apksTTh;=w~GNjt_#7UDp)xY(9KJ6|l;^*Iz*S9uN1W7vqTIo^?U0kyZ z+*emi;yjBR=9`db^szmC^RXeFN!Ir6;fuARFIG;h3QrAzl0y}QBh-D|u;C-PfcejR z?-1R&^N);ldXpt}4QJ9U3iXAhQC5oqRkvxNW7Bn|Btp<4u=D9eMKZe?nB>a%R7+Tx zi!H>Uqw}W#!!0xS^)5O)(-K@++%hXR*|m#RC3^M_Hc+7C zb$=r;Lo>;N!giZf&g(2IZX}ZJGn);Z(6p@z-XV!-(1efZi)oqV{bvk4EWIj&%y3L>WW2UWmbu|OR_TD-( zL!QfSJgRNjA1Jf6$2lZCmfzct?jyA(p+D7wJoh)HLh&6{ua1s9?5g>pH>1au3af%`*c@A^=NQzgQ0OW=irL?&jtwj<8MWg#E}=_mx6BN3Wr94S*+ zyworGMjwjq0jP$%W=i_%zpnvVpD2qeZ!=MRza&30?xW71!v=|)TP$@#c+!#&34Ue{ z=>*#g1E?Z+9#7fBiQj!F+#S{_wEPOa^!Xd$1HNtK=?<4! z)AjekAU_0KvC~^zfHhDdbPo(n#2zvk_t@~z{O&n^x3@we}e8z4%u z-cv^r#YiP;)xvEMlBxzEj&&K;evOQThNVX>%Br@5P)55-T1CPC7)EK;VsvoO5FCpw zFtQoSGTAd`;DI&9tk!x)*ViLknJm5<>IxS}OKv|YSdo-g9(8dugz+?OFc%~GEbMX^ z(^#9Nr)}lWj6|fK#JanPCoLUo7kS>BnuaxNa3IOLwt0_8AEW}rJ5?>k11fWG8(MRQ zqc~_&R<_Euhr*C!yv_?6Ft;rZqd31BlwWY%(78G=q8RyxDLvg|RsO{iz-U67cNb3! zP4WppW5Y=b-G*OL>>+6MD>M0Kjsu0Nwp*if&sR8c`kf9bah`%KZtwSns0MBsUB#e} zb*N6Zk#Ob4>7K@k^0ZfoVgrPyO{#-mAG@6ombqp>|@bx(Ein_j=Y4k3M8 zXvGzC)^xfxZDeW>vmBS!U_5W z;Ja|c^n?_CL=5UDaCRNQ@9KNKIq-_yXKMM%71X!Z#o0Sc)a%7{1j-a={s#gOcE

    fqW4 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 dnV>;F8_ks0;`?No2@ql^g@D)QUb`Pa{{uMI diff --git a/test-models/src/main/pegasus/com/datahub/test/TestEntityInfo.pdl b/test-models/src/main/pegasus/com/datahub/test/TestEntityInfo.pdl index 3b8aa4f39f7b73..d1daa7b8d45936 100644 --- a/test-models/src/main/pegasus/com/datahub/test/TestEntityInfo.pdl +++ b/test-models/src/main/pegasus/com/datahub/test/TestEntityInfo.pdl @@ -97,4 +97,10 @@ record TestEntityInfo includes CustomProperties { "fieldType": "DOUBLE" } doubleField: optional double + + @Searchable = { + "fieldName": "removed", + "fieldType": "BOOLEAN" + } + removed: optional boolean } From 08bdfbdf93da589053f63f412ced820d6d575fb7 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Thu, 25 Jan 2024 15:25:31 -0800 Subject: [PATCH 216/540] feat(ui): Supporting rendering custom assertion descriptions (#9722) Co-authored-by: John Joyce --- .../types/assertion/AssertionMapper.java | 1 + .../src/main/resources/entity.graphql | 5 +++++ .../tabs/Dataset/Validations/Assertions.tsx | 6 +++++- .../Validations/DatasetAssertionDescription.tsx | 17 ++++++++++------- .../Validations/DatasetAssertionsList.tsx | 14 +++++++------- datahub-web-react/src/graphql/assertion.graphql | 1 + 6 files changed, 29 insertions(+), 15 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java index 2536f4d2521ee0..43b7b5bb102adf 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/assertion/AssertionMapper.java @@ -66,6 +66,7 @@ private static com.linkedin.datahub.graphql.generated.AssertionInfo mapAssertion mapDatasetAssertionInfo(gmsAssertionInfo.getDatasetAssertion()); assertionInfo.setDatasetAssertion(datasetAssertion); } + assertionInfo.setDescription(gmsAssertionInfo.getDescription()); return assertionInfo; } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 2ad49825793803..3ea1b38d3db0d3 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -6803,6 +6803,11 @@ type AssertionInfo { Dataset-specific assertion information """ datasetAssertion: DatasetAssertionInfo + + """ + An optional human-readable description of the assertion + """ + description: String } """ diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/Assertions.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/Assertions.tsx index 68660164ee877c..b3086d78670121 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/Assertions.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/Assertions.tsx @@ -35,6 +35,8 @@ const getAssertionsStatusSummary = (assertions: Array) => { /** * Component used for rendering the Validations Tab on the Dataset Page. + * + * TODO: Note that only the legacy DATASET assertions are supported for viewing as of today. */ export const Assertions = () => { const { urn, entityData } = useEntityData(); @@ -47,7 +49,9 @@ export const Assertions = () => { const assertions = (combinedData && combinedData.dataset?.assertions?.assertions?.map((assertion) => assertion as Assertion)) || []; - const filteredAssertions = assertions.filter((assertion) => !removedUrns.includes(assertion.urn)); + const filteredAssertions = assertions.filter( + (assertion) => !removedUrns.includes(assertion.urn) && !!assertion.info?.datasetAssertion, + ); // Pre-sort the list of assertions based on which has been most recently executed. assertions.sort(sortAssertions); diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionDescription.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionDescription.tsx index a91d11d1e9887f..daebfd55975884 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionDescription.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionDescription.tsx @@ -19,6 +19,7 @@ const ViewLogicButton = styled(Button)` `; type Props = { + description?: string; assertionInfo: DatasetAssertionInfo; }; @@ -319,18 +320,20 @@ const TOOLTIP_MAX_WIDTH = 440; * * For example, Column 'X' values are in [1, 2, 3] */ -export const DatasetAssertionDescription = ({ assertionInfo }: Props) => { +export const DatasetAssertionDescription = ({ description, assertionInfo }: Props) => { const { scope, aggregation, fields, operator, parameters, nativeType, nativeParameters, logic } = assertionInfo; const [isLogicVisible, setIsLogicVisible] = useState(false); /** * Build a description component from a) input (aggregation, inputs) b) the operator text */ - const description = ( + const descriptionFragment = ( <> - - {getAggregationText(scope, aggregation, fields)}{' '} - {getOperatorText(operator, parameters || undefined, nativeType || undefined)} - + {description || ( + + {getAggregationText(scope, aggregation, fields)}{' '} + {getOperatorText(operator, parameters || undefined, nativeType || undefined)} + + )} ); @@ -349,7 +352,7 @@ export const DatasetAssertionDescription = ({ assertionInfo }: Props) => { } > -

    {description}
    +
    {descriptionFragment}
    {logic && (
    setIsLogicVisible(true)} type="link"> diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx index 05fc2d1c496db1..3eccfb8931fc0f 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx @@ -83,6 +83,7 @@ export const DatasetAssertionsList = ({ assertions, onDelete }: Props) => { type: assertion.info?.type, platform: assertion.platform, datasetAssertionInfo: assertion.info?.datasetAssertion, + description: assertion.info?.description, lastExecTime: assertion.runEvents?.runEvents?.length && assertion.runEvents.runEvents[0].timestampMillis, lastExecResult: assertion.runEvents?.runEvents?.length && @@ -101,6 +102,7 @@ export const DatasetAssertionsList = ({ assertions, onDelete }: Props) => { const resultColor = (record.lastExecResult && getResultColor(record.lastExecResult)) || 'default'; const resultText = (record.lastExecResult && getResultText(record.lastExecResult)) || 'No Evaluations'; const resultIcon = (record.lastExecResult && getResultIcon(record.lastExecResult)) || ; + const { description } = record; return (
    @@ -111,7 +113,10 @@ export const DatasetAssertionsList = ({ assertions, onDelete }: Props) => {
    - +
    ); }, @@ -146,12 +151,7 @@ export const DatasetAssertionsList = ({ assertions, onDelete }: Props) => { - - } - trigger={['click']} - > + } trigger={['click']}> diff --git a/datahub-web-react/src/graphql/assertion.graphql b/datahub-web-react/src/graphql/assertion.graphql index d4015fcebdb3eb..0b64c4c8d6dddb 100644 --- a/datahub-web-react/src/graphql/assertion.graphql +++ b/datahub-web-react/src/graphql/assertion.graphql @@ -46,6 +46,7 @@ fragment assertionDetails on Assertion { } logic } + description } } From 69ff9c3af3da11deb6f915f11820b2489caac6e0 Mon Sep 17 00:00:00 2001 From: John Joyce Date: Thu, 25 Jan 2024 16:51:40 -0800 Subject: [PATCH 217/540] infra(ui): Add a react context provider allowing sub-components to update theme conf (#9674) Co-authored-by: John Joyce --- datahub-web-react/src/App.tsx | 34 +++++-------------- datahub-web-react/src/CustomThemeProvider.tsx | 32 +++++++++++++++++ datahub-web-react/src/customThemeContext.tsx | 10 ++++++ 3 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 datahub-web-react/src/CustomThemeProvider.tsx create mode 100644 datahub-web-react/src/customThemeContext.tsx diff --git a/datahub-web-react/src/App.tsx b/datahub-web-react/src/App.tsx index 79c9ee91ceaa12..e8910e7dc2ea8e 100644 --- a/datahub-web-react/src/App.tsx +++ b/datahub-web-react/src/App.tsx @@ -1,20 +1,19 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import Cookies from 'js-cookie'; import { message } from 'antd'; import { BrowserRouter as Router } from 'react-router-dom'; import { ApolloClient, ApolloProvider, createHttpLink, InMemoryCache, ServerError } from '@apollo/client'; import { onError } from '@apollo/client/link/error'; -import { ThemeProvider } from 'styled-components'; import { Helmet, HelmetProvider } from 'react-helmet-async'; import './App.less'; import { Routes } from './app/Routes'; -import { Theme } from './conf/theme/types'; -import defaultThemeConfig from './conf/theme/theme_light.config.json'; import { PageRoutes } from './conf/Global'; import { isLoggedInVar } from './app/auth/checkAuthStatus'; import { GlobalCfg } from './conf'; import possibleTypesResult from './possibleTypes.generated'; import { ErrorCodes } from './app/shared/constants'; +import CustomThemeProvider from './CustomThemeProvider'; +import { useCustomTheme } from './customThemeContext'; /* Construct Apollo Client @@ -71,33 +70,16 @@ const client = new ApolloClient({ }); export const InnerApp: React.VFC = () => { - const [dynamicThemeConfig, setDynamicThemeConfig] = useState(defaultThemeConfig); - - useEffect(() => { - if (import.meta.env.DEV) { - import(/* @vite-ignore */ `./conf/theme/${import.meta.env.REACT_APP_THEME_CONFIG}`).then((theme) => { - setDynamicThemeConfig(theme); - }); - } else { - // Send a request to the server to get the theme config. - fetch(`/assets/conf/theme/${import.meta.env.REACT_APP_THEME_CONFIG}`) - .then((response) => response.json()) - .then((theme) => { - setDynamicThemeConfig(theme); - }); - } - }, []); - return ( - - {dynamicThemeConfig.content.title} - - + + + {useCustomTheme().theme?.content.title} + - + ); }; diff --git a/datahub-web-react/src/CustomThemeProvider.tsx b/datahub-web-react/src/CustomThemeProvider.tsx new file mode 100644 index 00000000000000..f2e2678a90d8c4 --- /dev/null +++ b/datahub-web-react/src/CustomThemeProvider.tsx @@ -0,0 +1,32 @@ +import React, { useEffect, useState } from 'react'; +import { ThemeProvider } from 'styled-components'; +import { Theme } from './conf/theme/types'; +import defaultThemeConfig from './conf/theme/theme_light.config.json'; +import { CustomThemeContext } from './customThemeContext'; + +const CustomThemeProvider = ({ children }: { children: React.ReactNode }) => { + const [currentTheme, setTheme] = useState(defaultThemeConfig); + + useEffect(() => { + if (import.meta.env.DEV) { + import(/* @vite-ignore */ `./conf/theme/${import.meta.env.REACT_APP_THEME_CONFIG}`).then((theme) => { + setTheme(theme); + }); + } else { + // Send a request to the server to get the theme config. + fetch(`/assets/conf/theme/${import.meta.env.REACT_APP_THEME_CONFIG}`) + .then((response) => response.json()) + .then((theme) => { + setTheme(theme); + }); + } + }, []); + + return ( + + {children} + + ); +}; + +export default CustomThemeProvider; diff --git a/datahub-web-react/src/customThemeContext.tsx b/datahub-web-react/src/customThemeContext.tsx new file mode 100644 index 00000000000000..0b273d00248853 --- /dev/null +++ b/datahub-web-react/src/customThemeContext.tsx @@ -0,0 +1,10 @@ +import React, { useContext } from 'react'; + +export const CustomThemeContext = React.createContext<{ + theme: any; + updateTheme: (theme: any) => void; +}>({ theme: undefined, updateTheme: (_) => null }); + +export function useCustomTheme() { + return useContext(CustomThemeContext); +} From f7f0b14f376cad8aa3951efd305fcd15a1f01966 Mon Sep 17 00:00:00 2001 From: tom Date: Fri, 26 Jan 2024 02:51:41 +0100 Subject: [PATCH 218/540] fix(ingestion/metabase): Fetch Dashboards through Collections (#9631) Co-authored-by: Harshal Sheth --- metadata-ingestion/developing.md | 2 +- .../docs/sources/metabase/metabase.md | 2 +- .../src/datahub/ingestion/source/metabase.py | 47 +- .../metabase/metabase_mces_golden.json | 61 +- .../metabase/setup/collection_dashboards.json | 1 + .../metabase/setup/collections.json | 1 + .../integration/metabase/setup/dashboard.json | 40 - .../metabase/setup/dashboard_1.json | 1084 ++++++++++++----- .../integration/metabase/test_metabase.py | 8 +- 9 files changed, 901 insertions(+), 345 deletions(-) create mode 100644 metadata-ingestion/tests/integration/metabase/setup/collection_dashboards.json create mode 100644 metadata-ingestion/tests/integration/metabase/setup/collections.json delete mode 100644 metadata-ingestion/tests/integration/metabase/setup/dashboard.json diff --git a/metadata-ingestion/developing.md b/metadata-ingestion/developing.md index d1eef21974f1df..fc3a689124b2c1 100644 --- a/metadata-ingestion/developing.md +++ b/metadata-ingestion/developing.md @@ -10,7 +10,7 @@ Also take a look at the guide to [adding a source](./adding-source.md). ### Requirements 1. Python 3.7+ must be installed in your host environment. -2. Java8 (gradle won't work with newer versions) +2. Java 17 (gradle won't work with newer or older versions) 4. On Debian/Ubuntu: `sudo apt install python3-dev python3-venv` 5. On Fedora (if using LDAP source integration): `sudo yum install openldap-devel` diff --git a/metadata-ingestion/docs/sources/metabase/metabase.md b/metadata-ingestion/docs/sources/metabase/metabase.md index a76786f7e5853a..68422b8decce95 100644 --- a/metadata-ingestion/docs/sources/metabase/metabase.md +++ b/metadata-ingestion/docs/sources/metabase/metabase.md @@ -19,4 +19,4 @@ The key in this map must be string, not integer although Metabase API provides If `database_id_to_instance_map` is not specified, `platform_instance_map` is used for platform instance mapping. If none of the above are specified, platform instance is not used when constructing `urn` when searching for dataset relations. ## Compatibility -Metabase version [v0.41.2](https://www.metabase.com/start/oss/) +Metabase version [v0.48.3](https://www.metabase.com/start/oss/) diff --git a/metadata-ingestion/src/datahub/ingestion/source/metabase.py b/metadata-ingestion/src/datahub/ingestion/source/metabase.py index af41a74f311f64..d22bfb2b8b52ff 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/metabase.py +++ b/metadata-ingestion/src/datahub/ingestion/source/metabase.py @@ -90,10 +90,17 @@ class MetabaseSource(Source): """ This plugin extracts Charts, dashboards, and associated metadata. This plugin is in beta and has only been tested on PostgreSQL and H2 database. - ### Dashboard - [/api/dashboard](https://www.metabase.com/docs/latest/api-documentation.html#dashboard) endpoint is used to - retrieve the following dashboard information. + ### Collection + + [/api/collection](https://www.metabase.com/docs/latest/api/collection) endpoint is used to + retrieve the available collections. + + [/api/collection//items?models=dashboard](https://www.metabase.com/docs/latest/api/collection#get-apicollectioniditems) endpoint is used to retrieve a given collection and list their dashboards. + + ### Dashboard + + [/api/dashboard/](https://www.metabase.com/docs/latest/api/dashboard) endpoint is used to retrieve a given Dashboard and grab its information. - Title and description - Last edited by @@ -187,19 +194,29 @@ def close(self) -> None: def emit_dashboard_mces(self) -> Iterable[MetadataWorkUnit]: try: - dashboard_response = self.session.get( - f"{self.config.connect_uri}/api/dashboard" + collections_response = self.session.get( + f"{self.config.connect_uri}/api/collection/" ) - dashboard_response.raise_for_status() - dashboards = dashboard_response.json() + collections_response.raise_for_status() + collections = collections_response.json() - for dashboard_info in dashboards: - dashboard_snapshot = self.construct_dashboard_from_api_data( - dashboard_info + for collection in collections: + collection_dashboards_response = self.session.get( + f"{self.config.connect_uri}/api/collection/{collection['id']}/items?models=dashboard" ) - if dashboard_snapshot is not None: - mce = MetadataChangeEvent(proposedSnapshot=dashboard_snapshot) - yield MetadataWorkUnit(id=dashboard_snapshot.urn, mce=mce) + collection_dashboards_response.raise_for_status() + collection_dashboards = collection_dashboards_response.json() + + if not collection_dashboards.get("data"): + continue + + for dashboard_info in collection_dashboards.get("data"): + dashboard_snapshot = self.construct_dashboard_from_api_data( + dashboard_info + ) + if dashboard_snapshot is not None: + mce = MetadataChangeEvent(proposedSnapshot=dashboard_snapshot) + yield MetadataWorkUnit(id=dashboard_snapshot.urn, mce=mce) except HTTPError as http_error: self.report.report_failure( @@ -254,10 +271,10 @@ def construct_dashboard_from_api_data( ) chart_urns = [] - cards_data = dashboard_details.get("ordered_cards", "{}") + cards_data = dashboard_details.get("dashcards", {}) for card_info in cards_data: chart_urn = builder.make_chart_urn( - self.platform, card_info.get("card_id", "") + self.platform, card_info.get("card").get("id", "") ) chart_urns.append(chart_urn) diff --git a/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json b/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json index 9b143348fdf603..10c1c312a4d1c7 100644 --- a/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json +++ b/metadata-ingestion/tests/integration/metabase/metabase_mces_golden.json @@ -191,20 +191,73 @@ "description": "", "charts": [ "urn:li:chart:(metabase,1)", - "urn:li:chart:(metabase,2)" + "urn:li:chart:(metabase,2)", + "urn:li:chart:(metabase,3)" ], "datasets": [], "lastModified": { "created": { - "time": 1639417721742, + "time": 1705398694904, "actor": "urn:li:corpuser:admin@metabase.com" }, "lastModified": { - "time": 1639417721742, + "time": 1705398694904, "actor": "urn:li:corpuser:admin@metabase.com" } }, - "dashboardUrl": "http://localhost:3000/dashboard/1" + "dashboardUrl": "http://localhost:3000/dashboard/10" + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:admin@metabase.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1636614000000, + "runId": "metabase-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DashboardSnapshot": { + "urn": "urn:li:dashboard:(metabase,1)", + "aspects": [ + { + "com.linkedin.pegasus2avro.dashboard.DashboardInfo": { + "customProperties": {}, + "title": "Dashboard 1", + "description": "", + "charts": [ + "urn:li:chart:(metabase,1)", + "urn:li:chart:(metabase,2)", + "urn:li:chart:(metabase,3)" + ], + "datasets": [], + "lastModified": { + "created": { + "time": 1705398694904, + "actor": "urn:li:corpuser:admin@metabase.com" + }, + "lastModified": { + "time": 1705398694904, + "actor": "urn:li:corpuser:admin@metabase.com" + } + }, + "dashboardUrl": "http://localhost:3000/dashboard/10" } }, { diff --git a/metadata-ingestion/tests/integration/metabase/setup/collection_dashboards.json b/metadata-ingestion/tests/integration/metabase/setup/collection_dashboards.json new file mode 100644 index 00000000000000..b602d2dfb7dcda --- /dev/null +++ b/metadata-ingestion/tests/integration/metabase/setup/collection_dashboards.json @@ -0,0 +1 @@ +{"total": 1, "data": [{"description": null, "collection_position": null, "database_id": null, "name": "This is a test", "id": 10, "entity_id": "Q4gEaOmoBkfQX3_gXiH9g", "last-edit-info": {"id": 14, "last_name": "Doe", "first_name": "John", "email": "john.doe@somewhere.com", "timestamp": "2024-01-12T14:55:38.43304Z"}, "model": "dashboard"}], "models": ["dashboard"], "limit": null, "offset": null} diff --git a/metadata-ingestion/tests/integration/metabase/setup/collections.json b/metadata-ingestion/tests/integration/metabase/setup/collections.json new file mode 100644 index 00000000000000..a8a98c4e6d62ee --- /dev/null +++ b/metadata-ingestion/tests/integration/metabase/setup/collections.json @@ -0,0 +1 @@ +[{"authority_level": null, "can_write": true, "name": "Our analytics", "effective_ancestors": [], "effective_location": null, "parent_id": null, "id": "root", "is_personal": false}, {"authority_level": null, "description": null, "archived": false, "slug": "john_doe_personal_collection", "can_write": true, "name": "John Doe", "personal_owner_id": 14, "type": null, "id": 150, "entity_id": "kdLA_-CQy4F5lL15k8-TU", "location": "/", "namespace": null, "is_personal": true, "created_at": "2024-01-12T11:51:24.394309Z"}] diff --git a/metadata-ingestion/tests/integration/metabase/setup/dashboard.json b/metadata-ingestion/tests/integration/metabase/setup/dashboard.json deleted file mode 100644 index 095abf1bbdc6d5..00000000000000 --- a/metadata-ingestion/tests/integration/metabase/setup/dashboard.json +++ /dev/null @@ -1,40 +0,0 @@ -[{ - "description": null, - "archived": false, - "collection_position": null, - "creator": { - "email": "admin@metabase.com", - "first_name": "FirstName", - "last_login": "2021-12-13T18:51:32.999", - "is_qbnewb": true, - "is_superuser": true, - "id": 1, - "last_name": "LastName", - "date_joined": "2021-12-13T07:34:21.806", - "common_name": "FirstName LastName" - }, - "enable_embedding": false, - "collection_id": null, - "show_in_getting_started": false, - "name": "Dashboard 1", - "caveats": null, - "creator_id": 1, - "updated_at": "2021-12-13T17:48:41.735", - "made_public_by_id": null, - "embedding_params": null, - "cache_ttl": null, - "id": 1, - "position": null, - "last-edit-info": { - "id": 1, - "email": "admin@metabase.com", - "first_name": "FirstName", - "last_name": "LastName", - "timestamp": "2021-12-13T17:48:41.742" - }, - "parameters": [], - "favorite": false, - "created_at": "2021-12-13T17:46:48.185", - "public_uuid": null, - "points_of_interest": null -}] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json b/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json index 288087a67da6dd..e968093c438508 100644 --- a/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json +++ b/metadata-ingestion/tests/integration/metabase/setup/dashboard_1.json @@ -2,332 +2,854 @@ "description": null, "archived": false, "collection_position": null, - "ordered_cards": [{ - "sizeX": 4, - "series": [], - "collection_authority_level": null, - "card": { - "description": null, - "archived": false, - "collection_position": null, - "table_id": null, - "result_metadata": [{ - "name": "customer_id", - "display_name": "customer_id", - "base_type": "type/Integer", - "effective_type": "type/Integer", - "field_ref": ["field", "customer_id", { - "base-type": "type/Integer" - }], - "semantic_type": null, - "fingerprint": { - "global": { - "distinct-count": 517, - "nil%": 0.0 + "dashcards": [ + { + "size_x": 12, + "dashboard_tab_id": null, + "series": [], + "action_id": null, + "collection_authority_level": null, + "card": { + "description": null, + "archived": false, + "collection_position": null, + "table_id": null, + "result_metadata": [ + { + "display_name": "EVENT_DATE", + "field_ref": [ + "field", + "EVENT_DATE", + { + "base-type": "type/Date" + } + ], + "name": "EVENT_DATE", + "base_type": "type/Date", + "effective_type": "type/Date", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2023-12-04T00:00:00Z", + "latest": "2024-01-15T00:00:00Z" + } + } + } }, - "type": { - "type/Number": { - "min": 1.0, - "q1": 127.95550051624855, - "q3": 457.48181481488376, - "max": 599.0, - "sd": 183.35453319901166, - "avg": 293.316 + { + "display_name": "AND_VIEWERS", + "field_ref": [ + "field", + "AND_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "AND_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 4720, + "q1": 5083.5, + "q3": 9003, + "max": 10560, + "sd": 2090.2420089751945, + "avg": 6688.214285714285 + } + } } - } - } - }, { - "name": "first_name", - "display_name": "first_name", - "base_type": "type/Text", - "effective_type": "type/Text", - "field_ref": ["field", "first_name", { - "base-type": "type/Text" - }], - "semantic_type": "type/Name", - "fingerprint": { - "global": { - "distinct-count": 509, - "nil%": 0.0 }, - "type": { - "type/Text": { - "percent-json": 0.0, - "percent-url": 0.0, - "percent-email": 0.0, - "percent-state": 0.0035, - "average-length": 5.629 + { + "display_name": "AND_REDACTED", + "field_ref": [ + "field", + "AND_REDACTED", + { + "base-type": "type/Number" + } + ], + "name": "AND_REDACTED", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 948, + "q1": 2019.5, + "q3": 2500.5, + "max": 3180, + "sd": 460.56365857271413, + "avg": 2251.0714285714284 + } + } } - } - } - }, { - "name": "last_name", - "display_name": "last_name", - "base_type": "type/Text", - "effective_type": "type/Text", - "field_ref": ["field", "last_name", { - "base-type": "type/Text" - }], - "semantic_type": "type/Name", - "fingerprint": { - "global": { - "distinct-count": 517, - "nil%": 0.0 }, - "type": { - "type/Text": { - "percent-json": 0.0, - "percent-url": 0.0, - "percent-email": 0.0, - "percent-state": 0.0015, - "average-length": 6.126 + { + "display_name": "AND_REDACTED", + "field_ref": [ + "field", + "AND_REDACTED", + { + "base-type": "type/Number" + } + ], + "name": "AND_REDACTED", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 3545, + "q1": 10909, + "q3": 13916, + "max": 18861, + "sd": 3132.780684756446, + "avg": 12122.32142857143 + } + } } - } - } - }, { - "name": "amount", - "display_name": "amount", - "base_type": "type/Decimal", - "effective_type": "type/Decimal", - "field_ref": ["field", "amount", { - "base-type": "type/Decimal" - }], - "semantic_type": null, - "fingerprint": { - "global": { - "distinct-count": 11, - "nil%": 0.0 }, - "type": { - "type/Number": { - "min": 0.99, - "q1": 2.399411317392306, - "q3": 5.52734176879965, - "max": 10.99, - "sd": 2.352151368009511, - "avg": 4.1405 + { + "display_name": "IOS_VIEWERS", + "field_ref": [ + "field", + "IOS_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "IOS_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 6477, + "q1": 7481.5, + "q3": 10428.5, + "max": 13182, + "sd": 1948.047456520796, + "avg": 9075.17857142857 + } + } } - } - } - }, { - "name": "payment_date", - "display_name": "payment_date", - "base_type": "type/DateTime", - "effective_type": "type/DateTime", - "field_ref": ["field", "payment_date", { - "base-type": "type/DateTime" - }], - "semantic_type": null, - "fingerprint": { - "global": { - "distinct-count": 1998, - "nil%": 0.0 }, - "type": { - "type/DateTime": { - "earliest": "2007-02-14T21:21:59.996577Z", - "latest": "2007-02-21T19:27:46.996577Z" + { + "display_name": "IOS_REDACTED", + "field_ref": [ + "field", + "IOS_REDACTED", + { + "base-type": "type/Number" + } + ], + "name": "IOS_REDACTED", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1470, + "q1": 3020, + "q3": 3806, + "max": 4670, + "sd": 665.7415088559197, + "avg": 3415.8571428571427 + } + } } - } - } - }, { - "name": "rental_id", - "display_name": "rental_id", - "base_type": "type/Integer", - "effective_type": "type/Integer", - "field_ref": ["field", "rental_id", { - "base-type": "type/Integer" - }], - "semantic_type": null, - "fingerprint": { - "global": { - "distinct-count": 2000, - "nil%": 0.0 }, - "type": { - "type/Number": { - "min": 1158.0, - "q1": 1731.7967120913397, - "q3": 2871.359273326854, - "max": 4591.0, - "sd": 660.7468728104022, - "avg": 2303.4565 + { + "display_name": "IOS_REDACTED", + "field_ref": [ + "field", + "IOS_REDACTED", + { + "base-type": "type/Number" + } + ], + "name": "IOS_REDACTED", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 4872, + "q1": 15019.5, + "q3": 20457, + "max": 27466, + "sd": 4688.492913816769, + "avg": 17683.89285714286 + } + } + } + }, + { + "display_name": "IOS_REDACTED/IOS_VIEWERS", + "field_ref": [ + "field", + "IOS_REDACTED/IOS_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "IOS_REDACTED/IOS_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 0.662587, + "q1": 1.8403745, + "q3": 2.241517, + "max": 2.576166, + "sd": 0.4488826998266724, + "avg": 1.974007857142857 + } + } + } + }, + { + "display_name": "AND_REDACTED/AND_VIEWERS", + "field_ref": [ + "field", + "AND_REDACTED/AND_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "AND_REDACTED/AND_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 0.671656, + "q1": 1.3536655, + "q3": 2.5325145, + "max": 3.097553, + "sd": 0.6816847359625038, + "avg": 1.93937275 + } + } + } + }, + { + "display_name": "IOS_REDACTED/IOS_VIEWERS", + "field_ref": [ + "field", + "IOS_REDACTED/IOS_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "IOS_REDACTED/IOS_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 0.199918, + "q1": 0.34496099999999996, + "q3": 0.4352085, + "max": 0.47286, + "sd": 0.06928869477079941, + "avg": 0.3833206785714286 + } + } + } + }, + { + "display_name": "AND_REDACTED/AND_VIEWERS", + "field_ref": [ + "field", + "AND_REDACTED/AND_VIEWERS", + { + "base-type": "type/Number" + } + ], + "name": "AND_REDACTED/AND_VIEWERS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 28, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 0.179613, + "q1": 0.245343, + "q3": 0.475772, + "max": 0.522253, + "sd": 0.11732033433182058, + "avg": 0.3620892142857142 + } + } } } - } - }], - "database_id": 2, - "enable_embedding": false, - "collection_id": null, - "query_type": "native", - "name": "Customer Payment", - "query_average_duration": 820, - "creator_id": 1, - "moderation_reviews": [], - "updated_at": "2021-12-13T17:48:40.478", - "made_public_by_id": null, - "embedding_params": null, - "cache_ttl": null, - "dataset_query": { - "type": "native", - "native": { - "query": "SELECT\n\tcustomer.customer_id,\n\tfirst_name,\n\tlast_name,\n\tamount,\n\tpayment_date,\n\trental_id\nFROM\n\tcustomer\nINNER JOIN payment \n ON payment.customer_id = customer.customer_id\nORDER BY payment_date", - "template-tags": {} + ], + "can_write": true, + "database_id": 3, + "enable_embedding": false, + "collection_id": 112, + "query_type": "native", + "name": "REDACTED iOS vs. Android", + "query_average_duration": 50982, + "creator_id": 42, + "moderation_reviews": [], + "updated_at": "2024-01-16T13:34:29.916717Z", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "type": "native", + "native": { + "query": "-- 1. Table with redacted search users Android\n-- 2. Table with redacted search users iOS \n-- 3. Redacted from Android redacted\n-- 4. redacted from iOS\n-- 5. Compare the numbers iOS vs. Android\n\n\n-- 1. Table with redacted search users Android (to include date, platform, auth_account_id)\n-- 2. Table with redacted search users iOS (to include date, platform, auth_account_id)\n-- 3. Redacted from Android redacted (to include date, platform, count of redacted)\n-- 4. Redacted from iOS redacted (to include date, plaform, count of redacted)\n-- 5. Compare the numbers iOS vs. Android\n\nwith AND_viewers as \n(\nselect event_date, platform, auth_account_id \nfrom TEAMS_PRD.REDACTED.MRT_CURR__MPARTICLE_SCREEN_VIEWS\nwhere screen_name='redacted_search'\nand event_date>'2023-12-01'\nand platform='Android'\nand dayofweekiso(event_date) NOT IN (6,7)\ngroup by event_date, platform, auth_account_id\norder by event_date desc\n), \niOS_viewers as \n(\nselect event_date, platform, auth_account_id \nfrom TEAMS_PRD.REDACTED.MRT_CURR__MPARTICLE_SCREEN_VIEWS\nwhere screen_name='redacted_search'\nand event_date>'2023-12-01'\nand platform='iOS'\nand dayofweekiso(event_date) NOT IN (6,7)\ngroup by event_date, platform, auth_account_id\norder by event_date desc\n), \nAND_redacted as\n(\nselect redacted_ts::date as redacted_date, platform, count(distinct at.auth_account_id) as AND_redacted, count(group_redacted_id) as AND_redacted\nfrom TEAMS_PRD.REDACTED.MRT_CURR__REDACTED_CUSTOMER at\njoin AND_viewers av on av.event_date=at.redacted_ts::date and av.auth_account_id=at.auth_account_id\nwhere instrument_type='REDACTED'\ngroup by 1,2\norder by 1 desc\n), \niOS_redacted as\n(\nselect redacted_ts::date as redacted_date, platform, count(distinct it.auth_account_id) as iOS_redacted, count(group_redacted_id) as iOS_redacted\nfrom TEAMS_PRD.REDACTED.MRT_CURR__REDACTED_CUSTOMER it\njoin iOS_viewers iv on iv.event_date=it.redacted_ts::date and iv.auth_account_id=it.auth_account_id\nwhere instrument_type='REDACTED'\ngroup by 1,2\norder by 1 desc\n)\nselect a.event_date, count(distinct a.auth_account_id) as AND_viewers, AND_redacted, AND_redacted, count(distinct i.auth_account_id) as iOS_viewers, iOS_redacted, iOS_redacted, iOS_redacted/iOS_viewers, AND_redacted/AND_viewers, iOS_redacted/iOS_viewers, AND_redacted/AND_viewers\nfrom AND_VIEWERS a\njoin AND_redacted at\non a.event_date=at.redacted_date\njoin ios_viewers i\non a.event_date=i.event_date\njoin ios_redacted it\non i.event_date=it.redacted_date\ngroup by 1, 3, 4, 6, 7\norder by 1 desc\n\n\n", + "template-tags": {} + }, + "database": 3 }, - "database": 2 - }, - "id": 1, - "display": "table", - "visualization_settings": { - "table.pivot_column": "amount", - "table.cell_column": "customer_id" + "id": 1, + "parameter_mappings": [], + "display": "line", + "entity_id": "DhQgvvtTEarZH8yQBlqES", + "collection_preview": true, + "visualization_settings": { + "graph.dimensions": [ + "EVENT_DATE" + ], + "series_settings": { + "IOS_REDACTED/IOS_VIEWERS": { + "axis": "right" + }, + "AND_REDACTED/AND_VIEWERS": { + "axis": "right" + } + }, + "graph.metrics": [ + "IOS_REDACTED/IOS_VIEWERS", + "AND_REDACTED/AND_VIEWERS", + "AND_VIEWERS", + "IOS_VIEWERS" + ] + }, + "metabase_version": "v0.48.3 (80d8323)", + "parameters": [], + "dataset": false, + "created_at": "2024-01-16T09:44:49.407327Z", + "public_uuid": null }, - "created_at": "2021-12-13T17:46:32.77", - "public_uuid": null + "updated_at": "2024-01-16T09:45:45.410379Z", + "col": 0, + "id": 12, + "parameter_mappings": [], + "card_id": 1, + "entity_id": "tA9M9vJlTHG0KxQnvknKW", + "visualization_settings": {}, + "size_y": 6, + "dashboard_id": 1, + "created_at": "2024-01-16T09:45:45.410379Z", + "row": 0 }, - "updated_at": "2021-12-13T17:48:41.68", - "col": 0, - "id": 1, - "parameter_mappings": [], - "card_id": 1, - "visualization_settings": {}, - "dashboard_id": 1, - "created_at": "2021-12-13T17:46:52.278", - "sizeY": 4, - "row": 0 - }, { - "sizeX": 4, - "series": [], - "collection_authority_level": null, - "card": { - "description": null, - "archived": false, - "collection_position": null, - "table_id": 21, - "result_metadata": [{ - "semantic_type": "type/Category", - "coercion_strategy": null, - "name": "rating", - "field_ref": ["field", 131, null], - "effective_type": "type/*", - "id": 131, - "display_name": "Rating", - "fingerprint": { - "global": { - "distinct-count": 5, - "nil%": 0.0 + { + "size_x": 12, + "dashboard_tab_id": null, + "series": [], + "action_id": null, + "collection_authority_level": null, + "card": { + "description": null, + "archived": false, + "collection_position": null, + "table_id": null, + "result_metadata": [ + { + "display_name": "CALENDAR_DATE", + "field_ref": [ + "field", + "CALENDAR_DATE", + { + "base-type": "type/Date" + } + ], + "name": "CALENDAR_DATE", + "base_type": "type/Date", + "effective_type": "type/Date", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 30, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2023-12-17T00:00:00Z", + "latest": "2024-01-15T00:00:00Z" + } + } + } + }, + { + "display_name": "REDACTED", + "field_ref": [ + "field", + "REDACTED", + { + "base-type": "type/Number" + } + ], + "name": "REDACTED", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 682175, + "q1": 738644, + "q3": 805974, + "max": 847312, + "sd": 46783.99996291344, + "avg": 775505.5666666667 + } + } + } }, - "type": { - "type/Text": { - "percent-json": 0.0, - "percent-url": 0.0, - "percent-email": 0.0, - "percent-state": 0.0, - "average-length": 2.926 + { + "display_name": "REDACTEDRS", + "field_ref": [ + "field", + "REDACTEDRS", + { + "base-type": "type/Number" + } + ], + "name": "REDACTEDRS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 46173, + "q1": 47556.94427191, + "q3": 48890, + "max": 50769, + "sd": 1164.9989906758983, + "avg": 48354.8 + } + } + } + }, + { + "display_name": "REDACTED/REDACTEDRS", + "field_ref": [ + "field", + "REDACTED/REDACTEDRS", + { + "base-type": "type/Number" + } + ], + "name": "REDACTED/REDACTEDRS", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 14.706168, + "q1": 15.398378, + "q3": 16.920933, + "max": 17.289964, + "sd": 0.8020030995826715, + "avg": 16.033017833333336 + } + } } } + ], + "can_write": true, + "database_id": 3, + "enable_embedding": false, + "collection_id": 112, + "query_type": "native", + "name": "Redacted redacted per redacted user", + "query_average_duration": 20433, + "creator_id": 1, + "moderation_reviews": [], + "updated_at": "2024-01-16T13:34:29.916788Z", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "type": "native", + "native": { + "query": "with dd as (\nselect distinct calendar_date as calendar_date from TEAMS_PRD.DATA_PLATFORM_MART.MRT__CALENDAR_DATES\nwhere calendar_date>'2022-01-01'\n), \nredacted as\n(\nselect dd.calendar_date, count(distinct auth_account_id) as redacted, max(redacted_ts), min(redacted_ts)\nfrom TEAMS_PRD.REDACTED.MRT_CURR__REDACTED_CUSTOMER t\njoin dd on redacted_ts::date BETWEEN dd.calendar_date-29 and dd.calendar_date\nwhere redacted_type='REGULAR'\nand instrument_type = 'REDACTED'\ngroup by dd.calendar_date\norder by dd.calendar_date desc\n),\nredacted as\n(\nselect dd.calendar_date, count(group_redacted_id) as redacted, max(redacted_ts), min(redacted_ts)\nfrom TEAMS_PRD.REDACTED.MRT_CURR__REDACTED_CUSTOMER t\njoin dd on redacted_ts::date BETWEEN dd.calendar_date-29 and dd.calendar_date\nwhere redacted_type='REGULAR'\nand instrument_type = 'REDACTED'\ngroup by dd.calendar_date\norder by dd.calendar_date desc\n)\nselect dd.calendar_date, redacted, redacted, redacted/redacted\nfrom dd\njoin redacted t on dd.calendar_date=t.calendar_date\njoin redacted tr on dd.calendar_date=tr.calendar_date\ngroup by dd.calendar_date, redacted, redacted, redacted/redacted\norder by dd.calendar_date desc \nlimit 30", + "template-tags": {} + }, + "database": 3 + }, + "id": 2, + "parameter_mappings": [], + "display": "line", + "entity_id": "b1jUcPcQM0XFMuviv4g3K", + "collection_preview": true, + "visualization_settings": { + "graph.dimensions": [ + "CALENDAR_DATE" + ], + "series_settings": { + "REDACTEDRS": { + "axis": "right" + } + }, + "graph.metrics": [ + "REDACTED/REDACTEDRS", + "REDACTEDRS" + ] }, - "base_type": "type/PostgresEnum" - }, { - "name": "count", - "display_name": "Count", - "base_type": "type/BigInteger", - "effective_type": "type/BigInteger", - "semantic_type": "type/Quantity", - "field_ref": ["aggregation", 0], - "fingerprint": { - "global": { - "distinct-count": 5, - "nil%": 0.0 + "metabase_version": "v0.48.3 (80d8323)", + "parameters": [], + "dataset": false, + "created_at": "2024-01-16T09:50:09.487369Z", + "public_uuid": null + }, + "updated_at": "2024-01-16T09:50:34.394488Z", + "col": 12, + "id": 1, + "parameter_mappings": [], + "card_id": 2, + "entity_id": "lXypX5aa14HjkN_Im82C2", + "visualization_settings": {}, + "size_y": 6, + "dashboard_id": 1, + "created_at": "2024-01-16T09:50:34.394488Z", + "row": 0 + }, + { + "size_x": 12, + "dashboard_tab_id": null, + "series": [], + "action_id": null, + "collection_authority_level": null, + "card": { + "description": null, + "archived": false, + "collection_position": null, + "table_id": null, + "result_metadata": [ + { + "display_name": "EVENT_DATE", + "field_ref": [ + "field", + "EVENT_DATE", + { + "base-type": "type/Date" + } + ], + "name": "EVENT_DATE", + "base_type": "type/Date", + "effective_type": "type/Date", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 11, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2024-01-01T00:00:00Z", + "latest": "2024-01-15T00:00:00Z" + } + } + } + }, + { + "display_name": "KNOCKOUT", + "field_ref": [ + "field", + "KNOCKOUT", + { + "base-type": "type/Number" + } + ], + "name": "KNOCKOUT", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 11, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 175, + "q1": 853.75, + "q3": 1116.75, + "max": 1174, + "sd": 296.0767713709648, + "avg": 916.3636363636364 + } + } + } + }, + { + "display_name": "EXPIRY", + "field_ref": [ + "field", + "EXPIRY", + { + "base-type": "type/Number" + } + ], + "name": "EXPIRY", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 10, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 78, + "q1": 295.5, + "q3": 408.3925271309261, + "max": 431, + "sd": 105.10704500218294, + "avg": 336.90909090909093 + } + } + } }, - "type": { - "type/Number": { - "min": 178.0, - "q1": 190.0, - "q3": 213.25, - "max": 223.0, - "sd": 17.131841699011815, - "avg": 200.0 + { + "display_name": "PRODUCT", + "field_ref": [ + "field", + "PRODUCT", + { + "base-type": "type/Number" + } + ], + "name": "PRODUCT", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 9, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 57, + "q1": 163.75, + "q3": 233, + "max": 255, + "sd": 59.31119777763877, + "avg": 195.27272727272728 + } + } + } + }, + { + "display_name": "ISSUER", + "field_ref": [ + "field", + "ISSUER", + { + "base-type": "type/Number" + } + ], + "name": "ISSUER", + "base_type": "type/Number", + "effective_type": "type/Number", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 10, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 43, + "q1": 214, + "q3": 292.25, + "max": 304, + "sd": 79.35879397910594, + "avg": 245.72727272727272 + } + } } } - } - }], - "database_id": 2, - "enable_embedding": false, - "collection_id": null, - "query_type": "query", - "name": "Films, Count, Grouped by Rating, Filtered by Release Year, Sorted by [Unknown Field] descending", - "query_average_duration": 25, - "creator_id": 1, - "moderation_reviews": [], - "updated_at": "2021-12-13T17:48:39.999", - "made_public_by_id": null, - "embedding_params": null, - "cache_ttl": null, - "dataset_query": { - "query": { - "source-table": 21, - "breakout": [ - ["field", 131, null] - ], - "aggregation": [ - ["count"] - ], - "order-by": [ - ["desc", ["aggregation", 0]] + ], + "can_write": true, + "database_id": 3, + "enable_embedding": false, + "collection_id": 112, + "query_type": "native", + "name": "Filter popularity", + "query_average_duration": 2830, + "creator_id": 1, + "moderation_reviews": [], + "updated_at": "2024-01-16T13:34:30.128815Z", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "type": "native", + "native": { + "query": "with issuer as\n(\n select event_date, count(*) as issuer_clicks, count(distinct auth_account_id) as issuer\n from TEAMS_PRD.REDACTED.MRT_CURR__MPARTICLE_EVENTS\n where event_name='redacted_search_filter_button_tapped' \n and event_attributes:filter_option::varchar='issuer'\n and event_date>'2023-12-31'\n and platform='Android'\n and dayofweekiso(event_date) NOT IN (6,7)\n and event_attributes:redacted_type::varchar='knock_out_product'\n group by 1\n order by 1 desc\n), expiry as\n(\n select event_date, count(*) as expiry_clicks, count(distinct auth_account_id) as expiry\n from TEAMS_PRD.REDACTED.MRT_CURR__MPARTICLE_EVENTS\n where event_name='redacted_search_filter_button_tapped' \n and event_attributes:filter_option::varchar='expiry'\n and event_date>'2023-12-31'\n and platform='Android'\n and dayofweekiso(event_date) NOT IN (6,7)\n and event_attributes:redacted_type::varchar='knock_out_product'\n group by 1\n order by 1 desc\n), product as\n(\n select event_date, count(*) as product_clicks, count(distinct auth_account_id) as product\n from TEAMS_PRD.REDACTED.MRT_CURR__MPARTICLE_EVENTS\n where event_name='redacted_search_filter_button_tapped' \n and event_attributes:filter_option::varchar='product'\n and event_date>'2023-12-31'\n and platform='Android'\n and dayofweekiso(event_date) NOT IN (6,7)\n and event_attributes:redacted_type::varchar='knock_out_product'\n group by 1\n order by 1 desc\n), knockout as \n(\n select event_date, count(*) as knockout_clicks, count(distinct auth_account_id) as knockout\n from TEAMS_PRD.SCHEMA.MRT_CURR__MPARTICLE_EVENTS\n where event_name='redacted_search_filter_button_tapped' \n and event_attributes:filter_option::varchar='knockout'\n and event_date>'2023-12-31'\n and platform='Android'\n and dayofweekiso(event_date) NOT IN (6,7)\n and event_attributes:redacted_type::varchar='knock_out_product'\n group by 1\n order by 1 desc\n)\nselect k.event_date, knockout, expiry, product, issuer\nfrom knockout k\njoin expiry e on k.event_date=e.event_date\njoin issuer i on k.event_date=i.event_date\njoin product p on k.event_date=p.event_date\nwhere k.event_date Date: Fri, 26 Jan 2024 20:54:06 +0200 Subject: [PATCH 219/540] fix(ingest/glue): Profiling breaks for non-partitioned tables due to absent `Table.PartitionKeys` (#9591) --- metadata-ingestion/src/datahub/ingestion/source/aws/glue.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/aws/glue.py b/metadata-ingestion/src/datahub/ingestion/source/aws/glue.py index 826c18f69fd013..93601533bf8d6d 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/aws/glue.py +++ b/metadata-ingestion/src/datahub/ingestion/source/aws/glue.py @@ -833,9 +833,8 @@ def get_profile_if_enabled( **{k: v for k, v in kwargs.items() if v} ) - partition_keys = response["Table"]["PartitionKeys"] - # check if this table is partitioned + partition_keys = response["Table"].get("PartitionKeys") if partition_keys: # ingest data profile with partitions # for cross-account ingestion From 051f570c47386540266e088d396feed70784f9d5 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Fri, 26 Jan 2024 14:17:14 -0600 Subject: [PATCH 220/540] fix(search): fix filters for hasX and numValues fields (#9729) --- .../metadata/models/ConfigEntitySpec.java | 12 ++++ .../metadata/models/DefaultEntitySpec.java | 12 ++++ .../linkedin/metadata/models/EntitySpec.java | 45 +++++++++++---- .../elasticsearch/query/ESBrowseDAO.java | 8 +-- .../elasticsearch/query/ESSearchDAO.java | 3 +- .../request/AutocompleteRequestHandler.java | 10 ++-- .../query/request/SearchRequestHandler.java | 13 +++-- .../metadata/search/utils/ESUtils.java | 55 +++++++++---------- .../ElasticSearchTimeseriesAspectService.java | 32 ++++++----- .../elastic/query/ESAggregatedStatsDAO.java | 2 +- .../fixtures/SampleDataFixtureTestBase.java | 54 ++++++++++++++++++ 11 files changed, 175 insertions(+), 71 deletions(-) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/ConfigEntitySpec.java b/entity-registry/src/main/java/com/linkedin/metadata/models/ConfigEntitySpec.java index b235e2adcae11a..8bd89071e299d2 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/ConfigEntitySpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/ConfigEntitySpec.java @@ -3,10 +3,12 @@ import com.linkedin.data.schema.RecordDataSchema; import com.linkedin.data.schema.TyperefDataSchema; import com.linkedin.metadata.models.annotation.EntityAnnotation; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -19,6 +21,7 @@ public class ConfigEntitySpec implements EntitySpec { private final Map _aspectSpecs; private List _searchableFieldSpecs; + private Map> searchableFieldTypeMap; public ConfigEntitySpec( @Nonnull final String entityName, @@ -89,4 +92,13 @@ public List getSearchableFieldSpecs() { return _searchableFieldSpecs; } + + @Override + public Map> getSearchableFieldTypes() { + if (searchableFieldTypeMap == null) { + searchableFieldTypeMap = EntitySpec.super.getSearchableFieldTypes(); + } + + return searchableFieldTypeMap; + } } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/DefaultEntitySpec.java b/entity-registry/src/main/java/com/linkedin/metadata/models/DefaultEntitySpec.java index 5db8ca264f69dd..2546674f9835cb 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/DefaultEntitySpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/DefaultEntitySpec.java @@ -3,10 +3,12 @@ import com.linkedin.data.schema.RecordDataSchema; import com.linkedin.data.schema.TyperefDataSchema; import com.linkedin.metadata.models.annotation.EntityAnnotation; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -24,6 +26,7 @@ public class DefaultEntitySpec implements EntitySpec { private final TyperefDataSchema _aspectTyperefSchema; private List _searchableFieldSpecs; + private Map> searchableFieldTypeMap; public DefaultEntitySpec( @Nonnull final Collection aspectSpecs, @@ -102,4 +105,13 @@ public List getSearchableFieldSpecs() { return _searchableFieldSpecs; } + + @Override + public Map> getSearchableFieldTypes() { + if (searchableFieldTypeMap == null) { + searchableFieldTypeMap = EntitySpec.super.getSearchableFieldTypes(); + } + + return searchableFieldTypeMap; + } } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java index fac08c7e206463..9a75cc1f751d3b 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpec.java @@ -3,7 +3,9 @@ import com.linkedin.data.schema.RecordDataSchema; import com.linkedin.data.schema.TyperefDataSchema; import com.linkedin.metadata.models.annotation.EntityAnnotation; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -39,16 +41,39 @@ default List getSearchableFieldSpecs() { .collect(Collectors.toList()); } - default Map> getSearchableFieldSpecMap() { - return getSearchableFieldSpecs().stream() - .collect( - Collectors.toMap( - searchableFieldSpec -> searchableFieldSpec.getSearchableAnnotation().getFieldName(), - searchableFieldSpec -> new HashSet<>(Collections.singleton(searchableFieldSpec)), - (set1, set2) -> { - set1.addAll(set2); - return set1; - })); + default Map> getSearchableFieldTypes() { + // Get additional fields and mint SearchableFieldSpecs for them + Map> fieldSpecMap = new HashMap<>(); + for (SearchableFieldSpec fieldSpec : getSearchableFieldSpecs()) { + SearchableAnnotation searchableAnnotation = fieldSpec.getSearchableAnnotation(); + if (searchableAnnotation.getNumValuesFieldName().isPresent()) { + String fieldName = searchableAnnotation.getNumValuesFieldName().get(); + Set fieldSet = new HashSet<>(); + fieldSet.add(SearchableAnnotation.FieldType.COUNT); + fieldSpecMap.put(fieldName, fieldSet); + } + if (searchableAnnotation.getHasValuesFieldName().isPresent()) { + String fieldName = searchableAnnotation.getHasValuesFieldName().get(); + Set fieldSet = new HashSet<>(); + fieldSet.add(SearchableAnnotation.FieldType.BOOLEAN); + fieldSpecMap.put(fieldName, fieldSet); + } + } + fieldSpecMap.putAll( + getSearchableFieldSpecs().stream() + .collect( + Collectors.toMap( + searchableFieldSpec -> + searchableFieldSpec.getSearchableAnnotation().getFieldName(), + searchableFieldSpec -> + new HashSet<>( + Collections.singleton( + searchableFieldSpec.getSearchableAnnotation().getFieldType())), + (set1, set2) -> { + set1.addAll(set2); + return set1; + }))); + return fieldSpecMap; } default List getSearchScoreFieldSpecs() { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java index d610ea4b4e028a..0a9a9fbbad0867 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java @@ -19,7 +19,7 @@ import com.linkedin.metadata.config.search.SearchConfiguration; import com.linkedin.metadata.config.search.custom.CustomSearchConfiguration; import com.linkedin.metadata.models.EntitySpec; -import com.linkedin.metadata.models.SearchableFieldSpec; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.elasticsearch.query.request.SearchRequestHandler; @@ -557,7 +557,7 @@ private QueryBuilder buildQueryStringV2( queryBuilder.filter(QueryBuilders.rangeQuery(BROWSE_PATH_V2_DEPTH).gt(browseDepthVal)); queryBuilder.filter( - SearchRequestHandler.getFilterQuery(filter, entitySpec.getSearchableFieldSpecMap())); + SearchRequestHandler.getFilterQuery(filter, entitySpec.getSearchableFieldTypes())); return queryBuilder; } @@ -583,9 +583,9 @@ private QueryBuilder buildQueryStringBrowseAcrossEntities( queryBuilder.filter(QueryBuilders.rangeQuery(BROWSE_PATH_V2_DEPTH).gt(browseDepthVal)); - Map> searchableFields = + Map> searchableFields = entitySpecs.stream() - .flatMap(entitySpec -> entitySpec.getSearchableFieldSpecMap().entrySet().stream()) + .flatMap(entitySpec -> entitySpec.getSearchableFieldTypes().entrySet().stream()) .collect( Collectors.toMap( Map.Entry::getKey, diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java index 1ec90ed6f61e29..7de2770626ae34 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java @@ -78,8 +78,7 @@ public long docCount(@Nonnull String entityName) { EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); CountRequest countRequest = new CountRequest(indexConvention.getIndexName(entitySpec)) - .query( - SearchRequestHandler.getFilterQuery(null, entitySpec.getSearchableFieldSpecMap())); + .query(SearchRequestHandler.getFilterQuery(null, entitySpec.getSearchableFieldTypes())); try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "docCount").time()) { return client.count(countRequest, RequestOptions.DEFAULT).getCount(); } catch (IOException e) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java index 333d9602734d25..38350322478741 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AutocompleteRequestHandler.java @@ -41,7 +41,7 @@ public class AutocompleteRequestHandler { private final List _defaultAutocompleteFields; - private final Map> searchableFields; + private final Map> searchableFieldTypes; private static final Map AUTOCOMPLETE_QUERY_BUILDER_BY_ENTITY_NAME = new ConcurrentHashMap<>(); @@ -56,14 +56,16 @@ public AutocompleteRequestHandler(@Nonnull EntitySpec entitySpec) { .map(SearchableAnnotation::getFieldName), Stream.of("urn")) .collect(Collectors.toList()); - searchableFields = + searchableFieldTypes = fieldSpecs.stream() .collect( Collectors.toMap( searchableFieldSpec -> searchableFieldSpec.getSearchableAnnotation().getFieldName(), searchableFieldSpec -> - new HashSet<>(Collections.singleton(searchableFieldSpec)), + new HashSet<>( + Collections.singleton( + searchableFieldSpec.getSearchableAnnotation().getFieldType())), (set1, set2) -> { set1.addAll(set2); return set1; @@ -81,7 +83,7 @@ public SearchRequest getSearchRequest( SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); searchSourceBuilder.size(limit); searchSourceBuilder.query(getQuery(input, field)); - searchSourceBuilder.postFilter(ESUtils.buildFilterQuery(filter, false, searchableFields)); + searchSourceBuilder.postFilter(ESUtils.buildFilterQuery(filter, false, searchableFieldTypes)); searchSourceBuilder.highlighter(getHighlights(field)); searchRequest.source(searchSourceBuilder); return searchRequest; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java index e6ee909c80dae4..277e15e1334d56 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java @@ -97,7 +97,7 @@ public class SearchRequestHandler { private final SearchConfiguration _configs; private final SearchQueryBuilder _searchQueryBuilder; private final AggregationQueryBuilder _aggregationQueryBuilder; - private final Map> searchableFields; + private final Map> searchableFieldTypes; private SearchRequestHandler( @Nonnull EntitySpec entitySpec, @@ -122,9 +122,9 @@ private SearchRequestHandler( _searchQueryBuilder = new SearchQueryBuilder(configs, customSearchConfiguration); _aggregationQueryBuilder = new AggregationQueryBuilder(configs, annotations); _configs = configs; - searchableFields = + searchableFieldTypes = _entitySpecs.stream() - .flatMap(entitySpec -> entitySpec.getSearchableFieldSpecMap().entrySet().stream()) + .flatMap(entitySpec -> entitySpec.getSearchableFieldTypes().entrySet().stream()) .collect( Collectors.toMap( Map.Entry::getKey, @@ -182,12 +182,13 @@ private BinaryOperator mapMerger() { } public BoolQueryBuilder getFilterQuery(@Nullable Filter filter) { - return getFilterQuery(filter, searchableFields); + return getFilterQuery(filter, searchableFieldTypes); } public static BoolQueryBuilder getFilterQuery( - @Nullable Filter filter, Map> searchableFields) { - BoolQueryBuilder filterQuery = ESUtils.buildFilterQuery(filter, false, searchableFields); + @Nullable Filter filter, + Map> searchableFieldTypes) { + BoolQueryBuilder filterQuery = ESUtils.buildFilterQuery(filter, false, searchableFieldTypes); return filterSoftDeletedByDefault(filter, filterQuery); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java index 77a67f100895c8..4d74bfb66b8dbc 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java @@ -132,7 +132,7 @@ private ESUtils() {} public static BoolQueryBuilder buildFilterQuery( @Nullable Filter filter, boolean isTimeseries, - final Map> searchableFields) { + final Map> searchableFieldTypes) { BoolQueryBuilder finalQueryBuilder = QueryBuilders.boolQuery(); if (filter == null) { return finalQueryBuilder; @@ -144,7 +144,7 @@ public static BoolQueryBuilder buildFilterQuery( .forEach( or -> finalQueryBuilder.should( - ESUtils.buildConjunctiveFilterQuery(or, isTimeseries, searchableFields))); + ESUtils.buildConjunctiveFilterQuery(or, isTimeseries, searchableFieldTypes))); } else if (filter.getCriteria() != null) { // Otherwise, build boolean query from the deprecated "criteria" field. log.warn("Received query Filter with a deprecated field 'criteria'. Use 'or' instead."); @@ -157,7 +157,7 @@ public static BoolQueryBuilder buildFilterQuery( || criterion.hasValues() || criterion.getCondition() == Condition.IS_NULL) { andQueryBuilder.must( - getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFieldTypes)); } }); finalQueryBuilder.should(andQueryBuilder); @@ -169,7 +169,7 @@ public static BoolQueryBuilder buildFilterQuery( public static BoolQueryBuilder buildConjunctiveFilterQuery( @Nonnull ConjunctiveCriterion conjunctiveCriterion, boolean isTimeseries, - Map> searchableFields) { + Map> searchableFieldTypes) { final BoolQueryBuilder andQueryBuilder = new BoolQueryBuilder(); conjunctiveCriterion .getAnd() @@ -181,10 +181,10 @@ public static BoolQueryBuilder buildConjunctiveFilterQuery( if (!criterion.isNegated()) { // `filter` instead of `must` (enables caching and bypasses scoring) andQueryBuilder.filter( - getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFieldTypes)); } else { andQueryBuilder.mustNot( - getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFields)); + getQueryBuilderFromCriterion(criterion, isTimeseries, searchableFieldTypes)); } } }); @@ -222,7 +222,7 @@ public static BoolQueryBuilder buildConjunctiveFilterQuery( public static QueryBuilder getQueryBuilderFromCriterion( @Nonnull final Criterion criterion, boolean isTimeseries, - final Map> searchableFields) { + final Map> searchableFieldTypes) { final String fieldName = toFacetField(criterion.getField()); if (fieldName.startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD)) { criterion.setField(fieldName); @@ -241,10 +241,11 @@ public static QueryBuilder getQueryBuilderFromCriterion( if (maybeFieldToExpand.isPresent()) { return getQueryBuilderFromCriterionForFieldToExpand( - maybeFieldToExpand.get(), criterion, isTimeseries, searchableFields); + maybeFieldToExpand.get(), criterion, isTimeseries, searchableFieldTypes); } - return getQueryBuilderFromCriterionForSingleField(criterion, isTimeseries, searchableFields); + return getQueryBuilderFromCriterionForSingleField( + criterion, isTimeseries, searchableFieldTypes); } public static String getElasticTypeForFieldType(SearchableAnnotation.FieldType fieldType) { @@ -446,7 +447,7 @@ private static QueryBuilder getQueryBuilderFromCriterionForFieldToExpand( @Nonnull final List fields, @Nonnull final Criterion criterion, final boolean isTimeseries, - final Map> searchableFields) { + final Map> searchableFieldTypes) { final BoolQueryBuilder orQueryBuilder = new BoolQueryBuilder(); for (String field : fields) { Criterion criterionToQuery = new Criterion(); @@ -461,7 +462,7 @@ private static QueryBuilder getQueryBuilderFromCriterionForFieldToExpand( criterionToQuery.setField(toKeywordField(field, isTimeseries)); orQueryBuilder.should( getQueryBuilderFromCriterionForSingleField( - criterionToQuery, isTimeseries, searchableFields)); + criterionToQuery, isTimeseries, searchableFieldTypes)); } return orQueryBuilder; } @@ -470,7 +471,7 @@ private static QueryBuilder getQueryBuilderFromCriterionForFieldToExpand( private static QueryBuilder getQueryBuilderFromCriterionForSingleField( @Nonnull Criterion criterion, boolean isTimeseries, - final Map> searchableFields) { + final Map> searchableFieldTypes) { final Condition condition = criterion.getCondition(); final String fieldName = toFacetField(criterion.getField()); @@ -485,10 +486,10 @@ private static QueryBuilder getQueryBuilderFromCriterionForSingleField( } else if (criterion.hasValues() || criterion.hasValue()) { if (condition == Condition.EQUAL) { return buildEqualsConditionFromCriterion( - fieldName, criterion, isTimeseries, searchableFields); + fieldName, criterion, isTimeseries, searchableFieldTypes); } else if (RANGE_QUERY_CONDITIONS.contains(condition)) { return buildRangeQueryFromCriterion( - criterion, fieldName, searchableFields, condition, isTimeseries); + criterion, fieldName, searchableFieldTypes, condition, isTimeseries); } else if (condition == Condition.CONTAIN) { return QueryBuilders.wildcardQuery( toKeywordField(criterion.getField(), isTimeseries), @@ -513,14 +514,14 @@ private static QueryBuilder buildEqualsConditionFromCriterion( @Nonnull final String fieldName, @Nonnull final Criterion criterion, final boolean isTimeseries, - final Map> searchableFields) { + final Map> searchableFieldTypes) { /* * If the newer 'values' field of Criterion.pdl is set, then we * handle using the following code to allow multi-match. */ if (!criterion.getValues().isEmpty()) { return buildEqualsConditionFromCriterionWithValues( - fieldName, criterion, isTimeseries, searchableFields); + fieldName, criterion, isTimeseries, searchableFieldTypes); } /* * Otherwise, we are likely using the deprecated 'value' field. @@ -537,8 +538,8 @@ private static QueryBuilder buildEqualsConditionFromCriterionWithValues( @Nonnull final String fieldName, @Nonnull final Criterion criterion, final boolean isTimeseries, - final Map> searchableFields) { - Set fieldTypes = getFieldTypes(searchableFields, fieldName); + final Map> searchableFieldTypes) { + Set fieldTypes = getFieldTypes(searchableFieldTypes, fieldName); if (fieldTypes.size() > 1) { log.warn( "Multiple field types for field name {}, determining best fit for set: {}", @@ -563,31 +564,27 @@ private static QueryBuilder buildEqualsConditionFromCriterionWithValues( } private static Set getFieldTypes( - Map> searchableFields, String fieldName) { - Set fieldSpecs = + Map> searchableFields, String fieldName) { + Set fieldTypes = searchableFields.getOrDefault(fieldName, Collections.emptySet()); - Set fieldTypes = - fieldSpecs.stream() - .map(SearchableFieldSpec::getSearchableAnnotation) - .map(SearchableAnnotation::getFieldType) - .map(ESUtils::getElasticTypeForFieldType) - .collect(Collectors.toSet()); + Set finalFieldTypes = + fieldTypes.stream().map(ESUtils::getElasticTypeForFieldType).collect(Collectors.toSet()); if (fieldTypes.size() > 1) { log.warn( "Multiple field types for field name {}, determining best fit for set: {}", fieldName, fieldTypes); } - return fieldTypes; + return finalFieldTypes; } private static RangeQueryBuilder buildRangeQueryFromCriterion( Criterion criterion, String fieldName, - Map> searchableFields, + Map> searchableFieldTypes, Condition condition, boolean isTimeseries) { - Set fieldTypes = getFieldTypes(searchableFields, fieldName); + Set fieldTypes = getFieldTypes(searchableFieldTypes, fieldName); // Determine criterion value, range query only accepts single value so take first value in // values if multiple diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java index 6cf8e92d61929f..cb06dc75c70bc9 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/ElasticSearchTimeseriesAspectService.java @@ -14,7 +14,7 @@ import com.linkedin.metadata.aspect.EnvelopedAspect; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; -import com.linkedin.metadata.models.SearchableFieldSpec; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.Criterion; @@ -296,7 +296,7 @@ public long countByFilter( ESUtils.buildFilterQuery( filter, true, - _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap())); + _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes())); CountRequest countRequest = new CountRequest(); countRequest.query(filterQueryBuilder); countRequest.indices(indexName); @@ -319,10 +319,11 @@ public List getAspectValues( @Nullable final Integer limit, @Nullable final Filter filter, @Nullable final SortCriterion sort) { - Map> searchableFields = - _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap(); + Map> searchableFieldTypes = + _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes(); final BoolQueryBuilder filterQueryBuilder = - QueryBuilders.boolQuery().must(ESUtils.buildFilterQuery(filter, true, searchableFields)); + QueryBuilders.boolQuery() + .must(ESUtils.buildFilterQuery(filter, true, searchableFieldTypes)); filterQueryBuilder.must(QueryBuilders.matchQuery("urn", urn.toString())); // NOTE: We are interested only in the un-exploded rows as only they carry the `event` payload. filterQueryBuilder.mustNot(QueryBuilders.termQuery(MappingsBuilder.IS_EXPLODED_FIELD, true)); @@ -333,7 +334,7 @@ public List getAspectValues( .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) .setValue(startTimeMillis.toString()); filterQueryBuilder.must( - ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFields)); + ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFieldTypes)); } if (endTimeMillis != null) { Criterion endTimeCriterion = @@ -342,7 +343,7 @@ public List getAspectValues( .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) .setValue(endTimeMillis.toString()); filterQueryBuilder.must( - ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFields)); + ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFieldTypes)); } final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); searchSourceBuilder.query(filterQueryBuilder); @@ -412,7 +413,7 @@ public DeleteAspectValuesResult deleteAspectValues( final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery( - filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes()); final Optional result = _bulkProcessor @@ -440,7 +441,7 @@ public String deleteAspectValuesAsync( final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery( - filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes()); final int batchSize = options.getBatchSize() > 0 ? options.getBatchSize() : DEFAULT_LIMIT; TimeValue timeout = options.getTimeoutSeconds() > 0 @@ -466,7 +467,7 @@ public String reindexAsync( final String indexName = _indexConvention.getTimeseriesAspectIndexName(entityName, aspectName); final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery( - filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes()); try { return this.reindexAsync(indexName, filterQueryBuilder, options); } catch (Exception e) { @@ -515,10 +516,11 @@ public TimeseriesScrollResult scrollAspects( @Nullable Long startTimeMillis, @Nullable Long endTimeMillis) { - Map> searchableFields = - _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap(); + Map> searchableFieldTypes = + _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes(); final BoolQueryBuilder filterQueryBuilder = - QueryBuilders.boolQuery().filter(ESUtils.buildFilterQuery(filter, true, searchableFields)); + QueryBuilders.boolQuery() + .filter(ESUtils.buildFilterQuery(filter, true, searchableFieldTypes)); if (startTimeMillis != null) { Criterion startTimeCriterion = @@ -527,7 +529,7 @@ public TimeseriesScrollResult scrollAspects( .setCondition(Condition.GREATER_THAN_OR_EQUAL_TO) .setValue(startTimeMillis.toString()); filterQueryBuilder.filter( - ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFields)); + ESUtils.getQueryBuilderFromCriterion(startTimeCriterion, true, searchableFieldTypes)); } if (endTimeMillis != null) { Criterion endTimeCriterion = @@ -536,7 +538,7 @@ public TimeseriesScrollResult scrollAspects( .setCondition(Condition.LESS_THAN_OR_EQUAL_TO) .setValue(endTimeMillis.toString()); filterQueryBuilder.filter( - ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFields)); + ESUtils.getQueryBuilderFromCriterion(endTimeCriterion, true, searchableFieldTypes)); } SearchResponse response = diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java index f8b2cd85523576..580888e54b7007 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeseries/elastic/query/ESAggregatedStatsDAO.java @@ -379,7 +379,7 @@ public GenericTable getAggregatedStats( // Setup the filter query builder using the input filter provided. final BoolQueryBuilder filterQueryBuilder = ESUtils.buildFilterQuery( - filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldSpecMap()); + filter, true, _entityRegistry.getEntitySpec(entityName).getSearchableFieldTypes()); AspectSpec aspectSpec = getTimeseriesAspectSpec(entityName, aspectName); // Build and attach the grouping aggregations diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java index a1af2325ee0ed8..4742115b16e1bd 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/SampleDataFixtureTestBase.java @@ -14,8 +14,10 @@ import com.datahub.authentication.Actor; import com.datahub.authentication.ActorType; import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.StringArray; import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.types.chart.ChartType; import com.linkedin.datahub.graphql.types.container.ContainerType; @@ -45,6 +47,7 @@ import com.linkedin.r2.RemoteInvocationException; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -64,6 +67,7 @@ import org.opensearch.search.sort.FieldSortBuilder; import org.opensearch.search.sort.SortBuilder; import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.testng.AssertJUnit; import org.testng.annotations.Test; public abstract class SampleDataFixtureTestBase extends AbstractTestNGSpringContextTests { @@ -1936,6 +1940,56 @@ public void testSortOrdering() { String.format("%s - Expected search results to have at least two results", query)); } + @Test + public void testFilterOnHasValuesField() { + AssertJUnit.assertNotNull(getSearchService()); + Filter filter = + new Filter() + .setOr( + new ConjunctiveCriterionArray( + new ConjunctiveCriterion() + .setAnd( + new CriterionArray( + ImmutableList.of( + new Criterion() + .setField("hasOwners") + .setValue("") + .setValues(new StringArray(ImmutableList.of("true")))))))); + SearchResult searchResult = + searchAcrossEntities( + getSearchService(), + "*", + SEARCHABLE_ENTITIES, + filter, + Collections.singletonList(DATASET_ENTITY_NAME)); + assertEquals(searchResult.getEntities().size(), 8); + } + + @Test + public void testFilterOnNumValuesField() { + AssertJUnit.assertNotNull(getSearchService()); + Filter filter = + new Filter() + .setOr( + new ConjunctiveCriterionArray( + new ConjunctiveCriterion() + .setAnd( + new CriterionArray( + ImmutableList.of( + new Criterion() + .setField("numInputDatasets") + .setValue("") + .setValues(new StringArray(ImmutableList.of("1")))))))); + SearchResult searchResult = + searchAcrossEntities( + getSearchService(), + "*", + SEARCHABLE_ENTITIES, + filter, + Collections.singletonList(DATA_JOB_ENTITY_NAME)); + assertEquals(searchResult.getEntities().size(), 4); + } + private Stream getTokens(AnalyzeRequest request) throws IOException { return getSearchClient() From 388b3ec0ac10f7e3d142c9bcbf9c89be6ea92853 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 26 Jan 2024 14:01:48 -0800 Subject: [PATCH 221/540] fix(ingest/airflow): fix plugin support for airflow 2.5.0 (#9719) --- .../src/datahub_airflow_plugin/_datahub_listener_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_datahub_listener_module.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_datahub_listener_module.py index e16563400e397f..0e1ef69ebf18c7 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_datahub_listener_module.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_datahub_listener_module.py @@ -29,6 +29,6 @@ def on_task_instance_failed(previous_state, task_instance, session): if hasattr(_listener, "on_dag_run_running"): @hookimpl - def on_dag_run_running(dag_run, session): + def on_dag_run_running(dag_run, msg): assert _listener - _listener.on_dag_run_running(dag_run, session) + _listener.on_dag_run_running(dag_run, msg) From 5adb799f137a00c315144715786179ef4a6b2405 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 26 Jan 2024 14:02:52 -0800 Subject: [PATCH 222/540] fix(cli): fix example data contract yaml + update airflow codecov (#9707) --- .github/workflows/airflow-plugin.yml | 4 +- .../airflow-plugin/build.gradle | 2 +- .../airflow-plugin/tests/conftest.py | 11 +++++ .../pet_of_the_week.dhub.dc.yaml | 42 +++++++++++-------- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index 7ae7b87b0f5ceb..c5c75de4f7aeec 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -87,8 +87,8 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} directory: . fail_ci_if_error: false - flags: airflow-${{ matrix.python-version }}-${{ matrix.extraPythonRequirement }} - name: pytest-airflow + flags: airflow,airflow-${{ matrix.extra_pip_extras }} + name: pytest-airflow-${{ matrix.python-version }}-${{ matrix.extra_pip_requirements }} verbose: true event-file: diff --git a/metadata-ingestion-modules/airflow-plugin/build.gradle b/metadata-ingestion-modules/airflow-plugin/build.gradle index dacf12dc020df4..9555f92c8831dd 100644 --- a/metadata-ingestion-modules/airflow-plugin/build.gradle +++ b/metadata-ingestion-modules/airflow-plugin/build.gradle @@ -108,7 +108,7 @@ task testQuick(type: Exec, dependsOn: installDevTest) { inputs.files(project.fileTree(dir: "src/", include: "**/*.py")) inputs.files(project.fileTree(dir: "tests/")) commandLine 'bash', '-x', '-c', - "source ${venv_name}/bin/activate && pytest -vv --continue-on-collection-errors --junit-xml=junit.quick.xml" + "source ${venv_name}/bin/activate && pytest --cov-config=setup.cfg --cov-report xml:coverage_quick.xml -vv --continue-on-collection-errors --junit-xml=junit.quick.xml" } diff --git a/metadata-ingestion-modules/airflow-plugin/tests/conftest.py b/metadata-ingestion-modules/airflow-plugin/tests/conftest.py index d2c45e723f1b00..994816ff037c8d 100644 --- a/metadata-ingestion-modules/airflow-plugin/tests/conftest.py +++ b/metadata-ingestion-modules/airflow-plugin/tests/conftest.py @@ -1,6 +1,17 @@ +import pathlib +import site + + def pytest_addoption(parser): parser.addoption( "--update-golden-files", action="store_true", default=False, ) + + +# See https://coverage.readthedocs.io/en/latest/subprocess.html#configuring-python-for-sub-process-measurement +coverage_startup_code = "import coverage; coverage.process_startup()" +site_packages_dir = pathlib.Path(site.getsitepackages()[0]) +pth_file_path = site_packages_dir / "datahub_coverage_startup.pth" +pth_file_path.write_text(coverage_startup_code) diff --git a/metadata-ingestion/examples/data_contract/pet_of_the_week.dhub.dc.yaml b/metadata-ingestion/examples/data_contract/pet_of_the_week.dhub.dc.yaml index c73904403f678d..bd081172b2a27b 100644 --- a/metadata-ingestion/examples/data_contract/pet_of_the_week.dhub.dc.yaml +++ b/metadata-ingestion/examples/data_contract/pet_of_the_week.dhub.dc.yaml @@ -1,21 +1,29 @@ -# id: pet_details_dc # Optional: This is the unique identifier for the data contract -display_name: Data Contract for SampleHiveDataset +version: 1 # datahub yaml format version + +# Note: this data contract yaml format is still in development, and will likely +# change in backwards-incompatible ways in the future. + entity: urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD) freshness: - time: 0700 - granularity: DAILY + type: cron + cron: 0 7 * * * # 7am daily + timezone: America/Los_Angeles schema: - properties: - field_foo: - type: string - native_type: VARCHAR(100) - field_bar: - type: boolean - required: - - field_bar + type: json-schema + json-schema: + properties: + field_foo: + type: string + native_type: VARCHAR(100) + field_bar: + type: boolean + required: + - field_bar data_quality: - - type: column_range - config: - column: field_foo - min: 0 - max: 100 + - type: unique + column: field_foo + - type: custom_sql + sql: SELECT COUNT(*) FROM SampleHiveDataset + operator: + type: greater_than + value: 100 From 2bb4b73f98ef46446e8025cd3657289bb24ff0df Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Fri, 26 Jan 2024 14:03:16 -0800 Subject: [PATCH 223/540] fix(ingest/metabase): add missing sql parser dep (#9725) --- metadata-ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 1fb570d76120e8..c1a5da5826ead9 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -340,7 +340,7 @@ "ldap": {"python-ldap>=2.4"}, "looker": looker_common, "lookml": looker_common, - "metabase": {"requests"} | sqllineage_lib, + "metabase": {"requests"} | sqlglot_lib, "mlflow": {"mlflow-skinny>=2.3.0"}, "mode": {"requests", "tenacity>=8.0.1"} | sqllineage_lib, "mongodb": {"pymongo[srv]>=3.11", "packaging"}, From dc16c73937dcb4a287653090faf3c32807257872 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Mon, 29 Jan 2024 15:26:14 +0530 Subject: [PATCH 224/540] feat(ui): include parent term groups, domains in glossary, domain dropdown (#9715) --- .../profile/AddRelatedTermsModal.tsx | 13 ++++- .../EntityDropdown/NodeParentSelect.tsx | 21 +++++-- .../profile/sidebar/Domain/SetDomainModal.tsx | 13 ++++- .../glossary/GloassarySearchResultItem.tsx | 56 +++++++++++++++++++ .../src/app/glossary/GlossarySearch.tsx | 55 ++++++++---------- datahub-web-react/src/app/glossary/utils.ts | 8 ++- .../src/app/shared/DomainLabel.tsx | 2 +- .../src/app/shared/tags/AddTagsTermsModal.tsx | 13 ++++- 8 files changed, 138 insertions(+), 43 deletions(-) create mode 100644 datahub-web-react/src/app/glossary/GloassarySearchResultItem.tsx diff --git a/datahub-web-react/src/app/entity/glossaryTerm/profile/AddRelatedTermsModal.tsx b/datahub-web-react/src/app/entity/glossaryTerm/profile/AddRelatedTermsModal.tsx index 5b303f75e2985a..f97f3c327676b9 100644 --- a/datahub-web-react/src/app/entity/glossaryTerm/profile/AddRelatedTermsModal.tsx +++ b/datahub-web-react/src/app/entity/glossaryTerm/profile/AddRelatedTermsModal.tsx @@ -10,11 +10,19 @@ import { BrowserWrapper } from '../../../shared/tags/AddTagsTermsModal'; import TermLabel from '../../../shared/TermLabel'; import { useEntityRegistry } from '../../../useEntityRegistry'; import { useEntityData, useRefetch } from '../../shared/EntityContext'; +import ParentEntities from '../../../search/filters/ParentEntities'; +import { getParentEntities } from '../../../search/filters/utils'; const StyledSelect = styled(Select)` width: 480px; `; +const SearchResultContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; + interface Props { onClose: () => void; relationshipType: TermRelationshipType; @@ -68,7 +76,10 @@ function AddRelatedTermsModal(props: Props) { return ( - + + + + ); }); diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/NodeParentSelect.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/NodeParentSelect.tsx index e7f5827e33dcc7..7227354a465695 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/NodeParentSelect.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/NodeParentSelect.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Select } from 'antd'; +import styled from 'styled-components'; import { EntityType, GlossaryNode, SearchResult } from '../../../../types.generated'; import { useEntityRegistry } from '../../../useEntityRegistry'; import { useEntityData } from '../EntityContext'; @@ -7,6 +8,14 @@ import ClickOutside from '../../../shared/ClickOutside'; import GlossaryBrowser from '../../../glossary/GlossaryBrowser/GlossaryBrowser'; import { BrowserWrapper } from '../../../shared/tags/AddTagsTermsModal'; import useParentSelector from './useParentSelector'; +import ParentEntities from '../../../search/filters/ParentEntities'; +import { getParentGlossary } from '../../../glossary/utils'; + +const SearchResultContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; // filter out entity itself and its children export function filterResultsForMove(entity: GlossaryNode, entityUrn: string) { @@ -46,10 +55,9 @@ function NodeParentSelect(props: Props) { setSelectedParentUrn, }); - let nodeSearchResults: SearchResult[] = []; - if (isMoving) { - nodeSearchResults = searchResults.filter((r) => filterResultsForMove(r.entity as GlossaryNode, entityDataUrn)); - } + const nodeSearchResults: SearchResult[] = searchResults.filter((r) => + filterResultsForMove(r.entity as GlossaryNode, entityDataUrn), + ); const isShowingGlossaryBrowser = !searchQuery && isFocusedOnInput; const shouldHideSelf = isMoving && entityType === EntityType.GlossaryNode; @@ -70,7 +78,10 @@ function NodeParentSelect(props: Props) { > {nodeSearchResults?.map((result) => ( - {entityRegistry.getDisplayName(result.entity.type, result.entity)} + + + {entityRegistry.getDisplayName(result.entity.type, result.entity)} + ))} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx index 9b512d2d679e94..ab63553c6376b8 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx @@ -16,6 +16,8 @@ import DomainNavigator from '../../../../../../domain/nestedDomains/domainNaviga import ClickOutside from '../../../../../../shared/ClickOutside'; import { ANTD_GRAY } from '../../../../constants'; import { getModalDomContainer } from '../../../../../../../utils/focus'; +import ParentEntities from '../../../../../../search/filters/ParentEntities'; +import { getParentDomains } from '../../../../../../domain/utils'; type Props = { urns: string[]; @@ -44,6 +46,12 @@ const LoadingWrapper = styled.div` } `; +const SearchResultContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; + export const SetDomainModal = ({ urns, onCloseModal, refetch, defaultValue, onOkOverride, titleOverride }: Props) => { const entityRegistry = useEntityRegistry(); const [isFocusedOnInput, setIsFocusedOnInput] = useState(false); @@ -88,7 +96,10 @@ export const SetDomainModal = ({ urns, onCloseModal, refetch, defaultValue, onOk const displayName = entityRegistry.getDisplayName(entity.type, entity); return ( - + + + + ); }; diff --git a/datahub-web-react/src/app/glossary/GloassarySearchResultItem.tsx b/datahub-web-react/src/app/glossary/GloassarySearchResultItem.tsx new file mode 100644 index 00000000000000..03a384aab4bd52 --- /dev/null +++ b/datahub-web-react/src/app/glossary/GloassarySearchResultItem.tsx @@ -0,0 +1,56 @@ +// Create a new component called SearchResultItem.js +import React from 'react'; +import { Link } from 'react-router-dom'; +import Highlight from 'react-highlighter'; +import styled from 'styled-components/macro'; +import { Entity } from '../../types.generated'; +import { IconStyleType } from '../entity/Entity'; +import { ANTD_GRAY } from '../entity/shared/constants'; +import ParentEntities from '../search/filters/ParentEntities'; +import { getParentGlossary } from './utils'; +import EntityRegistry from '../entity/EntityRegistry'; + +type Props = { + entity: Entity; + entityRegistry: EntityRegistry; + query: string; + onResultClick: () => void; +}; + +const SearchResult = styled(Link)` + color: #262626; + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 6px 8px; + width: 100%; + &:hover { + background-color: ${ANTD_GRAY[3]}; + color: #262626; + } +`; + +const IconWrapper = styled.span``; + +const highlightMatchStyle = { + fontWeight: 'bold', + background: 'none', + padding: 0, +}; + +function GlossarySearchResultItem({ entity, entityRegistry, query, onResultClick }: Props) { + return ( + + {entityRegistry.getIcon(entity.type, 12, IconStyleType.TAB_VIEW)} +
    + + + {entityRegistry.getDisplayName(entity.type, entity)} + +
    +
    + ); +} + +export default GlossarySearchResultItem; diff --git a/datahub-web-react/src/app/glossary/GlossarySearch.tsx b/datahub-web-react/src/app/glossary/GlossarySearch.tsx index 75cd3b10d581e8..321c218c38fe33 100644 --- a/datahub-web-react/src/app/glossary/GlossarySearch.tsx +++ b/datahub-web-react/src/app/glossary/GlossarySearch.tsx @@ -1,13 +1,12 @@ import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; import styled from 'styled-components/macro'; import { useGetSearchResultsForMultipleQuery } from '../../graphql/search.generated'; import { EntityType } from '../../types.generated'; -import { IconStyleType } from '../entity/Entity'; import { ANTD_GRAY } from '../entity/shared/constants'; import { SearchBar } from '../search/SearchBar'; import ClickOutside from '../shared/ClickOutside'; import { useEntityRegistry } from '../useEntityRegistry'; +import GloassarySearchResultItem from './GloassarySearchResultItem'; const GlossarySearchWrapper = styled.div` position: relative; @@ -28,20 +27,10 @@ const ResultsWrapper = styled.div` top: 45px; `; -const SearchResult = styled(Link)` - color: #262626; - display: inline-block; - height: 100%; - padding: 6px 8px; - width: 100%; - &:hover { - background-color: ${ANTD_GRAY[3]}; - color: #262626; - } -`; - -const IconWrapper = styled.span` - margin-right: 8px; +const TermNodeName = styled.span` + margin-top: 12px; + color: ${ANTD_GRAY[8]}; + font-weight: bold; `; function GlossarySearch() { @@ -63,6 +52,21 @@ function GlossarySearch() { const searchResults = data?.searchAcrossEntities?.searchResults; + const renderSearchResults = () => ( + + Glossary Terms + {searchResults?.map((result) => ( + setIsSearchBarFocused(false)} + /> + ))} + + ); + return ( setIsSearchBarFocused(false)}> @@ -84,23 +88,8 @@ function GlossarySearch() { entityRegistry={entityRegistry} onFocus={() => setIsSearchBarFocused(true)} /> - {isSearchBarFocused && searchResults && !!searchResults.length && ( - - {searchResults.map((result) => { - return ( - setIsSearchBarFocused(false)} - > - - {entityRegistry.getIcon(result.entity.type, 12, IconStyleType.ACCENT)} - - {entityRegistry.getDisplayName(result.entity.type, result.entity)} - - ); - })} - - )} + {isSearchBarFocused && searchResults && !!searchResults.length && renderSearchResults()} + ); diff --git a/datahub-web-react/src/app/glossary/utils.ts b/datahub-web-react/src/app/glossary/utils.ts index 60f71d7b2f9ef1..4cfbb06b8a4f3a 100644 --- a/datahub-web-react/src/app/glossary/utils.ts +++ b/datahub-web-react/src/app/glossary/utils.ts @@ -1,4 +1,5 @@ -import { EntityType } from '../../types.generated'; +import { Entity, EntityType } from '../../types.generated'; +import EntityRegistry from '../entity/EntityRegistry'; import { GenericEntityProperties } from '../entity/shared/types'; export const ROOT_NODES = 'rootNodes'; @@ -25,3 +26,8 @@ export function updateGlossarySidebar( ) { setUrnsToUpdate([...urnsToUpdate, ...parentNodesToUpdate]); } + +export function getParentGlossary(node: T, entityRegistry: EntityRegistry) { + const props = entityRegistry.getGenericEntityProperties(EntityType.GlossaryNode, node); + return props?.parentNodes?.nodes ?? []; +} diff --git a/datahub-web-react/src/app/shared/DomainLabel.tsx b/datahub-web-react/src/app/shared/DomainLabel.tsx index 40208026d4369f..f71975b23e5179 100644 --- a/datahub-web-react/src/app/shared/DomainLabel.tsx +++ b/datahub-web-react/src/app/shared/DomainLabel.tsx @@ -5,7 +5,7 @@ const DomainContainerWrapper = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: 12px; + padding: 2px; `; const DomainContentWrapper = styled.div` diff --git a/datahub-web-react/src/app/shared/tags/AddTagsTermsModal.tsx b/datahub-web-react/src/app/shared/tags/AddTagsTermsModal.tsx index d486ee02dae3eb..73e99c319441d6 100644 --- a/datahub-web-react/src/app/shared/tags/AddTagsTermsModal.tsx +++ b/datahub-web-react/src/app/shared/tags/AddTagsTermsModal.tsx @@ -20,6 +20,8 @@ import { FORBIDDEN_URN_CHARS_REGEX, handleBatchError } from '../../entity/shared import { TagTermLabel } from './TagTermLabel'; import { ENTER_KEY_CODE } from '../constants'; import { getModalDomContainer } from '../../../utils/focus'; +import ParentEntities from '../../search/filters/ParentEntities'; +import { getParentEntities } from '../../search/filters/utils'; export enum OperationType { ADD, @@ -69,6 +71,12 @@ export const BrowserWrapper = styled.div<{ isHidden: boolean; width?: string; ma `} `; +const SearchResultContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; + const CREATE_TAG_VALUE = '____reserved____.createTagValue'; const isValidTagName = (tagName: string) => { @@ -139,7 +147,10 @@ export default function EditTagTermsModal({ const tagOrTermComponent = ; return ( - {tagOrTermComponent} + + + {tagOrTermComponent} + ); }; From 90c88082b11cdfb6252eaebf11737887a38a0ee3 Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Mon, 29 Jan 2024 14:14:34 +0100 Subject: [PATCH 225/540] fix(ingest/s3): Not sorting schema fields to keep original order (#9349) --- .../source/data_lake_common/path_spec.py | 20 +++++++++++++++---- .../src/datahub/ingestion/source/s3/config.py | 5 +++++ .../src/datahub/ingestion/source/s3/source.py | 3 ++- .../ingestion/source/schema_inference/json.py | 2 +- .../unit/data_lake/test_schema_inference.py | 16 +++++---------- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/data_lake_common/path_spec.py b/metadata-ingestion/src/datahub/ingestion/source/data_lake_common/path_spec.py index 05b1b6b7cc0403..a4b3779b73803a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/data_lake_common/path_spec.py +++ b/metadata-ingestion/src/datahub/ingestion/source/data_lake_common/path_spec.py @@ -63,6 +63,11 @@ class Config: description="Not listing all the files but only taking a handful amount of sample file to infer the schema. File count and file size calculation will be disabled. This can affect performance significantly if enabled", ) + allow_double_stars: bool = Field( + default=False, + description="Allow double stars in the include path. This can affect performance significantly if enabled", + ) + def allowed(self, path: str) -> bool: logger.debug(f"Checking file to inclusion: {path}") if not pathlib.PurePath(path).globmatch( @@ -126,11 +131,18 @@ def get_parsable_include(cls, include: str) -> str: def get_named_vars(self, path: str) -> Union[None, parse.Result, parse.Match]: return self.compiled_include.parse(path) - @pydantic.validator("include") - def validate_no_double_stars(cls, v: str) -> str: - if "**" in v: + @pydantic.root_validator() + def validate_no_double_stars(cls, values: Dict) -> Dict: + if "include" not in values: + return values + + if ( + values.get("include") + and "**" in values["include"] + and not values.get("allow_double_stars") + ): raise ValueError("path_spec.include cannot contain '**'") - return v + return values @pydantic.validator("file_types", always=True) def validate_file_types(cls, v: Optional[List[str]]) -> List[str]: diff --git a/metadata-ingestion/src/datahub/ingestion/source/s3/config.py b/metadata-ingestion/src/datahub/ingestion/source/s3/config.py index f752a33b42d9c5..55e340e2850d55 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/s3/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/s3/config.py @@ -93,6 +93,11 @@ class DataLakeSourceConfig( "path_spec", "path_specs", lambda path_spec: [path_spec] ) + sort_schema_fields: bool = Field( + default=False, + description="Whether to sort schema fields by fieldPath when inferring schemas.", + ) + def is_profiling_enabled(self) -> bool: return self.profiling.enabled and is_profiling_enabled( self.profiling.operation_config diff --git a/metadata-ingestion/src/datahub/ingestion/source/s3/source.py b/metadata-ingestion/src/datahub/ingestion/source/s3/source.py index 94c571eabad11a..41fc5782352c94 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/s3/source.py +++ b/metadata-ingestion/src/datahub/ingestion/source/s3/source.py @@ -458,7 +458,8 @@ def get_fields(self, table_data: TableData, path_spec: PathSpec) -> List: ) file.close() logger.debug(f"Extracted fields in schema: {fields}") - fields = sorted(fields, key=lambda f: f.fieldPath) + if self.source_config.sort_schema_fields: + fields = sorted(fields, key=lambda f: f.fieldPath) if self.source_config.add_partition_columns_to_schema: self.add_partition_columns_to_schema( diff --git a/metadata-ingestion/src/datahub/ingestion/source/schema_inference/json.py b/metadata-ingestion/src/datahub/ingestion/source/schema_inference/json.py index c53c64be4cba80..251d136fe92ee7 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/schema_inference/json.py +++ b/metadata-ingestion/src/datahub/ingestion/source/schema_inference/json.py @@ -48,7 +48,7 @@ def infer_schema(self, file: IO[bytes]) -> List[SchemaField]: schema = construct_schema(datastore, delimiter=".") fields: List[SchemaField] = [] - for schema_field in sorted(schema.values(), key=lambda x: x["delimited_name"]): + for schema_field in schema.values(): mapped_type = _field_type_mapping.get(schema_field["type"], NullTypeClass) native_type = schema_field["type"] diff --git a/metadata-ingestion/tests/unit/data_lake/test_schema_inference.py b/metadata-ingestion/tests/unit/data_lake/test_schema_inference.py index 4a69deb572fbd7..de88deec9b9cb0 100644 --- a/metadata-ingestion/tests/unit/data_lake/test_schema_inference.py +++ b/metadata-ingestion/tests/unit/data_lake/test_schema_inference.py @@ -18,23 +18,23 @@ from tests.unit.test_schema_util import assert_field_paths_match expected_field_paths = [ - "boolean_field", "integer_field", + "boolean_field", "string_field", ] expected_field_paths_avro = [ - "[version=2.0].[type=test].[type=boolean].boolean_field", "[version=2.0].[type=test].[type=int].integer_field", + "[version=2.0].[type=test].[type=boolean].boolean_field", "[version=2.0].[type=test].[type=string].string_field", ] -expected_field_types = [BooleanTypeClass, NumberTypeClass, StringTypeClass] +expected_field_types = [NumberTypeClass, BooleanTypeClass, StringTypeClass] test_table = pd.DataFrame( { - "boolean_field": [True, False, True], "integer_field": [1, 2, 3], + "boolean_field": [True, False, True], "string_field": ["a", "b", "c"], } ) @@ -54,7 +54,6 @@ def test_infer_schema_csv(): file.seek(0) fields = csv_tsv.CsvInferrer(max_rows=100).infer_schema(file) - fields.sort(key=lambda x: x.fieldPath) assert_field_paths_match(fields, expected_field_paths) assert_field_types_match(fields, expected_field_types) @@ -70,7 +69,6 @@ def test_infer_schema_tsv(): file.seek(0) fields = csv_tsv.TsvInferrer(max_rows=100).infer_schema(file) - fields.sort(key=lambda x: x.fieldPath) assert_field_paths_match(fields, expected_field_paths) assert_field_types_match(fields, expected_field_types) @@ -82,7 +80,6 @@ def test_infer_schema_json(): file.seek(0) fields = json.JsonInferrer().infer_schema(file) - fields.sort(key=lambda x: x.fieldPath) assert_field_paths_match(fields, expected_field_paths) assert_field_types_match(fields, expected_field_types) @@ -92,9 +89,7 @@ def test_infer_schema_parquet(): with tempfile.TemporaryFile(mode="w+b") as file: test_table.to_parquet(file) file.seek(0) - fields = parquet.ParquetInferrer().infer_schema(file) - fields.sort(key=lambda x: x.fieldPath) assert_field_paths_match(fields, expected_field_paths) assert_field_types_match(fields, expected_field_types) @@ -108,8 +103,8 @@ def test_infer_schema_avro(): "type": "record", "name": "test", "fields": [ - {"name": "boolean_field", "type": "boolean"}, {"name": "integer_field", "type": "int"}, + {"name": "boolean_field", "type": "boolean"}, {"name": "string_field", "type": "string"}, ], } @@ -124,7 +119,6 @@ def test_infer_schema_avro(): file.seek(0) fields = AvroInferrer().infer_schema(file) - fields.sort(key=lambda x: x.fieldPath) assert_field_paths_match(fields, expected_field_paths_avro) assert_field_types_match(fields, expected_field_types) From 5735eb3a55f49c966d68b4bfca95b1965b34292b Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Mon, 29 Jan 2024 15:12:30 +0100 Subject: [PATCH 226/540] fix(ingest/test): Fixing breaking change in moto 5.0 library (#9736) --- metadata-ingestion/setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index c1a5da5826ead9..af2b54ba1cefa5 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -234,7 +234,8 @@ # ujson 5.2.0 has the JSONDecodeError exception type, which we need for error handling. "ujson>=5.2.0", "smart-open[s3]>=5.2.1", - "moto[s3]", + # moto 5.0.0 drops support for Python 3.7 + "moto[s3]<5.0.0", *path_spec_common, } From fdf929b3f4284753fef9ff59b5018134b874c56b Mon Sep 17 00:00:00 2001 From: Shirshanka Das Date: Mon, 29 Jan 2024 06:14:34 -0800 Subject: [PATCH 227/540] build(graphql): simplify gradle graphql codegen task (#9734) --- datahub-graphql-core/build.gradle | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/datahub-graphql-core/build.gradle b/datahub-graphql-core/build.gradle index f273a4dd0eea5f..fe70f2622490d9 100644 --- a/datahub-graphql-core/build.gradle +++ b/datahub-graphql-core/build.gradle @@ -31,30 +31,16 @@ dependencies { graphqlCodegen { // For options: https://github.com/kobylynskyi/graphql-java-codegen/blob/master/docs/codegen-options.md - graphqlSchemaPaths = [ - "$projectDir/src/main/resources/entity.graphql".toString(), - "$projectDir/src/main/resources/app.graphql".toString(), - "$projectDir/src/main/resources/search.graphql".toString(), - "$projectDir/src/main/resources/analytics.graphql".toString(), - "$projectDir/src/main/resources/recommendation.graphql".toString(), - "$projectDir/src/main/resources/ingestion.graphql".toString(), - "$projectDir/src/main/resources/auth.graphql".toString(), - "$projectDir/src/main/resources/timeline.graphql".toString(), - "$projectDir/src/main/resources/tests.graphql".toString(), - "$projectDir/src/main/resources/properties.graphql".toString(), - "$projectDir/src/main/resources/step.graphql".toString(), - "$projectDir/src/main/resources/lineage.graphql".toString(), - "$projectDir/src/main/resources/forms.graphql".toString() - ] - outputDir = new File("$projectDir/src/mainGeneratedGraphQL/java") + graphqlSchemaPaths = fileTree(dir: "${projectDir}/src/main/resources", include: '**/*.graphql').collect { it.absolutePath } + outputDir = new File("${projectDir}/src/mainGeneratedGraphQL/java") packageName = "com.linkedin.datahub.graphql.generated" generateToString = true generateApis = true generateParameterizedFieldsResolvers = false modelValidationAnnotation = "@javax.annotation.Nonnull" customTypesMapping = [ - Long: "Long", - Float: "Float" + Long: "Long", + Float: "Float" ] } From f3cc4e068a51c0124f1b4dc55713ddd5344ebcb8 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Mon, 29 Jan 2024 21:56:16 +0530 Subject: [PATCH 228/540] feat(ui/secret): support to edit secrets (#9737) --- .../app/ingest/secret/SecretBuilderModal.tsx | 73 ++++++++++++++----- .../src/app/ingest/secret/SecretsList.tsx | 69 +++++++++++++++++- .../src/app/ingest/secret/cacheUtils.ts | 45 ++++++++++++ .../src/app/ingest/secret/types.ts | 4 + 4 files changed, 170 insertions(+), 21 deletions(-) diff --git a/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx b/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx index c099d9a580efab..2d20ac77891ea0 100644 --- a/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx +++ b/datahub-web-react/src/app/ingest/secret/SecretBuilderModal.tsx @@ -1,5 +1,5 @@ import { Button, Form, Input, Modal, Typography } from 'antd'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useEnterKeyListener } from '../../shared/useEnterKeyListener'; import { SecretBuilderState } from './types'; @@ -9,12 +9,14 @@ const VALUE_FIELD_NAME = 'value'; type Props = { initialState?: SecretBuilderState; + editSecret?: SecretBuilderState; visible: boolean; onSubmit?: (source: SecretBuilderState, resetState: () => void) => void; + onUpdate?: (source: SecretBuilderState, resetState: () => void) => void; onCancel?: () => void; }; -export const SecretBuilderModal = ({ initialState, visible, onSubmit, onCancel }: Props) => { +export const SecretBuilderModal = ({ initialState, editSecret, visible, onSubmit, onUpdate, onCancel }: Props) => { const [createButtonEnabled, setCreateButtonEnabled] = useState(false); const [form] = Form.useForm(); @@ -23,38 +25,69 @@ export const SecretBuilderModal = ({ initialState, visible, onSubmit, onCancel } querySelectorToExecuteClick: '#createSecretButton', }); + useEffect(() => { + if (editSecret) { + form.setFieldsValue({ + name: editSecret.name, + description: editSecret.description, + value: editSecret.value, + }); + } + }, [editSecret, form]); + function resetValues() { + setCreateButtonEnabled(false); form.resetFields(); } + const onCloseModal = () => { + setCreateButtonEnabled(false); + form.resetFields(); + onCancel?.(); + }; + + const titleText = editSecret ? 'Edit Secret' : 'Create a new Secret'; + return ( Create a new Secret} + title={{titleText}} visible={visible} - onCancel={onCancel} + onCancel={onCloseModal} zIndex={1051} // one higher than other modals - needed for managed ingestion forms footer={ <> - } @@ -81,11 +114,15 @@ export const SecretBuilderModal = ({ initialState, visible, onSubmit, onCancel } }, { whitespace: false }, { min: 1, max: 50 }, - { pattern: /^[a-zA-Z_]+[a-zA-Z0-9_]*$/, message: 'Please start the secret name with a letter, followed by letters, digits, or underscores only.' }, + { + pattern: /^[a-zA-Z_]+[a-zA-Z0-9_]*$/, + message: + 'Please start the secret name with a letter, followed by letters, digits, or underscores only.', + }, ]} hasFeedback > - + Value}> diff --git a/datahub-web-react/src/app/ingest/secret/SecretsList.tsx b/datahub-web-react/src/app/ingest/secret/SecretsList.tsx index 1a960997e6beeb..2219b6147d9e06 100644 --- a/datahub-web-react/src/app/ingest/secret/SecretsList.tsx +++ b/datahub-web-react/src/app/ingest/secret/SecretsList.tsx @@ -9,6 +9,7 @@ import { useCreateSecretMutation, useDeleteSecretMutation, useListSecretsQuery, + useUpdateSecretMutation, } from '../../../graphql/ingestion.generated'; import { Message } from '../../shared/Message'; import TabToolbar from '../../entity/shared/components/styled/TabToolbar'; @@ -18,7 +19,11 @@ import { StyledTable } from '../../entity/shared/components/styled/StyledTable'; import { SearchBar } from '../../search/SearchBar'; import { useEntityRegistry } from '../../useEntityRegistry'; import { scrollToTop } from '../../shared/searchUtils'; -import { addSecretToListSecretsCache, removeSecretFromListSecretsCache } from './cacheUtils'; +import { + addSecretToListSecretsCache, + removeSecretFromListSecretsCache, + updateSecretInListSecretsCache, +} from './cacheUtils'; import { ONE_SECOND_IN_MS } from '../../entity/shared/tabs/Dataset/Queries/utils/constants'; const DeleteButtonContainer = styled.div` @@ -48,10 +53,12 @@ export const SecretsList = () => { // Whether or not there is an urn to show in the modal const [isCreatingSecret, setIsCreatingSecret] = useState(false); + const [editSecret, setEditSecret] = useState(undefined); const [deleteSecretMutation] = useDeleteSecretMutation(); const [createSecretMutation] = useCreateSecretMutation(); - const { loading, error, data, client } = useListSecretsQuery({ + const [updateSecretMutation] = useUpdateSecretMutation(); + const { loading, error, data, client, refetch } = useListSecretsQuery({ variables: { input: { start, @@ -125,6 +132,47 @@ export const SecretsList = () => { }); }); }; + const onUpdate = (state: SecretBuilderState, resetBuilderState: () => void) => { + updateSecretMutation({ + variables: { + input: { + urn: state.urn as string, + name: state.name as string, + value: state.value as string, + description: state.description as string, + }, + }, + }) + .then(() => { + message.success({ + content: `Successfully updated Secret!`, + duration: 3, + }); + resetBuilderState(); + setIsCreatingSecret(false); + setEditSecret(undefined); + updateSecretInListSecretsCache( + { + urn: state.urn, + name: state.name, + description: state.description, + }, + client, + pageSize, + page, + ); + setTimeout(() => { + refetch(); + }, 2000); + }) + .catch((e) => { + message.destroy(); + message.error({ + content: `Failed to update Secret!: \n ${e.message || ''}`, + duration: 3, + }); + }); + }; const onDeleteSecret = (urn: string) => { Modal.confirm({ @@ -140,6 +188,16 @@ export const SecretsList = () => { }); }; + const onEditSecret = (urnData: any) => { + setIsCreatingSecret(true); + setEditSecret(urnData); + }; + + const onCancel = () => { + setIsCreatingSecret(false); + setEditSecret(undefined); + }; + const tableColumns = [ { title: 'Name', @@ -161,6 +219,9 @@ export const SecretsList = () => { key: 'x', render: (_, record: any) => ( + @@ -234,8 +295,10 @@ export const SecretsList = () => {
    setIsCreatingSecret(false)} + onCancel={onCancel} /> ); diff --git a/datahub-web-react/src/app/ingest/secret/cacheUtils.ts b/datahub-web-react/src/app/ingest/secret/cacheUtils.ts index 72e287f8846edb..b3a3a45f33892c 100644 --- a/datahub-web-react/src/app/ingest/secret/cacheUtils.ts +++ b/datahub-web-react/src/app/ingest/secret/cacheUtils.ts @@ -64,6 +64,51 @@ export const addSecretToListSecretsCache = (secret, client, pageSize) => { }); }; +export const updateSecretInListSecretsCache = (updatedSecret, client, pageSize, page) => { + const currData: ListSecretsQuery | null = client.readQuery({ + query: ListSecretsDocument, + variables: { + input: { + start: (page - 1) * pageSize, + count: pageSize, + }, + }, + }); + + const updatedSecretIndex = (currData?.listSecrets?.secrets || []) + .map((secret, index) => { + if (secret.urn === updatedSecret.urn) { + return index; + } + return -1; + }) + .find((index) => index !== -1); + + if (updatedSecretIndex !== undefined) { + const newSecrets = (currData?.listSecrets?.secrets || []).map((secret, index) => { + return index === updatedSecretIndex ? updatedSecret : secret; + }); + + client.writeQuery({ + query: ListSecretsDocument, + variables: { + input: { + start: (page - 1) * pageSize, + count: pageSize, + }, + }, + data: { + listSecrets: { + start: currData?.listSecrets?.start || 0, + count: currData?.listSecrets?.count || 1, + total: currData?.listSecrets?.total || 1, + secrets: newSecrets, + }, + }, + }); + } +}; + export const clearSecretListCache = (client) => { // Remove any caching of 'listSecrets' client.cache.evict({ id: 'ROOT_QUERY', fieldName: 'listSecrets' }); diff --git a/datahub-web-react/src/app/ingest/secret/types.ts b/datahub-web-react/src/app/ingest/secret/types.ts index 23e45cab9b1790..e0dbc8d443d9bb 100644 --- a/datahub-web-react/src/app/ingest/secret/types.ts +++ b/datahub-web-react/src/app/ingest/secret/types.ts @@ -2,6 +2,10 @@ * The object represents the state of the Ingestion Source Builder form. */ export interface SecretBuilderState { + /** + * The name of the secret. + */ + urn?: string; /** * The name of the secret. */ From 1498c36875450b1a1f44d53e8e8c47c41a91dc69 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 29 Jan 2024 10:50:47 -0800 Subject: [PATCH 229/540] chore(cli): drop support for python 3.7 (#9731) --- .github/workflows/metadata-ingestion.yml | 4 +- docs/cli.md | 2 +- docs/how/updating-datahub.md | 11 +- docs/quickstart.md | 2 +- .../airflow-plugin/setup.py | 14 +- .../airflow-plugin/tests/unit/test_airflow.py | 204 ++-- metadata-ingestion/build.gradle | 2 +- metadata-ingestion/cli-ingestion.md | 21 +- metadata-ingestion/developing.md | 6 +- metadata-ingestion/setup.py | 23 +- metadata-ingestion/src/datahub/__init__.py | 11 +- .../src/datahub/ingestion/api/report.py | 21 +- .../src/datahub/ingestion/source/feast.py | 5 - .../ingestion/source/iceberg/iceberg.py | 5 - .../src/datahub/ingestion/source/mlflow.py | 6 - .../source/schema_inference/object.py | 2 +- .../feast/test_feast_repository.py | 7 - .../tests/integration/iceberg/test_iceberg.py | 9 +- .../integration/mlflow/test_mlflow_source.py | 184 ++-- .../integration/sql_server/test_sql_server.py | 5 - metadata-ingestion/tests/unit/test_iceberg.py | 899 +++++++++--------- .../tests/unit/test_mlflow_source.py | 225 ++--- 22 files changed, 805 insertions(+), 863 deletions(-) diff --git a/.github/workflows/metadata-ingestion.yml b/.github/workflows/metadata-ingestion.yml index 1da08b14b8b5b2..e7d6b7b97c0993 100644 --- a/.github/workflows/metadata-ingestion.yml +++ b/.github/workflows/metadata-ingestion.yml @@ -31,7 +31,7 @@ jobs: # DATAHUB_LOOKML_GIT_TEST_SSH_KEY: ${{ secrets.DATAHUB_LOOKML_GIT_TEST_SSH_KEY }} strategy: matrix: - python-version: ["3.7", "3.10"] + python-version: ["3.8", "3.10"] command: [ "testQuick", @@ -40,7 +40,7 @@ jobs: "testIntegrationBatch2", ] include: - - python-version: "3.7" + - python-version: "3.8" - python-version: "3.10" fail-fast: false steps: diff --git a/docs/cli.md b/docs/cli.md index cb5077db429061..927270b42259d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ source venv/bin/activate # activate the environment Once inside the virtual environment, install `datahub` using the following commands ```shell -# Requires Python 3.7+ +# Requires Python 3.8+ python3 -m pip install --upgrade pip wheel setuptools python3 -m pip install --upgrade acryl-datahub # validate that the install was successful diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index b671e2fc5d123c..6b6903b04f383e 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -10,8 +10,10 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - Neo4j 5.x, may require migration from 4.x - Build requires JDK17 (Runtime Java 11) - Build requires Docker Compose > 2.20 +- #9731 - The `acryl-datahub` CLI now requires Python 3.8+ - #9601 - The Unity Catalog(UC) ingestion source config `include_metastore` is now disabled by default. This change will affect the urns of all entities in the workspace.
    - Entity Hierarchy with `include_metastore: true` (Old) + Entity Hierarchy with `include_metastore: true` (Old) + ``` - UC Metastore - Catalog @@ -19,16 +21,19 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - Table ``` - Entity Hierarchy with `include_metastore: false` (New) + Entity Hierarchy with `include_metastore: false` (New) + ``` - Catalog - Schema - Table ``` + We recommend using `platform_instance` for differentiating across metastores. If stateful ingestion is enabled, running ingestion with latest cli version will perform all required cleanup. Otherwise, we recommend soft deleting all databricks data via the DataHub CLI: - `datahub delete --platform databricks --soft` and then reingesting with latest cli version. + `datahub delete --platform databricks --soft` and then reingesting with latest cli version. + - #9601 - The Unity Catalog(UC) ingestion source config `include_hive_metastore` is now enabled by default. This requires config `warehouse_id` to be set. You can disable `include_hive_metastore` by setting it to `False` to avoid ingesting legacy hive metastore catalog in Databricks. ### Potential Downtime diff --git a/docs/quickstart.md b/docs/quickstart.md index 5856ef84c0074e..507be6ba05471a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -22,7 +22,7 @@ If you're interested in a managed version, [Acryl Data](https://www.acryldata.io | Linux | [Docker for Linux](https://docs.docker.com/desktop/install/linux-install/) and [Docker Compose](https://docs.docker.com/compose/install/linux/) | - **Launch the Docker engine** from command line or the desktop app. -- Ensure you have **Python 3.7+** installed & configured. (Check using `python3 --version`). +- Ensure you have **Python 3.8+** installed & configured. (Check using `python3 --version`). :::note Docker Resource Allocation diff --git a/metadata-ingestion-modules/airflow-plugin/setup.py b/metadata-ingestion-modules/airflow-plugin/setup.py index 838322f83833bb..1a3e844cedc1ff 100644 --- a/metadata-ingestion-modules/airflow-plugin/setup.py +++ b/metadata-ingestion-modules/airflow-plugin/setup.py @@ -18,16 +18,10 @@ def get_long_description(): _self_pin = f"=={_version}" if not _version.endswith("dev0") else "" -rest_common = {"requests", "requests_file"} - base_requirements = { - # Compatibility. - "dataclasses>=0.6; python_version < '3.7'", - "mypy_extensions>=0.4.3", + f"acryl-datahub[datahub-rest]{_self_pin}", # Actual dependencies. - "pydantic>=1.5.1", "apache-airflow >= 2.0.2", - *rest_common, } plugins: Dict[str, Set[str]] = { @@ -42,9 +36,8 @@ def get_long_description(): }, "plugin-v1": set(), "plugin-v2": { - # The v2 plugin requires Python 3.8+. f"acryl-datahub[sql-parser]{_self_pin}", - "openlineage-airflow==1.2.0; python_version >= '3.8'", + "openlineage-airflow==1.2.0", }, } @@ -144,7 +137,6 @@ def get_long_description(): "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -161,7 +153,7 @@ def get_long_description(): ], # Package info. zip_safe=False, - python_requires=">=3.7", + python_requires=">=3.8", package_data={ "datahub_airflow_plugin": ["py.typed"], }, diff --git a/metadata-ingestion-modules/airflow-plugin/tests/unit/test_airflow.py b/metadata-ingestion-modules/airflow-plugin/tests/unit/test_airflow.py index 93b4af0501985e..b484713e18faf6 100644 --- a/metadata-ingestion-modules/airflow-plugin/tests/unit/test_airflow.py +++ b/metadata-ingestion-modules/airflow-plugin/tests/unit/test_airflow.py @@ -1,7 +1,6 @@ import datetime import json import os -import sys from contextlib import contextmanager from typing import Iterator from unittest import mock @@ -318,137 +317,134 @@ def test_lineage_backend(mock_emit, inlets, outlets, capture_executions): # Check that the right things were emitted. assert mock_emitter.emit.call_count == 17 if capture_executions else 9 - # Running further checks based on python version because args only exists in python 3.8+ - if sys.version_info > (3, 8): - assert mock_emitter.method_calls[0].args[0].aspectName == "dataFlowInfo" + # TODO: Replace this with a golden file-based comparison. + assert mock_emitter.method_calls[0].args[0].aspectName == "dataFlowInfo" + assert ( + mock_emitter.method_calls[0].args[0].entityUrn + == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + ) + + assert mock_emitter.method_calls[1].args[0].aspectName == "ownership" + assert ( + mock_emitter.method_calls[1].args[0].entityUrn + == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + ) + + assert mock_emitter.method_calls[2].args[0].aspectName == "globalTags" + assert ( + mock_emitter.method_calls[2].args[0].entityUrn + == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + ) + + assert mock_emitter.method_calls[3].args[0].aspectName == "dataJobInfo" + assert ( + mock_emitter.method_calls[3].args[0].entityUrn + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + ) + + assert mock_emitter.method_calls[4].args[0].aspectName == "dataJobInputOutput" + assert ( + mock_emitter.method_calls[4].args[0].entityUrn + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + ) + assert ( + mock_emitter.method_calls[4].args[0].aspect.inputDatajobs[0] + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task1_upstream)" + ) + assert ( + mock_emitter.method_calls[4].args[0].aspect.inputDatajobs[1] + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,testDag,PROD),testTask)" + ) + assert ( + mock_emitter.method_calls[4].args[0].aspect.inputDatasets[0] + == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableConsumed,PROD)" + ) + assert ( + mock_emitter.method_calls[4].args[0].aspect.outputDatasets[0] + == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableProduced,PROD)" + ) + + assert mock_emitter.method_calls[5].args[0].aspectName == "status" + assert ( + mock_emitter.method_calls[5].args[0].entityUrn + == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableConsumed,PROD)" + ) + + assert mock_emitter.method_calls[6].args[0].aspectName == "status" + assert ( + mock_emitter.method_calls[6].args[0].entityUrn + == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableProduced,PROD)" + ) + + assert mock_emitter.method_calls[7].args[0].aspectName == "ownership" + assert ( + mock_emitter.method_calls[7].args[0].entityUrn + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + ) + + assert mock_emitter.method_calls[8].args[0].aspectName == "globalTags" + assert ( + mock_emitter.method_calls[8].args[0].entityUrn + == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + ) + + if capture_executions: assert ( - mock_emitter.method_calls[0].args[0].entityUrn - == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + mock_emitter.method_calls[9].args[0].aspectName + == "dataProcessInstanceProperties" ) - - assert mock_emitter.method_calls[1].args[0].aspectName == "ownership" assert ( - mock_emitter.method_calls[1].args[0].entityUrn - == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + mock_emitter.method_calls[9].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) - assert mock_emitter.method_calls[2].args[0].aspectName == "globalTags" assert ( - mock_emitter.method_calls[2].args[0].entityUrn - == "urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod)" + mock_emitter.method_calls[10].args[0].aspectName + == "dataProcessInstanceRelationships" ) - - assert mock_emitter.method_calls[3].args[0].aspectName == "dataJobInfo" assert ( - mock_emitter.method_calls[3].args[0].entityUrn - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + mock_emitter.method_calls[10].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) - assert ( - mock_emitter.method_calls[4].args[0].aspectName == "dataJobInputOutput" + mock_emitter.method_calls[11].args[0].aspectName + == "dataProcessInstanceInput" ) assert ( - mock_emitter.method_calls[4].args[0].entityUrn - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + mock_emitter.method_calls[11].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) assert ( - mock_emitter.method_calls[4].args[0].aspect.inputDatajobs[0] - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task1_upstream)" + mock_emitter.method_calls[12].args[0].aspectName + == "dataProcessInstanceOutput" ) assert ( - mock_emitter.method_calls[4].args[0].aspect.inputDatajobs[1] - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,testDag,PROD),testTask)" + mock_emitter.method_calls[12].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) + assert mock_emitter.method_calls[13].args[0].aspectName == "status" assert ( - mock_emitter.method_calls[4].args[0].aspect.inputDatasets[0] + mock_emitter.method_calls[13].args[0].entityUrn == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableConsumed,PROD)" ) + assert mock_emitter.method_calls[14].args[0].aspectName == "status" assert ( - mock_emitter.method_calls[4].args[0].aspect.outputDatasets[0] + mock_emitter.method_calls[14].args[0].entityUrn == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableProduced,PROD)" ) - - assert mock_emitter.method_calls[5].args[0].aspectName == "status" assert ( - mock_emitter.method_calls[5].args[0].entityUrn - == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableConsumed,PROD)" + mock_emitter.method_calls[15].args[0].aspectName + == "dataProcessInstanceRunEvent" ) - - assert mock_emitter.method_calls[6].args[0].aspectName == "status" assert ( - mock_emitter.method_calls[6].args[0].entityUrn - == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableProduced,PROD)" + mock_emitter.method_calls[15].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) - - assert mock_emitter.method_calls[7].args[0].aspectName == "ownership" assert ( - mock_emitter.method_calls[7].args[0].entityUrn - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + mock_emitter.method_calls[16].args[0].aspectName + == "dataProcessInstanceRunEvent" ) - - assert mock_emitter.method_calls[8].args[0].aspectName == "globalTags" assert ( - mock_emitter.method_calls[8].args[0].entityUrn - == "urn:li:dataJob:(urn:li:dataFlow:(airflow,test_lineage_is_sent_to_backend,prod),task2)" + mock_emitter.method_calls[16].args[0].entityUrn + == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" ) - - if capture_executions: - assert ( - mock_emitter.method_calls[9].args[0].aspectName - == "dataProcessInstanceProperties" - ) - assert ( - mock_emitter.method_calls[9].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) - - assert ( - mock_emitter.method_calls[10].args[0].aspectName - == "dataProcessInstanceRelationships" - ) - assert ( - mock_emitter.method_calls[10].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) - assert ( - mock_emitter.method_calls[11].args[0].aspectName - == "dataProcessInstanceInput" - ) - assert ( - mock_emitter.method_calls[11].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) - assert ( - mock_emitter.method_calls[12].args[0].aspectName - == "dataProcessInstanceOutput" - ) - assert ( - mock_emitter.method_calls[12].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) - assert mock_emitter.method_calls[13].args[0].aspectName == "status" - assert ( - mock_emitter.method_calls[13].args[0].entityUrn - == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableConsumed,PROD)" - ) - assert mock_emitter.method_calls[14].args[0].aspectName == "status" - assert ( - mock_emitter.method_calls[14].args[0].entityUrn - == "urn:li:dataset:(urn:li:dataPlatform:snowflake,mydb.schema.tableProduced,PROD)" - ) - assert ( - mock_emitter.method_calls[15].args[0].aspectName - == "dataProcessInstanceRunEvent" - ) - assert ( - mock_emitter.method_calls[15].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) - assert ( - mock_emitter.method_calls[16].args[0].aspectName - == "dataProcessInstanceRunEvent" - ) - assert ( - mock_emitter.method_calls[16].args[0].entityUrn - == "urn:li:dataProcessInstance:5e274228107f44cc2dd7c9782168cc29" - ) diff --git a/metadata-ingestion/build.gradle b/metadata-ingestion/build.gradle index b3cc350cc109fa..8338124288ec99 100644 --- a/metadata-ingestion/build.gradle +++ b/metadata-ingestion/build.gradle @@ -17,7 +17,7 @@ def get_coverage_arg(test_name) { task checkPythonVersion(type: Exec) { commandLine python_executable, '-c', - 'import sys; assert (3, 11) > sys.version_info >= (3, 7), f"Python version {sys.version_info[:2]} not allowed"' + 'import sys; assert (3, 11) > sys.version_info >= (3, 8), f"Python version {sys.version_info[:2]} not allowed"' } task environmentSetup(type: Exec, dependsOn: checkPythonVersion) { diff --git a/metadata-ingestion/cli-ingestion.md b/metadata-ingestion/cli-ingestion.md index cbdde2cd301678..48cc4ef09db910 100644 --- a/metadata-ingestion/cli-ingestion.md +++ b/metadata-ingestion/cli-ingestion.md @@ -2,26 +2,31 @@ ## Installing the CLI -Make sure you have installed DataHub CLI before following this guide. +Make sure you have installed DataHub CLI before following this guide. + ```shell -# Requires Python 3.7+ +# Requires Python 3.8+ python3 -m pip install --upgrade pip wheel setuptools python3 -m pip install --upgrade acryl-datahub # validate that the install was successful datahub version # If you see "command not found", try running this instead: python3 -m datahub version ``` -Check out the [CLI Installation Guide](../docs/cli.md#installation) for more installation options and troubleshooting tips. + +Check out the [CLI Installation Guide](../docs/cli.md#installation) for more installation options and troubleshooting tips. After that, install the required plugin for the ingestion. ```shell pip install 'acryl-datahub[datahub-rest]' # install the required plugin ``` -Check out the [alternative installation options](../docs/cli.md#alternate-installation-options) for more reference. + +Check out the [alternative installation options](../docs/cli.md#alternate-installation-options) for more reference. ## Configuring a Recipe + Create a recipe.yml file that defines the source and sink for metadata, as shown below. + ```yaml # my_reipe.yml source: @@ -29,7 +34,7 @@ source: config: option_1: ... - + sink: type: config: @@ -39,7 +44,8 @@ sink: For more information and examples on configuring recipes, please refer to [Recipes](recipe_overview.md). ## Ingesting Metadata -You can run ingestion using `datahub ingest` like below. + +You can run ingestion using `datahub ingest` like below. ```shell datahub ingest -c @@ -48,6 +54,7 @@ datahub ingest -c ## Reference Please refer the following pages for advanced guids on CLI ingestion. + - [Reference for `datahub ingest` command](../docs/cli.md#ingest) - [UI Ingestion Guide](../docs/ui-ingestion.md) @@ -56,4 +63,4 @@ DataHub server uses a 3 digit versioning scheme, while the CLI uses a 4 digit sc We do this because we do CLI releases at a much higher frequency than server releases, usually every few days vs twice a month. For ingestion sources, any breaking changes will be highlighted in the [release notes](../docs/how/updating-datahub.md). When fields are deprecated or otherwise changed, we will try to maintain backwards compatibility for two server releases, which is about 4-6 weeks. The CLI will also print warnings whenever deprecated options are used. -::: \ No newline at end of file +::: diff --git a/metadata-ingestion/developing.md b/metadata-ingestion/developing.md index fc3a689124b2c1..47e325171ddcc6 100644 --- a/metadata-ingestion/developing.md +++ b/metadata-ingestion/developing.md @@ -9,10 +9,10 @@ Also take a look at the guide to [adding a source](./adding-source.md). ### Requirements -1. Python 3.7+ must be installed in your host environment. +1. Python 3.8+ must be installed in your host environment. 2. Java 17 (gradle won't work with newer or older versions) -4. On Debian/Ubuntu: `sudo apt install python3-dev python3-venv` -5. On Fedora (if using LDAP source integration): `sudo yum install openldap-devel` +3. On Debian/Ubuntu: `sudo apt install python3-dev python3-venv` +4. On Fedora (if using LDAP source integration): `sudo yum install openldap-devel` ### Set up your Python environment diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index af2b54ba1cefa5..f8d51997330a9d 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -1,4 +1,3 @@ -import sys from typing import Dict, Set import setuptools @@ -11,7 +10,6 @@ base_requirements = { # Typing extension should be >=3.10.0.2 ideally but we can't restrict due to a Airflow 2.1 dependency conflict. "typing_extensions>=3.7.4.3", - "mypy_extensions>=0.4.3", # Actual dependencies. "typing-inspect", # pydantic 1.8.2 is incompatible with mypy 0.910. @@ -48,9 +46,7 @@ "click-spinner", "requests_file", "jsonref", - # jsonschema drops python 3.7 support in v4.18.0 - "jsonschema<=4.17.3; python_version < '3.8'", - "jsonschema; python_version >= '3.8'", + "jsonschema", "ruamel.yaml", } @@ -463,7 +459,7 @@ "black==22.12.0", "coverage>=5.1", "faker>=18.4.0", - "flake8>=3.8.3", # DEPRECATION: Once we drop Python 3.7, we can pin to 6.x. + "flake8>=6.0.0", "flake8-tidy-imports>=4.3.0", "flake8-bugbear==23.3.12", "isort>=5.7.0", @@ -489,9 +485,9 @@ "delta-lake", "druid", "elasticsearch", - "feast" if sys.version_info >= (3, 8) else None, - "iceberg" if sys.version_info >= (3, 8) else None, - "mlflow" if sys.version_info >= (3, 8) else None, + "feast", + "iceberg", + "mlflow", "json-schema", "ldap", "looker", @@ -544,14 +540,14 @@ "clickhouse", "delta-lake", "druid", - "feast" if sys.version_info >= (3, 8) else None, + "feast", "hana", "hive", - "iceberg" if sys.version_info >= (3, 8) else None, + "iceberg", "kafka-connect", "ldap", "mongodb", - "mssql" if sys.version_info >= (3, 8) else None, + "mssql", "mysql", "mariadb", "redash", @@ -699,7 +695,6 @@ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -716,7 +711,7 @@ ], # Package info. zip_safe=False, - python_requires=">=3.7", + python_requires=">=3.8", package_dir={"": "src"}, packages=setuptools.find_namespace_packages(where="./src"), package_data={ diff --git a/metadata-ingestion/src/datahub/__init__.py b/metadata-ingestion/src/datahub/__init__.py index a470de7b500be3..b254deb7fa30e5 100644 --- a/metadata-ingestion/src/datahub/__init__.py +++ b/metadata-ingestion/src/datahub/__init__.py @@ -16,16 +16,9 @@ def nice_version_name() -> str: return __version__ -if sys.version_info < (3, 7): +if sys.version_info < (3, 8): warnings.warn( - "DataHub requires Python 3.7 or newer. " - "Please upgrade your Python version to continue using DataHub.", - FutureWarning, - stacklevel=2, - ) -elif sys.version_info < (3, 8): - warnings.warn( - "DataHub will require Python 3.8 or newer soon. " + "DataHub requires Python 3.8 or newer. " "Please upgrade your Python version to continue using DataHub.", FutureWarning, stacklevel=2, diff --git a/metadata-ingestion/src/datahub/ingestion/api/report.py b/metadata-ingestion/src/datahub/ingestion/api/report.py index fcca7675917746..08b20d9e856911 100644 --- a/metadata-ingestion/src/datahub/ingestion/api/report.py +++ b/metadata-ingestion/src/datahub/ingestion/api/report.py @@ -2,11 +2,10 @@ import json import logging import pprint -import sys from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Optional import humanfriendly import pydantic @@ -19,12 +18,6 @@ logger = logging.getLogger(__name__) LogLevel = Literal["ERROR", "WARNING", "INFO", "DEBUG"] -# The sort_dicts option was added in Python 3.8. -if sys.version_info >= (3, 8): - PPRINT_OPTIONS = {"sort_dicts": False} -else: - PPRINT_OPTIONS: Dict = {} - @runtime_checkable class SupportsAsObj(Protocol): @@ -32,14 +25,6 @@ def as_obj(self) -> dict: ... -def _stacklevel_if_supported(level: int) -> dict: - # The logging module added support for stacklevel in Python 3.8. - if sys.version_info >= (3, 8): - return {"stacklevel": level} - else: - return {} - - @dataclass class Report(SupportsAsObj): @staticmethod @@ -95,7 +80,7 @@ def as_obj(self) -> dict: } def as_string(self) -> str: - return pprint.pformat(self.as_obj(), width=150, **PPRINT_OPTIONS) + return pprint.pformat(self.as_obj(), width=150, sort_dicts=False) def as_json(self) -> str: return json.dumps(self.as_obj()) @@ -118,7 +103,7 @@ def logger_sev(self) -> int: return log_levels[self.severity] def log(self, msg: str) -> None: - logger.log(level=self.logger_sev, msg=msg, **_stacklevel_if_supported(3)) + logger.log(level=self.logger_sev, msg=msg, stacklevel=3) class EntityFilterReport(ReportAttribute): diff --git a/metadata-ingestion/src/datahub/ingestion/source/feast.py b/metadata-ingestion/src/datahub/ingestion/source/feast.py index 8faba7d1133729..db0c8e9c39e7bf 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/feast.py +++ b/metadata-ingestion/src/datahub/ingestion/source/feast.py @@ -1,8 +1,3 @@ -import sys - -if sys.version_info < (3, 8): - raise ImportError("Feast is only supported on Python 3.8+") - from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple, Union diff --git a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py index cc7f646dcb884c..2585260434a384 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py +++ b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py @@ -1,8 +1,3 @@ -import sys - -if sys.version_info < (3, 8): - raise ImportError("Iceberg is only supported on Python 3.8+") - import json import logging import uuid diff --git a/metadata-ingestion/src/datahub/ingestion/source/mlflow.py b/metadata-ingestion/src/datahub/ingestion/source/mlflow.py index 0668defe7b0c6c..cef6d2b1bb5774 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/mlflow.py +++ b/metadata-ingestion/src/datahub/ingestion/source/mlflow.py @@ -1,9 +1,3 @@ -import sys - -if sys.version_info < (3, 8): - raise ImportError("MLflow is only supported on Python 3.8+") - - from dataclasses import dataclass from typing import Any, Callable, Iterable, Optional, TypeVar, Union diff --git a/metadata-ingestion/src/datahub/ingestion/source/schema_inference/object.py b/metadata-ingestion/src/datahub/ingestion/source/schema_inference/object.py index b58bdf41ccaa5a..5a11d020547e8e 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/schema_inference/object.py +++ b/metadata-ingestion/src/datahub/ingestion/source/schema_inference/object.py @@ -1,7 +1,7 @@ from collections import Counter from typing import Any, Counter as CounterType, Dict, Sequence, Tuple, Union -from mypy_extensions import TypedDict +from typing_extensions import TypedDict class BasicSchemaDescription(TypedDict): diff --git a/metadata-ingestion/tests/integration/feast/test_feast_repository.py b/metadata-ingestion/tests/integration/feast/test_feast_repository.py index eab37f67ed155d..a6bdce67222896 100644 --- a/metadata-ingestion/tests/integration/feast/test_feast_repository.py +++ b/metadata-ingestion/tests/integration/feast/test_feast_repository.py @@ -1,6 +1,3 @@ -import sys - -import pytest from freezegun import freeze_time from datahub.ingestion.run.pipeline import Pipeline @@ -8,10 +5,6 @@ FROZEN_TIME = "2020-04-14 07:00:00" -pytestmark = pytest.mark.skipif( - sys.version_info < (3, 8), reason="requires python 3.8 or higher" -) - @freeze_time(FROZEN_TIME) def test_feast_repository_ingest(pytestconfig, tmp_path, mock_time): diff --git a/metadata-ingestion/tests/integration/iceberg/test_iceberg.py b/metadata-ingestion/tests/integration/iceberg/test_iceberg.py index 65ede11c3f1c01..a9ab43169405de 100644 --- a/metadata-ingestion/tests/integration/iceberg/test_iceberg.py +++ b/metadata-ingestion/tests/integration/iceberg/test_iceberg.py @@ -1,5 +1,4 @@ import subprocess -import sys from typing import Any, Dict, List from unittest.mock import patch @@ -15,13 +14,7 @@ validate_all_providers_have_committed_successfully, ) -pytestmark = [ - pytest.mark.integration_batch_1, - # Skip tests if not on Python 3.8 or higher. - pytest.mark.skipif( - sys.version_info < (3, 8), reason="Requires python 3.8 or higher" - ), -] +pytestmark = pytest.mark.integration_batch_1 FROZEN_TIME = "2020-04-14 07:00:00" GMS_PORT = 8080 GMS_SERVER = f"http://localhost:{GMS_PORT}" diff --git a/metadata-ingestion/tests/integration/mlflow/test_mlflow_source.py b/metadata-ingestion/tests/integration/mlflow/test_mlflow_source.py index 76af666526555a..155199d5a04e97 100644 --- a/metadata-ingestion/tests/integration/mlflow/test_mlflow_source.py +++ b/metadata-ingestion/tests/integration/mlflow/test_mlflow_source.py @@ -1,104 +1,106 @@ -import sys +from pathlib import Path +from typing import Any, Dict, TypeVar -if sys.version_info >= (3, 8): - from pathlib import Path - from typing import Any, Dict, TypeVar +import pytest +from mlflow import MlflowClient - import pytest - from mlflow import MlflowClient +from datahub.ingestion.run.pipeline import Pipeline +from tests.test_helpers import mce_helpers - from datahub.ingestion.run.pipeline import Pipeline - from tests.test_helpers import mce_helpers +T = TypeVar("T") - T = TypeVar("T") - @pytest.fixture - def tracking_uri(tmp_path: Path) -> str: - return str(tmp_path / "mlruns") +@pytest.fixture +def tracking_uri(tmp_path: Path) -> str: + return str(tmp_path / "mlruns") - @pytest.fixture - def sink_file_path(tmp_path: Path) -> str: - return str(tmp_path / "mlflow_source_mcps.json") - @pytest.fixture - def pipeline_config(tracking_uri: str, sink_file_path: str) -> Dict[str, Any]: - source_type = "mlflow" - return { - "run_id": "mlflow-source-test", - "source": { - "type": source_type, - "config": { - "tracking_uri": tracking_uri, - }, +@pytest.fixture +def sink_file_path(tmp_path: Path) -> str: + return str(tmp_path / "mlflow_source_mcps.json") + + +@pytest.fixture +def pipeline_config(tracking_uri: str, sink_file_path: str) -> Dict[str, Any]: + source_type = "mlflow" + return { + "run_id": "mlflow-source-test", + "source": { + "type": source_type, + "config": { + "tracking_uri": tracking_uri, }, - "sink": { - "type": "file", - "config": { - "filename": sink_file_path, - }, + }, + "sink": { + "type": "file", + "config": { + "filename": sink_file_path, }, - } + }, + } + + +@pytest.fixture +def generate_mlflow_data(tracking_uri: str) -> None: + client = MlflowClient(tracking_uri=tracking_uri) + experiment_name = "test-experiment" + run_name = "test-run" + model_name = "test-model" + test_experiment_id = client.create_experiment(experiment_name) + test_run = client.create_run( + experiment_id=test_experiment_id, + run_name=run_name, + ) + client.log_param( + run_id=test_run.info.run_id, + key="p", + value=1, + ) + client.log_metric( + run_id=test_run.info.run_id, + key="m", + value=0.85, + ) + client.create_registered_model( + name=model_name, + tags=dict( + model_id=1, + model_env="test", + ), + description="This a test registered model", + ) + client.create_model_version( + name=model_name, + source="dummy_dir/dummy_file", + run_id=test_run.info.run_id, + tags=dict(model_version_id=1), + ) + client.transition_model_version_stage( + name=model_name, + version="1", + stage="Archived", + ) - @pytest.fixture - def generate_mlflow_data(tracking_uri: str) -> None: - client = MlflowClient(tracking_uri=tracking_uri) - experiment_name = "test-experiment" - run_name = "test-run" - model_name = "test-model" - test_experiment_id = client.create_experiment(experiment_name) - test_run = client.create_run( - experiment_id=test_experiment_id, - run_name=run_name, - ) - client.log_param( - run_id=test_run.info.run_id, - key="p", - value=1, - ) - client.log_metric( - run_id=test_run.info.run_id, - key="m", - value=0.85, - ) - client.create_registered_model( - name=model_name, - tags=dict( - model_id=1, - model_env="test", - ), - description="This a test registered model", - ) - client.create_model_version( - name=model_name, - source="dummy_dir/dummy_file", - run_id=test_run.info.run_id, - tags=dict(model_version_id=1), - ) - client.transition_model_version_stage( - name=model_name, - version="1", - stage="Archived", - ) - def test_ingestion( - pytestconfig, - mock_time, - sink_file_path, - pipeline_config, - generate_mlflow_data, - ): - print(f"MCPs file path: {sink_file_path}") - golden_file_path = ( - pytestconfig.rootpath / "tests/integration/mlflow/mlflow_mcps_golden.json" - ) +def test_ingestion( + pytestconfig, + mock_time, + sink_file_path, + pipeline_config, + generate_mlflow_data, +): + print(f"MCPs file path: {sink_file_path}") + golden_file_path = ( + pytestconfig.rootpath / "tests/integration/mlflow/mlflow_mcps_golden.json" + ) - pipeline = Pipeline.create(pipeline_config) - pipeline.run() - pipeline.pretty_print_summary() - pipeline.raise_from_status() + pipeline = Pipeline.create(pipeline_config) + pipeline.run() + pipeline.pretty_print_summary() + pipeline.raise_from_status() - mce_helpers.check_golden_file( - pytestconfig=pytestconfig, - output_path=sink_file_path, - golden_path=golden_file_path, - ) + mce_helpers.check_golden_file( + pytestconfig=pytestconfig, + output_path=sink_file_path, + golden_path=golden_file_path, + ) diff --git a/metadata-ingestion/tests/integration/sql_server/test_sql_server.py b/metadata-ingestion/tests/integration/sql_server/test_sql_server.py index 5ed672d527264a..f439a322c26771 100644 --- a/metadata-ingestion/tests/integration/sql_server/test_sql_server.py +++ b/metadata-ingestion/tests/integration/sql_server/test_sql_server.py @@ -1,6 +1,5 @@ import os import subprocess -import sys import time import pytest @@ -9,10 +8,6 @@ from tests.test_helpers.click_helpers import run_datahub_cmd from tests.test_helpers.docker_helpers import cleanup_image, wait_for_port -pytestmark = pytest.mark.skipif( - sys.version_info < (3, 8), reason="requires python 3.8 or higher" -) - @pytest.fixture(scope="module") def mssql_runner(docker_compose_runner, pytestconfig): diff --git a/metadata-ingestion/tests/unit/test_iceberg.py b/metadata-ingestion/tests/unit/test_iceberg.py index 768d4f958af1fb..e2b463004f5a13 100644 --- a/metadata-ingestion/tests/unit/test_iceberg.py +++ b/metadata-ingestion/tests/unit/test_iceberg.py @@ -1,482 +1,477 @@ -import sys import uuid from decimal import Decimal from typing import Any, Optional import pytest from pydantic import ValidationError +from pyiceberg.schema import Schema +from pyiceberg.types import ( + BinaryType, + BooleanType, + DateType, + DecimalType, + DoubleType, + FixedType, + FloatType, + IcebergType, + IntegerType, + ListType, + LongType, + MapType, + NestedField, + PrimitiveType, + StringType, + StructType, + TimestampType, + TimestamptzType, + TimeType, + UUIDType, +) + +from datahub.ingestion.api.common import PipelineContext +from datahub.ingestion.source.iceberg.iceberg import ( + IcebergProfiler, + IcebergSource, + IcebergSourceConfig, +) +from datahub.ingestion.source.iceberg.iceberg_common import IcebergCatalogConfig +from datahub.metadata.com.linkedin.pegasus2avro.schema import ArrayType, SchemaField +from datahub.metadata.schema_classes import ( + ArrayTypeClass, + BooleanTypeClass, + BytesTypeClass, + DateTypeClass, + FixedTypeClass, + NumberTypeClass, + RecordTypeClass, + StringTypeClass, + TimeTypeClass, +) -if sys.version_info >= (3, 8): - from pyiceberg.schema import Schema - from pyiceberg.types import ( - BinaryType, - BooleanType, - DateType, - DecimalType, - DoubleType, - FixedType, - FloatType, - IcebergType, - IntegerType, - ListType, - LongType, - MapType, - NestedField, - PrimitiveType, - StringType, - StructType, - TimestampType, - TimestamptzType, - TimeType, - UUIDType, - ) - from datahub.ingestion.api.common import PipelineContext - from datahub.ingestion.source.iceberg.iceberg import ( - IcebergProfiler, - IcebergSource, - IcebergSourceConfig, +def with_iceberg_source() -> IcebergSource: + catalog: IcebergCatalogConfig = IcebergCatalogConfig( + name="test", type="rest", config={} ) - from datahub.ingestion.source.iceberg.iceberg_common import IcebergCatalogConfig - from datahub.metadata.com.linkedin.pegasus2avro.schema import ArrayType, SchemaField - from datahub.metadata.schema_classes import ( - ArrayTypeClass, - BooleanTypeClass, - BytesTypeClass, - DateTypeClass, - FixedTypeClass, - NumberTypeClass, - RecordTypeClass, - StringTypeClass, - TimeTypeClass, + return IcebergSource( + ctx=PipelineContext(run_id="iceberg-source-test"), + config=IcebergSourceConfig(catalog=catalog), ) - pytestmark = pytest.mark.skipif( - sys.version_info < (3, 8), reason="requires python 3.8 or higher" + +def with_iceberg_profiler() -> IcebergProfiler: + iceberg_source_instance = with_iceberg_source() + return IcebergProfiler( + iceberg_source_instance.report, iceberg_source_instance.config.profiling ) - def with_iceberg_source() -> IcebergSource: - catalog: IcebergCatalogConfig = IcebergCatalogConfig( - name="test", type="rest", config={} - ) - return IcebergSource( - ctx=PipelineContext(run_id="iceberg-source-test"), - config=IcebergSourceConfig(catalog=catalog), - ) - def with_iceberg_profiler() -> IcebergProfiler: - iceberg_source_instance = with_iceberg_source() - return IcebergProfiler( - iceberg_source_instance.report, iceberg_source_instance.config.profiling - ) +def assert_field( + schema_field: SchemaField, + expected_description: Optional[str], + expected_nullable: bool, + expected_type: Any, +) -> None: + assert ( + schema_field.description == expected_description + ), f"Field description '{schema_field.description}' is different from expected description '{expected_description}'" + assert ( + schema_field.nullable == expected_nullable + ), f"Field nullable '{schema_field.nullable}' is different from expected nullable '{expected_nullable}'" + assert isinstance( + schema_field.type.type, expected_type + ), f"Field type {schema_field.type.type} is different from expected type {expected_type}" - def assert_field( - schema_field: SchemaField, - expected_description: Optional[str], - expected_nullable: bool, - expected_type: Any, - ) -> None: - assert ( - schema_field.description == expected_description - ), f"Field description '{schema_field.description}' is different from expected description '{expected_description}'" + +def test_config_no_catalog(): + """ + Test when no Iceberg catalog is provided. + """ + with pytest.raises(ValidationError, match="catalog"): + IcebergSourceConfig() # type: ignore + + +def test_config_catalog_not_configured(): + """ + Test when an Iceberg catalog is provided, but not properly configured. + """ + with pytest.raises(ValidationError): + IcebergCatalogConfig() # type: ignore + + with pytest.raises(ValidationError, match="conf"): + IcebergCatalogConfig(type="a type") # type: ignore + + with pytest.raises(ValidationError, match="type"): + IcebergCatalogConfig(conf={}) # type: ignore + + +def test_config_for_tests(): + """ + Test valid iceberg source that will be used in unit tests. + """ + with_iceberg_source() + + +@pytest.mark.parametrize( + "iceberg_type, expected_schema_field_type", + [ + (BinaryType(), BytesTypeClass), + (BooleanType(), BooleanTypeClass), + (DateType(), DateTypeClass), + ( + DecimalType(3, 2), + NumberTypeClass, + ), + (DoubleType(), NumberTypeClass), + (FixedType(4), FixedTypeClass), + (FloatType(), NumberTypeClass), + (IntegerType(), NumberTypeClass), + (LongType(), NumberTypeClass), + (StringType(), StringTypeClass), + ( + TimestampType(), + TimeTypeClass, + ), + ( + TimestamptzType(), + TimeTypeClass, + ), + (TimeType(), TimeTypeClass), + ( + UUIDType(), + StringTypeClass, + ), + ], +) +def test_iceberg_primitive_type_to_schema_field( + iceberg_type: PrimitiveType, expected_schema_field_type: Any +) -> None: + """ + Test converting a primitive typed Iceberg field to a SchemaField + """ + iceberg_source_instance = with_iceberg_source() + for column in [ + NestedField( + 1, "required_field", iceberg_type, True, "required field documentation" + ), + NestedField( + 1, "optional_field", iceberg_type, False, "optional field documentation" + ), + ]: + schema = Schema(column) + schema_fields = iceberg_source_instance._get_schema_fields_for_schema(schema) assert ( - schema_field.nullable == expected_nullable - ), f"Field nullable '{schema_field.nullable}' is different from expected nullable '{expected_nullable}'" - assert isinstance( - schema_field.type.type, expected_type - ), f"Field type {schema_field.type.type} is different from expected type {expected_type}" - - def test_config_no_catalog(): - """ - Test when no Iceberg catalog is provided. - """ - with pytest.raises(ValidationError, match="catalog"): - IcebergSourceConfig() # type: ignore - - def test_config_catalog_not_configured(): - """ - Test when an Iceberg catalog is provided, but not properly configured. - """ - with pytest.raises(ValidationError): - IcebergCatalogConfig() # type: ignore - - with pytest.raises(ValidationError, match="conf"): - IcebergCatalogConfig(type="a type") # type: ignore - - with pytest.raises(ValidationError, match="type"): - IcebergCatalogConfig(conf={}) # type: ignore - - def test_config_for_tests(): - """ - Test valid iceberg source that will be used in unit tests. - """ - with_iceberg_source() - - @pytest.mark.parametrize( - "iceberg_type, expected_schema_field_type", - [ - (BinaryType(), BytesTypeClass), - (BooleanType(), BooleanTypeClass), - (DateType(), DateTypeClass), - ( - DecimalType(3, 2), - NumberTypeClass, - ), - (DoubleType(), NumberTypeClass), - (FixedType(4), FixedTypeClass), - (FloatType(), NumberTypeClass), - (IntegerType(), NumberTypeClass), - (LongType(), NumberTypeClass), - (StringType(), StringTypeClass), - ( - TimestampType(), - TimeTypeClass, - ), - ( - TimestamptzType(), - TimeTypeClass, - ), - (TimeType(), TimeTypeClass), - ( - UUIDType(), - StringTypeClass, - ), - ], - ) - def test_iceberg_primitive_type_to_schema_field( - iceberg_type: PrimitiveType, expected_schema_field_type: Any - ) -> None: - """ - Test converting a primitive typed Iceberg field to a SchemaField - """ + len(schema_fields) == 1 + ), f"Expected 1 field, but got {len(schema_fields)}" + assert_field( + schema_fields[0], + column.doc, + column.optional, + expected_schema_field_type, + ) + + +@pytest.mark.parametrize( + "iceberg_type, expected_array_nested_type", + [ + (BinaryType(), "bytes"), + (BooleanType(), "boolean"), + (DateType(), "date"), + ( + DecimalType(3, 2), + "decimal", + ), + (DoubleType(), "double"), + (FixedType(4), "fixed"), + (FloatType(), "float"), + (IntegerType(), "int"), + (LongType(), "long"), + (StringType(), "string"), + ( + TimestampType(), + "timestamp-micros", + ), + ( + TimestamptzType(), + "timestamp-micros", + ), + (TimeType(), "time-micros"), + ( + UUIDType(), + "uuid", + ), + ], +) +def test_iceberg_list_to_schema_field( + iceberg_type: PrimitiveType, expected_array_nested_type: Any +) -> None: + """ + Test converting a list typed Iceberg field to an ArrayType SchemaField, including the list nested type. + """ + for list_column in [ + NestedField( + 1, + "listField", + ListType(2, iceberg_type, True), + True, + "required field, required element documentation", + ), + NestedField( + 1, + "listField", + ListType(2, iceberg_type, False), + True, + "required field, optional element documentation", + ), + NestedField( + 1, + "listField", + ListType(2, iceberg_type, True), + False, + "optional field, required element documentation", + ), + NestedField( + 1, + "listField", + ListType(2, iceberg_type, False), + False, + "optional field, optional element documentation", + ), + ]: iceberg_source_instance = with_iceberg_source() - for column in [ - NestedField( - 1, "required_field", iceberg_type, True, "required field documentation" - ), - NestedField( - 1, "optional_field", iceberg_type, False, "optional field documentation" - ), - ]: - schema = Schema(column) - schema_fields = iceberg_source_instance._get_schema_fields_for_schema( - schema - ) - assert ( - len(schema_fields) == 1 - ), f"Expected 1 field, but got {len(schema_fields)}" - assert_field( - schema_fields[0], - column.doc, - column.optional, - expected_schema_field_type, - ) - - @pytest.mark.parametrize( - "iceberg_type, expected_array_nested_type", - [ - (BinaryType(), "bytes"), - (BooleanType(), "boolean"), - (DateType(), "date"), - ( - DecimalType(3, 2), - "decimal", - ), - (DoubleType(), "double"), - (FixedType(4), "fixed"), - (FloatType(), "float"), - (IntegerType(), "int"), - (LongType(), "long"), - (StringType(), "string"), - ( - TimestampType(), - "timestamp-micros", - ), - ( - TimestamptzType(), - "timestamp-micros", - ), - (TimeType(), "time-micros"), - ( - UUIDType(), - "uuid", - ), - ], - ) - def test_iceberg_list_to_schema_field( - iceberg_type: PrimitiveType, expected_array_nested_type: Any - ) -> None: - """ - Test converting a list typed Iceberg field to an ArrayType SchemaField, including the list nested type. - """ - for list_column in [ - NestedField( - 1, - "listField", - ListType(2, iceberg_type, True), - True, - "required field, required element documentation", - ), - NestedField( - 1, - "listField", - ListType(2, iceberg_type, False), - True, - "required field, optional element documentation", - ), - NestedField( - 1, - "listField", - ListType(2, iceberg_type, True), - False, - "optional field, required element documentation", - ), - NestedField( - 1, - "listField", - ListType(2, iceberg_type, False), - False, - "optional field, optional element documentation", - ), - ]: - iceberg_source_instance = with_iceberg_source() - schema = Schema(list_column) - schema_fields = iceberg_source_instance._get_schema_fields_for_schema( - schema - ) - assert ( - len(schema_fields) == 1 - ), f"Expected 1 field, but got {len(schema_fields)}" - assert_field( - schema_fields[0], list_column.doc, list_column.optional, ArrayTypeClass - ) - assert isinstance( - schema_fields[0].type.type, ArrayType - ), f"Field type {schema_fields[0].type.type} was expected to be {ArrayType}" - arrayType: ArrayType = schema_fields[0].type.type - assert arrayType.nestedType == [ - expected_array_nested_type - ], f"List Field nested type {arrayType.nestedType} was expected to be {expected_array_nested_type}" - - @pytest.mark.parametrize( - "iceberg_type, expected_map_type", - [ - (BinaryType(), BytesTypeClass), - (BooleanType(), BooleanTypeClass), - (DateType(), DateTypeClass), - ( - DecimalType(3, 2), - NumberTypeClass, - ), - (DoubleType(), NumberTypeClass), - (FixedType(4), FixedTypeClass), - (FloatType(), NumberTypeClass), - (IntegerType(), NumberTypeClass), - (LongType(), NumberTypeClass), - (StringType(), StringTypeClass), - ( - TimestampType(), - TimeTypeClass, - ), - ( - TimestamptzType(), - TimeTypeClass, - ), - (TimeType(), TimeTypeClass), - ( - UUIDType(), - StringTypeClass, - ), - ], - ) - def test_iceberg_map_to_schema_field( - iceberg_type: PrimitiveType, expected_map_type: Any - ) -> None: - """ - Test converting a map typed Iceberg field to a MapType SchemaField, where the key is the same type as the value. - """ - for map_column in [ - NestedField( - 1, - "mapField", - MapType(11, iceberg_type, 12, iceberg_type, True), - True, - "required field, required value documentation", - ), - NestedField( - 1, - "mapField", - MapType(11, iceberg_type, 12, iceberg_type, False), - True, - "required field, optional value documentation", - ), - NestedField( - 1, - "mapField", - MapType(11, iceberg_type, 12, iceberg_type, True), - False, - "optional field, required value documentation", - ), - NestedField( - 1, - "mapField", - MapType(11, iceberg_type, 12, iceberg_type, False), - False, - "optional field, optional value documentation", - ), - ]: - iceberg_source_instance = with_iceberg_source() - schema = Schema(map_column) - schema_fields = iceberg_source_instance._get_schema_fields_for_schema( - schema - ) - # Converting an Iceberg Map type will be done by creating an array of struct(key, value) records. - # The first field will be the array. - assert ( - len(schema_fields) == 3 - ), f"Expected 3 fields, but got {len(schema_fields)}" - assert_field( - schema_fields[0], map_column.doc, map_column.optional, ArrayTypeClass - ) - - # The second field will be the key type - assert_field(schema_fields[1], None, False, expected_map_type) - - # The third field will be the value type - assert_field( - schema_fields[2], - None, - not map_column.field_type.value_required, - expected_map_type, - ) - - @pytest.mark.parametrize( - "iceberg_type, expected_schema_field_type", - [ - (BinaryType(), BytesTypeClass), - (BooleanType(), BooleanTypeClass), - (DateType(), DateTypeClass), - ( - DecimalType(3, 2), - NumberTypeClass, - ), - (DoubleType(), NumberTypeClass), - (FixedType(4), FixedTypeClass), - (FloatType(), NumberTypeClass), - (IntegerType(), NumberTypeClass), - (LongType(), NumberTypeClass), - (StringType(), StringTypeClass), - ( - TimestampType(), - TimeTypeClass, - ), - ( - TimestamptzType(), - TimeTypeClass, - ), - (TimeType(), TimeTypeClass), - ( - UUIDType(), - StringTypeClass, - ), - ], - ) - def test_iceberg_struct_to_schema_field( - iceberg_type: PrimitiveType, expected_schema_field_type: Any - ) -> None: - """ - Test converting a struct typed Iceberg field to a RecordType SchemaField. - """ - field1 = NestedField(11, "field1", iceberg_type, True, "field documentation") - struct_column = NestedField( - 1, "structField", StructType(field1), True, "struct documentation" + schema = Schema(list_column) + schema_fields = iceberg_source_instance._get_schema_fields_for_schema(schema) + assert ( + len(schema_fields) == 1 + ), f"Expected 1 field, but got {len(schema_fields)}" + assert_field( + schema_fields[0], list_column.doc, list_column.optional, ArrayTypeClass ) + assert isinstance( + schema_fields[0].type.type, ArrayType + ), f"Field type {schema_fields[0].type.type} was expected to be {ArrayType}" + arrayType: ArrayType = schema_fields[0].type.type + assert arrayType.nestedType == [ + expected_array_nested_type + ], f"List Field nested type {arrayType.nestedType} was expected to be {expected_array_nested_type}" + + +@pytest.mark.parametrize( + "iceberg_type, expected_map_type", + [ + (BinaryType(), BytesTypeClass), + (BooleanType(), BooleanTypeClass), + (DateType(), DateTypeClass), + ( + DecimalType(3, 2), + NumberTypeClass, + ), + (DoubleType(), NumberTypeClass), + (FixedType(4), FixedTypeClass), + (FloatType(), NumberTypeClass), + (IntegerType(), NumberTypeClass), + (LongType(), NumberTypeClass), + (StringType(), StringTypeClass), + ( + TimestampType(), + TimeTypeClass, + ), + ( + TimestamptzType(), + TimeTypeClass, + ), + (TimeType(), TimeTypeClass), + ( + UUIDType(), + StringTypeClass, + ), + ], +) +def test_iceberg_map_to_schema_field( + iceberg_type: PrimitiveType, expected_map_type: Any +) -> None: + """ + Test converting a map typed Iceberg field to a MapType SchemaField, where the key is the same type as the value. + """ + for map_column in [ + NestedField( + 1, + "mapField", + MapType(11, iceberg_type, 12, iceberg_type, True), + True, + "required field, required value documentation", + ), + NestedField( + 1, + "mapField", + MapType(11, iceberg_type, 12, iceberg_type, False), + True, + "required field, optional value documentation", + ), + NestedField( + 1, + "mapField", + MapType(11, iceberg_type, 12, iceberg_type, True), + False, + "optional field, required value documentation", + ), + NestedField( + 1, + "mapField", + MapType(11, iceberg_type, 12, iceberg_type, False), + False, + "optional field, optional value documentation", + ), + ]: iceberg_source_instance = with_iceberg_source() - schema = Schema(struct_column) + schema = Schema(map_column) schema_fields = iceberg_source_instance._get_schema_fields_for_schema(schema) + # Converting an Iceberg Map type will be done by creating an array of struct(key, value) records. + # The first field will be the array. assert ( - len(schema_fields) == 2 - ), f"Expected 2 fields, but got {len(schema_fields)}" + len(schema_fields) == 3 + ), f"Expected 3 fields, but got {len(schema_fields)}" assert_field( - schema_fields[0], struct_column.doc, struct_column.optional, RecordTypeClass + schema_fields[0], map_column.doc, map_column.optional, ArrayTypeClass ) + + # The second field will be the key type + assert_field(schema_fields[1], None, False, expected_map_type) + + # The third field will be the value type assert_field( - schema_fields[1], field1.doc, field1.optional, expected_schema_field_type + schema_fields[2], + None, + not map_column.field_type.value_required, + expected_map_type, ) - @pytest.mark.parametrize( - "value_type, value, expected_value", - [ - (BinaryType(), bytes([1, 2, 3, 4, 5]), "b'\\x01\\x02\\x03\\x04\\x05'"), - (BooleanType(), True, "True"), - (DateType(), 19543, "2023-07-05"), - (DecimalType(3, 2), Decimal((0, (3, 1, 4), -2)), "3.14"), - (DoubleType(), 3.4, "3.4"), - (FixedType(4), bytes([1, 2, 3, 4]), "b'\\x01\\x02\\x03\\x04'"), - (FloatType(), 3.4, "3.4"), - (IntegerType(), 3, "3"), - (LongType(), 4294967295000, "4294967295000"), - (StringType(), "a string", "a string"), - ( - TimestampType(), - 1688559488157000, - "2023-07-05T12:18:08.157000", - ), - ( - TimestamptzType(), - 1688559488157000, - "2023-07-05T12:18:08.157000+00:00", - ), - (TimeType(), 40400000000, "11:13:20"), - ( - UUIDType(), - uuid.UUID("00010203-0405-0607-0809-0a0b0c0d0e0f"), - "00010203-0405-0607-0809-0a0b0c0d0e0f", - ), - ], + +@pytest.mark.parametrize( + "iceberg_type, expected_schema_field_type", + [ + (BinaryType(), BytesTypeClass), + (BooleanType(), BooleanTypeClass), + (DateType(), DateTypeClass), + ( + DecimalType(3, 2), + NumberTypeClass, + ), + (DoubleType(), NumberTypeClass), + (FixedType(4), FixedTypeClass), + (FloatType(), NumberTypeClass), + (IntegerType(), NumberTypeClass), + (LongType(), NumberTypeClass), + (StringType(), StringTypeClass), + ( + TimestampType(), + TimeTypeClass, + ), + ( + TimestamptzType(), + TimeTypeClass, + ), + (TimeType(), TimeTypeClass), + ( + UUIDType(), + StringTypeClass, + ), + ], +) +def test_iceberg_struct_to_schema_field( + iceberg_type: PrimitiveType, expected_schema_field_type: Any +) -> None: + """ + Test converting a struct typed Iceberg field to a RecordType SchemaField. + """ + field1 = NestedField(11, "field1", iceberg_type, True, "field documentation") + struct_column = NestedField( + 1, "structField", StructType(field1), True, "struct documentation" + ) + iceberg_source_instance = with_iceberg_source() + schema = Schema(struct_column) + schema_fields = iceberg_source_instance._get_schema_fields_for_schema(schema) + assert len(schema_fields) == 2, f"Expected 2 fields, but got {len(schema_fields)}" + assert_field( + schema_fields[0], struct_column.doc, struct_column.optional, RecordTypeClass + ) + assert_field( + schema_fields[1], field1.doc, field1.optional, expected_schema_field_type ) - def test_iceberg_profiler_value_render( - value_type: IcebergType, value: Any, expected_value: Optional[str] - ) -> None: - iceberg_profiler_instance = with_iceberg_profiler() - assert ( - iceberg_profiler_instance._render_value("a.dataset", value_type, value) - == expected_value - ) - def test_avro_decimal_bytes_nullable() -> None: - """ - The following test exposes a problem with decimal (bytes) not preserving extra attributes like _nullable. Decimal (fixed) and Boolean for example do. - NOTE: This bug was by-passed by mapping the Decimal type to fixed instead of bytes. - """ - import avro.schema - - decimal_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "bytes", "precision": 3, "scale": 2, "logicalType": "decimal", "native_data_type": "decimal(3, 2)", "_nullable": false}, "name": "required_field", "doc": "required field documentation"}]}""" - decimal_avro_schema = avro.schema.parse(decimal_avro_schema_string) - print("\nDecimal (bytes)") - print( - f"Original avro schema string: {decimal_avro_schema_string}" - ) - print( - f"After avro parsing, _nullable attribute is missing: {decimal_avro_schema}" - ) - decimal_fixed_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "fixed", "logicalType": "decimal", "precision": 3, "scale": 2, "native_data_type": "decimal(3, 2)", "_nullable": false, "name": "bogusName", "size": 16}, "name": "required_field", "doc": "required field documentation"}]}""" - decimal_fixed_avro_schema = avro.schema.parse(decimal_fixed_avro_schema_string) - print("\nDecimal (fixed)") - print( - f"Original avro schema string: {decimal_fixed_avro_schema_string}" - ) - print( - f"After avro parsing, _nullable attribute is preserved: {decimal_fixed_avro_schema}" - ) +@pytest.mark.parametrize( + "value_type, value, expected_value", + [ + (BinaryType(), bytes([1, 2, 3, 4, 5]), "b'\\x01\\x02\\x03\\x04\\x05'"), + (BooleanType(), True, "True"), + (DateType(), 19543, "2023-07-05"), + (DecimalType(3, 2), Decimal((0, (3, 1, 4), -2)), "3.14"), + (DoubleType(), 3.4, "3.4"), + (FixedType(4), bytes([1, 2, 3, 4]), "b'\\x01\\x02\\x03\\x04'"), + (FloatType(), 3.4, "3.4"), + (IntegerType(), 3, "3"), + (LongType(), 4294967295000, "4294967295000"), + (StringType(), "a string", "a string"), + ( + TimestampType(), + 1688559488157000, + "2023-07-05T12:18:08.157000", + ), + ( + TimestamptzType(), + 1688559488157000, + "2023-07-05T12:18:08.157000+00:00", + ), + (TimeType(), 40400000000, "11:13:20"), + ( + UUIDType(), + uuid.UUID("00010203-0405-0607-0809-0a0b0c0d0e0f"), + "00010203-0405-0607-0809-0a0b0c0d0e0f", + ), + ], +) +def test_iceberg_profiler_value_render( + value_type: IcebergType, value: Any, expected_value: Optional[str] +) -> None: + iceberg_profiler_instance = with_iceberg_profiler() + assert ( + iceberg_profiler_instance._render_value("a.dataset", value_type, value) + == expected_value + ) - boolean_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "boolean", "native_data_type": "boolean", "_nullable": false}, "name": "required_field", "doc": "required field documentation"}]}""" - boolean_avro_schema = avro.schema.parse(boolean_avro_schema_string) - print("\nBoolean") - print( - f"Original avro schema string: {boolean_avro_schema_string}" - ) - print( - f"After avro parsing, _nullable attribute is preserved: {boolean_avro_schema}" - ) + +def test_avro_decimal_bytes_nullable() -> None: + """ + The following test exposes a problem with decimal (bytes) not preserving extra attributes like _nullable. Decimal (fixed) and Boolean for example do. + NOTE: This bug was by-passed by mapping the Decimal type to fixed instead of bytes. + """ + import avro.schema + + decimal_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "bytes", "precision": 3, "scale": 2, "logicalType": "decimal", "native_data_type": "decimal(3, 2)", "_nullable": false}, "name": "required_field", "doc": "required field documentation"}]}""" + decimal_avro_schema = avro.schema.parse(decimal_avro_schema_string) + print("\nDecimal (bytes)") + print( + f"Original avro schema string: {decimal_avro_schema_string}" + ) + print(f"After avro parsing, _nullable attribute is missing: {decimal_avro_schema}") + + decimal_fixed_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "fixed", "logicalType": "decimal", "precision": 3, "scale": 2, "native_data_type": "decimal(3, 2)", "_nullable": false, "name": "bogusName", "size": 16}, "name": "required_field", "doc": "required field documentation"}]}""" + decimal_fixed_avro_schema = avro.schema.parse(decimal_fixed_avro_schema_string) + print("\nDecimal (fixed)") + print( + f"Original avro schema string: {decimal_fixed_avro_schema_string}" + ) + print( + f"After avro parsing, _nullable attribute is preserved: {decimal_fixed_avro_schema}" + ) + + boolean_avro_schema_string = """{"type": "record", "name": "__struct_", "fields": [{"type": {"type": "boolean", "native_data_type": "boolean", "_nullable": false}, "name": "required_field", "doc": "required field documentation"}]}""" + boolean_avro_schema = avro.schema.parse(boolean_avro_schema_string) + print("\nBoolean") + print( + f"Original avro schema string: {boolean_avro_schema_string}" + ) + print( + f"After avro parsing, _nullable attribute is preserved: {boolean_avro_schema}" + ) diff --git a/metadata-ingestion/tests/unit/test_mlflow_source.py b/metadata-ingestion/tests/unit/test_mlflow_source.py index 97b5afd3d6a4ef..374816055b2164 100644 --- a/metadata-ingestion/tests/unit/test_mlflow_source.py +++ b/metadata-ingestion/tests/unit/test_mlflow_source.py @@ -1,133 +1,140 @@ -import sys +import datetime +from pathlib import Path +from typing import Any, TypeVar, Union + +import pytest +from mlflow import MlflowClient +from mlflow.entities.model_registry import RegisteredModel +from mlflow.entities.model_registry.model_version import ModelVersion +from mlflow.store.entities import PagedList + +from datahub.ingestion.api.common import PipelineContext +from datahub.ingestion.source.mlflow import MLflowConfig, MLflowSource + +T = TypeVar("T") + + +@pytest.fixture +def tracking_uri(tmp_path: Path) -> str: + return str(tmp_path / "mlruns") + + +@pytest.fixture +def source(tracking_uri: str) -> MLflowSource: + return MLflowSource( + ctx=PipelineContext(run_id="mlflow-source-test"), + config=MLflowConfig(tracking_uri=tracking_uri), + ) + + +@pytest.fixture +def registered_model(source: MLflowSource) -> RegisteredModel: + model_name = "abc" + return RegisteredModel(name=model_name) + + +@pytest.fixture +def model_version( + source: MLflowSource, + registered_model: RegisteredModel, +) -> ModelVersion: + version = "1" + return ModelVersion( + name=registered_model.name, + version=version, + creation_timestamp=datetime.datetime.now(), + ) + + +def dummy_search_func(page_token: Union[None, str], **kwargs: Any) -> PagedList[T]: + dummy_pages = dict( + page_1=PagedList(items=["a", "b"], token="page_2"), + page_2=PagedList(items=["c", "d"], token="page_3"), + page_3=PagedList(items=["e"], token=None), + ) + if page_token is None: + page_to_return = dummy_pages["page_1"] + else: + page_to_return = dummy_pages[page_token] + if kwargs.get("case", "") == "upper": + page_to_return = PagedList( + items=[e.upper() for e in page_to_return.to_list()], + token=page_to_return.token, + ) + return page_to_return -if sys.version_info >= (3, 8): - import datetime - from pathlib import Path - from typing import Any, TypeVar, Union - import pytest - from mlflow import MlflowClient - from mlflow.entities.model_registry import RegisteredModel - from mlflow.entities.model_registry.model_version import ModelVersion - from mlflow.store.entities import PagedList +def test_stages(source): + mlflow_registered_model_stages = { + "Production", + "Staging", + "Archived", + None, + } + workunits = source._get_tags_workunits() + names = [wu.get_metadata()["metadata"].aspect.name for wu in workunits] - from datahub.ingestion.api.common import PipelineContext - from datahub.ingestion.source.mlflow import MLflowConfig, MLflowSource + assert len(names) == len(mlflow_registered_model_stages) + assert set(names) == { + "mlflow_" + str(stage).lower() for stage in mlflow_registered_model_stages + } - T = TypeVar("T") - @pytest.fixture - def tracking_uri(tmp_path: Path) -> str: - return str(tmp_path / "mlruns") +def test_config_model_name_separator(source, model_version): + name_version_sep = "+" + source.config.model_name_separator = name_version_sep + expected_model_name = ( + f"{model_version.name}{name_version_sep}{model_version.version}" + ) + expected_urn = f"urn:li:mlModel:(urn:li:dataPlatform:mlflow,{expected_model_name},{source.config.env})" - @pytest.fixture - def source(tracking_uri: str) -> MLflowSource: - return MLflowSource( - ctx=PipelineContext(run_id="mlflow-source-test"), - config=MLflowConfig(tracking_uri=tracking_uri), - ) + urn = source._make_ml_model_urn(model_version) - @pytest.fixture - def registered_model(source: MLflowSource) -> RegisteredModel: - model_name = "abc" - return RegisteredModel(name=model_name) - - @pytest.fixture - def model_version( - source: MLflowSource, - registered_model: RegisteredModel, - ) -> ModelVersion: - version = "1" - return ModelVersion( - name=registered_model.name, - version=version, - creation_timestamp=datetime.datetime.now(), - ) + assert urn == expected_urn - def dummy_search_func(page_token: Union[None, str], **kwargs: Any) -> PagedList[T]: - dummy_pages = dict( - page_1=PagedList(items=["a", "b"], token="page_2"), - page_2=PagedList(items=["c", "d"], token="page_3"), - page_3=PagedList(items=["e"], token=None), - ) - if page_token is None: - page_to_return = dummy_pages["page_1"] - else: - page_to_return = dummy_pages[page_token] - if kwargs.get("case", "") == "upper": - page_to_return = PagedList( - items=[e.upper() for e in page_to_return.to_list()], - token=page_to_return.token, - ) - return page_to_return - - def test_stages(source): - mlflow_registered_model_stages = { - "Production", - "Staging", - "Archived", - None, - } - workunits = source._get_tags_workunits() - names = [wu.get_metadata()["metadata"].aspect.name for wu in workunits] - - assert len(names) == len(mlflow_registered_model_stages) - assert set(names) == { - "mlflow_" + str(stage).lower() for stage in mlflow_registered_model_stages - } - - def test_config_model_name_separator(source, model_version): - name_version_sep = "+" - source.config.model_name_separator = name_version_sep - expected_model_name = ( - f"{model_version.name}{name_version_sep}{model_version.version}" - ) - expected_urn = f"urn:li:mlModel:(urn:li:dataPlatform:mlflow,{expected_model_name},{source.config.env})" - urn = source._make_ml_model_urn(model_version) +def test_model_without_run(source, registered_model, model_version): + run = source._get_mlflow_run(model_version) + wu = source._get_ml_model_properties_workunit( + registered_model=registered_model, + model_version=model_version, + run=run, + ) + aspect = wu.get_metadata()["metadata"].aspect - assert urn == expected_urn + assert aspect.hyperParams is None + assert aspect.trainingMetrics is None - def test_model_without_run(source, registered_model, model_version): - run = source._get_mlflow_run(model_version) - wu = source._get_ml_model_properties_workunit( - registered_model=registered_model, - model_version=model_version, - run=run, - ) - aspect = wu.get_metadata()["metadata"].aspect - assert aspect.hyperParams is None - assert aspect.trainingMetrics is None +def test_traverse_mlflow_search_func(source): + expected_items = ["a", "b", "c", "d", "e"] - def test_traverse_mlflow_search_func(source): - expected_items = ["a", "b", "c", "d", "e"] + items = list(source._traverse_mlflow_search_func(dummy_search_func)) - items = list(source._traverse_mlflow_search_func(dummy_search_func)) + assert items == expected_items - assert items == expected_items - def test_traverse_mlflow_search_func_with_kwargs(source): - expected_items = ["A", "B", "C", "D", "E"] +def test_traverse_mlflow_search_func_with_kwargs(source): + expected_items = ["A", "B", "C", "D", "E"] + + items = list(source._traverse_mlflow_search_func(dummy_search_func, case="upper")) + + assert items == expected_items - items = list( - source._traverse_mlflow_search_func(dummy_search_func, case="upper") - ) - assert items == expected_items +def test_make_external_link_local(source, model_version): + expected_url = None - def test_make_external_link_local(source, model_version): - expected_url = None + url = source._make_external_url(model_version) - url = source._make_external_url(model_version) + assert url == expected_url - assert url == expected_url - def test_make_external_link_remote(source, model_version): - tracking_uri_remote = "https://dummy-mlflow-tracking-server.org" - source.client = MlflowClient(tracking_uri=tracking_uri_remote) - expected_url = f"{tracking_uri_remote}/#/models/{model_version.name}/versions/{model_version.version}" +def test_make_external_link_remote(source, model_version): + tracking_uri_remote = "https://dummy-mlflow-tracking-server.org" + source.client = MlflowClient(tracking_uri=tracking_uri_remote) + expected_url = f"{tracking_uri_remote}/#/models/{model_version.name}/versions/{model_version.version}" - url = source._make_external_url(model_version) + url = source._make_external_url(model_version) - assert url == expected_url + assert url == expected_url From f378fb6c8066027fae671cb63a4ec3db60dd9744 Mon Sep 17 00:00:00 2001 From: Hyejin Yoon <0327jane@gmail.com> Date: Tue, 30 Jan 2024 04:33:17 +0900 Subject: [PATCH 230/540] docs: Add slack survey page (#9590) Co-authored-by: Harshal Sheth --- docs-website/docusaurus.config.js | 2 +- docs-website/src/pages/slack/index.js | 48 +++++++++++++++++++ .../src/pages/slack/slacksurvey.module.scss | 0 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs-website/src/pages/slack/index.js create mode 100644 docs-website/src/pages/slack/slacksurvey.module.scss diff --git a/docs-website/docusaurus.config.js b/docs-website/docusaurus.config.js index 22edf749acaede..6138f33244d037 100644 --- a/docs-website/docusaurus.config.js +++ b/docs-website/docusaurus.config.js @@ -62,7 +62,7 @@ module.exports = { position: "right", items: [ { - href: "https://slack.datahubproject.io", + to: "/slack", label: "Join Slack", }, { diff --git a/docs-website/src/pages/slack/index.js b/docs-website/src/pages/slack/index.js new file mode 100644 index 00000000000000..c85a1eefe55450 --- /dev/null +++ b/docs-website/src/pages/slack/index.js @@ -0,0 +1,48 @@ +import React, { useEffect } from 'react'; +import Layout from '@theme/Layout'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + +function SlackSurvey() { + const { siteConfig = {} } = useDocusaurusContext(); + + useEffect(() => { + const script = document.createElement('script'); + script.src = "//js.hsforms.net/forms/embed/v2.js"; + script.async = true; + script.type = 'text/javascript'; + document.body.appendChild(script); + + script.onload = () => { + if (window.hbspt) { + window.hbspt.forms.create({ + region: "na1", + portalId: "14552909", + formId: "91357965-a8dc-4e20-875e-5f87e6b9defb", + target: '#hubspotForm' // Targeting the div with the specific ID + }); + } + }; + + return () => { + document.body.removeChild(script); + }; + }, []); + + return ( + +
    +
    +
    +

    Join the DataHub Slack Community!

    +
    We will send the link to join our Slack community to your email.
    +
    +
    +
    +
    +
    + ); +} + +export default SlackSurvey; diff --git a/docs-website/src/pages/slack/slacksurvey.module.scss b/docs-website/src/pages/slack/slacksurvey.module.scss new file mode 100644 index 00000000000000..e69de29bb2d1d6 From 1d06d38b681be03732111e5d2a6a908dac6a5977 Mon Sep 17 00:00:00 2001 From: Shirshanka Das Date: Mon, 29 Jan 2024 16:03:27 -0800 Subject: [PATCH 231/540] feat(platform): add support for via nodes (#9733) --- .../datahub/graphql/GmsGraphQLEngine.java | 3 +- .../graphql/resolvers/BatchLoadUtils.java | 5 +- .../search/SearchAcrossLineageResolver.java | 52 +- .../resolvers/search/SearchResolver.java | 13 +- .../mappers/GroupingCriterionInputMapper.java | 29 + .../mappers/SearchFlagsInputMapper.java | 13 + .../common/mappers/UrnToEntityMapper.java | 6 + .../UrnSearchAcrossLineageResultsMapper.java | 1 + .../graphql/types/query/QueryType.java | 3 + .../src/main/resources/entity.graphql | 5 + .../src/main/resources/search.graphql | 49 + .../SearchAcrossLineageResolverTest.java | 20 +- .../resolvers/search/SearchResolverTest.java | 61 +- .../ReindexDataJobViaNodesCLLConfig.java | 15 + .../upgrade/config/SystemUpdateConfig.java | 11 +- .../datahub/upgrade/system/SystemUpdate.java | 6 +- .../system/via/ReindexDataJobViaNodesCLL.java | 34 + .../via/ReindexDataJobViaNodesCLLStep.java | 84 ++ .../annotation/RelationshipAnnotation.java | 45 +- .../src/datahub/ingestion/graph/client.py | 6 +- .../metadata/entity/EntityServiceImpl.java | 2 +- .../graph/dgraph/DgraphGraphService.java | 2 +- .../graph/elastic/ESGraphQueryDAO.java | 453 ++++++-- .../graph/elastic/ESGraphWriteDAO.java | 23 + .../elastic/ElasticSearchGraphService.java | 39 +- .../GraphRelationshipMappingsBuilder.java | 17 +- .../graph/neo4j/Neo4jGraphService.java | 3 +- .../metadata/search/LineageSearchService.java | 45 +- .../metadata/search/utils/SearchUtils.java | 24 + .../service/UpdateIndicesService.java | 46 +- .../metadata/graph/GraphServiceTestBase.java | 61 +- .../graph/dgraph/DgraphGraphServiceTest.java | 2 +- .../search/SearchGraphServiceTestBase.java | 21 +- .../fixtures/LineageDataFixtureTestBase.java | 25 +- .../search/utils/SearchUtilsTest.java | 175 ++-- .../linkedin/dataset/FineGrainedLineage.pdl | 7 +- .../pegasus/com/linkedin/dataset/Upstream.pdl | 8 + .../metadata/graph/LineageRelationship.pdl | 9 + .../metadata/query/GroupingCriterion.pdl | 21 + .../linkedin/metadata/query/GroupingSpec.pdl | 15 + .../linkedin/metadata/query/SearchFlags.pdl | 5 + .../metadata/search/LineageSearchEntity.pdl | 5 + .../com/linkedin/query/QueryProperties.pdl | 5 + .../search/GraphQueryConfiguration.java | 4 + .../src/main/resources/application.yml | 3 +- .../linkedin/metadata/boot/BootstrapStep.java | 11 + .../com.linkedin.entity.aspects.snapshot.json | 13 +- ...com.linkedin.entity.entities.snapshot.json | 58 +- .../com.linkedin.entity.runs.snapshot.json | 13 +- ...nkedin.lineage.relationships.snapshot.json | 13 +- ...nkedin.operations.operations.snapshot.json | 13 +- ...m.linkedin.platform.platform.snapshot.json | 13 +- .../linkedin/entity/client/EntityClient.java | 1 + .../com/linkedin/metadata/graph/Edge.java | 27 + .../metadata/graph/GraphIndexUtils.java | 35 +- .../metadata/graph/RelatedEntities.java | 8 +- .../metadata/graph/RelatedEntity.java | 13 + smoke-test/requirements.txt | 3 +- smoke-test/tests/lineage/__init__.py | 0 smoke-test/tests/lineage/test_lineage.py | 991 ++++++++++++++++++ 60 files changed, 2401 insertions(+), 292 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/GroupingCriterionInputMapper.java create mode 100644 datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java create mode 100644 datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java create mode 100644 datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java create mode 100644 metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingCriterion.pdl create mode 100644 metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingSpec.pdl create mode 100644 smoke-test/tests/lineage/__init__.py create mode 100644 smoke-test/tests/lineage/test_lineage.py diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 4b5bbdb6e15ec9..41f48e0a7dc3e6 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -878,7 +878,8 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { "scrollAcrossEntities", new ScrollAcrossEntitiesResolver(this.entityClient, this.viewService)) .dataFetcher( - "searchAcrossLineage", new SearchAcrossLineageResolver(this.entityClient)) + "searchAcrossLineage", + new SearchAcrossLineageResolver(this.entityClient, this.entityRegistry)) .dataFetcher( "scrollAcrossLineage", new ScrollAcrossLineageResolver(this.entityClient)) .dataFetcher( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/BatchLoadUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/BatchLoadUtils.java index 5ab07701c15a23..3126f25546f658 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/BatchLoadUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/BatchLoadUtils.java @@ -28,8 +28,9 @@ public static CompletableFuture> batchLoadEntitiesOfSameType( .filter(entity -> entities.get(0).getClass().isAssignableFrom(entity.objectClass())) .collect(Collectors.toList())); - final DataLoader loader = dataLoaderRegistry.getDataLoader(filteredEntity.name()); - List keyList = new ArrayList(); + final DataLoader loader = + dataLoaderRegistry.getDataLoader(filteredEntity.name()); + List keyList = new ArrayList(); for (Entity entity : entities) { keyList.add(filteredEntity.getKeyProvider().apply(entity)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java index 2dc5032f2a4eb8..1a8b7734c093e5 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolver.java @@ -2,7 +2,9 @@ import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.*; +import static com.linkedin.metadata.Constants.QUERY_ENTITY_NAME; +import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.FacetFilterInput; @@ -14,31 +16,63 @@ import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchAcrossLineageResultsMapper; import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.search.LineageSearchResult; import com.linkedin.r2.RemoteInvocationException; +import graphql.VisibleForTesting; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import javax.annotation.Nullable; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** Resolver responsible for resolving 'searchAcrossEntities' field of the Query type */ @Slf4j -@RequiredArgsConstructor public class SearchAcrossLineageResolver implements DataFetcher> { private static final int DEFAULT_START = 0; private static final int DEFAULT_COUNT = 10; + private static final Set TRANSIENT_ENTITIES = ImmutableSet.of(QUERY_ENTITY_NAME); + private final EntityClient _entityClient; + private final EntityRegistry _entityRegistry; + + @VisibleForTesting final Set _allEntities; + private final List _allowedEntities; + + public SearchAcrossLineageResolver(EntityClient entityClient, EntityRegistry entityRegistry) { + this._entityClient = entityClient; + this._entityRegistry = entityRegistry; + this._allEntities = + entityRegistry.getEntitySpecs().values().stream() + .map(EntitySpec::getName) + .collect(Collectors.toSet()); + + this._allowedEntities = + this._allEntities.stream() + .filter(e -> !TRANSIENT_ENTITIES.contains(e)) + .collect(Collectors.toList()); + } + + private List getEntityNamesFromInput(List inputTypes) { + if (inputTypes != null && !inputTypes.isEmpty()) { + return inputTypes.stream().map(EntityTypeMapper::getName).collect(Collectors.toList()); + } else { + return this._allowedEntities; + } + } + @Override public CompletableFuture get(DataFetchingEnvironment environment) throws URISyntaxException { @@ -50,12 +84,7 @@ public CompletableFuture get(DataFetchingEnvironment final LineageDirection lineageDirection = input.getDirection(); - List entityTypes = - (input.getTypes() == null || input.getTypes().isEmpty()) - ? SEARCHABLE_ENTITY_TYPES - : input.getTypes(); - List entityNames = - entityTypes.stream().map(EntityTypeMapper::getName).collect(Collectors.toList()); + List entityNames = getEntityNamesFromInput(input.getTypes()); // escape forward slash since it is a reserved character in Elasticsearch final String sanitizedQuery = @@ -99,8 +128,7 @@ public CompletableFuture get(DataFetchingEnvironment } else { searchFlags = new SearchFlags().setFulltext(true).setSkipHighlighting(true); } - - return UrnSearchAcrossLineageResultsMapper.map( + LineageSearchResult salResults = _entityClient.searchAcrossLineage( urn, resolvedDirection, @@ -114,7 +142,9 @@ public CompletableFuture get(DataFetchingEnvironment startTimeMillis, endTimeMillis, searchFlags, - ResolverUtils.getAuthentication(environment))); + getAuthentication(environment)); + + return UrnSearchAcrossLineageResultsMapper.map(salResults); } catch (RemoteInvocationException e) { log.error( "Failed to execute search across relationships: source urn {}, direction {}, entity types {}, query {}, filters: {}, start: {}, count: {}", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java index bc177c600beeef..7428207034f5dd 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolver.java @@ -1,6 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.search; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; +import static com.linkedin.metadata.Constants.*; import static com.linkedin.metadata.search.utils.SearchUtils.applyDefaultSearchFlags; import com.linkedin.datahub.graphql.generated.SearchInput; @@ -10,6 +11,9 @@ import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper; import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.query.GroupingCriterion; +import com.linkedin.metadata.query.GroupingCriterionArray; +import com.linkedin.metadata.query.GroupingSpec; import com.linkedin.metadata.query.SearchFlags; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; @@ -28,7 +32,14 @@ public class SearchResolver implements DataFetcher { + + public static final GroupingCriterionInputMapper INSTANCE = new GroupingCriterionInputMapper(); + + public static com.linkedin.metadata.query.GroupingCriterion map( + @Nonnull final GroupingCriterion groupingCriterion) { + return INSTANCE.apply(groupingCriterion); + } + + @Override + public com.linkedin.metadata.query.GroupingCriterion apply(GroupingCriterion input) { + return new com.linkedin.metadata.query.GroupingCriterion() + .setBaseEntityType( + input.getBaseEntityType() != null + ? EntityTypeMapper.getName(input.getBaseEntityType()) + : null, + SetMode.REMOVE_OPTIONAL_IF_NULL) + .setGroupingEntityType(EntityTypeMapper.getName(input.getGroupingEntityType())); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/SearchFlagsInputMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/SearchFlagsInputMapper.java index e2d29d02974491..faede5cf9bb1be 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/SearchFlagsInputMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/SearchFlagsInputMapper.java @@ -2,6 +2,9 @@ import com.linkedin.datahub.graphql.generated.SearchFlags; import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.metadata.query.GroupingCriterionArray; +import com.linkedin.metadata.query.GroupingSpec; +import java.util.stream.Collectors; import javax.annotation.Nonnull; /** @@ -42,6 +45,16 @@ public com.linkedin.metadata.query.SearchFlags apply(@Nonnull final SearchFlags if (searchFlags.getGetSuggestions() != null) { result.setGetSuggestions(searchFlags.getGetSuggestions()); } + if (searchFlags.getGroupingSpec() != null + && searchFlags.getGroupingSpec().getGroupingCriteria() != null) { + result.setGroupingSpec( + new GroupingSpec() + .setGroupingCriteria( + new GroupingCriterionArray( + searchFlags.getGroupingSpec().getGroupingCriteria().stream() + .map(GroupingCriterionInputMapper::map) + .collect(Collectors.toList())))); + } return result; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java index 18a082fee95f1a..3ca018ea6f5c77 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java @@ -30,6 +30,7 @@ import com.linkedin.datahub.graphql.generated.MLPrimaryKey; import com.linkedin.datahub.graphql.generated.Notebook; import com.linkedin.datahub.graphql.generated.OwnershipTypeEntity; +import com.linkedin.datahub.graphql.generated.QueryEntity; import com.linkedin.datahub.graphql.generated.Role; import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity; @@ -198,6 +199,11 @@ public Entity apply(Urn input) { ((StructuredPropertyEntity) partialEntity).setUrn(input.toString()); ((StructuredPropertyEntity) partialEntity).setType(EntityType.STRUCTURED_PROPERTY); } + if (input.getEntityType().equals(QUERY_ENTITY_NAME)) { + partialEntity = new QueryEntity(); + ((QueryEntity) partialEntity).setUrn(input.toString()); + ((QueryEntity) partialEntity).setType(EntityType.QUERY); + } return partialEntity; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/UrnSearchAcrossLineageResultsMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/UrnSearchAcrossLineageResultsMapper.java index 642fe90cf2aedf..970789facf6995 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/UrnSearchAcrossLineageResultsMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mappers/UrnSearchAcrossLineageResultsMapper.java @@ -62,6 +62,7 @@ private SearchAcrossLineageResult mapResult(LineageSearchEntity searchEntity) { .setMatchedFields(getMatchedFieldEntry(searchEntity.getMatchedFields())) .setPaths(searchEntity.getPaths().stream().map(this::mapPath).collect(Collectors.toList())) .setDegree(searchEntity.getDegree()) + .setDegrees(searchEntity.getDegrees().stream().collect(Collectors.toList())) .build(); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/query/QueryType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/query/QueryType.java index 0c1fd33e381104..087c93a97e314f 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/query/QueryType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/query/QueryType.java @@ -21,7 +21,9 @@ import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor public class QueryType implements com.linkedin.datahub.graphql.types.EntityType { @@ -50,6 +52,7 @@ public List> batchLoad( final List viewUrns = urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); try { + log.debug("Fetching query entities: {}", viewUrns); final Map entities = _entityClient.batchGetV2( QUERY_ENTITY_NAME, diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 3ea1b38d3db0d3..0074dc3fcb44c7 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -10948,6 +10948,11 @@ enum QuerySource { The query was provided manually, e.g. from the UI. """ MANUAL + + """ + The query was extracted by the system, e.g. from a dashboard. + """ + SYSTEM } """ diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql index 8896dd02b5ad32..2b921601058fbc 100644 --- a/datahub-graphql-core/src/main/resources/search.graphql +++ b/datahub-graphql-core/src/main/resources/search.graphql @@ -143,6 +143,15 @@ input SearchFlags { Whether to request for search suggestions on the _entityName virtualized field """ getSuggestions: Boolean + + """ + Additional grouping specifications to apply to the search results + Grouping specifications will control how search results are grouped together + in the response. This is currently being used to group schema fields (columns) + as datasets, and in the future will be used to group other entities as well. + Note: This is an experimental feature and is subject to change. + """ + groupingSpec: GroupingSpec } """ @@ -278,6 +287,7 @@ input ScrollAcrossEntitiesInput { searchFlags: SearchFlags } + """ Input arguments for a search query over the results of a multi-hop graph query """ @@ -669,6 +679,12 @@ type SearchAcrossLineageResult { Degree of relationship (number of hops to get to entity) """ degree: Int! + + """ + Degrees of relationship (for entities discoverable at multiple degrees) + """ + degrees: [Int!] + } """ @@ -1303,4 +1319,37 @@ input SortCriterion { The order in which we will be sorting """ sortOrder: SortOrder! +} + +""" +A grouping specification for search results. +""" +input GroupingSpec { + + """ + A list of grouping criteria for grouping search results. + There is no implied order in the grouping criteria. + """ + groupingCriteria: [GroupingCriterion!] + +} + +""" +A single grouping criterion for grouping search results +""" +input GroupingCriterion { + + """ + The base entity type that needs to be grouped + e.g. schemaField + Omitting this field will result in all base entities being grouped into the groupingEntityType. + """ + baseEntityType: EntityType + + """ + The type of entity being grouped into + e.g. dataset, domain, etc. + """ + groupingEntityType: EntityType! + } \ No newline at end of file diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolverTest.java index 273f7156c12a8b..a50591b7fc3991 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossLineageResolverTest.java @@ -14,6 +14,8 @@ import com.linkedin.datahub.graphql.generated.SearchAcrossLineageResult; import com.linkedin.datahub.graphql.generated.SearchAcrossLineageResults; import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.models.registry.ConfigEntityRegistry; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.search.AggregationMetadataArray; import com.linkedin.metadata.search.LineageSearchEntity; @@ -22,6 +24,7 @@ import com.linkedin.metadata.search.MatchedFieldArray; import com.linkedin.metadata.search.SearchResultMetadata; import graphql.schema.DataFetchingEnvironment; +import java.io.InputStream; import java.util.Collections; import java.util.List; import org.testng.annotations.BeforeMethod; @@ -43,13 +46,28 @@ public class SearchAcrossLineageResolverTest { private Authentication _authentication; private SearchAcrossLineageResolver _resolver; + private EntityRegistry _entityRegistry; + @BeforeMethod public void setupTest() { _entityClient = mock(EntityClient.class); _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); _authentication = mock(Authentication.class); - _resolver = new SearchAcrossLineageResolver(_entityClient); + _entityRegistry = mock(EntityRegistry.class); + _resolver = new SearchAcrossLineageResolver(_entityClient, _entityRegistry); + } + + @Test + public void testAllEntitiesInitialization() { + InputStream inputStream = ClassLoader.getSystemResourceAsStream("entity-registry.yml"); + EntityRegistry entityRegistry = new ConfigEntityRegistry(inputStream); + SearchAcrossLineageResolver resolver = + new SearchAcrossLineageResolver(_entityClient, entityRegistry); + assertTrue(resolver._allEntities.contains("dataset")); + assertTrue(resolver._allEntities.contains("dataFlow")); + // Test for case sensitivity + assertFalse(resolver._allEntities.contains("dataflow")); } @Test diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolverTest.java index 24724cb8e23ad3..9716799628a453 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/SearchResolverTest.java @@ -1,14 +1,17 @@ package com.linkedin.datahub.graphql.resolvers.search; import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.metadata.Constants.*; import com.datahub.authentication.Authentication; +import com.linkedin.data.template.SetMode; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.SearchFlags; import com.linkedin.datahub.graphql.generated.SearchInput; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.query.GroupingCriterionArray; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.search.SearchEntityArray; @@ -19,6 +22,22 @@ import org.testng.annotations.Test; public class SearchResolverTest { + + private com.linkedin.metadata.query.SearchFlags setConvertSchemaFieldsToDatasets( + com.linkedin.metadata.query.SearchFlags flags, boolean value) { + if (value) { + return flags.setGroupingSpec( + new com.linkedin.metadata.query.GroupingSpec() + .setGroupingCriteria( + new GroupingCriterionArray( + new com.linkedin.metadata.query.GroupingCriterion() + .setBaseEntityType(SCHEMA_FIELD_ENTITY_NAME) + .setGroupingEntityType(DATASET_ENTITY_NAME)))); + } else { + return flags.setGroupingSpec(null, SetMode.REMOVE_IF_NULL); + } + } + @Test public void testDefaultSearchFlags() throws Exception { EntityClient mockClient = initMockSearchEntityClient(); @@ -40,12 +59,14 @@ public void testDefaultSearchFlags() throws Exception { null, 0, 10, - new com.linkedin.metadata.query.SearchFlags() - .setFulltext(true) - .setSkipAggregates(false) - .setSkipHighlighting(true) // empty/wildcard - .setMaxAggValues(20) - .setSkipCache(false)); + setConvertSchemaFieldsToDatasets( + new com.linkedin.metadata.query.SearchFlags() + .setFulltext(true) + .setSkipAggregates(false) + .setSkipHighlighting(true) // empty/wildcard + .setMaxAggValues(20) + .setSkipCache(false), + true)); } @Test @@ -77,12 +98,14 @@ public void testOverrideSearchFlags() throws Exception { null, 1, 11, - new com.linkedin.metadata.query.SearchFlags() - .setFulltext(false) - .setSkipAggregates(true) - .setSkipHighlighting(true) - .setMaxAggValues(10) - .setSkipCache(true)); + setConvertSchemaFieldsToDatasets( + new com.linkedin.metadata.query.SearchFlags() + .setFulltext(false) + .setSkipAggregates(true) + .setSkipHighlighting(true) + .setMaxAggValues(10) + .setSkipCache(true), + false)); } @Test @@ -107,12 +130,14 @@ public void testNonWildCardSearchFlags() throws Exception { null, 0, 10, - new com.linkedin.metadata.query.SearchFlags() - .setFulltext(true) - .setSkipAggregates(false) - .setSkipHighlighting(false) // empty/wildcard - .setMaxAggValues(20) - .setSkipCache(false)); + setConvertSchemaFieldsToDatasets( + new com.linkedin.metadata.query.SearchFlags() + .setFulltext(true) + .setSkipAggregates(false) + .setSkipHighlighting(false) // empty/wildcard + .setMaxAggValues(20) + .setSkipCache(false), + true)); } private EntityClient initMockSearchEntityClient() throws Exception { diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java new file mode 100644 index 00000000000000..06311e1853874d --- /dev/null +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java @@ -0,0 +1,15 @@ +package com.linkedin.datahub.upgrade.config; + +import com.linkedin.datahub.upgrade.system.via.ReindexDataJobViaNodesCLL; +import com.linkedin.metadata.entity.EntityService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ReindexDataJobViaNodesCLLConfig { + + @Bean + public ReindexDataJobViaNodesCLL _reindexDataJobViaNodesCLL(EntityService entityService) { + return new ReindexDataJobViaNodesCLL(entityService); + } +} diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java index 3b63d81486eb4b..177d4b531ba86d 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java @@ -4,6 +4,7 @@ import com.linkedin.datahub.upgrade.system.elasticsearch.BuildIndices; import com.linkedin.datahub.upgrade.system.elasticsearch.CleanIndices; import com.linkedin.datahub.upgrade.system.entity.steps.BackfillBrowsePathsV2; +import com.linkedin.datahub.upgrade.system.via.ReindexDataJobViaNodesCLL; import com.linkedin.gms.factory.common.TopicConventionFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.gms.factory.kafka.DataHubKafkaProducerFactory; @@ -34,11 +35,17 @@ public SystemUpdate systemUpdate( @Qualifier("duheKafkaEventProducer") final KafkaEventProducer kafkaEventProducer, final GitVersion gitVersion, @Qualifier("revision") String revision, - final BackfillBrowsePathsV2 backfillBrowsePathsV2) { + final BackfillBrowsePathsV2 backfillBrowsePathsV2, + final ReindexDataJobViaNodesCLL reindexDataJobViaNodesCLL) { String version = String.format("%s-%s", gitVersion.getVersion(), revision); return new SystemUpdate( - buildIndices, cleanIndices, kafkaEventProducer, version, backfillBrowsePathsV2); + buildIndices, + cleanIndices, + kafkaEventProducer, + version, + backfillBrowsePathsV2, + reindexDataJobViaNodesCLL); } @Value("#{systemEnvironment['DATAHUB_REVISION'] ?: '0'}") diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/SystemUpdate.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/SystemUpdate.java index aba751bff8177d..ed9c8ddda45c88 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/SystemUpdate.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/SystemUpdate.java @@ -7,6 +7,7 @@ import com.linkedin.datahub.upgrade.system.elasticsearch.CleanIndices; import com.linkedin.datahub.upgrade.system.elasticsearch.steps.DataHubStartupStep; import com.linkedin.datahub.upgrade.system.entity.steps.BackfillBrowsePathsV2; +import com.linkedin.datahub.upgrade.system.via.ReindexDataJobViaNodesCLL; import com.linkedin.metadata.dao.producer.KafkaEventProducer; import java.util.List; import java.util.stream.Collectors; @@ -24,11 +25,12 @@ public SystemUpdate( final CleanIndices cleanIndicesJob, final KafkaEventProducer kafkaEventProducer, final String version, - final BackfillBrowsePathsV2 backfillBrowsePathsV2) { + final BackfillBrowsePathsV2 backfillBrowsePathsV2, + final ReindexDataJobViaNodesCLL upgradeViaNodeCll) { _preStartupUpgrades = List.of(buildIndicesJob); _steps = List.of(new DataHubStartupStep(kafkaEventProducer, version)); - _postStartupUpgrades = List.of(cleanIndicesJob, backfillBrowsePathsV2); + _postStartupUpgrades = List.of(cleanIndicesJob, backfillBrowsePathsV2, upgradeViaNodeCll); } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java new file mode 100644 index 00000000000000..41179a50c4b541 --- /dev/null +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java @@ -0,0 +1,34 @@ +package com.linkedin.datahub.upgrade.system.via; + +import static com.linkedin.metadata.Constants.*; + +import com.google.common.collect.ImmutableList; +import com.linkedin.datahub.upgrade.Upgrade; +import com.linkedin.datahub.upgrade.UpgradeStep; +import com.linkedin.metadata.entity.EntityService; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * A job that reindexes all datajob inputoutput aspects as part of the via node upgrade. This is + * required to index column-level lineage correctly using via nodes. + */ +@Slf4j +public class ReindexDataJobViaNodesCLL implements Upgrade { + + private final List _steps; + + public ReindexDataJobViaNodesCLL(EntityService entityService) { + _steps = ImmutableList.of(new ReindexDataJobViaNodesCLLStep(entityService)); + } + + @Override + public String id() { + return this.getClass().getName(); + } + + @Override + public List steps() { + return _steps; + } +} diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java new file mode 100644 index 00000000000000..70afbc3d205b25 --- /dev/null +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java @@ -0,0 +1,84 @@ +package com.linkedin.datahub.upgrade.system.via; + +import static com.linkedin.metadata.Constants.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.upgrade.UpgradeContext; +import com.linkedin.datahub.upgrade.UpgradeStep; +import com.linkedin.datahub.upgrade.UpgradeStepResult; +import com.linkedin.datahub.upgrade.impl.DefaultUpgradeStepResult; +import com.linkedin.metadata.boot.BootstrapStep; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; +import com.linkedin.metadata.entity.restoreindices.RestoreIndicesResult; +import java.net.URISyntaxException; +import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ReindexDataJobViaNodesCLLStep implements UpgradeStep { + + private static final String UPGRADE_ID = "via-node-cll-reindex-datajob"; + private static final Urn UPGRADE_ID_URN = BootstrapStep.getUpgradeUrn(UPGRADE_ID); + + private static final Integer BATCH_SIZE = 5000; + + private final EntityService _entityService; + + public ReindexDataJobViaNodesCLLStep(EntityService entityService) { + _entityService = entityService; + } + + @Override + public Function executable() { + return (context) -> { + RestoreIndicesArgs args = + new RestoreIndicesArgs() + .setAspectName(DATA_JOB_INPUT_OUTPUT_ASPECT_NAME) + .setUrnLike("urn:li:" + DATA_JOB_ENTITY_NAME + ":%"); + RestoreIndicesResult result = + _entityService.restoreIndices(args, x -> context.report().addLine((String) x)); + context.report().addLine("Rows migrated: " + result.rowsMigrated); + context.report().addLine("Rows ignored: " + result.ignored); + try { + BootstrapStep.setUpgradeResult(UPGRADE_ID_URN, _entityService); + context.report().addLine("State updated: " + UPGRADE_ID_URN); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.SUCCEEDED); + }; + } + + @Override + public String id() { + return UPGRADE_ID; + } + + /** + * Returns whether the upgrade should proceed if the step fails after exceeding the maximum + * retries. + */ + @Override + public boolean isOptional() { + return false; + } + + @Override + /** + * Returns whether the upgrade should be skipped. Uses previous run history or the environment + * variable SKIP_REINDEX_DATA_JOB_INPUT_OUTPUT to determine whether to skip. + */ + public boolean skip(UpgradeContext context) { + boolean previouslyRun = _entityService.exists(UPGRADE_ID_URN, true); + boolean envFlagRecommendsSkip = + Boolean.parseBoolean(System.getenv("SKIP_REINDEX_DATA_JOB_INPUT_OUTPUT")); + if (previouslyRun) { + log.info("{} was already run. Skipping.", id()); + } + if (envFlagRecommendsSkip) { + log.info("Environment variable SKIP_REINDEX_DATA_JOB_INPUT_OUTPUT is set to true. Skipping."); + } + return (previouslyRun || envFlagRecommendsSkip); + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/annotation/RelationshipAnnotation.java b/entity-registry/src/main/java/com/linkedin/metadata/models/annotation/RelationshipAnnotation.java index a22ef56d60006b..630e7951c03114 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/annotation/RelationshipAnnotation.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/annotation/RelationshipAnnotation.java @@ -6,10 +6,12 @@ import java.util.Map; import java.util.Optional; import javax.annotation.Nonnull; +import lombok.AllArgsConstructor; import lombok.Value; /** Simple object representation of the @Relationship annotation metadata. */ @Value +@AllArgsConstructor public class RelationshipAnnotation { public static final String ANNOTATION_NAME = "Relationship"; @@ -23,6 +25,8 @@ public class RelationshipAnnotation { private static final String UPDATED_ACTOR = "updatedActor"; private static final String PROPERTIES = "properties"; + private static final String VIA = "via"; + String name; List validDestinationTypes; boolean isUpstream; @@ -32,6 +36,7 @@ public class RelationshipAnnotation { String updatedOn; String updatedActor; String properties; + String via; @Nonnull public static RelationshipAnnotation fromPegasusAnnotationObject( @@ -78,6 +83,7 @@ public static RelationshipAnnotation fromPegasusAnnotationObject( final Optional updatedActor = AnnotationUtils.getField(map, UPDATED_ACTOR, String.class); final Optional properties = AnnotationUtils.getField(map, PROPERTIES, String.class); + final Optional via = AnnotationUtils.getField(map, VIA, String.class); return new RelationshipAnnotation( name.get(), @@ -88,6 +94,43 @@ public static RelationshipAnnotation fromPegasusAnnotationObject( createdActor.orElse(null), updatedOn.orElse(null), updatedActor.orElse(null), - properties.orElse(null)); + properties.orElse(null), + via.orElse(null)); + } + + /** + * Constructor for backwards compatibility + * + * @param name + * @param entityTypes + * @param isUpstream + * @param isLineage + * @param createdOn + * @param createdActor + * @param updatedOn + * @param updatedActor + * @param properties + */ + public RelationshipAnnotation( + String name, + List validDestinationTypes, + boolean isUpstream, + boolean isLineage, + String createdOn, + String createdActor, + String updatedOn, + String updatedActor, + String properties) { + this( + name, + validDestinationTypes, + isUpstream, + isLineage, + createdOn, + createdActor, + updatedOn, + updatedActor, + properties, + null); } } diff --git a/metadata-ingestion/src/datahub/ingestion/graph/client.py b/metadata-ingestion/src/datahub/ingestion/graph/client.py index 5c24b06dde9998..d64f756dddc132 100644 --- a/metadata-ingestion/src/datahub/ingestion/graph/client.py +++ b/metadata-ingestion/src/datahub/ingestion/graph/client.py @@ -83,6 +83,7 @@ class DatahubClientConfig(ConfigModel): class RelatedEntity: urn: str relationship_type: str + via: Optional[str] = None def _graphql_entity_type(entity_type: str) -> str: @@ -833,6 +834,7 @@ def get_related_entities( yield RelatedEntity( urn=related_entity["urn"], relationship_type=related_entity["relationshipType"], + via=related_entity.get("via"), ) done = response.get("count", 0) == 0 or response.get("count", 0) < len( response.get("entities", []) @@ -840,9 +842,9 @@ def get_related_entities( start = start + response.get("count", 0) def exists(self, entity_urn: str) -> bool: - entity_urn_parsed: Urn = Urn.create_from_string(entity_urn) + entity_urn_parsed: Urn = Urn.from_string(entity_urn) try: - key_aspect_class = KEY_ASPECTS.get(entity_urn_parsed.get_type()) + key_aspect_class = KEY_ASPECTS.get(entity_urn_parsed.entity_type) if key_aspect_class: result = self.get_aspect(entity_urn, key_aspect_class) return result is not None diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index b3b11d200ec0dc..e6e69c96c15421 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -1948,6 +1948,7 @@ public RollbackRunResult deleteUrn(Urn urn) { */ @Override public Set exists(@Nonnull final Collection urns, boolean includeSoftDeleted) { + final Set dbKeys = urns.stream() .map( @@ -1960,7 +1961,6 @@ public Set exists(@Nonnull final Collection urns, boolean includeSoftD .getName(), ASPECT_LATEST_VERSION)) .collect(Collectors.toSet()); - final Map aspects = _aspectDao.batchGet(dbKeys); final Set existingUrnStrings = aspects.values().stream() diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java index 24e272dee7a254..3bcaf6a08f4e5e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/dgraph/DgraphGraphService.java @@ -653,7 +653,7 @@ protected static List getRelatedEntitiesFromResponseData( }) // for undirected we get duplicate relationships .distinct() - .map(relationship -> new RelatedEntity(relationship, urn)); + .map(relationship -> new RelatedEntity(relationship, urn, null)); } return Stream.empty(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java index 3051319aa54cf3..270615aa0e3568 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphQueryDAO.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.graph.elastic; import static com.linkedin.metadata.graph.elastic.ElasticSearchGraphService.*; +import static com.linkedin.metadata.graph.elastic.GraphRelationshipMappingsBuilder.*; import com.codahale.metrics.Timer; import com.datahub.util.exception.ESQueryException; @@ -11,6 +12,7 @@ import com.linkedin.common.UrnArrayArray; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; +import com.linkedin.data.template.IntegerArray; import com.linkedin.metadata.config.search.GraphQueryConfiguration; import com.linkedin.metadata.graph.GraphFilters; import com.linkedin.metadata.graph.LineageDirection; @@ -34,7 +36,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -182,6 +184,24 @@ public static BoolQueryBuilder buildQuery( @Nullable final Filter destinationEntityFilter, @Nonnull final List relationshipTypes, @Nonnull final RelationshipFilter relationshipFilter) { + return buildQuery( + sourceTypes, + sourceEntityFilter, + destinationTypes, + destinationEntityFilter, + relationshipTypes, + relationshipFilter, + null); + } + + public static BoolQueryBuilder buildQuery( + @Nullable final List sourceTypes, + @Nonnull final Filter sourceEntityFilter, + @Nullable final List destinationTypes, + @Nonnull final Filter destinationEntityFilter, + @Nonnull final List relationshipTypes, + @Nonnull final RelationshipFilter relationshipFilter, + @Nullable final String lifecycleOwner) { BoolQueryBuilder finalQuery = QueryBuilders.boolQuery(); final RelationshipDirection relationshipDirection = relationshipFilter.getDirection(); @@ -221,6 +241,9 @@ public static BoolQueryBuilder buildQuery( if (relationshipFilter.getOr() != null) { addFilterToQueryBuilder(new Filter().setOr(relationshipFilter.getOr()), null, finalQuery); } + if (lifecycleOwner != null) { + finalQuery.filter(QueryBuilders.termQuery(EDGE_FIELD_LIFECYCLE_OWNER, lifecycleOwner)); + } return finalQuery; } @@ -235,14 +258,16 @@ public LineageResponse getLineage( int maxHops, @Nullable Long startTimeMillis, @Nullable Long endTimeMillis) { - List result = new ArrayList<>(); + Map result = new HashMap<>(); long currentTime = System.currentTimeMillis(); long remainingTime = graphQueryConfiguration.getTimeoutSeconds() * 1000; + boolean exploreMultiplePaths = graphQueryConfiguration.isEnableMultiPathSearch(); long timeoutTime = currentTime + remainingTime; // Do a Level-order BFS Set visitedEntities = ConcurrentHashMap.newKeySet(); visitedEntities.add(entityUrn); + Set viaEntities = ConcurrentHashMap.newKeySet(); Map existingPaths = new HashMap<>(); List currentLevel = ImmutableList.of(entityUrn); @@ -267,12 +292,23 @@ public LineageResponse getLineage( direction, graphFilters, visitedEntities, + viaEntities, i + 1, + maxHops - (i + 1), remainingTime, existingPaths, startTimeMillis, - endTimeMillis); - result.addAll(oneHopRelationships); + endTimeMillis, + exploreMultiplePaths); + for (LineageRelationship oneHopRelnship : oneHopRelationships) { + if (result.containsKey(oneHopRelnship.getEntity())) { + result.put( + oneHopRelnship.getEntity(), + mergeLineageRelationships(result.get(oneHopRelnship.getEntity()), oneHopRelnship)); + } else { + result.put(oneHopRelnship.getEntity(), oneHopRelnship); + } + } currentLevel = oneHopRelationships.stream() .map(LineageRelationship::getEntity) @@ -280,7 +316,8 @@ public LineageResponse getLineage( currentTime = System.currentTimeMillis(); remainingTime = timeoutTime - currentTime; } - LineageResponse response = new LineageResponse(result.size(), result); + List resultList = new ArrayList<>(result.values()); + LineageResponse response = new LineageResponse(resultList.size(), resultList); List subList; if (offset >= response.getTotal()) { @@ -295,6 +332,39 @@ public LineageResponse getLineage( return new LineageResponse(response.getTotal(), subList); } + /** + * Merges two lineage relationship objects. The merged relationship object will have the minimum + * degree of the two relationships, and the union of the paths. In addition, the merged + * relationship object will have the union of the degrees in the new degrees field. + * + * @param existingRelationship + * @param newRelationship + * @return the merged relationship object + */ + private LineageRelationship mergeLineageRelationships( + final LineageRelationship existingRelationship, final LineageRelationship newRelationship) { + try { + LineageRelationship copyRelationship = existingRelationship.copy(); + copyRelationship.setDegree( + Math.min(existingRelationship.getDegree(), newRelationship.getDegree())); + Set degrees = new HashSet<>(); + if (copyRelationship.hasDegrees()) { + degrees = copyRelationship.getDegrees().stream().collect(Collectors.toSet()); + } + degrees.add(newRelationship.getDegree()); + copyRelationship.setDegrees(new IntegerArray(degrees)); + UrnArrayArray copyPaths = + new UrnArrayArray( + existingRelationship.getPaths().size() + newRelationship.getPaths().size()); + copyPaths.addAll(existingRelationship.getPaths()); + copyPaths.addAll(newRelationship.getPaths()); + copyRelationship.setPaths(copyPaths); + return copyRelationship; + } catch (CloneNotSupportedException e) { + throw new RuntimeException("Failed to clone lineage relationship", e); + } + } + // Get 1-hop lineage relationships asynchronously in batches with timeout @WithSpan public List getLineageRelationshipsInBatches( @@ -302,11 +372,14 @@ public List getLineageRelationshipsInBatches( @Nonnull LineageDirection direction, GraphFilters graphFilters, Set visitedEntities, + Set viaEntities, int numHops, + int remainingHops, long remainingTime, Map existingPaths, @Nullable Long startTimeMillis, - @Nullable Long endTimeMillis) { + @Nullable Long endTimeMillis, + boolean exploreMultiplePaths) { List> batches = Lists.partition(entityUrns, graphQueryConfiguration.getBatchSize()); return ConcurrencyUtils.getAllCompleted( batches.stream() @@ -319,10 +392,13 @@ public List getLineageRelationshipsInBatches( direction, graphFilters, visitedEntities, + viaEntities, numHops, + remainingHops, existingPaths, startTimeMillis, - endTimeMillis))) + endTimeMillis, + exploreMultiplePaths))) .collect(Collectors.toList()), remainingTime, TimeUnit.MILLISECONDS) @@ -338,10 +414,13 @@ private List getLineageRelationships( @Nonnull LineageDirection direction, GraphFilters graphFilters, Set visitedEntities, + Set viaEntities, int numHops, + int remainingHops, Map existingPaths, @Nullable Long startTimeMillis, - @Nullable Long endTimeMillis) { + @Nullable Long endTimeMillis, + boolean exploreMultiplePaths) { Map> urnsPerEntityType = entityUrns.stream().collect(Collectors.groupingBy(Urn::getEntityType)); Map> edgesPerEntityType = @@ -365,7 +444,15 @@ private List getLineageRelationships( entry.getValue().stream().map(edgeInfo -> Pair.of(entry.getKey(), edgeInfo))) .collect(Collectors.toSet()); return extractRelationships( - entityUrnSet, response, validEdges, visitedEntities, numHops, existingPaths); + entityUrnSet, + response, + validEdges, + visitedEntities, + viaEntities, + numHops, + remainingHops, + existingPaths, + exploreMultiplePaths); } @VisibleForTesting @@ -408,7 +495,6 @@ public static QueryBuilder getLineageQuery( return finalQuery; } - // Get search query for given list of edges and source urns @VisibleForTesting public static QueryBuilder getLineageQueryForEntityType( @Nonnull List urns, @@ -464,27 +550,88 @@ public static void addEdgeToPaths( @Nonnull final Map existingPaths, @Nonnull final Urn parentUrn, @Nonnull final Urn childUrn) { + addEdgeToPaths(existingPaths, parentUrn, null, childUrn); + } + + /** + * Utility method to log paths to the debug log. + * + * @param paths + * @param message + */ + private static void logPaths(UrnArrayArray paths, String message) { + if (log.isDebugEnabled()) { + log.debug("xxxxxxxxxx"); + log.debug(message); + log.debug("---------"); + if (paths != null) { + paths.forEach(path -> log.debug("{}", path)); + } else { + log.debug("EMPTY"); + } + log.debug("xxxxxxxxxx"); + } + } + + private static boolean containsCycle(final UrnArray path) { + Set urnSet = path.stream().collect(Collectors.toUnmodifiableSet()); + // path contains a cycle if any urn is repeated twice + return (path.size() != urnSet.size()); + } + + public static boolean addEdgeToPaths( + @Nonnull final Map existingPaths, + @Nonnull final Urn parentUrn, + final Urn viaUrn, + @Nonnull final Urn childUrn) { + boolean edgeAdded = false; // Collect all full-paths to this child node. This is what will be returned. UrnArrayArray pathsToParent = existingPaths.get(parentUrn); - if (pathsToParent != null && pathsToParent.size() > 0) { + logPaths(pathsToParent, String.format("Paths to Parent: %s, Child: %s", parentUrn, childUrn)); + logPaths(existingPaths.get(childUrn), String.format("Existing Paths to Child: %s", childUrn)); + if (pathsToParent != null && !pathsToParent.isEmpty()) { // If there are existing paths to this parent node, then we attempt // to append the child to each of the existing paths (lengthen it). // We then store this as a separate, unique path associated with the child. - for (final UrnArray pathToParent : pathsToParent) { + for (UrnArray pathToParent : pathsToParent) { + if (containsCycle(pathToParent)) { + log.debug("Skipping extending path {} because it contains a cycle", pathToParent); + continue; + } UrnArray pathToChild = clonePath(pathToParent); + if (viaUrn != null) { + pathToChild.add(viaUrn); + } pathToChild.add(childUrn); // Save these paths to the global structure for easy access on future iterations. existingPaths.putIfAbsent(childUrn, new UrnArrayArray()); - existingPaths.get(childUrn).add(pathToChild); + UrnArrayArray existingPathsToChild = existingPaths.get(childUrn); + boolean dupExists = false; + for (UrnArray existingPathToChild : existingPathsToChild) { + if (existingPathToChild.equals(pathToChild)) { + dupExists = true; + } + } + if (!dupExists) { + existingPathsToChild.add(pathToChild); + edgeAdded = true; + } } } else { // No existing paths to this parent urn. Let's create a new path to the child! UrnArray pathToChild = new UrnArray(); - pathToChild.addAll(ImmutableList.of(parentUrn, childUrn)); + if (viaUrn == null) { + pathToChild.addAll(ImmutableList.of(parentUrn, childUrn)); + } else { + pathToChild.addAll(ImmutableList.of(parentUrn, viaUrn, childUrn)); + } // Save these paths to the global structure for easy access on future iterations. existingPaths.putIfAbsent(childUrn, new UrnArrayArray()); existingPaths.get(childUrn).add(pathToChild); + edgeAdded = true; } + logPaths(existingPaths.get(childUrn), String.format("New paths to Child: %s", childUrn)); + return edgeAdded; } // Given set of edges and the search response, extract all valid edges that originate from the @@ -495,101 +642,198 @@ private static List extractRelationships( @Nonnull SearchResponse searchResponse, Set> validEdges, Set visitedEntities, + Set viaEntities, int numHops, - Map existingPaths) { - final List result = new LinkedList<>(); - final SearchHit[] hits = searchResponse.getHits().getHits(); - for (SearchHit hit : hits) { - final Map document = hit.getSourceAsMap(); - final Urn sourceUrn = - UrnUtils.getUrn(((Map) document.get(SOURCE)).get("urn").toString()); - final Urn destinationUrn = - UrnUtils.getUrn(((Map) document.get(DESTINATION)).get("urn").toString()); - final String type = document.get(RELATIONSHIP_TYPE).toString(); - final Number createdOnNumber = (Number) document.getOrDefault(CREATED_ON, null); - final Long createdOn = createdOnNumber != null ? createdOnNumber.longValue() : null; - final Number updatedOnNumber = (Number) document.getOrDefault(UPDATED_ON, null); - final Long updatedOn = updatedOnNumber != null ? updatedOnNumber.longValue() : null; - final String createdActorString = (String) document.getOrDefault(CREATED_ACTOR, null); - final Urn createdActor = - createdActorString == null ? null : UrnUtils.getUrn(createdActorString); - final String updatedActorString = (String) document.getOrDefault(UPDATED_ACTOR, null); - final Urn updatedActor = - updatedActorString == null ? null : UrnUtils.getUrn(updatedActorString); - final Map properties; - if (document.containsKey(PROPERTIES) && document.get(PROPERTIES) instanceof Map) { - properties = (Map) document.get(PROPERTIES); - } else { - properties = Collections.emptyMap(); - } - boolean isManual = properties.containsKey(SOURCE) && properties.get(SOURCE).equals("UI"); - - // Potential outgoing edge - if (entityUrns.contains(sourceUrn)) { - // Skip if already visited - // Skip if edge is not a valid outgoing edge - // TODO: Verify if this honors multiple paths to the same node. - if (!visitedEntities.contains(destinationUrn) - && validEdges.contains( - Pair.of( - sourceUrn.getEntityType(), - new EdgeInfo( - type, - RelationshipDirection.OUTGOING, - destinationUrn.getEntityType().toLowerCase())))) { - visitedEntities.add(destinationUrn); - // Append the edge to a set of unique graph paths. - addEdgeToPaths(existingPaths, sourceUrn, destinationUrn); - final LineageRelationship relationship = - createLineageRelationship( - type, - destinationUrn, - numHops, - existingPaths.getOrDefault( + int remainingHops, + Map existingPaths, + boolean exploreMultiplePaths) { + try { + Map lineageRelationshipMap = new HashMap<>(); + final SearchHit[] hits = searchResponse.getHits().getHits(); + log.debug("numHits: {}, numHops {}, remainingHops {}", hits.length, numHops, remainingHops); + int index = -1; + for (SearchHit hit : hits) { + index++; + final Map document = hit.getSourceAsMap(); + log.debug("{}: hit: {}", index, document); + final Urn sourceUrn = + UrnUtils.getUrn(((Map) document.get(SOURCE)).get("urn").toString()); + final Urn destinationUrn = + UrnUtils.getUrn( + ((Map) document.get(DESTINATION)).get("urn").toString()); + final String type = document.get(RELATIONSHIP_TYPE).toString(); + if (sourceUrn.equals(destinationUrn)) { + log.debug("Skipping a self-edge of type {} on {}", type, sourceUrn); + continue; + } + final Number createdOnNumber = (Number) document.getOrDefault(CREATED_ON, null); + final Long createdOn = createdOnNumber != null ? createdOnNumber.longValue() : null; + final Number updatedOnNumber = (Number) document.getOrDefault(UPDATED_ON, null); + final Long updatedOn = updatedOnNumber != null ? updatedOnNumber.longValue() : null; + final String createdActorString = (String) document.getOrDefault(CREATED_ACTOR, null); + final Urn createdActor = + createdActorString == null ? null : UrnUtils.getUrn(createdActorString); + final String updatedActorString = (String) document.getOrDefault(UPDATED_ACTOR, null); + final Urn updatedActor = + updatedActorString == null ? null : UrnUtils.getUrn(updatedActorString); + final Map properties; + if (document.containsKey(PROPERTIES) && document.get(PROPERTIES) instanceof Map) { + properties = (Map) document.get(PROPERTIES); + } else { + properties = Collections.emptyMap(); + } + boolean isManual = properties.containsKey(SOURCE) && properties.get(SOURCE).equals("UI"); + Urn viaEntity = null; + String viaContent = (String) document.getOrDefault(EDGE_FIELD_VIA, null); + if (viaContent != null) { + try { + viaEntity = Urn.createFromString(viaContent); + } catch (Exception e) { + log.warn( + "Failed to parse urn from via entity {}, will swallow exception and continue...", + viaContent); + } + } + log.debug("{}: viaEntity {}", index, viaEntity); + + // Potential outgoing edge + if (entityUrns.contains(sourceUrn)) { + log.debug("{}: entity urns contains source urn {}", index, sourceUrn); + // Skip if already visited or if we're exploring multiple paths + // Skip if edge is not a valid outgoing edge + if ((exploreMultiplePaths || !visitedEntities.contains(destinationUrn)) + && validEdges.contains( + Pair.of( + sourceUrn.getEntityType(), + new EdgeInfo( + type, + RelationshipDirection.OUTGOING, + destinationUrn.getEntityType().toLowerCase())))) { + + if (visitedEntities.contains(destinationUrn)) { + log.debug("Found a second path to the same urn {}", destinationUrn); + } + // Append the edge to a set of unique graph paths. + if (addEdgeToPaths(existingPaths, sourceUrn, viaEntity, destinationUrn)) { + final LineageRelationship relationship = + createLineageRelationship( + type, destinationUrn, - new UrnArrayArray()), // Fetch the paths to the next level entity. - createdOn, - createdActor, - updatedOn, - updatedActor, - isManual); - result.add(relationship); + numHops, + existingPaths.getOrDefault(destinationUrn, new UrnArrayArray()), + // Fetch the paths to the next level entity. + createdOn, + createdActor, + updatedOn, + updatedActor, + isManual); + log.debug("Adding relationship {} to urn {}", relationship, destinationUrn); + lineageRelationshipMap.put(relationship.getEntity(), relationship); + if ((viaEntity != null) && (!viaEntities.contains(viaEntity))) { + UrnArrayArray viaPaths = getViaPaths(existingPaths, destinationUrn, viaEntity); + LineageRelationship viaRelationship = + createLineageRelationship( + type, + viaEntity, + numHops, + viaPaths, + createdOn, + createdActor, + updatedOn, + updatedActor, + isManual); + viaEntities.add(viaEntity); + lineageRelationshipMap.put(viaRelationship.getEntity(), viaRelationship); + log.debug("Adding via entity {} with paths {}", viaEntity, viaPaths); + } + } + visitedEntities.add(destinationUrn); + } } - } - // Potential incoming edge - if (entityUrns.contains(destinationUrn)) { - // Skip if already visited - // Skip if edge is not a valid outgoing edge - // TODO: Verify if this honors multiple paths to the same node. - if (!visitedEntities.contains(sourceUrn) - && validEdges.contains( - Pair.of( - destinationUrn.getEntityType(), - new EdgeInfo( + // Potential incoming edge + if (entityUrns.contains(destinationUrn)) { + // Skip if already visited or if we're exploring multiple paths + // Skip if edge is not a valid outgoing edge + log.debug("entity urns contains destination urn {}", destinationUrn); + if ((exploreMultiplePaths || !visitedEntities.contains(sourceUrn)) + && validEdges.contains( + Pair.of( + destinationUrn.getEntityType(), + new EdgeInfo( + type, + RelationshipDirection.INCOMING, + sourceUrn.getEntityType().toLowerCase())))) { + if (visitedEntities.contains(sourceUrn)) { + log.debug("Found a second path to the same urn {}", sourceUrn); + } + visitedEntities.add(sourceUrn); + // Append the edge to a set of unique graph paths. + if (addEdgeToPaths(existingPaths, destinationUrn, viaEntity, sourceUrn)) { + log.debug("Adding incoming edge: {}, {}, {}", destinationUrn, viaEntity, sourceUrn); + final LineageRelationship relationship = + createLineageRelationship( + type, + sourceUrn, + numHops, + existingPaths.getOrDefault(sourceUrn, new UrnArrayArray()), + // Fetch the paths to the next level entity. + createdOn, + createdActor, + updatedOn, + updatedActor, + isManual); + log.debug("Adding relationship {} to urn {}", relationship, sourceUrn); + lineageRelationshipMap.put(relationship.getEntity(), relationship); + if ((viaEntity != null) && (!viaEntities.contains(viaEntity))) { + UrnArrayArray viaPaths = getViaPaths(existingPaths, sourceUrn, viaEntity); + viaEntities.add(viaEntity); + LineageRelationship viaRelationship = + createLineageRelationship( type, - RelationshipDirection.INCOMING, - sourceUrn.getEntityType().toLowerCase())))) { - visitedEntities.add(sourceUrn); - // Append the edge to a set of unique graph paths. - addEdgeToPaths(existingPaths, destinationUrn, sourceUrn); - final LineageRelationship relationship = - createLineageRelationship( - type, - sourceUrn, - numHops, - existingPaths.getOrDefault( - sourceUrn, new UrnArrayArray()), // Fetch the paths to the next level entity. - createdOn, - createdActor, - updatedOn, - updatedActor, - isManual); - result.add(relationship); + viaEntity, + numHops, + viaPaths, + createdOn, + createdActor, + updatedOn, + updatedActor, + isManual); + lineageRelationshipMap.put(viaRelationship.getEntity(), viaRelationship); + log.debug("Adding via relationship {} to urn {}", viaRelationship, viaEntity); + } + } + } + } + } + List result = new ArrayList<>(lineageRelationshipMap.values()); + log.debug("Number of lineage relationships in list: {}", result.size()); + log.debug("Result: {}", result); + return result; + } catch (Exception e) { + // This exception handler merely exists to log the exception at an appropriate point and + // rethrow + log.error("Caught exception", e); + throw e; + } + } + + private static UrnArrayArray getViaPaths( + Map existingPaths, Urn destinationUrn, Urn viaEntity) { + UrnArrayArray destinationPaths = + existingPaths.getOrDefault(destinationUrn, new UrnArrayArray()); + UrnArrayArray viaPaths = new UrnArrayArray(); + for (UrnArray destPath : destinationPaths) { + UrnArray viaPath = new UrnArray(); + for (Urn urn : destPath) { + viaPath.add(urn); + if (urn.equals(viaEntity)) { + break; } } + viaPaths.add(viaPath); } - return result; + return viaPaths; } private static LineageRelationship createLineageRelationship( @@ -607,6 +851,7 @@ private static LineageRelationship createLineageRelationship( .setType(type) .setEntity(entityUrn) .setDegree(numHops) + .setDegrees(new IntegerArray(ImmutableList.of(numHops))) .setPaths(paths); if (createdOn != null) { relationship.setCreatedOn(createdOn); @@ -658,8 +903,10 @@ private static QueryBuilder buildEntityTypesFilter( } private static QueryBuilder buildUrnFilters(@Nonnull List urns, @Nonnull String prefix) { - return QueryBuilders.termsQuery( - prefix + ".urn", urns.stream().map(Object::toString).collect(Collectors.toList())); + // dedup urns while preserving order + LinkedHashSet urnSet = new LinkedHashSet<>(); + urns.forEach(urn -> urnSet.add(urn.toString())); + return QueryBuilders.termsQuery(prefix + ".urn", urnSet); } private static QueryBuilder buildEdgeFilters(@Nonnull List edgeInfos) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphWriteDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphWriteDAO.java index 5d722a034fafc5..ddbd00f90ef684 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphWriteDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ESGraphWriteDAO.java @@ -75,4 +75,27 @@ public BulkByScrollResponse deleteByQuery( .deleteByQuery(finalQuery, indexConvention.getIndexName(INDEX_NAME)) .orElse(null); } + + public BulkByScrollResponse deleteByQuery( + @Nullable final String sourceType, + @Nonnull final Filter sourceEntityFilter, + @Nullable final String destinationType, + @Nonnull final Filter destinationEntityFilter, + @Nonnull final List relationshipTypes, + @Nonnull final RelationshipFilter relationshipFilter, + String lifecycleOwner) { + BoolQueryBuilder finalQuery = + buildQuery( + sourceType == null ? ImmutableList.of() : ImmutableList.of(sourceType), + sourceEntityFilter, + destinationType == null ? ImmutableList.of() : ImmutableList.of(destinationType), + destinationEntityFilter, + relationshipTypes, + relationshipFilter, + lifecycleOwner); + + return bulkProcessor + .deleteByQuery(finalQuery, indexConvention.getIndexName(INDEX_NAME)) + .orElse(null); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java index 67590ffd6e7c10..90f46190ac18ef 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/ElasticSearchGraphService.java @@ -1,5 +1,7 @@ package com.linkedin.metadata.graph.elastic; +import static com.linkedin.metadata.graph.elastic.GraphRelationshipMappingsBuilder.*; + import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; @@ -81,9 +83,9 @@ private String toDocument(@Nonnull final Edge edge) { destinationObject.put("urn", edge.getDestination().toString()); destinationObject.put("entityType", edge.getDestination().getEntityType()); - searchDocument.set("source", sourceObject); - searchDocument.set("destination", destinationObject); - searchDocument.put("relationshipType", edge.getRelationshipType()); + searchDocument.set(EDGE_FIELD_SOURCE, sourceObject); + searchDocument.set(EDGE_FIELD_DESTINATION, destinationObject); + searchDocument.put(EDGE_FIELD_RELNSHIP_TYPE, edge.getRelationshipType()); if (edge.getCreatedOn() != null) { searchDocument.put("createdOn", edge.getCreatedOn()); } @@ -108,8 +110,15 @@ private String toDocument(@Nonnull final Edge edge) { entry.getKey(), entry.getValue())); } } - searchDocument.set("properties", propertiesObject); + searchDocument.set(EDGE_FIELD_PROPERTIES, propertiesObject); + } + if (edge.getLifecycleOwner() != null) { + searchDocument.put(EDGE_FIELD_LIFECYCLE_OWNER, edge.getLifecycleOwner().toString()); + } + if (edge.getVia() != null) { + searchDocument.put(EDGE_FIELD_VIA, edge.getVia().toString()); } + log.debug("Search doc for write {}", searchDocument); return searchDocument.toString(); } @@ -192,8 +201,8 @@ public RelatedEntitiesResult findRelatedEntities( final List relationships = searchHitsToRelatedEntities(response.getHits().getHits(), relationshipDirection).stream() .map(RelatedEntities::asRelatedEntity) + .filter(Objects::nonNull) .collect(Collectors.toList()); - return new RelatedEntitiesResult(offset, relationships.size(), totalCount, relationships); } @@ -277,6 +286,10 @@ public void removeNode(@Nonnull final Urn urn) { _graphWriteDAO.deleteByQuery( null, urnFilter, null, emptyFilter, relationshipTypes, incomingFilter); + // Delete all edges where this entity is a lifecycle owner + _graphWriteDAO.deleteByQuery( + null, emptyFilter, null, emptyFilter, relationshipTypes, incomingFilter, urn.toString()); + return; } @@ -394,15 +407,15 @@ private static List searchHitsToRelatedEntities( return Arrays.stream(searchHits) .map( hit -> { + final Map hitMap = hit.getSourceAsMap(); final String destinationUrnStr = - ((HashMap) - hit.getSourceAsMap().getOrDefault("destination", EMPTY_HASH)) + ((Map) hitMap.getOrDefault(EDGE_FIELD_DESTINATION, EMPTY_HASH)) .getOrDefault("urn", null); final String sourceUrnStr = - ((HashMap) - hit.getSourceAsMap().getOrDefault("source", EMPTY_HASH)) + ((Map) hitMap.getOrDefault(EDGE_FIELD_SOURCE, EMPTY_HASH)) .getOrDefault("urn", null); - final String relationshipType = (String) hit.getSourceAsMap().get("relationshipType"); + final String relationshipType = (String) hitMap.get(EDGE_FIELD_RELNSHIP_TYPE); + String viaEntity = (String) hitMap.get(EDGE_FIELD_VIA); if (destinationUrnStr == null || sourceUrnStr == null || relationshipType == null) { log.error( @@ -414,7 +427,11 @@ private static List searchHitsToRelatedEntities( } return new RelatedEntities( - relationshipType, sourceUrnStr, destinationUrnStr, relationshipDirection); + relationshipType, + sourceUrnStr, + destinationUrnStr, + relationshipDirection, + viaEntity); }) .filter(Objects::nonNull) .collect(Collectors.toList()); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/GraphRelationshipMappingsBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/GraphRelationshipMappingsBuilder.java index 21f2bf6c89204a..ab4eaa1b99392d 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/GraphRelationshipMappingsBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/elastic/GraphRelationshipMappingsBuilder.java @@ -7,16 +7,23 @@ @Slf4j public class GraphRelationshipMappingsBuilder { + public static final String EDGE_FIELD_SOURCE = "source"; + public static final String EDGE_FIELD_DESTINATION = "destination"; + public static final String EDGE_FIELD_RELNSHIP_TYPE = "relationshipType"; + public static final String EDGE_FIELD_PROPERTIES = "properties"; + public static final String EDGE_FIELD_VIA = "via"; + public static final String EDGE_FIELD_LIFECYCLE_OWNER = "lifecycleOwner"; private GraphRelationshipMappingsBuilder() {} public static Map getMappings() { Map mappings = new HashMap<>(); - mappings.put("source", getMappingsForEntity()); - mappings.put("destination", getMappingsForEntity()); - mappings.put("relationshipType", getMappingsForKeyword()); - mappings.put("properties", getMappingsForEdgeProperties()); - + mappings.put(EDGE_FIELD_SOURCE, getMappingsForEntity()); + mappings.put(EDGE_FIELD_DESTINATION, getMappingsForEntity()); + mappings.put(EDGE_FIELD_RELNSHIP_TYPE, getMappingsForKeyword()); + mappings.put(EDGE_FIELD_PROPERTIES, getMappingsForEdgeProperties()); + mappings.put(EDGE_FIELD_LIFECYCLE_OWNER, getMappingsForKeyword()); + mappings.put(EDGE_FIELD_VIA, getMappingsForKeyword()); return ImmutableMap.of("properties", mappings); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java index a1f73a134ec8ef..11acc138d4dbac 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/graph/neo4j/Neo4jGraphService.java @@ -534,7 +534,8 @@ record -> .get(0) .asNode() .get("urn") - .asString())); // Urn TODO: Validate this works against Neo4j. + .asString(), // Urn TODO: Validate this works against Neo4j. + null)); final int totalCount = runQuery(countStatement).single().get(0).asInt(); return new RelatedEntitiesResult(offset, relatedEntities.size(), totalCount, relatedEntities); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/LineageSearchService.java b/metadata-io/src/main/java/com/linkedin/metadata/search/LineageSearchService.java index f6358e4aeb2078..cf9279414a394d 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/LineageSearchService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/LineageSearchService.java @@ -19,6 +19,9 @@ import com.linkedin.metadata.graph.LineageRelationship; import com.linkedin.metadata.graph.LineageRelationshipArray; import com.linkedin.metadata.query.FreshnessStats; +import com.linkedin.metadata.query.GroupingCriterion; +import com.linkedin.metadata.query.GroupingCriterionArray; +import com.linkedin.metadata.query.GroupingSpec; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; import com.linkedin.metadata.query.filter.Criterion; @@ -55,13 +58,22 @@ @RequiredArgsConstructor @Slf4j public class LineageSearchService { + private static final SearchFlags DEFAULT_SERVICE_SEARCH_FLAGS = new SearchFlags() .setFulltext(false) .setMaxAggValues(20) .setSkipCache(false) .setSkipAggregates(false) - .setSkipHighlighting(true); + .setSkipHighlighting(true) + .setGroupingSpec( + new GroupingSpec() + .setGroupingCriteria( + new GroupingCriterionArray( + new GroupingCriterion() // Convert schema fields to datasets by default to + // maintain backwards compatibility + .setBaseEntityType(SCHEMA_FIELD_ENTITY_NAME) + .setGroupingEntityType(DATASET_ENTITY_NAME)))); private final SearchService _searchService; private final GraphService _graphService; @Nullable private final Cache cache; @@ -206,14 +218,18 @@ public LineageSearchResult searchAcrossLineage( } } - // set schemaField relationship entity to be its reference urn - LineageRelationshipArray updatedRelationships = convertSchemaFieldRelationships(lineageResult); - lineageResult.setRelationships(updatedRelationships); + if (SearchUtils.convertSchemaFieldToDataset(searchFlags)) { + // set schemaField relationship entity to be its reference urn + LineageRelationshipArray updatedRelationships = + convertSchemaFieldRelationships(lineageResult); + lineageResult.setRelationships(updatedRelationships); + } // Filter hopped result based on the set of entities to return and inputFilters before sending // to search List lineageRelationships = filterRelationships(lineageResult, new HashSet<>(entities), inputFilters); + log.debug("Lineage relationships found: {}", lineageRelationships); String lineageGraphInfo = String.format( @@ -247,7 +263,9 @@ public LineageSearchResult searchAcrossLineage( lineageRelationships, input, reducedFilters, sortCriterion, from, size, finalFlags); if (!lineageSearchResult.getEntities().isEmpty()) { log.debug( - "Lineage entity result: {}", lineageSearchResult.getEntities().get(0).toString()); + "Lineage entity results number -> {}; first -> {}", + lineageSearchResult.getNumEntities(), + lineageSearchResult.getEntities().get(0).toString()); } numEntities = lineageSearchResult.getNumEntities(); return lineageSearchResult; @@ -470,9 +488,17 @@ private Map generateUrnToRelationshipMap( if (existingRelationship == null) { urnToRelationship.put(relationship.getEntity(), relationship); } else { - UrnArrayArray paths = existingRelationship.getPaths(); - paths.addAll(relationship.getPaths()); - existingRelationship.setPaths(paths); + UrnArrayArray newPaths = + new UrnArrayArray( + existingRelationship.getPaths().size() + relationship.getPaths().size()); + log.debug( + "Found {} paths for {}, will add to existing paths: {}", + relationship.getPaths().size(), + relationship.getEntity(), + existingRelationship.getPaths().size()); + newPaths.addAll(existingRelationship.getPaths()); + newPaths.addAll(relationship.getPaths()); + existingRelationship.setPaths(newPaths); } } return urnToRelationship; @@ -665,6 +691,9 @@ private LineageSearchEntity buildLineageSearchEntity( if (lineageRelationship != null) { entity.setPaths(lineageRelationship.getPaths()); entity.setDegree(lineageRelationship.getDegree()); + if (lineageRelationship.hasDegrees()) { + entity.setDegrees(lineageRelationship.getDegrees()); + } } return entity; } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/SearchUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/SearchUtils.java index b8cf0626b72511..13ccfd7f972af3 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/SearchUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/SearchUtils.java @@ -1,5 +1,7 @@ package com.linkedin.metadata.search.utils; +import static com.linkedin.metadata.Constants.*; + import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.LongMap; @@ -196,6 +198,28 @@ public static SearchFlags applyDefaultSearchFlags( if (!finalSearchFlags.hasSkipCache() || finalSearchFlags.isSkipCache() == null) { finalSearchFlags.setSkipCache(defaultFlags.isSkipCache()); } + if ((!finalSearchFlags.hasGroupingSpec() || finalSearchFlags.getGroupingSpec() == null) + && (defaultFlags.getGroupingSpec() != null)) { + finalSearchFlags.setGroupingSpec(defaultFlags.getGroupingSpec()); + } return finalSearchFlags; } + + /** + * Returns true if the search flags contain a grouping spec that requires conversion of schema + * field entity to dataset entity. + * + * @param searchFlags the search flags + * @return true if the search flags contain a grouping spec that requires conversion of schema + * field entity to dataset entity. + */ + public static boolean convertSchemaFieldToDataset(@Nullable SearchFlags searchFlags) { + return (searchFlags != null) + && (searchFlags.getGroupingSpec() != null) + && (searchFlags.getGroupingSpec().getGroupingCriteria().stream() + .anyMatch( + grouping -> + grouping.getBaseEntityType().equals(SCHEMA_FIELD_ENTITY_NAME) + && grouping.getGroupingEntityType().equals(DATASET_ENTITY_NAME))); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java index ee2d794471f6be..ed633b063afb21 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java @@ -14,7 +14,9 @@ import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; +import com.linkedin.datajob.DataJobInputOutput; import com.linkedin.dataset.FineGrainedLineage; +import com.linkedin.dataset.FineGrainedLineageArray; import com.linkedin.dataset.UpstreamLineage; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; @@ -275,21 +277,36 @@ private void handleDeleteChangeEvent(@Nonnull final MCLBatchItem event) { // TODO: remove this method once we implement sourceOverride when creating graph edges private void updateFineGrainedEdgesAndRelationships( - RecordTemplate aspect, + Urn entity, + FineGrainedLineageArray fineGrainedLineageArray, List edgesToAdd, HashMap> urnToRelationshipTypesBeingAdded) { - UpstreamLineage upstreamLineage = new UpstreamLineage(aspect.data()); - if (upstreamLineage.getFineGrainedLineages() != null) { - for (FineGrainedLineage fineGrainedLineage : upstreamLineage.getFineGrainedLineages()) { + if (fineGrainedLineageArray != null) { + for (FineGrainedLineage fineGrainedLineage : fineGrainedLineageArray) { if (!fineGrainedLineage.hasDownstreams() || !fineGrainedLineage.hasUpstreams()) { break; } + // Fine grained lineage array is present either on datajob (datajob input/output) or dataset + // We set the datajob as the viaEntity in scenario 1, and the query (if present) as the + // viaEntity in scenario 2 + Urn viaEntity = + entity.getEntityType().equals("dataJob") ? entity : fineGrainedLineage.getQuery(); // for every downstream, create an edge with each of the upstreams for (Urn downstream : fineGrainedLineage.getDownstreams()) { for (Urn upstream : fineGrainedLineage.getUpstreams()) { // TODO: add edges uniformly across aspects edgesToAdd.add( - new Edge(downstream, upstream, DOWNSTREAM_OF, null, null, null, null, null)); + new Edge( + downstream, + upstream, + DOWNSTREAM_OF, + null, + null, + null, + null, + null, + entity, + viaEntity)); Set relationshipTypes = urnToRelationshipTypesBeingAdded.getOrDefault(downstream, new HashSet<>()); relationshipTypes.add(DOWNSTREAM_OF); @@ -357,12 +374,23 @@ private Pair, HashMap>> getEdgesAndRelationshipTypes // inputFields // since @Relationship only links between the parent entity urn and something else. if (aspectSpec.getName().equals(Constants.UPSTREAM_LINEAGE_ASPECT_NAME)) { - updateFineGrainedEdgesAndRelationships(aspect, edgesToAdd, urnToRelationshipTypesBeingAdded); - } - if (aspectSpec.getName().equals(Constants.INPUT_FIELDS_ASPECT_NAME)) { + UpstreamLineage upstreamLineage = new UpstreamLineage(aspect.data()); + updateFineGrainedEdgesAndRelationships( + urn, + upstreamLineage.getFineGrainedLineages(), + edgesToAdd, + urnToRelationshipTypesBeingAdded); + } else if (aspectSpec.getName().equals(Constants.INPUT_FIELDS_ASPECT_NAME)) { final InputFields inputFields = new InputFields(aspect.data()); updateInputFieldEdgesAndRelationships( urn, inputFields, edgesToAdd, urnToRelationshipTypesBeingAdded); + } else if (aspectSpec.getName().equals(Constants.DATA_JOB_INPUT_OUTPUT_ASPECT_NAME)) { + DataJobInputOutput dataJobInputOutput = new DataJobInputOutput(aspect.data()); + updateFineGrainedEdgesAndRelationships( + urn, + dataJobInputOutput.getFineGrainedLineages(), + edgesToAdd, + urnToRelationshipTypesBeingAdded); } Map> extractedFields = @@ -394,7 +422,7 @@ private void updateGraphService( edgeAndRelationTypes.getSecond(); log.debug("Here's the relationship types found {}", urnToRelationshipTypesBeingAdded); - if (urnToRelationshipTypesBeingAdded.size() > 0) { + if (!urnToRelationshipTypesBeingAdded.isEmpty()) { for (Map.Entry> entry : urnToRelationshipTypesBeingAdded.entrySet()) { _graphService.removeEdgesFromNode( entry.getKey(), diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/GraphServiceTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/GraphServiceTestBase.java index 3a51344d5779df..2de61c8ed31bbc 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/GraphServiceTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/GraphServiceTestBase.java @@ -14,6 +14,9 @@ import com.linkedin.common.urn.DataJobUrn; import com.linkedin.common.urn.Urn; import com.linkedin.data.schema.annotation.PathSpecBasedSchemaAnnotationVisitor; +import com.linkedin.metadata.config.search.GraphQueryConfiguration; +import com.linkedin.metadata.graph.dgraph.DgraphGraphService; +import com.linkedin.metadata.graph.neo4j.Neo4jGraphService; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; @@ -36,6 +39,8 @@ import java.util.stream.IntStream; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; import org.testng.Assert; import org.testng.annotations.BeforeMethod; @@ -198,6 +203,19 @@ public void testStaticUrns() { @Nonnull protected abstract GraphService getGraphService() throws Exception; + /** + * Graph services that support multi-path search should override this method to provide a + * multi-path search enabled GraphService instance. + * + * @param enableMultiPathSearch + * @return + * @throws Exception + */ + @Nonnull + protected GraphService getGraphService(boolean enableMultiPathSearch) throws Exception { + return getGraphService(); + } + /** * Allows the specific GraphService test implementation to wait for GraphService writes to be * synced / become available to reads. @@ -235,7 +253,12 @@ protected GraphService getPopulatedGraphService() throws Exception { } protected GraphService getLineagePopulatedGraphService() throws Exception { - GraphService service = getGraphService(); + return getLineagePopulatedGraphService( + GraphQueryConfiguration.testDefaults.isEnableMultiPathSearch()); + } + + protected GraphService getLineagePopulatedGraphService(boolean multiPathSearch) throws Exception { + GraphService service = getGraphService(multiPathSearch); List edges = Arrays.asList( @@ -1821,9 +1844,16 @@ public void run() { assertEquals(throwables.size(), 0); } - @Test - public void testPopulatedGraphServiceGetLineageMultihop() throws Exception { - GraphService service = getLineagePopulatedGraphService(); + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testPopulatedGraphServiceGetLineageMultihop(boolean attemptMultiPathAlgo) + throws Exception { + + GraphService service = getLineagePopulatedGraphService(attemptMultiPathAlgo); + // Implementations other than Neo4J and DGraph explore more of the graph to discover nodes at + // multiple hops + boolean expandedGraphAlgoEnabled = + (!((service instanceof Neo4jGraphService) || (service instanceof DgraphGraphService))); EntityLineageResult upstreamLineage = service.getLineage(datasetOneUrn, LineageDirection.UPSTREAM, 0, 1000, 2); @@ -1838,16 +1868,23 @@ public void testPopulatedGraphServiceGetLineageMultihop() throws Exception { Map relationships = downstreamLineage.getRelationships().stream() .collect(Collectors.toMap(LineageRelationship::getEntity, Function.identity())); + Set entities = relationships.keySet().stream().collect(Collectors.toUnmodifiableSet()); + assertEquals(entities.size(), 5); assertTrue(relationships.containsKey(datasetTwoUrn)); - assertEquals(relationships.get(datasetTwoUrn).getDegree().intValue(), 1); + assertEquals(relationships.get(dataJobTwoUrn).getDegree(), 1); assertTrue(relationships.containsKey(datasetThreeUrn)); - assertEquals(relationships.get(datasetThreeUrn).getDegree().intValue(), 2); + assertEquals(relationships.get(datasetThreeUrn).getDegree(), 2); assertTrue(relationships.containsKey(datasetFourUrn)); - assertEquals(relationships.get(datasetFourUrn).getDegree().intValue(), 2); + assertEquals(relationships.get(datasetFourUrn).getDegree(), 2); assertTrue(relationships.containsKey(dataJobOneUrn)); - assertEquals(relationships.get(dataJobOneUrn).getDegree().intValue(), 1); + assertEquals(relationships.get(dataJobOneUrn).getDegree(), 1); + // dataJobOne is present both at degree 1 and degree 2 + if (expandedGraphAlgoEnabled && attemptMultiPathAlgo) { + relationships.get(dataJobOneUrn).getDegrees().contains(Integer.valueOf(1)); + relationships.get(dataJobOneUrn).getDegrees().contains(Integer.valueOf(2)); + } assertTrue(relationships.containsKey(dataJobTwoUrn)); - assertEquals(relationships.get(dataJobTwoUrn).getDegree().intValue(), 1); + assertEquals(relationships.get(dataJobTwoUrn).getDegree(), 1); upstreamLineage = service.getLineage(datasetThreeUrn, LineageDirection.UPSTREAM, 0, 1000, 2); assertEquals(upstreamLineage.getTotal().intValue(), 3); @@ -1856,11 +1893,11 @@ public void testPopulatedGraphServiceGetLineageMultihop() throws Exception { upstreamLineage.getRelationships().stream() .collect(Collectors.toMap(LineageRelationship::getEntity, Function.identity())); assertTrue(relationships.containsKey(datasetOneUrn)); - assertEquals(relationships.get(datasetOneUrn).getDegree().intValue(), 2); + assertEquals(relationships.get(datasetOneUrn).getDegree(), 2); assertTrue(relationships.containsKey(datasetTwoUrn)); - assertEquals(relationships.get(datasetTwoUrn).getDegree().intValue(), 1); + assertEquals(relationships.get(datasetTwoUrn).getDegree(), 1); assertTrue(relationships.containsKey(dataJobOneUrn)); - assertEquals(relationships.get(dataJobOneUrn).getDegree().intValue(), 1); + assertEquals(relationships.get(dataJobOneUrn).getDegree(), 1); downstreamLineage = service.getLineage(datasetThreeUrn, LineageDirection.DOWNSTREAM, 0, 1000, 2); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/dgraph/DgraphGraphServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/dgraph/DgraphGraphServiceTest.java index 40b8e83b56d030..1ccf018a74c3ad 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/dgraph/DgraphGraphServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/dgraph/DgraphGraphServiceTest.java @@ -820,7 +820,7 @@ public void testGetDestinationUrnsFromResponseData() { } @Override - public void testPopulatedGraphServiceGetLineageMultihop() { + public void testPopulatedGraphServiceGetLineageMultihop(boolean attemptMultiHop) { // TODO: Remove this overridden method once the multihop for dGraph is implemented! } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java index bd500cd4691008..7f0e4294e0cbf6 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/graph/search/SearchGraphServiceTestBase.java @@ -57,12 +57,14 @@ public abstract class SearchGraphServiceTestBase extends GraphServiceTestBase { private final IndexConvention _indexConvention = new IndexConventionImpl(null); private final String _indexName = _indexConvention.getIndexName(INDEX_NAME); private ElasticSearchGraphService _client; + private boolean _enableMultiPathSearch = + GraphQueryConfiguration.testDefaults.isEnableMultiPathSearch(); private static final String TAG_RELATIONSHIP = "SchemaFieldTaggedWith"; @BeforeClass public void setup() { - _client = buildService(); + _client = buildService(_enableMultiPathSearch); _client.configure(); } @@ -73,8 +75,10 @@ public void wipe() throws Exception { } @Nonnull - private ElasticSearchGraphService buildService() { + private ElasticSearchGraphService buildService(boolean enableMultiPathSearch) { LineageRegistry lineageRegistry = new LineageRegistry(SnapshotEntityRegistry.getInstance()); + GraphQueryConfiguration configuration = GraphQueryConfiguration.testDefaults; + configuration.setEnableMultiPathSearch(enableMultiPathSearch); ESGraphQueryDAO readDAO = new ESGraphQueryDAO( getSearchClient(), @@ -93,10 +97,21 @@ private ElasticSearchGraphService buildService() { @Override @Nonnull - protected GraphService getGraphService() { + protected GraphService getGraphService(boolean enableMultiPathSearch) { + if (enableMultiPathSearch != _enableMultiPathSearch) { + _enableMultiPathSearch = enableMultiPathSearch; + _client = buildService(enableMultiPathSearch); + _client.configure(); + } return _client; } + @Override + @Nonnull + protected GraphService getGraphService() { + return getGraphService(GraphQueryConfiguration.testDefaults.isEnableMultiPathSearch()); + } + @Override protected void syncAfterWrite() throws Exception { SearchTestUtils.syncAfterWrite(getBulkProcessor()); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/LineageDataFixtureTestBase.java b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/LineageDataFixtureTestBase.java index 44fe5ea8ac9aea..59942f76744dab 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/LineageDataFixtureTestBase.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/fixtures/LineageDataFixtureTestBase.java @@ -10,7 +10,9 @@ import com.linkedin.metadata.search.LineageSearchService; import com.linkedin.metadata.search.SearchResult; import com.linkedin.metadata.search.SearchService; +import com.linkedin.util.Pair; import java.net.URISyntaxException; +import java.util.stream.Stream; import javax.annotation.Nonnull; import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; import org.testng.annotations.Test; @@ -49,16 +51,17 @@ public void testDatasetLineage() throws URISyntaxException { Urn.createFromString( "urn:li:dataset:(urn:li:dataPlatform:9cf8c96,e3859789eed1cef55288b44f016ee08290d9fd08973e565c112d8,PROD)"); - // 1 hops - LineageSearchResult lineageResult = lineage(getLineageService(), testUrn, 1); - assertEquals(lineageResult.getEntities().size(), 10); - - // 2 hops - lineageResult = lineage(getLineageService(), testUrn, 2); - assertEquals(lineageResult.getEntities().size(), 5); - - // 3 hops - lineageResult = lineage(getLineageService(), testUrn, 3); - assertEquals(lineageResult.getEntities().size(), 12); + Stream> hopsExpectedResultsStream = + Stream.of( + Pair.of(1, 10), // Hop 1 -> 10 results + Pair.of(2, 5), // Hop 2 -> 5 results + Pair.of(3, 12) // Hop 3 -> 12 results + ); + hopsExpectedResultsStream.forEach( + hopsExpectedResults -> { + LineageSearchResult lineageResult = + lineage(getLineageService(), testUrn, hopsExpectedResults.getFirst()); + assertEquals(lineageResult.getEntities().size(), hopsExpectedResults.getSecond()); + }); } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/SearchUtilsTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/SearchUtilsTest.java index 5ea58e3416205d..f4e82242545301 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/utils/SearchUtilsTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/utils/SearchUtilsTest.java @@ -1,22 +1,46 @@ package com.linkedin.metadata.search.utils; +import static com.linkedin.metadata.Constants.*; import static org.testng.Assert.assertEquals; +import com.linkedin.data.template.SetMode; +import com.linkedin.metadata.query.GroupingCriterion; +import com.linkedin.metadata.query.GroupingCriterionArray; +import com.linkedin.metadata.query.GroupingSpec; import com.linkedin.metadata.query.SearchFlags; import java.util.Set; import org.testng.annotations.Test; public class SearchUtilsTest { - @Test - public void testApplyDefaultSearchFlags() { - SearchFlags defaultFlags = + private SearchFlags getDefaultSearchFlags() { + return setConvertSchemaFieldsToDatasets( new SearchFlags() .setFulltext(true) .setSkipCache(true) .setSkipAggregates(true) .setMaxAggValues(1) - .setSkipHighlighting(true); + .setSkipHighlighting(true), + true); + } + + private SearchFlags setConvertSchemaFieldsToDatasets(SearchFlags flags, boolean value) { + if (value) { + return flags.setGroupingSpec( + new GroupingSpec() + .setGroupingCriteria( + new GroupingCriterionArray( + new GroupingCriterion() + .setBaseEntityType(SCHEMA_FIELD_ENTITY_NAME) + .setGroupingEntityType(DATASET_ENTITY_NAME)))); + } else { + return flags.setGroupingSpec(null, SetMode.REMOVE_IF_NULL); + } + } + + @Test + public void testApplyDefaultSearchFlags() { + SearchFlags defaultFlags = getDefaultSearchFlags(); assertEquals( SearchUtils.applyDefaultSearchFlags(null, "not empty", defaultFlags), @@ -33,12 +57,14 @@ public void testApplyDefaultSearchFlags() { .setSkipHighlighting(false), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(false) - .setSkipAggregates(false) - .setSkipCache(false) - .setMaxAggValues(2) - .setSkipHighlighting(false), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(false) + .setSkipAggregates(false) + .setSkipCache(false) + .setMaxAggValues(2) + .setSkipHighlighting(false), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected no default values"); assertEquals( @@ -51,12 +77,14 @@ public void testApplyDefaultSearchFlags() { .setSkipHighlighting(false), null, defaultFlags), - new SearchFlags() - .setFulltext(false) - .setSkipAggregates(false) - .setSkipCache(false) - .setMaxAggValues(2) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(false) + .setSkipAggregates(false) + .setSkipCache(false) + .setMaxAggValues(2) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected skip highlight due to query null query"); for (String query : Set.of("*", "")) { assertEquals( @@ -69,94 +97,105 @@ public void testApplyDefaultSearchFlags() { .setSkipHighlighting(false), query, defaultFlags), - new SearchFlags() - .setFulltext(false) - .setSkipAggregates(false) - .setSkipCache(false) - .setMaxAggValues(2) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(false) + .setSkipAggregates(false) + .setSkipCache(false) + .setMaxAggValues(2) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), String.format("Expected skip highlight due to query string `%s`", query)); } assertEquals( SearchUtils.applyDefaultSearchFlags( new SearchFlags().setFulltext(false), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(false) - .setSkipAggregates(true) - .setSkipCache(true) - .setMaxAggValues(1) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(false) + .setSkipAggregates(true) + .setSkipCache(true) + .setMaxAggValues(1) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected all default values except fulltext"); assertEquals( SearchUtils.applyDefaultSearchFlags( new SearchFlags().setSkipCache(false), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(true) - .setSkipAggregates(true) - .setSkipCache(false) - .setMaxAggValues(1) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(true) + .setSkipAggregates(true) + .setSkipCache(false) + .setMaxAggValues(1) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected all default values except skipCache"); assertEquals( SearchUtils.applyDefaultSearchFlags( new SearchFlags().setSkipAggregates(false), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(true) - .setSkipAggregates(false) - .setSkipCache(true) - .setMaxAggValues(1) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(true) + .setSkipAggregates(false) + .setSkipCache(true) + .setMaxAggValues(1) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected all default values except skipAggregates"); assertEquals( SearchUtils.applyDefaultSearchFlags( new SearchFlags().setMaxAggValues(2), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(true) - .setSkipAggregates(true) - .setSkipCache(true) - .setMaxAggValues(2) - .setSkipHighlighting(true), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(true) + .setSkipAggregates(true) + .setSkipCache(true) + .setMaxAggValues(2) + .setSkipHighlighting(true), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected all default values except maxAggValues"); assertEquals( SearchUtils.applyDefaultSearchFlags( new SearchFlags().setSkipHighlighting(false), "not empty", defaultFlags), - new SearchFlags() - .setFulltext(true) - .setSkipAggregates(true) - .setSkipCache(true) - .setMaxAggValues(1) - .setSkipHighlighting(false), + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(true) + .setSkipAggregates(true) + .setSkipCache(true) + .setMaxAggValues(1) + .setSkipHighlighting(false), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected all default values except skipHighlighting"); } @Test public void testImmutableDefaults() throws CloneNotSupportedException { - SearchFlags defaultFlags = - new SearchFlags() - .setFulltext(true) - .setSkipCache(true) - .setSkipAggregates(true) - .setMaxAggValues(1) - .setSkipHighlighting(true); + SearchFlags defaultFlags = getDefaultSearchFlags(); + SearchFlags copyFlags = defaultFlags.copy(); assertEquals( SearchUtils.applyDefaultSearchFlags( + setConvertSchemaFieldsToDatasets( + new SearchFlags() + .setFulltext(false) + .setSkipCache(false) + .setSkipAggregates(false) + .setMaxAggValues(2) + .setSkipHighlighting(false), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), + "not empty", + defaultFlags), + setConvertSchemaFieldsToDatasets( new SearchFlags() .setFulltext(false) - .setSkipCache(false) .setSkipAggregates(false) + .setSkipCache(false) .setMaxAggValues(2) .setSkipHighlighting(false), - "not empty", - defaultFlags), - new SearchFlags() - .setFulltext(false) - .setSkipAggregates(false) - .setSkipCache(false) - .setMaxAggValues(2) - .setSkipHighlighting(false), + SearchUtils.convertSchemaFieldToDataset(defaultFlags)), "Expected no default values"); assertEquals(defaultFlags, copyFlags, "Expected defaults to be unmodified"); diff --git a/metadata-models/src/main/pegasus/com/linkedin/dataset/FineGrainedLineage.pdl b/metadata-models/src/main/pegasus/com/linkedin/dataset/FineGrainedLineage.pdl index ce72d7c04a3f6f..3aa76cc27250cb 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/dataset/FineGrainedLineage.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/dataset/FineGrainedLineage.pdl @@ -42,6 +42,9 @@ record FineGrainedLineage { // Other information e.g. user who created this lineage etc. can added here. - // It may be useful to add a "query" field here, but the semantics are tricky. - // To be considered in a future iteration when required. + /** + * The query that was used to generate this lineage. + * Present only if the lineage was generated from a detected query. + */ + query: optional Urn } \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/dataset/Upstream.pdl b/metadata-models/src/main/pegasus/com/linkedin/dataset/Upstream.pdl index c4a9fa17271627..b4c98e4f34724c 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/dataset/Upstream.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/dataset/Upstream.pdl @@ -2,6 +2,8 @@ namespace com.linkedin.dataset import com.linkedin.common.AuditStamp import com.linkedin.common.DatasetUrn +import com.linkedin.common.Urn + /** * Upstream lineage information about a dataset including the source reporting the lineage @@ -33,6 +35,7 @@ record Upstream { "updatedOn": "upstreams/*/auditStamp/time" "updatedActor": "upstreams/*/auditStamp/actor" "properties": "upstreams/*/properties" + "via": "upstreams/*/query" } @Searchable = { "fieldName": "upstreams", @@ -50,4 +53,9 @@ record Upstream { * A generic properties bag that allows us to store specific information on this graph edge. */ properties: optional map[string, string] + + /** + * If the lineage is generated by a query, a reference to the query + */ + query: optional Urn } \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/graph/LineageRelationship.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/graph/LineageRelationship.pdl index ad4bd27b4cdae9..c25a1cee7db474 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/metadata/graph/LineageRelationship.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/graph/LineageRelationship.pdl @@ -31,7 +31,9 @@ record LineageRelationship { /** * Degree of relationship (number of hops to get to entity) + * Deprecated by degrees. degree field is populated by min(degrees) for backward compatibility. */ + @deprecated degree: int = 1 /** @@ -58,4 +60,11 @@ record LineageRelationship { * Whether this lineage edge is a manual edge. */ isManual: optional boolean + + /** + * The different depths at which this entity is discovered in the lineage graph. + * Marked as optional to maintain backward compatibility, but is filled out by implementations. + * Replaces the deprecated field "degree". + **/ + degrees: optional array[int] } diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingCriterion.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingCriterion.pdl new file mode 100644 index 00000000000000..da0a1c2fd35142 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingCriterion.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.metadata.query + +/** +* +**/ + +record GroupingCriterion { + + /** + * The type of the entity to be grouped. + * e.g. schemaField + * Omitting this field will result in all base entities being grouped. + */ + baseEntityType: optional string + + /** + * The type of the entity to be grouped into. + * e.g. dataset, domain, etc. + */ + groupingEntityType: string +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingSpec.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingSpec.pdl new file mode 100644 index 00000000000000..c4c8a6c0e6bd90 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/GroupingSpec.pdl @@ -0,0 +1,15 @@ +namespace com.linkedin.metadata.query + +/** + * A set of directives to control how results are grouped. + * The underlying generic groupings are nested to allow for further evolution of the grouping spec. + */ + +record GroupingSpec { + + /** + * A list of generic directives to group results by. + **/ + groupingCriteria: array[GroupingCriterion] = [] + +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl index be1a30c7f082c6..67f41ea175b51f 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/query/SearchFlags.pdl @@ -33,4 +33,9 @@ record SearchFlags { * Whether to request for search suggestions on the _entityName virtualized field */ getSuggestions:optional boolean = false + + /** + * Instructions for grouping results before returning + */ + groupingSpec: optional GroupingSpec } diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/LineageSearchEntity.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/LineageSearchEntity.pdl index 2e81a63319ae98..e99115893712d2 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/LineageSearchEntity.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/LineageSearchEntity.pdl @@ -22,6 +22,11 @@ record LineageSearchEntity includes SearchEntity { /** * Degree of relationship (number of hops to get to entity) */ + @deprecated degree: int = 1 + /** + * The degrees of separation (number of hops) between the source and this entity + */ + degrees: array[int] = [] } \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl index 9587775dbed3a8..1f4929b878de6d 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/query/QueryProperties.pdl @@ -74,4 +74,9 @@ record QueryProperties { } } lastModified: AuditStamp + + /** + * The urn of the DataPlatform where the Query was executed. + */ + dataPlatform: optional Urn } diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/GraphQueryConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/GraphQueryConfiguration.java index 6f3e1cb278f5f7..4da50f47e2febd 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/GraphQueryConfiguration.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/GraphQueryConfiguration.java @@ -8,6 +8,9 @@ public class GraphQueryConfiguration { private long timeoutSeconds; private int batchSize; private int maxResult; + // When set to true, the graph walk (typically in search-across-lineage or scroll-across-lineage) + // will return all paths between the source and destination nodes within the hops limit. + private boolean enableMultiPathSearch; public static GraphQueryConfiguration testDefaults; @@ -16,5 +19,6 @@ public class GraphQueryConfiguration { testDefaults.setBatchSize(1000); testDefaults.setTimeoutSeconds(10); testDefaults.setMaxResult(10000); + testDefaults.setEnableMultiPathSearch(true); } } diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index 2b202d513c9bf8..a7222f2adc3c60 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -226,6 +226,7 @@ elasticsearch: timeoutSeconds: ${ELASTICSEARCH_SEARCH_GRAPH_TIMEOUT_SECONDS:50} # graph dao timeout seconds batchSize: ${ELASTICSEARCH_SEARCH_GRAPH_BATCH_SIZE:1000} # graph dao batch size maxResult: ${ELASTICSEARCH_SEARCH_GRAPH_MAX_RESULT:10000} # graph dao max result size + enableMultiPathSearch: ${ELASTICSEARCH_SEARCH_GRAPH_MULTI_PATH_SEARCH:true} # TODO: Kafka topic convention kafka: @@ -394,4 +395,4 @@ springdoc.api-docs.groups.enabled: true forms: hook: - enabled: {$FORMS_HOOK_ENABLED:true} \ No newline at end of file + enabled: { $FORMS_HOOK_ENABLED:true } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java index 7ff91affdf765d..a79bdacfc55e93 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java @@ -48,6 +48,17 @@ static void setUpgradeResult(Urn urn, EntityService entityService) throws URI final DataHubUpgradeResult upgradeResult = new DataHubUpgradeResult().setTimestampMs(System.currentTimeMillis()); + // Workaround because entity service does not auto-generate the key aspect for us + final MetadataChangeProposal keyProposal = new MetadataChangeProposal(); + final DataHubUpgradeKey upgradeKey = new DataHubUpgradeKey().setId(urn.getId()); + keyProposal.setEntityUrn(urn); + keyProposal.setEntityType(Constants.DATA_HUB_UPGRADE_ENTITY_NAME); + keyProposal.setAspectName(Constants.DATA_HUB_UPGRADE_KEY_ASPECT_NAME); + keyProposal.setAspect(GenericRecordUtils.serializeAspect(upgradeKey)); + keyProposal.setChangeType(ChangeType.UPSERT); + entityService.ingestProposal(keyProposal, auditStamp, false); + + // Ingest the upgrade result final MetadataChangeProposal upgradeProposal = new MetadataChangeProposal(); upgradeProposal.setEntityUrn(urn); upgradeProposal.setEntityType(Constants.DATA_HUB_UPGRADE_ENTITY_NAME); diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json index ee45b8921143a1..fe16d24e3475ac 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json @@ -1818,6 +1818,11 @@ "type" : "float", "doc" : "The confidence in this lineage between 0 (low confidence) and 1 (high confidence)", "default" : 1.0 + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "The query that was used to generate this lineage. \nPresent only if the lineage was generated from a detected query.", + "optional" : true } ] } }, @@ -1986,7 +1991,8 @@ "name" : "DownstreamOf", "properties" : "upstreams/*/properties", "updatedActor" : "upstreams/*/auditStamp/actor", - "updatedOn" : "upstreams/*/auditStamp/time" + "updatedOn" : "upstreams/*/auditStamp/time", + "via" : "upstreams/*/query" }, "Searchable" : { "fieldName" : "upstreams", @@ -2005,6 +2011,11 @@ }, "doc" : "A generic properties bag that allows us to store specific information on this graph edge.", "optional" : true + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "If the lineage is generated by a query, a reference to the query", + "optional" : true } ] }, { "type" : "record", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json index 505f44c52d5833..55fed125936eb0 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json @@ -1873,6 +1873,11 @@ "type" : "float", "doc" : "The confidence in this lineage between 0 (low confidence) and 1 (high confidence)", "default" : 1.0 + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "The query that was used to generate this lineage. \nPresent only if the lineage was generated from a detected query.", + "optional" : true } ] } }, @@ -2258,7 +2263,8 @@ "name" : "DownstreamOf", "properties" : "upstreams/*/properties", "updatedActor" : "upstreams/*/auditStamp/actor", - "updatedOn" : "upstreams/*/auditStamp/time" + "updatedOn" : "upstreams/*/auditStamp/time", + "via" : "upstreams/*/query" }, "Searchable" : { "fieldName" : "upstreams", @@ -2277,6 +2283,11 @@ }, "doc" : "A generic properties bag that allows us to store specific information on this graph edge.", "optional" : true + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "If the lineage is generated by a query, a reference to the query", + "optional" : true } ] }, { "type" : "record", @@ -5653,6 +5664,35 @@ "doc" : "Specific entities to recommend" } ] }, "com.linkedin.metadata.query.FreshnessStats", { + "type" : "record", + "name" : "GroupingCriterion", + "namespace" : "com.linkedin.metadata.query", + "doc" : "\n", + "fields" : [ { + "name" : "baseEntityType", + "type" : "string", + "doc" : "The type of the entity to be grouped.\ne.g. schemaField\nOmitting this field will result in all base entities being grouped.", + "optional" : true + }, { + "name" : "groupingEntityType", + "type" : "string", + "doc" : "The type of the entity to be grouped into.\ne.g. dataset, domain, etc." + } ] + }, { + "type" : "record", + "name" : "GroupingSpec", + "namespace" : "com.linkedin.metadata.query", + "doc" : "A set of directives to control how results are grouped.\nThe underlying generic groupings are nested to allow for further evolution of the grouping spec.", + "fields" : [ { + "name" : "groupingCriteria", + "type" : { + "type" : "array", + "items" : "GroupingCriterion" + }, + "doc" : "A list of generic directives to group results by.\n", + "default" : [ ] + } ] + }, { "type" : "record", "name" : "ListResult", "namespace" : "com.linkedin.metadata.query", @@ -5740,6 +5780,11 @@ "doc" : "Whether to request for search suggestions on the _entityName virtualized field", "default" : false, "optional" : true + }, { + "name" : "groupingSpec", + "type" : "GroupingSpec", + "doc" : "Instructions for grouping results before returning", + "optional" : true } ] }, { "type" : "enum", @@ -6092,7 +6137,16 @@ "name" : "degree", "type" : "int", "doc" : "Degree of relationship (number of hops to get to entity)", - "default" : 1 + "default" : 1, + "deprecated" : true + }, { + "name" : "degrees", + "type" : { + "type" : "array", + "items" : "int" + }, + "doc" : "The degrees of separation (number of hops) between the source and this entity ", + "default" : [ ] } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json index e8c15d1b4ca04a..f9f1999923ec0b 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json @@ -1560,6 +1560,11 @@ "type" : "float", "doc" : "The confidence in this lineage between 0 (low confidence) and 1 (high confidence)", "default" : 1.0 + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "The query that was used to generate this lineage. \nPresent only if the lineage was generated from a detected query.", + "optional" : true } ] } }, @@ -1728,7 +1733,8 @@ "name" : "DownstreamOf", "properties" : "upstreams/*/properties", "updatedActor" : "upstreams/*/auditStamp/actor", - "updatedOn" : "upstreams/*/auditStamp/time" + "updatedOn" : "upstreams/*/auditStamp/time", + "via" : "upstreams/*/query" }, "Searchable" : { "fieldName" : "upstreams", @@ -1747,6 +1753,11 @@ }, "doc" : "A generic properties bag that allows us to store specific information on this graph edge.", "optional" : true + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "If the lineage is generated by a query, a reference to the query", + "optional" : true } ] }, { "type" : "record", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.lineage.relationships.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.lineage.relationships.snapshot.json index 9aa40edd0b118d..056ca0e4da2065 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.lineage.relationships.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.lineage.relationships.snapshot.json @@ -141,8 +141,9 @@ }, { "name" : "degree", "type" : "int", - "doc" : "Degree of relationship (number of hops to get to entity)", - "default" : 1 + "doc" : "Degree of relationship (number of hops to get to entity)\nDeprecated by degrees. degree field is populated by min(degrees) for backward compatibility.", + "default" : 1, + "deprecated" : true }, { "name" : "createdOn", "type" : "long", @@ -168,6 +169,14 @@ "type" : "boolean", "doc" : "Whether this lineage edge is a manual edge.", "optional" : true + }, { + "name" : "degrees", + "type" : { + "type" : "array", + "items" : "int" + }, + "doc" : "The different depths at which this entity is discovered in the lineage graph.\nMarked as optional to maintain backward compatibility, but is filled out by implementations. \nReplaces the deprecated field \"degree\".\n", + "optional" : true } ] } }, diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json index 67f70d40e010c4..88dad7e49152aa 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.operations.operations.snapshot.json @@ -1560,6 +1560,11 @@ "type" : "float", "doc" : "The confidence in this lineage between 0 (low confidence) and 1 (high confidence)", "default" : 1.0 + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "The query that was used to generate this lineage. \nPresent only if the lineage was generated from a detected query.", + "optional" : true } ] } }, @@ -1728,7 +1733,8 @@ "name" : "DownstreamOf", "properties" : "upstreams/*/properties", "updatedActor" : "upstreams/*/auditStamp/actor", - "updatedOn" : "upstreams/*/auditStamp/time" + "updatedOn" : "upstreams/*/auditStamp/time", + "via" : "upstreams/*/query" }, "Searchable" : { "fieldName" : "upstreams", @@ -1747,6 +1753,11 @@ }, "doc" : "A generic properties bag that allows us to store specific information on this graph edge.", "optional" : true + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "If the lineage is generated by a query, a reference to the query", + "optional" : true } ] }, { "type" : "record", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json index 4c8cd1f20d4769..4d34126cd59fcd 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.platform.platform.snapshot.json @@ -1873,6 +1873,11 @@ "type" : "float", "doc" : "The confidence in this lineage between 0 (low confidence) and 1 (high confidence)", "default" : 1.0 + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "The query that was used to generate this lineage. \nPresent only if the lineage was generated from a detected query.", + "optional" : true } ] } }, @@ -2258,7 +2263,8 @@ "name" : "DownstreamOf", "properties" : "upstreams/*/properties", "updatedActor" : "upstreams/*/auditStamp/actor", - "updatedOn" : "upstreams/*/auditStamp/time" + "updatedOn" : "upstreams/*/auditStamp/time", + "via" : "upstreams/*/query" }, "Searchable" : { "fieldName" : "upstreams", @@ -2277,6 +2283,11 @@ }, "doc" : "A generic properties bag that allows us to store specific information on this graph edge.", "optional" : true + }, { + "name" : "query", + "type" : "com.linkedin.common.Urn", + "doc" : "If the lineage is generated by a query, a reference to the query", + "optional" : true } ] }, { "type" : "record", diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java index 2f470dca01f2a3..b1b24ac97f0b84 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java @@ -381,6 +381,7 @@ public LineageSearchResult searchAcrossLineage( * @param endTimeMillis end time to filter to * @param startTimeMillis start time to filter from * @param searchFlags configuration flags for the search request + * @param authentication a reference to an authentication * @return a {@link SearchResult} that contains a list of matched documents and related search * result metadata */ diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/Edge.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/Edge.java index d27b0ed3039720..458b23317c6c89 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/Edge.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/Edge.java @@ -17,4 +17,31 @@ public class Edge { @EqualsAndHashCode.Exclude private Long updatedOn; @EqualsAndHashCode.Exclude private Urn updatedActor; @EqualsAndHashCode.Exclude private Map properties; + // The entity who owns the lifecycle of this edge + @EqualsAndHashCode.Exclude private Urn lifecycleOwner; + // An entity through which the edge between source and destination is created + @EqualsAndHashCode.Include private Urn via; + + // For backwards compatibility + public Edge( + Urn source, + Urn destination, + String relationshipType, + Long createdOn, + Urn createdActor, + Long updatedOn, + Urn updatedActor, + Map properties) { + this( + source, + destination, + relationshipType, + createdOn, + createdActor, + updatedOn, + updatedActor, + properties, + null, + null); + } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphIndexUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphIndexUtils.java index 2afe907399745d..8a08835ab68967 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphIndexUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/GraphIndexUtils.java @@ -53,6 +53,17 @@ private static List> getPropertiesList( return (List>) value; } + @Nullable + private static List getViaList( + @Nullable final String path, @Nonnull final RecordTemplate aspect) { + if (path == null) { + return null; + } + final PathSpec viaPathSpec = new PathSpec(path.split("/")); + final Object value = RecordUtils.getNullableFieldValue(aspect, viaPathSpec); + return (List) value; + } + @Nullable private static boolean isValueListValid( @Nullable final List entryList, final int valueListSize) { @@ -94,6 +105,15 @@ private static Map getProperties( return null; } + @Nullable + private static Urn getVia( + @Nullable final List viaList, final int index, final int valueListSize) { + if (isValueListValid(viaList, valueListSize)) { + return viaList.get(index); + } + return null; + } + /** * Used to create new edges for the graph db, adding all the metadata associated with each edge * based on the aspect. Returns a list of Edges to be consumed by the graph service. @@ -116,12 +136,14 @@ public static List extractGraphEdges( extractedFieldsEntry.getKey().getRelationshipAnnotation().getUpdatedActor(); final String propertiesPath = extractedFieldsEntry.getKey().getRelationshipAnnotation().getProperties(); + final String viaNodePath = extractedFieldsEntry.getKey().getRelationshipAnnotation().getVia(); final List createdOnList = getTimestampList(createdOnPath, aspect); final List createdActorList = getActorList(createdActorPath, aspect); final List updatedOnList = getTimestampList(updatedOnPath, aspect); final List updatedActorList = getActorList(updatedActorPath, aspect); final List> propertiesList = getPropertiesList(propertiesPath, aspect); + final List viaList = getViaList(viaNodePath, aspect); int index = 0; for (Object fieldValue : extractedFieldsEntry.getValue()) { @@ -146,6 +168,11 @@ public static List extractGraphEdges( ? getProperties(propertiesList, index, extractedFieldsEntry.getValue().size()) : null; + Urn viaNode = + viaNodePath != null + ? getVia(viaList, index, extractedFieldsEntry.getValue().size()) + : null; + SystemMetadata systemMetadata; if (isNewAspectVersion) { systemMetadata = event.hasSystemMetadata() ? event.getSystemMetadata() : null; @@ -177,7 +204,9 @@ public static List extractGraphEdges( createdActor, updatedOn, updatedActor, - properties)); + properties, + null, + viaNode)); } catch (URISyntaxException e) { log.error("Invalid destination urn: {}", fieldValue, e); } @@ -198,6 +227,8 @@ public static Edge mergeEdges(@Nonnull final Edge oldEdge, @Nonnull final Edge n null, newEdge.getUpdatedOn(), newEdge.getUpdatedActor(), - newEdge.getProperties()); + newEdge.getProperties(), + oldEdge.getLifecycleOwner(), + oldEdge.getVia()); } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java index 0c6f8a0d65d5cf..3c54e987fec354 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntities.java @@ -17,15 +17,17 @@ public RelatedEntities( @Nonnull String relationshipType, @Nonnull String sourceUrn, @Nonnull String destinationUrn, - @Nonnull RelationshipDirection relationshipDirection) { + @Nonnull RelationshipDirection relationshipDirection, + String viaEntity) { super( relationshipType, - relationshipDirection == RelationshipDirection.OUTGOING ? destinationUrn : sourceUrn); + relationshipDirection == RelationshipDirection.OUTGOING ? destinationUrn : sourceUrn, + viaEntity); this.sourceUrn = sourceUrn; this.destinationUrn = destinationUrn; } public RelatedEntity asRelatedEntity() { - return new RelatedEntity(relationshipType, urn); + return new RelatedEntity(relationshipType, urn, via); } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntity.java b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntity.java index be1b55655f671d..39c455a3fbd748 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntity.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/graph/RelatedEntity.java @@ -11,4 +11,17 @@ public class RelatedEntity { /** Urn associated with the related entity. */ String urn; + + /** Urn associated with an entity through which this relationship is established */ + String via; + + /** + * Constructor for backwards compatibility + * + * @param relationshipType + * @param urn + */ + public RelatedEntity(String relationshipType, String urn) { + this(relationshipType, urn, null); + } } diff --git a/smoke-test/requirements.txt b/smoke-test/requirements.txt index 59d5bc2600400f..e37de9caddc696 100644 --- a/smoke-test/requirements.txt +++ b/smoke-test/requirements.txt @@ -6,4 +6,5 @@ tenacity slack-sdk==3.18.1 aiohttp joblib -pytest-xdist \ No newline at end of file +pytest-xdist +networkx \ No newline at end of file diff --git a/smoke-test/tests/lineage/__init__.py b/smoke-test/tests/lineage/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/smoke-test/tests/lineage/test_lineage.py b/smoke-test/tests/lineage/test_lineage.py new file mode 100644 index 00000000000000..52d61d666c7d9d --- /dev/null +++ b/smoke-test/tests/lineage/test_lineage.py @@ -0,0 +1,991 @@ +import logging +import time +from enum import Enum +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union + +import datahub.emitter.mce_builder as builder +import networkx as nx +import pytest +from datahub.cli.cli_utils import get_url_and_token +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.graph.client import ( + DatahubClientConfig, + DataHubGraph, + get_default_graph, +) +from datahub.metadata.schema_classes import ( + AuditStampClass, + ChangeAuditStampsClass, + ChartInfoClass, + DataFlowInfoClass, + DataJobInfoClass, + DataJobInputOutputClass, + DatasetLineageTypeClass, + DatasetPropertiesClass, + EdgeClass, +) +from datahub.metadata.schema_classes import ( + FineGrainedLineageClass as FineGrainedLineage, +) +from datahub.metadata.schema_classes import ( + FineGrainedLineageDownstreamTypeClass as FineGrainedLineageDownstreamType, +) +from datahub.metadata.schema_classes import ( + FineGrainedLineageUpstreamTypeClass as FineGrainedLineageUpstreamType, +) +from datahub.metadata.schema_classes import ( + OtherSchemaClass, + QueryLanguageClass, + QueryPropertiesClass, + QuerySourceClass, + QueryStatementClass, + SchemaFieldClass, + SchemaFieldDataTypeClass, + SchemaMetadataClass, + StringTypeClass, + UpstreamClass, + UpstreamLineageClass, +) +from datahub.utilities.urns.dataset_urn import DatasetUrn +from datahub.utilities.urns.urn import Urn +from pydantic import BaseModel, validator +from tests.utils import ingest_file_via_rest, wait_for_writes_to_sync + +logger = logging.getLogger(__name__) + + +class DeleteAgent: + def delete_entity(self, urn: str) -> None: + pass + + +class DataHubGraphDeleteAgent(DeleteAgent): + def __init__(self, graph: DataHubGraph): + self.graph = graph + + def delete_entity(self, urn: str) -> None: + self.graph.delete_entity(urn, hard=True) + + +class DataHubConsoleDeleteAgent(DeleteAgent): + def delete_entity(self, urn: str) -> None: + print(f"Would delete {urn}") + + +class DataHubConsoleEmitter: + def emit_mcp(self, mcp: MetadataChangeProposalWrapper) -> None: + print(mcp) + + +INFINITE_HOPS: int = -1 + + +@pytest.mark.dependency(depends="wait_for_healthchecks") +def ingest_tableau_cll_via_rest(wait_for_healthchecks) -> None: + ingest_file_via_rest( + "tests/lineage/tableau_cll_mcps.json", + ) + yield + + +def search_across_lineage( + graph: DataHubGraph, + main_entity: str, + hops: int = INFINITE_HOPS, + direction: str = "UPSTREAM", + convert_schema_fields_to_datasets: bool = True, +): + def _explain_sal_result(result: dict) -> str: + explain = "" + entities = [ + x["entity"]["urn"] for x in result["searchAcrossLineage"]["searchResults"] + ] + number_of_results = len(entities) + explain += f"Number of results: {number_of_results}\n" + explain += "Entities: " + try: + for e in entities: + explain += f"\t{e.replace('urn:li:','')}\n" + for entity in entities: + paths = [ + x["paths"][0]["path"] + for x in result["searchAcrossLineage"]["searchResults"] + if x["entity"]["urn"] == entity + ] + explain += f"Paths for entity {entity}: " + for path in paths: + explain += ( + "\t" + + " -> ".join( + [ + x["urn"] + .replace("urn:li:schemaField", "field") + .replace("urn:li:dataset", "dataset") + .replace("urn:li:dataPlatform", "platform") + for x in path + ] + ) + + "\n" + ) + except Exception: + # breakpoint() + pass + return explain + + variable: dict[str, Any] = { + "input": ( + { + "urn": main_entity, + "query": "*", + "direction": direction, + "searchFlags": { + "groupingSpec": { + "groupingCriteria": [ + { + "baseEntityType": "SCHEMA_FIELD", + "groupingEntityType": "DATASET", + }, + ] + }, + "skipCache": True, + }, + } + if convert_schema_fields_to_datasets + else { + "urn": main_entity, + "query": "*", + "direction": direction, + "searchFlags": { + "skipCache": True, + }, + } + ) + } + if hops != INFINITE_HOPS: + variable["input"].update( + { + "orFilters": [ + { + "and": [ + { + "field": "degree", + "condition": "EQUAL", + "values": ["{}".format(hops)], + "negated": False, + } + ] + } + ] + } + ) + result = graph.execute_graphql( + """ + query($input: SearchAcrossLineageInput!) { + searchAcrossLineage(input: $input) + { + searchResults { + entity { + urn + } + paths { + path { + urn + } + } + } + } + } + """, + variables=variable, + ) + print(f"Query -> Entity {main_entity} with hops {hops} and direction {direction}") + print(result) + print(_explain_sal_result(result)) + return result + + +class Direction(Enum): + UPSTREAM = "UPSTREAM" + DOWNSTREAM = "DOWNSTREAM" + + def opposite(self): + if self == Direction.UPSTREAM: + return Direction.DOWNSTREAM + else: + return Direction.UPSTREAM + + +class Path(BaseModel): + path: List[str] + + def add_node(self, node: str) -> None: + self.path.append(node) + + def __hash__(self) -> int: + return ".".join(self.path).__hash__() + + +class LineageExpectation(BaseModel): + direction: Direction + main_entity: str + hops: int + impacted_entities: Dict[str, List[Path]] + + +class ImpactQuery(BaseModel): + main_entity: str + hops: int + direction: Direction + upconvert_schema_fields_to_datasets: bool + + def __hash__(self) -> int: + raw_string = ( + f"{self.main_entity}{self.hops}{self.direction}" + + f"{self.upconvert_schema_fields_to_datasets}" + ) + return raw_string.__hash__() + + +class ScenarioExpectation: + """ + This class stores the expectations for the lineage of a scenario. It is used + to store the pre-materialized expectations for all datasets and schema + fields across all hops and directions possible. This makes it easy to check + that the results of a lineage query match the expectations. + """ + + def __init__(self): + self._graph = nx.DiGraph() + + def __simplify(self, urn_or_list: Union[str, List[str]]) -> str: + if isinstance(urn_or_list, list): + return ",".join([self.__simplify(x) for x in urn_or_list]) + else: + return ( + urn_or_list.replace("urn:li:schemaField", "F") + .replace("urn:li:dataset", "D") + .replace("urn:li:dataPlatform", "P") + .replace("urn:li:query", "Q") + ) + + def extend_impacted_entities( + self, + direction: Direction, + parent_entity: str, + child_entity: str, + path_extension: Optional[List[str]] = None, + ) -> None: + via_node = path_extension[0] if path_extension else None + if via_node: + self._graph.add_edge(parent_entity, child_entity, via=via_node) + else: + self._graph.add_edge(parent_entity, child_entity) + + def generate_query_expectation_pairs( + self, max_hops: int + ) -> Iterable[Tuple[ImpactQuery, LineageExpectation]]: + upconvert_options = [ + True + ] # TODO: Add False once search-across-lineage supports returning schema fields + for main_entity in self._graph.nodes(): + for direction in [Direction.UPSTREAM, Direction.DOWNSTREAM]: + for upconvert_schema_fields_to_datasets in upconvert_options: + possible_hops = [h for h in range(1, max_hops)] + [INFINITE_HOPS] + for hops in possible_hops: + query = ImpactQuery( + main_entity=main_entity, + hops=hops, + direction=direction, + upconvert_schema_fields_to_datasets=upconvert_schema_fields_to_datasets, + ) + yield query, self.get_expectation_for_query(query) + + def get_expectation_for_query(self, query: ImpactQuery) -> LineageExpectation: + graph_to_walk = ( + self._graph + if query.direction == Direction.DOWNSTREAM + else self._graph.reverse() + ) + entity_paths = nx.shortest_path(graph_to_walk, source=query.main_entity) + lineage_expectation = LineageExpectation( + direction=query.direction, + main_entity=query.main_entity, + hops=query.hops, + impacted_entities={}, + ) + for entity, paths in entity_paths.items(): + if entity == query.main_entity: + continue + if query.hops != INFINITE_HOPS and len(paths) != ( + query.hops + 1 + ): # +1 because the path includes the main entity + print( + f"Skipping {entity} because it is less than or more than {query.hops} hops away" + ) + continue + path_graph = nx.path_graph(paths) + expanded_path: List[str] = [] + via_entity = None + for ea in path_graph.edges(): + expanded_path.append(ea[0]) + if "via" in graph_to_walk.edges[ea[0], ea[1]]: + via_entity = graph_to_walk.edges[ea[0], ea[1]]["via"] + expanded_path.append(via_entity) + if via_entity and not via_entity.startswith( + "urn:li:query" + ): # Transient nodes like queries are not included as impacted entities + if via_entity not in lineage_expectation.impacted_entities: + lineage_expectation.impacted_entities[via_entity] = [] + via_path = Path(path=[x for x in expanded_path]) + if via_path not in lineage_expectation.impacted_entities[via_entity]: + lineage_expectation.impacted_entities[via_entity].append( + Path(path=[x for x in expanded_path]) + ) + + expanded_path.append(paths[-1]) + if entity not in lineage_expectation.impacted_entities: + lineage_expectation.impacted_entities[entity] = [] + lineage_expectation.impacted_entities[entity].append( + Path(path=expanded_path) + ) + + if query.upconvert_schema_fields_to_datasets: + entries_to_add: Dict[str, List[Path]] = {} + entries_to_remove = [] + for impacted_entity in lineage_expectation.impacted_entities: + if impacted_entity.startswith("urn:li:schemaField"): + impacted_dataset_entity = Urn.create_from_string( + impacted_entity + ).entity_ids[0] + if impacted_dataset_entity in entries_to_add: + entries_to_add[impacted_dataset_entity].extend( + lineage_expectation.impacted_entities[impacted_entity] + ) + else: + entries_to_add[ + impacted_dataset_entity + ] = lineage_expectation.impacted_entities[impacted_entity] + entries_to_remove.append(impacted_entity) + for impacted_entity in entries_to_remove: + del lineage_expectation.impacted_entities[impacted_entity] + lineage_expectation.impacted_entities.update(entries_to_add) + return lineage_expectation + + +class Scenario(BaseModel): + class Config: + arbitrary_types_allowed = True + + class LineageStyle(Enum): + DATASET_QUERY_DATASET = "DATASET_QUERY_DATASET" + DATASET_JOB_DATASET = "DATASET_JOB_DATASET" + + lineage_style: LineageStyle + default_platform: str = "mysql" + default_transformation_platform: str = "airflow" + hop_platform_map: Dict[int, str] = {} + hop_transformation_map: Dict[int, str] = {} + num_hops: int = 1 + default_datasets_at_each_hop: int = 2 + default_dataset_fanin: int = 2 # Number of datasets that feed into a transformation + default_column_fanin: int = 2 # Number of columns that feed into a transformation + default_dataset_fanout: int = ( + 1 # Number of datasets that a transformation feeds into + ) + default_column_fanout: int = 1 # Number of columns that a transformation feeds into + # num_upstream_datasets: int = 2 + # num_downstream_datasets: int = 1 + default_dataset_prefix: str = "librarydb." + hop_dataset_prefix_map: Dict[int, str] = {} + query_id: str = "guid-guid-guid" + query_string: str = "SELECT * FROM foo" + transformation_job: str = "job1" + transformation_flow: str = "flow1" + _generated_urns: Set[str] = set() + expectations: Optional[ScenarioExpectation] = None + + @validator("expectations", pre=True, always=True) + def expectations_validator( + cls, v: Optional[ScenarioExpectation] + ) -> ScenarioExpectation: + if v is None: + return ScenarioExpectation() + else: + return v + + def get_column_name(self, column_index: int) -> str: + return f"column_{column_index}" + + def set_upstream_dataset_prefix(self, dataset): + self.upstream_dataset_prefix = dataset + + def set_downstream_dataset_prefix(self, dataset): + self.downstream_dataset_prefix = dataset + + def set_transformation_query(self, query: str) -> None: + self.transformation_query = query + + def set_transformation_job(self, job: str) -> None: + self.transformation_job = job + + def set_transformation_flow(self, flow: str) -> None: + self.transformation_flow = flow + + def get_transformation_job_urn(self, hop_index: int) -> str: + return builder.make_data_job_urn( + orchestrator=self.default_transformation_platform, + flow_id=f"layer_{hop_index}_{self.transformation_flow}", + job_id=self.transformation_job, + cluster="PROD", + ) + + def get_transformation_query_urn(self, hop_index: int = 0) -> str: + return f"urn:li:query:{self.query_id}_{hop_index}" # TODO - add hop index to query id + + def get_transformation_flow_urn(self, hop_index: int) -> str: + return builder.make_data_flow_urn( + orchestrator=self.default_transformation_platform, + flow_id=f"layer_{hop_index}_{self.transformation_flow}", + cluster="PROD", + ) + + def get_upstream_dataset_urns(self, hop_index: int) -> List[str]: + return [ + self.get_dataset_urn(hop_index=hop_index, index=i) + for i in range(self.default_dataset_fanin) + ] + + def get_dataset_urn(self, hop_index: int, index: int) -> str: + platform = self.hop_platform_map.get(hop_index, self.default_platform) + prefix = self.hop_dataset_prefix_map.get( + index, f"{self.default_dataset_prefix}layer_{hop_index}." + ) + return builder.make_dataset_urn(platform, f"{prefix}{index}") + + def get_column_urn( + self, hop_index: int, dataset_index: int, column_index: int = 0 + ) -> str: + return builder.make_schema_field_urn( + self.get_dataset_urn(hop_index, dataset_index), + self.get_column_name(column_index), + ) + + def get_upstream_column_urn( + self, hop_index: int, dataset_index: int, column_index: int = 0 + ) -> str: + return builder.make_schema_field_urn( + self.get_dataset_urn(hop_index, dataset_index), + self.get_column_name(column_index), + ) + + def get_downstream_column_urn( + self, hop_index: int, dataset_index: int, column_index: int = 0 + ) -> str: + return builder.make_schema_field_urn( + self.get_dataset_urn(hop_index + 1, dataset_index), + self.get_column_name(column_index), + ) + + def get_downstream_dataset_urns(self, hop_index: int) -> List[str]: + return [ + self.get_dataset_urn(hop_index + 1, i) + for i in range(self.default_dataset_fanout) + ] + + def get_lineage_mcps(self) -> Iterable[MetadataChangeProposalWrapper]: + for hop_index in range(0, self.num_hops): + yield from self.get_lineage_mcps_for_hop(hop_index) + + def get_lineage_mcps_for_hop( + self, hop_index: int + ) -> Iterable[MetadataChangeProposalWrapper]: + if self.lineage_style == Scenario.LineageStyle.DATASET_JOB_DATASET: + fine_grained_lineage = FineGrainedLineage( + upstreamType=FineGrainedLineageUpstreamType.FIELD_SET, + upstreams=[ + self.get_upstream_column_urn(hop_index, dataset_index, 0) + for dataset_index in range(self.default_dataset_fanin) + ], + downstreamType=FineGrainedLineageDownstreamType.FIELD, + downstreams=[ + self.get_downstream_column_urn(hop_index, dataset_index, 0) + for dataset_index in range(self.default_dataset_fanout) + ], + ) + datajob_io = DataJobInputOutputClass( + inputDatasets=self.get_upstream_dataset_urns(hop_index), + outputDatasets=self.get_downstream_dataset_urns(hop_index), + inputDatajobs=[], # not supporting job -> job lineage for now + fineGrainedLineages=[fine_grained_lineage], + ) + yield MetadataChangeProposalWrapper( + entityUrn=self.get_transformation_job_urn(hop_index), + aspect=datajob_io, + ) + + # Add field level expectations + for upstream_field_urn in fine_grained_lineage.upstreams or []: + for downstream_field_urn in fine_grained_lineage.downstreams or []: + self.expectations.extend_impacted_entities( + Direction.DOWNSTREAM, + upstream_field_urn, + downstream_field_urn, + path_extension=[ + self.get_transformation_job_urn(hop_index), + downstream_field_urn, + ], + ) + + # Add table level expectations + for upstream_dataset_urn in datajob_io.inputDatasets: + # No path extension, because we don't use via nodes for dataset -> dataset edges + self.expectations.extend_impacted_entities( + Direction.DOWNSTREAM, + upstream_dataset_urn, + self.get_transformation_job_urn(hop_index), + ) + for downstream_dataset_urn in datajob_io.outputDatasets: + self.expectations.extend_impacted_entities( + Direction.DOWNSTREAM, + self.get_transformation_job_urn(hop_index), + downstream_dataset_urn, + ) + + if self.lineage_style == Scenario.LineageStyle.DATASET_QUERY_DATASET: + # we emit upstream lineage from the downstream dataset + for downstream_dataset_index in range(self.default_dataset_fanout): + mcp_entity_urn = self.get_dataset_urn( + hop_index + 1, downstream_dataset_index + ) + fine_grained_lineages = [ + FineGrainedLineage( + upstreamType=FineGrainedLineageUpstreamType.FIELD_SET, + upstreams=[ + self.get_upstream_column_urn( + hop_index, d_i, upstream_col_index + ) + for d_i in range(self.default_dataset_fanin) + ], + downstreamType=FineGrainedLineageDownstreamType.FIELD, + downstreams=[ + self.get_downstream_column_urn( + hop_index, + downstream_dataset_index, + downstream_col_index, + ) + for downstream_col_index in range( + self.default_column_fanout + ) + ], + query=self.get_transformation_query_urn(hop_index), + ) + for upstream_col_index in range(self.default_column_fanin) + ] + upstream_lineage = UpstreamLineageClass( + upstreams=[ + UpstreamClass( + dataset=self.get_dataset_urn(hop_index, i), + type=DatasetLineageTypeClass.TRANSFORMED, + query=self.get_transformation_query_urn(hop_index), + ) + for i in range(self.default_dataset_fanin) + ], + fineGrainedLineages=fine_grained_lineages, + ) + for fine_grained_lineage in fine_grained_lineages: + # Add field level expectations + for upstream_field_urn in fine_grained_lineage.upstreams or []: + for downstream_field_urn in ( + fine_grained_lineage.downstreams or [] + ): + self.expectations.extend_impacted_entities( + Direction.DOWNSTREAM, + upstream_field_urn, + downstream_field_urn, + path_extension=[ + self.get_transformation_query_urn(hop_index), + downstream_field_urn, + ], + ) + + # Add table level expectations + for upstream_dataset in upstream_lineage.upstreams: + self.expectations.extend_impacted_entities( + Direction.DOWNSTREAM, + upstream_dataset.dataset, + mcp_entity_urn, + path_extension=[ + self.get_transformation_query_urn(hop_index), + mcp_entity_urn, + ], + ) + + yield MetadataChangeProposalWrapper( + entityUrn=mcp_entity_urn, + aspect=upstream_lineage, + ) + + def get_entity_mcps(self) -> Iterable[MetadataChangeProposalWrapper]: + for hop_index in range( + 0, self.num_hops + 1 + ): # we generate entities with last hop inclusive + for mcp in self.get_entity_mcps_for_hop(hop_index): + assert mcp.entityUrn + self._generated_urns.add(mcp.entityUrn) + yield mcp + + def get_entity_mcps_for_hop( + self, hop_index: int + ) -> Iterable[MetadataChangeProposalWrapper]: + if self.lineage_style == Scenario.LineageStyle.DATASET_JOB_DATASET: + # Construct the DataJobInfo aspect with the job -> flow lineage. + dataflow_urn = self.get_transformation_flow_urn(hop_index) + + dataflow_info = DataFlowInfoClass( + name=self.transformation_flow.title() + " Flow" + ) + + dataflow_info_mcp = MetadataChangeProposalWrapper( + entityUrn=dataflow_urn, + aspect=dataflow_info, + ) + yield dataflow_info_mcp + + datajob_info = DataJobInfoClass( + name=self.transformation_job.title() + " Job", + type="AIRFLOW", + flowUrn=dataflow_urn, + ) + + # Construct a MetadataChangeProposalWrapper object with the DataJobInfo aspect. + # NOTE: This will overwrite all of the existing dataJobInfo aspect information associated with this job. + datajob_info_mcp = MetadataChangeProposalWrapper( + entityUrn=self.get_transformation_job_urn(hop_index), + aspect=datajob_info, + ) + yield datajob_info_mcp + + if self.lineage_style == Scenario.LineageStyle.DATASET_QUERY_DATASET: + query_urn = self.get_transformation_query_urn(hop_index=hop_index) + + fake_auditstamp = AuditStampClass( + time=int(time.time() * 1000), + actor="urn:li:corpuser:datahub", + ) + + query_properties = QueryPropertiesClass( + statement=QueryStatementClass( + value=self.query_string, + language=QueryLanguageClass.SQL, + ), + source=QuerySourceClass.SYSTEM, + created=fake_auditstamp, + lastModified=fake_auditstamp, + ) + + query_info_mcp = MetadataChangeProposalWrapper( + entityUrn=query_urn, + aspect=query_properties, + ) + yield query_info_mcp + # Generate schema and properties mcps for all datasets + for dataset_index in range(self.default_datasets_at_each_hop): + dataset_urn = DatasetUrn.from_string( + self.get_dataset_urn(hop_index, dataset_index) + ) + yield from MetadataChangeProposalWrapper.construct_many( + entityUrn=str(dataset_urn), + aspects=[ + SchemaMetadataClass( + schemaName=str(dataset_urn), + platform=builder.make_data_platform_urn(dataset_urn.platform), + version=0, + hash="", + platformSchema=OtherSchemaClass(rawSchema=""), + fields=[ + SchemaFieldClass( + fieldPath=self.get_column_name(i), + type=SchemaFieldDataTypeClass(type=StringTypeClass()), + nativeDataType="string", + ) + for i in range(self.default_column_fanin) + ], + ), + DatasetPropertiesClass( + name=dataset_urn.name, + ), + ], + ) + + def cleanup(self, delete_agent: DeleteAgent) -> None: + """Delete all entities created by this scenario.""" + for urn in self._generated_urns: + delete_agent.delete_entity(urn) + + def generate_expectation(self, query: ImpactQuery) -> LineageExpectation: + return self.expectations.generate_query_expectation_pairs(query) + + def test_expectation(self, graph: DataHubGraph) -> bool: + print("Testing expectation...") + try: + for hop_index in range(self.num_hops): + for dataset_urn in self.get_upstream_dataset_urns(hop_index): + assert graph.exists(dataset_urn) is True + for dataset_urn in self.get_downstream_dataset_urns(hop_index): + assert graph.exists(dataset_urn) is True + + if self.lineage_style == Scenario.LineageStyle.DATASET_JOB_DATASET: + assert graph.exists(self.get_transformation_job_urn(hop_index)) is True + assert graph.exists(self.get_transformation_flow_urn(hop_index)) is True + + if self.lineage_style == Scenario.LineageStyle.DATASET_QUERY_DATASET: + assert ( + graph.exists(self.get_transformation_query_urn(hop_index)) is True + ) + + wait_for_writes_to_sync() # Wait for the graph to update + # We would like to check that lineage is correct for all datasets and schema fields for all values of hops and for all directions of lineage exploration + # Since we already have expectations stored for all datasets and schema_fields, we can just check that the results match the expectations + + for ( + query, + expectation, + ) in self.expectations.generate_query_expectation_pairs(self.num_hops): + impacted_entities_expectation = set( + [x for x in expectation.impacted_entities.keys()] + ) + if len(impacted_entities_expectation) == 0: + continue + result = search_across_lineage( + graph, + query.main_entity, + query.hops, + query.direction.value, + query.upconvert_schema_fields_to_datasets, + ) + impacted_entities = set( + [ + x["entity"]["urn"] + for x in result["searchAcrossLineage"]["searchResults"] + ] + ) + try: + assert ( + impacted_entities == impacted_entities_expectation + ), f"Expected impacted entities to be {impacted_entities_expectation}, found {impacted_entities}" + except Exception: + # breakpoint() + raise + search_results = result["searchAcrossLineage"]["searchResults"] + for impacted_entity in impacted_entities: + # breakpoint() + impacted_entity_paths: List[Path] = [] + # breakpoint() + entity_paths_response = [ + x["paths"] + for x in search_results + if x["entity"]["urn"] == impacted_entity + ] + for path_response in entity_paths_response: + for p in path_response: + q = p["path"] + impacted_entity_paths.append( + Path(path=[x["urn"] for x in q]) + ) + # if len(impacted_entity_paths) > 1: + # breakpoint() + try: + assert len(impacted_entity_paths) == len( + expectation.impacted_entities[impacted_entity] + ), f"Expected length of impacted entity paths to be {len(expectation.impacted_entities[impacted_entity])}, found {len(impacted_entity_paths)}" + assert set(impacted_entity_paths) == set( + expectation.impacted_entities[impacted_entity] + ), f"Expected impacted entity paths to be {expectation.impacted_entities[impacted_entity]}, found {impacted_entity_paths}" + except Exception: + breakpoint() + raise + # for i in range(len(impacted_entity_paths)): + # assert impacted_entity_paths[i].path == expectation.impacted_entities[impacted_entity][i].path, f"Expected impacted entity paths to be {expectation.impacted_entities[impacted_entity][i].path}, found {impacted_entity_paths[i].path}" + print("Test passed!") + return True + except AssertionError as e: + print("Test failed!") + raise e + return False + + +@pytest.mark.dependency() +def test_healthchecks(wait_for_healthchecks): + # Call to wait_for_healthchecks fixture will do the actual functionality. + pass + + +# @tenacity.retry( +# stop=tenacity.stop_after_attempt(sleep_times), wait=tenacity.wait_fixed(sleep_sec) +# ) +@pytest.mark.parametrize( + "lineage_style", + [ + Scenario.LineageStyle.DATASET_QUERY_DATASET, + Scenario.LineageStyle.DATASET_JOB_DATASET, + ], +) +@pytest.mark.parametrize( + "graph_level", + [ + 1, + 2, + 3, + # TODO - convert this to range of 1 to 10 to make sure we can handle large graphs + ], +) +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_lineage_via_node( + lineage_style: Scenario.LineageStyle, graph_level: int +) -> None: + scenario: Scenario = Scenario( + hop_platform_map={0: "mysql", 1: "snowflake"}, + lineage_style=lineage_style, + num_hops=graph_level, + default_dataset_prefix=f"{lineage_style.value}.", + ) + + # Create an emitter to the GMS REST API. + (url, token) = get_url_and_token() + with DataHubGraph( + DatahubClientConfig(server=url, token=token, retry_max_times=0) + ) as graph: + emitter = graph + # emitter = DataHubConsoleEmitter() + + # Emit metadata! + for mcp in scenario.get_entity_mcps(): + emitter.emit_mcp(mcp) + + for mcps in scenario.get_lineage_mcps(): + emitter.emit_mcp(mcps) + + wait_for_writes_to_sync() + try: + scenario.test_expectation(graph) + finally: + scenario.cleanup(DataHubGraphDeleteAgent(graph)) + + +@pytest.fixture(scope="module") +def chart_urn_fixture(): + return "urn:li:chart:(tableau,2241f3d6-df8d-b515-9c0c-f5e5b347b26e)" + + +@pytest.fixture(scope="module") +def intermediates_fixture(): + return [ + "urn:li:dataset:(urn:li:dataPlatform:tableau,6bd53e72-9fe4-ea86-3d23-14b826c13fa5,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:tableau,1c5653d6-c448-0850-108b-5c78aeaf6b51,PROD)", + ] + + +@pytest.fixture(scope="module") +def destination_urn_fixture(): + return "urn:li:dataset:(urn:li:dataPlatform:external,sales target %28us%29.xlsx.sheet1,PROD)" + + +@pytest.mark.dependency(depends=["test_healthchecks"]) +@pytest.fixture(scope="module", autouse=False) +def ingest_multipath_metadata( + chart_urn_fixture, intermediates_fixture, destination_urn_fixture +): + (url, token) = get_url_and_token() + fake_auditstamp = AuditStampClass( + time=int(time.time() * 1000), + actor="urn:li:corpuser:datahub", + ) + with DataHubGraph( + DatahubClientConfig(server=url, token=token, retry_max_times=0) + ) as graph: + chart_urn = chart_urn_fixture + intermediates = intermediates_fixture + destination_urn = destination_urn_fixture + for mcp in MetadataChangeProposalWrapper.construct_many( + entityUrn=destination_urn, + aspects=[ + DatasetPropertiesClass( + name="sales target (us).xlsx.sheet1", + ), + ], + ): + graph.emit_mcp(mcp) + + for intermediate in intermediates: + for mcp in MetadataChangeProposalWrapper.construct_many( + entityUrn=intermediate, + aspects=[ + DatasetPropertiesClass( + name="intermediate", + ), + UpstreamLineageClass( + upstreams=[ + UpstreamClass( + dataset=destination_urn, + type="TRANSFORMED", + ) + ] + ), + ], + ): + graph.emit_mcp(mcp) + + for mcp in MetadataChangeProposalWrapper.construct_many( + entityUrn=chart_urn, + aspects=[ + ChartInfoClass( + title="chart", + description="chart", + lastModified=ChangeAuditStampsClass(created=fake_auditstamp), + inputEdges=[ + EdgeClass( + destinationUrn=intermediate_entity, + sourceUrn=chart_urn, + ) + for intermediate_entity in intermediates + ], + ) + ], + ): + graph.emit_mcp(mcp) + wait_for_writes_to_sync() + yield + for urn in [chart_urn] + intermediates + [destination_urn]: + graph.delete_entity(urn, hard=True) + wait_for_writes_to_sync() + + +@pytest.mark.dependency(depends=["test_healthchecks"]) +def test_simple_lineage_multiple_paths( + ingest_multipath_metadata, + chart_urn_fixture, + intermediates_fixture, + destination_urn_fixture, +): + chart_urn = chart_urn_fixture + intermediates = intermediates_fixture + destination_urn = destination_urn_fixture + results = search_across_lineage( + get_default_graph(), + chart_urn, + direction="UPSTREAM", + convert_schema_fields_to_datasets=True, + ) + assert destination_urn in [ + x["entity"]["urn"] for x in results["searchAcrossLineage"]["searchResults"] + ] + for search_result in results["searchAcrossLineage"]["searchResults"]: + if search_result["entity"]["urn"] == destination_urn: + assert ( + len(search_result["paths"]) == 2 + ) # 2 paths from the chart to the dataset + for path in search_result["paths"]: + assert len(path["path"]) == 3 + assert path["path"][-1]["urn"] == destination_urn + assert path["path"][0]["urn"] == chart_urn + assert path["path"][1]["urn"] in intermediates From d1604b39cc902db5b66bffc8e0e3abdfd74818ed Mon Sep 17 00:00:00 2001 From: Ellie O'Neil <110510035+eboneil@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:17:07 -0800 Subject: [PATCH 232/540] feat(ingestion/tableau) Ingest tags from datasource (#9730) Co-authored-by: Harshal Sheth --- .../src/datahub/ingestion/source/tableau.py | 9 +- .../ingestion/source/tableau_common.py | 3 + .../publishedDatasourcesConnection_all.json | 7 +- .../tableau/tableau_cll_mces_golden.json | 3611 ++++++++++++---- ...bleau_extract_all_project_mces_golden.json | 3693 +++++++++++++---- .../tableau/tableau_mces_golden.json | 3608 ++++++++++++---- .../tableau_mces_golden_deleted_stateful.json | 301 +- .../tableau_nested_project_mces_golden.json | 3667 ++++++++++++---- .../tableau_signout_timeout_mces_golden.json | 3608 ++++++++++++---- ...au_with_platform_instance_mces_golden.json | 25 + 10 files changed, 14469 insertions(+), 4063 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau.py index acdece14a6440e..d761eb1c15f0d6 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau.py @@ -1743,8 +1743,15 @@ def emit_datasource( aspects=[self.get_data_platform_instance()], ) - # Browse path + # Tags + if datasource_info: + tags = self.get_tags(datasource_info) + if tags: + dataset_snapshot.aspects.append( + builder.make_global_tag_aspect_with_tag_list(tags) + ) + # Browse path if browse_path and is_embedded_ds and workbook and workbook.get(c.NAME): browse_path = ( f"{browse_path}/{workbook[c.NAME].replace('/', REPLACE_SLASH_CHAR)}" diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py b/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py index 121b2e257a6bac..f0f106dd0f20a5 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/tableau_common.py @@ -399,6 +399,9 @@ class MetadataQueryException(Exception): description uri projectName + tags { + name + } } """ diff --git a/metadata-ingestion/tests/integration/tableau/setup/publishedDatasourcesConnection_all.json b/metadata-ingestion/tests/integration/tableau/setup/publishedDatasourcesConnection_all.json index 0c2b27db8af95d..93ce61e6d4fa4e 100644 --- a/metadata-ingestion/tests/integration/tableau/setup/publishedDatasourcesConnection_all.json +++ b/metadata-ingestion/tests/integration/tableau/setup/publishedDatasourcesConnection_all.json @@ -250,7 +250,12 @@ }, "description": "description for test publish datasource", "uri": "sites/4989/datasources/155429", - "projectName": "default" + "projectName": "default", + "tags": [ + { + "name": "tag on published datasource" + } + ] }, { "__typename": "PublishedDatasource", diff --git a/metadata-ingestion/tests/integration/tableau/tableau_cll_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_cll_mces_golden.json index 4575144c6f8440..c8312ab99ad190 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_cll_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_cll_mces_golden.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -260,7 +275,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -275,7 +291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -290,7 +307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -307,7 +325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -331,7 +350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -346,7 +366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -366,7 +387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -387,7 +409,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -402,7 +425,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -417,7 +441,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -434,7 +459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +484,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -477,7 +504,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -492,7 +520,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -512,7 +541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -533,7 +563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -548,7 +579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -563,7 +595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -580,7 +613,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -604,7 +638,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -619,7 +654,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -639,7 +675,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -660,7 +697,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -675,7 +713,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -690,7 +729,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -707,7 +747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -731,7 +772,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -746,7 +788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -766,7 +809,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -786,7 +830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -850,7 +895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -865,7 +911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1037,7 +1084,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1061,7 +1109,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1123,7 +1172,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1138,7 +1188,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1596,7 +1647,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1620,7 +1672,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1682,7 +1735,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1697,7 +1751,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2207,7 +2262,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2231,7 +2287,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2293,7 +2350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2308,7 +2366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2714,7 +2773,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2738,7 +2798,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2800,7 +2861,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2815,7 +2877,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2857,7 +2920,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2881,7 +2945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2943,7 +3008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2958,7 +3024,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3168,7 +3235,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3192,7 +3260,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3263,7 +3332,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3278,7 +3348,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3363,7 +3434,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3387,7 +3459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3452,7 +3525,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3467,7 +3541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3862,7 +3937,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3886,7 +3962,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3951,7 +4028,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3966,7 +4044,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4329,7 +4408,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4353,7 +4433,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4418,7 +4499,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4433,7 +4515,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4828,7 +4911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4852,7 +4936,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4914,7 +4999,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4929,7 +5015,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5208,7 +5295,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5232,7 +5320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5297,7 +5386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5312,7 +5402,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5568,7 +5659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5592,7 +5684,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5654,7 +5747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5669,7 +5763,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5951,7 +6046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5975,7 +6071,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6040,7 +6137,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6055,7 +6153,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6389,7 +6488,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6413,7 +6513,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6478,7 +6579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6493,7 +6595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6801,7 +6904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6825,7 +6929,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6887,7 +6992,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6902,7 +7008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7129,7 +7236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7153,7 +7261,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7218,7 +7327,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7233,7 +7343,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7489,7 +7600,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7513,7 +7625,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7575,7 +7688,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7590,7 +7704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7739,7 +7854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7763,7 +7879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7828,7 +7945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7843,7 +7961,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8177,7 +8296,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8201,7 +8321,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8266,7 +8387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8281,7 +8403,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8511,7 +8634,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8535,7 +8659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8600,7 +8725,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8615,7 +8741,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8871,7 +8998,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8895,7 +9023,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8960,7 +9089,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8975,7 +9105,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9257,7 +9388,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9281,7 +9413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9343,7 +9476,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9358,7 +9492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9695,7 +9830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9719,7 +9855,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9781,7 +9918,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9796,7 +9934,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9881,7 +10020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9905,7 +10045,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9925,7 +10066,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9991,7 +10133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10006,7 +10149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10030,7 +10174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10091,7 +10236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10106,7 +10252,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10130,7 +10277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10189,7 +10337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10204,7 +10353,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10228,7 +10378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10301,7 +10452,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10316,7 +10468,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10340,7 +10493,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10683,7 +10837,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12473,7 +12628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12490,7 +12646,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12505,7 +12662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12529,7 +12687,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12545,7 +12704,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", "type": "TRANSFORMED" }, { @@ -12553,7 +12712,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", "type": "TRANSFORMED" } ], @@ -12629,7 +12788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12871,7 +13031,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12888,7 +13049,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12903,7 +13065,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12927,7 +13090,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12959,7 +13123,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13373,7 +13538,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13390,7 +13556,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13405,7 +13572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13429,7 +13597,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13863,7 +14032,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14612,7 +14782,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14629,7 +14800,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14644,7 +14816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14668,7 +14841,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14813,7 +14987,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21430,7 +21605,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21447,7 +21623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21462,7 +21639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21486,7 +21664,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21674,7 +21853,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25577,7 +25757,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25594,7 +25775,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25609,7 +25791,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25633,7 +25816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25783,7 +25967,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30948,7 +31133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30965,7 +31151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30980,7 +31167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31004,7 +31192,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31126,7 +31315,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31355,7 +31545,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31372,7 +31563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31387,7 +31579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31411,7 +31604,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31514,7 +31708,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31528,469 +31723,18 @@ } }, { - "com.linkedin.pegasus2avro.common.BrowsePaths": { - "paths": [ - "/prod/tableau/default" - ] - } - }, - { - "com.linkedin.pegasus2avro.common.Ownership": { - "owners": [ - { - "owner": "urn:li:corpuser:jawadqu@gmail.com", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - { - "com.linkedin.pegasus2avro.dataset.DatasetProperties": { - "customProperties": {}, - "name": "test publish datasource", - "description": "description for test publish datasource", - "tags": [] - } - }, - { - "com.linkedin.pegasus2avro.schema.SchemaMetadata": { - "schemaName": "test", - "platform": "urn:li:dataPlatform:tableau", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.pegasus2avro.schema.OtherSchema": { - "rawSchema": "" - } - }, - "fields": [ - { - "fieldPath": "payment_date", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.TimeType": {} - } - }, - "nativeDataType": "DATETIME", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:YEAR" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "staff_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_id", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "INTEGER", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "amount", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "REAL", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "Published SQL Query", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.ArrayType": {} - } - }, - "nativeDataType": "TABLE", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ { - "fieldPath": "staff_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false + "tag": "urn:li:tag:tag on published datasource" } ] } - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Published Data Source" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", - "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "changeType": "UPSERT", - "aspectName": "upstreamLineage", - "aspect": { - "json": { - "upstreams": [ - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", - "type": "TRANSFORMED" - } - ], - "fineGrainedLineages": [ - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" - ], - "transformOperation": "GroupField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" - ], - "transformOperation": "BinField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" - ], - "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" - ], - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" - ], - "transformOperation": "SetField", - "confidenceScore": 1.0 - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "proposedSnapshot": { - "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { - "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "aspects": [ - { - "com.linkedin.pegasus2avro.common.DataPlatformInstance": { - "platform": "urn:li:dataPlatform:tableau" - } }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ - "/prod/tableau/Samples" + "/prod/tableau/default" ] } }, @@ -32011,8 +31755,473 @@ { "com.linkedin.pegasus2avro.dataset.DatasetProperties": { "customProperties": {}, - "name": "Superstore Datasource", - "description": "Description for Superstore dataset", + "name": "test publish datasource", + "description": "description for test publish datasource", + "tags": [] + } + }, + { + "com.linkedin.pegasus2avro.schema.SchemaMetadata": { + "schemaName": "test", + "platform": "urn:li:dataPlatform:tableau", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "hash": "", + "platformSchema": { + "com.linkedin.pegasus2avro.schema.OtherSchema": { + "rawSchema": "" + } + }, + "fields": [ + { + "fieldPath": "payment_date", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.TimeType": {} + } + }, + "nativeDataType": "DATETIME", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:YEAR" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_id", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "INTEGER", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "amount", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "REAL", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "Published SQL Query", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.ArrayType": {} + } + }, + "nativeDataType": "TABLE", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + } + ] + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Published Data Source" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "type": "TRANSFORMED" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" + ], + "transformOperation": "GroupField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" + ], + "transformOperation": "BinField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" + ], + "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" + ], + "transformOperation": "SetField", + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { + "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "aspects": [ + { + "com.linkedin.pegasus2avro.common.DataPlatformInstance": { + "platform": "urn:li:dataPlatform:tableau" + } + }, + { + "com.linkedin.pegasus2avro.common.BrowsePaths": { + "paths": [ + "/prod/tableau/Samples" + ] + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:jawadqu@gmail.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + { + "com.linkedin.pegasus2avro.dataset.DatasetProperties": { + "customProperties": {}, + "name": "Superstore Datasource", + "description": "Description for Superstore dataset", "tags": [] } }, @@ -32853,7 +33062,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32870,7 +33080,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32885,7 +33096,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32905,7 +33117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32920,7 +33133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32952,7 +33166,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33086,7 +33301,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33104,7 +33320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33128,7 +33345,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33168,7 +33386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33314,7 +33533,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33332,7 +33552,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33354,7 +33575,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33406,7 +33628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33492,7 +33715,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33510,7 +33734,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33532,7 +33757,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33695,7 +33921,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33720,7 +33947,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33955,7 +34183,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33980,7 +34209,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34191,7 +34421,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34216,7 +34447,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34379,7 +34611,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34404,7 +34637,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34555,7 +34789,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34580,7 +34815,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34719,7 +34955,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34744,7 +34981,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34835,7 +35073,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34860,7 +35099,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34927,7 +35167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34949,7 +35190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35016,7 +35258,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35038,7 +35281,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35333,7 +35577,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35355,7 +35600,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36144,7 +36390,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36169,7 +36416,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37052,7 +37300,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37077,7 +37326,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38032,7 +38282,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38057,7 +38308,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38880,7 +39132,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38905,7 +39158,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39212,7 +39466,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39237,7 +39492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40084,7 +40340,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40109,7 +40366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41100,7 +41358,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41125,7 +41384,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41984,7 +42244,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42009,7 +42270,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42030,7 +42292,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42052,7 +42315,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42073,7 +42337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42095,7 +42360,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42115,7 +42381,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42137,7 +42404,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42152,7 +42420,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42167,7 +42436,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42182,7 +42452,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42197,7 +42468,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42212,7 +42484,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42227,7 +42500,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42242,7 +42516,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42257,7 +42532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42272,7 +42548,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42287,7 +42564,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42302,7 +42580,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42317,7 +42596,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42332,7 +42612,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42347,7 +42628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42362,7 +42644,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42377,7 +42660,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42392,7 +42676,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42407,7 +42692,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42422,7 +42708,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42437,7 +42724,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42452,7 +42740,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42467,7 +42756,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42482,7 +42772,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42497,7 +42788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42512,7 +42804,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42527,7 +42820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42542,7 +42836,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42557,7 +42852,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42572,7 +42868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42587,7 +42884,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42602,7 +42900,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42617,7 +42916,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42632,7 +42932,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42647,7 +42948,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42662,7 +42964,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42677,7 +42980,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42692,7 +42996,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42707,7 +43012,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42722,7 +43028,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42737,7 +43044,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42752,7 +43060,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42767,7 +43076,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42782,7 +43092,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42797,7 +43108,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42812,7 +43124,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42827,7 +43140,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42842,7 +43156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42857,7 +43172,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42872,7 +43188,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42887,7 +43204,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42902,7 +43220,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42917,7 +43236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42932,7 +43252,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42947,7 +43268,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42962,7 +43284,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42977,7 +43300,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42992,7 +43316,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43007,7 +43332,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43022,7 +43348,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43037,7 +43364,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43052,7 +43380,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43067,7 +43396,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43082,7 +43412,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43097,7 +43428,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43112,7 +43444,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43127,7 +43460,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43142,7 +43476,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43157,7 +43492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43172,7 +43508,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43187,7 +43524,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43202,7 +43540,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43217,7 +43556,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43232,7 +43572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43247,7 +43588,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43262,7 +43604,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43277,7 +43620,1697 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:252a054d4dd93cd657735aa46dd71370", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,222d1406-de0e-cd8d-0b94-9b45a0007e59)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,38130558-4194-2e2a-3046-c0d887829cb4)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,692a2da4-2a82-32c1-f713-63b8e4325d86)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f4317efd-c3e6-6ace-8fe6-e71b590bbbcc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8a6a269a-d6de-fae4-5050-513255b40ffc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c57a5574-db47-46df-677f-0b708dab14db)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e604255e-0573-3951-6db7-05bee48116c1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,20fc5eb7-81eb-aa18-8c39-af501c62d085)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b5351c1-535d-4a4a-1339-c51ddd6abf8a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b73b9dd-4ec7-75ca-f2e9-fa1984ca8b72)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,373c6466-bb0c-b319-8752-632456349261)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,53b8dc2f-8ada-51f7-7422-fe82e9b803cc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,58af9ecf-b839-da50-65e1-2e1fa20e3362)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,618b3e76-75c1-cb31-0c61-3f4890b72c31)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,721c3c41-7a2b-16a8-3281-6f948a44be96)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7ef184c1-5a41-5ec8-723e-ae44c20aa335)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7fbc77ba-0ab6-3727-0db3-d8402a804da5)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8385ea9a-0749-754f-7ad9-824433de2120)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b207c2f2-b675-32e3-2663-17bb836a018b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b679da5e-7d03-f01e-b2ea-01fb3c1926dc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c14973c2-e1c3-563a-a9c1-8a408396d22a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e70a540d-55ed-b9cc-5a3c-01ebe81a1274)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f76d3570-23b8-f74b-d85c-cc5484c2079c)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,130496dc-29ca-8a89-e32b-d73c4d8b65ff)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,8f7dd564-36b6-593f-3c6f-687ad06cd40b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,20e44c22-1ccd-301a-220c-7b6837d09a52)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,39b7a1de-6276-cfc7-9b59-1d22f3bbb06b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,5dcaaf46-e6fb-2548-e763-272a7ab2c9b1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,801c95e3-b07e-7bfe-3789-a561c7beccd3,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4644ccb1-2adc-cf26-c654-04ed1dcc7090,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,618c87db-5959-338b-bcc7-6f5f4cc0b6c6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d00f4ba6-707e-4684-20af-69eb47587cc2,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,06c3e060-8133-4b58-9b53-a0fced25e056,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,3ade7817-ae27-259e-8e48-1570e7f932f6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,dfe2c02a-54b7-f7a2-39fc-c651da2f6ad8,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d8d4c0ea-3162-fa11-31e6-26675da44a38,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "urn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4fb670d5-3e19-9656-e684-74aa9729cf18,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "SubProject1" + }, + { + "id": "AbcJoinWorkbook" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity11,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity10,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity7,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.campaignstable,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.address,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.actor,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.task,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_request,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_req_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_cat_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sys_user_group,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.problem,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.incident,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.cmdb_ci,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.customer,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.payment,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.staff,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_extract_all_project_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_extract_all_project_mces_golden.json index 12eb780447c185..52b7de1b70a4d8 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_extract_all_project_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_extract_all_project_mces_golden.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -258,7 +273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -273,7 +289,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -288,7 +305,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -305,7 +323,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -320,7 +339,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -340,7 +360,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -361,7 +382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -376,7 +398,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -391,7 +414,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -408,7 +432,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -432,7 +457,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -447,7 +473,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -467,7 +494,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -488,7 +516,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -503,7 +532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -518,7 +548,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -535,7 +566,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -559,7 +591,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -578,7 +611,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -593,7 +627,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -613,7 +648,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -634,7 +670,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -649,7 +686,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -664,7 +702,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -681,7 +720,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -705,7 +745,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -720,7 +761,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -740,7 +782,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -761,7 +804,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -776,7 +820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -791,7 +836,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -808,7 +854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -832,7 +879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -847,7 +895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -867,7 +916,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -888,7 +938,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -903,7 +954,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -918,7 +970,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -935,7 +988,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -959,7 +1013,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -974,7 +1029,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -998,7 +1054,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1018,7 +1075,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1082,7 +1140,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1097,7 +1156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1269,7 +1329,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1293,7 +1354,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1355,7 +1417,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1370,7 +1433,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1828,7 +1892,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1852,7 +1917,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1914,7 +1980,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1929,7 +1996,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2439,7 +2507,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2463,7 +2532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2525,7 +2595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2540,7 +2611,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2946,7 +3018,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2970,7 +3043,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3032,7 +3106,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3047,7 +3122,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3089,7 +3165,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3113,7 +3190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3175,7 +3253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3190,7 +3269,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3400,7 +3480,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3424,7 +3505,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3495,7 +3577,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3510,7 +3593,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3595,7 +3679,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3619,7 +3704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3684,7 +3770,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3699,7 +3786,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4094,7 +4182,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4118,7 +4207,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4183,7 +4273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4198,7 +4289,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4561,7 +4653,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4585,7 +4678,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4650,7 +4744,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4665,7 +4760,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5060,7 +5156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5084,7 +5181,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5146,7 +5244,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5161,7 +5260,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5440,7 +5540,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5464,7 +5565,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5529,7 +5631,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5544,7 +5647,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5800,7 +5904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5824,7 +5929,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5886,7 +5992,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5901,7 +6008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6183,7 +6291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6207,7 +6316,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6272,7 +6382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6287,7 +6398,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6621,7 +6733,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6645,7 +6758,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6710,7 +6824,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6725,7 +6840,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7033,7 +7149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7057,7 +7174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7119,7 +7237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7134,7 +7253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7361,7 +7481,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7385,7 +7506,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7450,7 +7572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7465,7 +7588,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7721,7 +7845,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7745,7 +7870,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7807,7 +7933,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7822,7 +7949,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7971,7 +8099,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7995,7 +8124,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8060,7 +8190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8075,7 +8206,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8409,7 +8541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8433,7 +8566,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8498,7 +8632,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8513,7 +8648,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8743,7 +8879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8767,7 +8904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8832,7 +8970,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8847,7 +8986,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9103,7 +9243,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9127,7 +9268,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9192,7 +9334,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9207,7 +9350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9489,7 +9633,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9513,7 +9658,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9575,7 +9721,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9590,7 +9737,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9927,7 +10075,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9951,7 +10100,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10013,7 +10163,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10028,7 +10179,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10113,7 +10265,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10137,7 +10290,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10157,7 +10311,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10223,7 +10378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10238,7 +10394,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10262,7 +10419,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10323,7 +10481,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10338,7 +10497,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10362,7 +10522,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10421,7 +10582,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10436,7 +10598,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10460,7 +10623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10533,7 +10697,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10548,7 +10713,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10572,7 +10738,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10915,7 +11082,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12705,7 +12873,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12722,7 +12891,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12737,7 +12907,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12761,7 +12932,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12777,7 +12949,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", "type": "TRANSFORMED" }, { @@ -12785,7 +12957,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", "type": "TRANSFORMED" } ], @@ -12861,7 +13033,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13103,7 +13276,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13120,7 +13294,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13135,7 +13310,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13159,7 +13335,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13191,7 +13368,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13605,7 +13783,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13622,7 +13801,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13637,7 +13817,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13661,7 +13842,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14095,7 +14277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14844,7 +15027,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14861,7 +15045,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14876,7 +15061,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14900,7 +15086,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -15045,7 +15232,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21662,7 +21850,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21679,7 +21868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21694,7 +21884,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21718,7 +21909,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21906,7 +22098,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25809,7 +26002,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25826,7 +26020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25841,7 +26036,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25865,7 +26061,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -26015,7 +26212,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31180,7 +31378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31197,7 +31396,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31212,7 +31412,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31236,7 +31437,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31358,7 +31560,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31587,7 +31790,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31604,7 +31808,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31619,7 +31824,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31643,7 +31849,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31746,7 +31953,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31760,469 +31968,18 @@ } }, { - "com.linkedin.pegasus2avro.common.BrowsePaths": { - "paths": [ - "/prod/tableau/default" - ] - } - }, - { - "com.linkedin.pegasus2avro.common.Ownership": { - "owners": [ - { - "owner": "urn:li:corpuser:jawadqu@gmail.com", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - { - "com.linkedin.pegasus2avro.dataset.DatasetProperties": { - "customProperties": {}, - "name": "test publish datasource", - "description": "description for test publish datasource", - "tags": [] - } - }, - { - "com.linkedin.pegasus2avro.schema.SchemaMetadata": { - "schemaName": "test", - "platform": "urn:li:dataPlatform:tableau", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.pegasus2avro.schema.OtherSchema": { - "rawSchema": "" - } - }, - "fields": [ - { - "fieldPath": "payment_date", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.TimeType": {} - } - }, - "nativeDataType": "DATETIME", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:YEAR" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "staff_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_id", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "INTEGER", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "amount", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "REAL", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "Published SQL Query", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.ArrayType": {} - } - }, - "nativeDataType": "TABLE", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ { - "fieldPath": "staff_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false + "tag": "urn:li:tag:tag on published datasource" } ] } - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Published Data Source" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", - "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "changeType": "UPSERT", - "aspectName": "upstreamLineage", - "aspect": { - "json": { - "upstreams": [ - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", - "type": "TRANSFORMED" - } - ], - "fineGrainedLineages": [ - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" - ], - "transformOperation": "GroupField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" - ], - "transformOperation": "BinField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" - ], - "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" - ], - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" - ], - "transformOperation": "SetField", - "confidenceScore": 1.0 - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "proposedSnapshot": { - "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { - "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "aspects": [ - { - "com.linkedin.pegasus2avro.common.DataPlatformInstance": { - "platform": "urn:li:dataPlatform:tableau" - } }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ - "/prod/tableau/Samples" + "/prod/tableau/default" ] } }, @@ -32243,8 +32000,473 @@ { "com.linkedin.pegasus2avro.dataset.DatasetProperties": { "customProperties": {}, - "name": "Superstore Datasource", - "description": "Description for Superstore dataset", + "name": "test publish datasource", + "description": "description for test publish datasource", + "tags": [] + } + }, + { + "com.linkedin.pegasus2avro.schema.SchemaMetadata": { + "schemaName": "test", + "platform": "urn:li:dataPlatform:tableau", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "hash": "", + "platformSchema": { + "com.linkedin.pegasus2avro.schema.OtherSchema": { + "rawSchema": "" + } + }, + "fields": [ + { + "fieldPath": "payment_date", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.TimeType": {} + } + }, + "nativeDataType": "DATETIME", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:YEAR" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_id", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "INTEGER", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "amount", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "REAL", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "Published SQL Query", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.ArrayType": {} + } + }, + "nativeDataType": "TABLE", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + } + ] + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Published Data Source" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "type": "TRANSFORMED" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" + ], + "transformOperation": "GroupField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" + ], + "transformOperation": "BinField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" + ], + "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" + ], + "transformOperation": "SetField", + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { + "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "aspects": [ + { + "com.linkedin.pegasus2avro.common.DataPlatformInstance": { + "platform": "urn:li:dataPlatform:tableau" + } + }, + { + "com.linkedin.pegasus2avro.common.BrowsePaths": { + "paths": [ + "/prod/tableau/Samples" + ] + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:jawadqu@gmail.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + { + "com.linkedin.pegasus2avro.dataset.DatasetProperties": { + "customProperties": {}, + "name": "Superstore Datasource", + "description": "Description for Superstore dataset", "tags": [] } }, @@ -33085,7 +33307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33102,7 +33325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33117,7 +33341,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33137,7 +33362,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33152,7 +33378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33184,7 +33411,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33318,7 +33546,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33336,7 +33565,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33360,7 +33590,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33400,7 +33631,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33546,7 +33778,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33564,7 +33797,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33586,7 +33820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33672,7 +33907,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33690,7 +33926,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33712,7 +33949,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33875,7 +34113,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33900,7 +34139,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34135,7 +34375,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34160,7 +34401,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34371,7 +34613,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34396,7 +34639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34559,7 +34803,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34584,7 +34829,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34735,7 +34981,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34760,7 +35007,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34899,7 +35147,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34924,7 +35173,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35015,7 +35265,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35040,7 +35291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35107,7 +35359,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35129,7 +35382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35196,7 +35450,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35218,7 +35473,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35513,7 +35769,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35535,7 +35792,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36324,7 +36582,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36349,7 +36608,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37232,7 +37492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37257,7 +37518,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38212,7 +38474,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38237,7 +38500,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39060,7 +39324,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39085,7 +39350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39392,7 +39658,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39417,7 +39684,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40264,7 +40532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40289,7 +40558,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41280,7 +41550,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41305,7 +41576,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42164,7 +42436,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42189,7 +42462,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42210,7 +42484,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42232,7 +42507,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42253,7 +42529,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42275,7 +42552,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42295,7 +42573,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42317,7 +42596,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42332,7 +42612,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42347,7 +42628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42362,7 +42644,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42377,7 +42660,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42392,7 +42676,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42407,7 +42692,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42422,7 +42708,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42437,7 +42724,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42452,7 +42740,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42467,7 +42756,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42482,7 +42772,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42497,7 +42788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42512,7 +42804,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42527,7 +42820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42542,7 +42836,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42557,7 +42852,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42572,7 +42868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42587,7 +42884,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42602,7 +42900,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42617,7 +42916,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42632,7 +42932,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42647,7 +42948,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42662,7 +42964,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42677,7 +42980,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42692,7 +42996,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42707,7 +43012,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42722,7 +43028,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42737,7 +43044,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42752,7 +43060,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42767,7 +43076,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42782,7 +43092,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42797,7 +43108,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42812,7 +43124,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42827,7 +43140,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42842,7 +43156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42857,7 +43172,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42872,7 +43188,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42887,7 +43204,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42902,7 +43220,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42917,7 +43236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42932,7 +43252,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42947,7 +43268,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42962,7 +43284,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42977,7 +43300,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42992,7 +43316,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43007,7 +43332,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43022,7 +43348,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43037,7 +43364,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43052,7 +43380,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43067,7 +43396,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43082,7 +43412,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43097,7 +43428,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43112,7 +43444,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43127,7 +43460,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43142,7 +43476,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43157,7 +43492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43172,7 +43508,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43187,7 +43524,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43202,7 +43540,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43217,7 +43556,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43232,7 +43572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43247,7 +43588,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43262,7 +43604,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43277,7 +43620,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43292,7 +43636,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43307,7 +43652,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43322,7 +43668,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43337,7 +43684,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43352,7 +43700,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43367,7 +43716,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43382,7 +43732,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43397,7 +43748,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43412,7 +43764,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43427,7 +43780,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43442,7 +43796,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43457,7 +43812,1743 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:252a054d4dd93cd657735aa46dd71370", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:595877512935338b94eac9e06cf20607", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce", + "urn": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,222d1406-de0e-cd8d-0b94-9b45a0007e59)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,38130558-4194-2e2a-3046-c0d887829cb4)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,692a2da4-2a82-32c1-f713-63b8e4325d86)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f4317efd-c3e6-6ace-8fe6-e71b590bbbcc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8a6a269a-d6de-fae4-5050-513255b40ffc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c57a5574-db47-46df-677f-0b708dab14db)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e604255e-0573-3951-6db7-05bee48116c1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,20fc5eb7-81eb-aa18-8c39-af501c62d085)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b5351c1-535d-4a4a-1339-c51ddd6abf8a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b73b9dd-4ec7-75ca-f2e9-fa1984ca8b72)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,373c6466-bb0c-b319-8752-632456349261)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,53b8dc2f-8ada-51f7-7422-fe82e9b803cc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,58af9ecf-b839-da50-65e1-2e1fa20e3362)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,618b3e76-75c1-cb31-0c61-3f4890b72c31)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,721c3c41-7a2b-16a8-3281-6f948a44be96)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7ef184c1-5a41-5ec8-723e-ae44c20aa335)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7fbc77ba-0ab6-3727-0db3-d8402a804da5)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8385ea9a-0749-754f-7ad9-824433de2120)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b207c2f2-b675-32e3-2663-17bb836a018b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b679da5e-7d03-f01e-b2ea-01fb3c1926dc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c14973c2-e1c3-563a-a9c1-8a408396d22a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e70a540d-55ed-b9cc-5a3c-01ebe81a1274)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f76d3570-23b8-f74b-d85c-cc5484c2079c)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,130496dc-29ca-8a89-e32b-d73c4d8b65ff)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,8f7dd564-36b6-593f-3c6f-687ad06cd40b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,20e44c22-1ccd-301a-220c-7b6837d09a52)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,39b7a1de-6276-cfc7-9b59-1d22f3bbb06b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,5dcaaf46-e6fb-2548-e763-272a7ab2c9b1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,801c95e3-b07e-7bfe-3789-a561c7beccd3,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4644ccb1-2adc-cf26-c654-04ed1dcc7090,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,618c87db-5959-338b-bcc7-6f5f4cc0b6c6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d00f4ba6-707e-4684-20af-69eb47587cc2,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,06c3e060-8133-4b58-9b53-a0fced25e056,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,3ade7817-ae27-259e-8e48-1570e7f932f6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,dfe2c02a-54b7-f7a2-39fc-c651da2f6ad8,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d8d4c0ea-3162-fa11-31e6-26675da44a38,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "urn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4fb670d5-3e19-9656-e684-74aa9729cf18,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "SubProject1" + }, + { + "id": "AbcJoinWorkbook" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity11,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity10,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity7,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.campaignstable,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.address,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.actor,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.task,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_request,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_req_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_cat_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sys_user_group,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.problem,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.incident,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.cmdb_ci,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.customer,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.payment,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.staff,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_mces_golden.json index 77ea484abfa0e8..9d0e8a14a1f8c0 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_mces_golden.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -260,7 +275,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -275,7 +291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -290,7 +307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -307,7 +325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -331,7 +350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -346,7 +366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -366,7 +387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -387,7 +409,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -402,7 +425,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -417,7 +441,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -434,7 +459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +484,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -477,7 +504,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -492,7 +520,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -512,7 +541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -533,7 +563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -548,7 +579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -563,7 +595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -580,7 +613,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -604,7 +638,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -619,7 +654,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -639,7 +675,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -660,7 +697,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -675,7 +713,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -690,7 +729,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -707,7 +747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -731,7 +772,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -746,7 +788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -766,7 +809,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -786,7 +830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -850,7 +895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -865,7 +911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1037,7 +1084,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1061,7 +1109,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1123,7 +1172,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1138,7 +1188,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1596,7 +1647,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1620,7 +1672,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1682,7 +1735,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1697,7 +1751,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2207,7 +2262,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2231,7 +2287,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2293,7 +2350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2308,7 +2366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2714,7 +2773,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2738,7 +2798,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2800,7 +2861,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2815,7 +2877,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2857,7 +2920,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2881,7 +2945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2943,7 +3008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2958,7 +3024,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3168,7 +3235,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3192,7 +3260,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3263,7 +3332,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3278,7 +3348,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3363,7 +3434,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3387,7 +3459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3452,7 +3525,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3467,7 +3541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3862,7 +3937,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3886,7 +3962,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3951,7 +4028,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3966,7 +4044,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4329,7 +4408,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4353,7 +4433,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4418,7 +4499,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4433,7 +4515,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4828,7 +4911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4852,7 +4936,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4914,7 +4999,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4929,7 +5015,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5208,7 +5295,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5232,7 +5320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5297,7 +5386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5312,7 +5402,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5568,7 +5659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5592,7 +5684,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5654,7 +5747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5669,7 +5763,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5951,7 +6046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5975,7 +6071,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6040,7 +6137,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6055,7 +6153,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6389,7 +6488,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6413,7 +6513,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6478,7 +6579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6493,7 +6595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6801,7 +6904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6825,7 +6929,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6887,7 +6992,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6902,7 +7008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7129,7 +7236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7153,7 +7261,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7218,7 +7327,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7233,7 +7343,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7489,7 +7600,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7513,7 +7625,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7575,7 +7688,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7590,7 +7704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7739,7 +7854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7763,7 +7879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7828,7 +7945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7843,7 +7961,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8177,7 +8296,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8201,7 +8321,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8266,7 +8387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8281,7 +8403,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8511,7 +8634,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8535,7 +8659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8600,7 +8725,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8615,7 +8741,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8871,7 +8998,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8895,7 +9023,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8960,7 +9089,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8975,7 +9105,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9257,7 +9388,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9281,7 +9413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9343,7 +9476,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9358,7 +9492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9695,7 +9830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9719,7 +9855,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9781,7 +9918,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9796,7 +9934,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9881,7 +10020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9905,7 +10045,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9925,7 +10066,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9991,7 +10133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10006,7 +10149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10030,7 +10174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10091,7 +10236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10106,7 +10252,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10130,7 +10277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10189,7 +10337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10204,7 +10353,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10228,7 +10378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10301,7 +10452,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10316,7 +10468,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10340,7 +10493,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10683,7 +10837,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12473,7 +12628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12490,7 +12646,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12505,7 +12662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12529,7 +12687,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12545,7 +12704,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", "type": "TRANSFORMED" }, { @@ -12553,7 +12712,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", "type": "TRANSFORMED" } ], @@ -12629,7 +12788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12871,7 +13031,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12888,7 +13049,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12903,7 +13065,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12927,7 +13090,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12959,7 +13123,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13373,7 +13538,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13390,7 +13556,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13405,7 +13572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13429,7 +13597,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13863,7 +14032,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14612,7 +14782,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14629,7 +14800,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14644,7 +14816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14668,7 +14841,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14813,7 +14987,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21430,7 +21605,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21447,7 +21623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21462,7 +21639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21486,7 +21664,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21674,7 +21853,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25577,7 +25757,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25594,7 +25775,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25609,7 +25791,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25633,7 +25816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25783,7 +25967,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30948,7 +31133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30965,7 +31151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30980,7 +31167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31004,7 +31192,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31126,7 +31315,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31355,7 +31545,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31372,7 +31563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31387,7 +31579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31411,7 +31604,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31514,7 +31708,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31528,469 +31723,18 @@ } }, { - "com.linkedin.pegasus2avro.common.BrowsePaths": { - "paths": [ - "/prod/tableau/default" - ] - } - }, - { - "com.linkedin.pegasus2avro.common.Ownership": { - "owners": [ - { - "owner": "urn:li:corpuser:jawadqu@gmail.com", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - { - "com.linkedin.pegasus2avro.dataset.DatasetProperties": { - "customProperties": {}, - "name": "test publish datasource", - "description": "description for test publish datasource", - "tags": [] - } - }, - { - "com.linkedin.pegasus2avro.schema.SchemaMetadata": { - "schemaName": "test", - "platform": "urn:li:dataPlatform:tableau", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.pegasus2avro.schema.OtherSchema": { - "rawSchema": "" - } - }, - "fields": [ - { - "fieldPath": "payment_date", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.TimeType": {} - } - }, - "nativeDataType": "DATETIME", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:YEAR" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "staff_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_id", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "INTEGER", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "amount", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "REAL", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "Published SQL Query", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.ArrayType": {} - } - }, - "nativeDataType": "TABLE", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ { - "fieldPath": "staff_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false + "tag": "urn:li:tag:tag on published datasource" } ] } - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Published Data Source" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", - "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "changeType": "UPSERT", - "aspectName": "upstreamLineage", - "aspect": { - "json": { - "upstreams": [ - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", - "type": "TRANSFORMED" - } - ], - "fineGrainedLineages": [ - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" - ], - "transformOperation": "GroupField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" - ], - "transformOperation": "BinField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" - ], - "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" - ], - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" - ], - "transformOperation": "SetField", - "confidenceScore": 1.0 - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "proposedSnapshot": { - "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { - "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "aspects": [ - { - "com.linkedin.pegasus2avro.common.DataPlatformInstance": { - "platform": "urn:li:dataPlatform:tableau" - } }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ - "/prod/tableau/Samples" + "/prod/tableau/default" ] } }, @@ -32011,8 +31755,473 @@ { "com.linkedin.pegasus2avro.dataset.DatasetProperties": { "customProperties": {}, - "name": "Superstore Datasource", - "description": "Description for Superstore dataset", + "name": "test publish datasource", + "description": "description for test publish datasource", + "tags": [] + } + }, + { + "com.linkedin.pegasus2avro.schema.SchemaMetadata": { + "schemaName": "test", + "platform": "urn:li:dataPlatform:tableau", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "hash": "", + "platformSchema": { + "com.linkedin.pegasus2avro.schema.OtherSchema": { + "rawSchema": "" + } + }, + "fields": [ + { + "fieldPath": "payment_date", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.TimeType": {} + } + }, + "nativeDataType": "DATETIME", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:YEAR" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_id", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "INTEGER", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "amount", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "REAL", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "Published SQL Query", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.ArrayType": {} + } + }, + "nativeDataType": "TABLE", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + } + ] + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Published Data Source" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "type": "TRANSFORMED" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" + ], + "transformOperation": "GroupField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" + ], + "transformOperation": "BinField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" + ], + "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" + ], + "transformOperation": "SetField", + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { + "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "aspects": [ + { + "com.linkedin.pegasus2avro.common.DataPlatformInstance": { + "platform": "urn:li:dataPlatform:tableau" + } + }, + { + "com.linkedin.pegasus2avro.common.BrowsePaths": { + "paths": [ + "/prod/tableau/Samples" + ] + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:jawadqu@gmail.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + { + "com.linkedin.pegasus2avro.dataset.DatasetProperties": { + "customProperties": {}, + "name": "Superstore Datasource", + "description": "Description for Superstore dataset", "tags": [] } }, @@ -32853,7 +33062,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32870,7 +33080,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32885,7 +33096,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32905,7 +33117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32920,7 +33133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32952,7 +33166,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33086,7 +33301,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33104,7 +33320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33128,7 +33345,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33168,7 +33386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33314,7 +33533,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33332,7 +33552,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33354,7 +33575,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33440,7 +33662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33458,7 +33681,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33480,7 +33704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33643,7 +33868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33668,7 +33894,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33903,7 +34130,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33928,7 +34156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34139,7 +34368,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34164,7 +34394,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34327,7 +34558,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34352,7 +34584,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34503,7 +34736,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34528,7 +34762,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34667,7 +34902,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34692,7 +34928,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34783,7 +35020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34808,7 +35046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34875,7 +35114,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34897,7 +35137,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34964,7 +35205,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34986,7 +35228,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35281,7 +35524,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35303,7 +35547,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36092,7 +36337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36117,7 +36363,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37000,7 +37247,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37025,7 +37273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37980,7 +38229,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38005,7 +38255,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38828,7 +39079,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38853,7 +39105,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39160,7 +39413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39185,7 +39439,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40032,7 +40287,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40057,7 +40313,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41048,7 +41305,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41073,7 +41331,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41932,7 +42191,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41957,7 +42217,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41978,7 +42239,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42000,7 +42262,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42021,7 +42284,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42043,7 +42307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42063,7 +42328,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42085,7 +42351,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42100,7 +42367,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42115,7 +42383,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42130,7 +42399,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42145,7 +42415,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42160,7 +42431,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42175,7 +42447,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42190,7 +42463,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42205,7 +42479,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42220,7 +42495,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42235,7 +42511,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42250,7 +42527,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42265,7 +42543,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42280,7 +42559,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42295,7 +42575,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42310,7 +42591,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42325,7 +42607,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42340,7 +42623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42355,7 +42639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42370,7 +42655,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42385,7 +42671,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42400,7 +42687,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42415,7 +42703,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42430,7 +42719,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42445,7 +42735,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42460,7 +42751,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42475,7 +42767,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42490,7 +42783,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42505,7 +42799,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42520,7 +42815,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42535,7 +42831,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42550,7 +42847,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42565,7 +42863,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42580,7 +42879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42595,7 +42895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42610,7 +42911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42625,7 +42927,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42640,7 +42943,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42655,7 +42959,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42670,7 +42975,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42685,7 +42991,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42700,7 +43007,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42715,7 +43023,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42730,7 +43039,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42745,7 +43055,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42760,7 +43071,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42775,7 +43087,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42790,7 +43103,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42805,7 +43119,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42820,7 +43135,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42835,7 +43151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42850,7 +43167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42865,7 +43183,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42880,7 +43199,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42895,7 +43215,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42910,7 +43231,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42925,7 +43247,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42940,7 +43263,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42955,7 +43279,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42970,7 +43295,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42985,7 +43311,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43000,7 +43327,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43015,7 +43343,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43030,7 +43359,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43045,7 +43375,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43060,7 +43391,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43075,7 +43407,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43090,7 +43423,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43105,7 +43439,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43120,7 +43455,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43135,7 +43471,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43150,7 +43487,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43165,7 +43503,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43180,7 +43519,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43195,7 +43535,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43210,7 +43551,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43225,7 +43567,1697 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:252a054d4dd93cd657735aa46dd71370", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,222d1406-de0e-cd8d-0b94-9b45a0007e59)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,38130558-4194-2e2a-3046-c0d887829cb4)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,692a2da4-2a82-32c1-f713-63b8e4325d86)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f4317efd-c3e6-6ace-8fe6-e71b590bbbcc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8a6a269a-d6de-fae4-5050-513255b40ffc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c57a5574-db47-46df-677f-0b708dab14db)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e604255e-0573-3951-6db7-05bee48116c1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,20fc5eb7-81eb-aa18-8c39-af501c62d085)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b5351c1-535d-4a4a-1339-c51ddd6abf8a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b73b9dd-4ec7-75ca-f2e9-fa1984ca8b72)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,373c6466-bb0c-b319-8752-632456349261)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,53b8dc2f-8ada-51f7-7422-fe82e9b803cc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,58af9ecf-b839-da50-65e1-2e1fa20e3362)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,618b3e76-75c1-cb31-0c61-3f4890b72c31)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,721c3c41-7a2b-16a8-3281-6f948a44be96)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7ef184c1-5a41-5ec8-723e-ae44c20aa335)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7fbc77ba-0ab6-3727-0db3-d8402a804da5)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8385ea9a-0749-754f-7ad9-824433de2120)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b207c2f2-b675-32e3-2663-17bb836a018b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b679da5e-7d03-f01e-b2ea-01fb3c1926dc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c14973c2-e1c3-563a-a9c1-8a408396d22a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e70a540d-55ed-b9cc-5a3c-01ebe81a1274)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f76d3570-23b8-f74b-d85c-cc5484c2079c)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,130496dc-29ca-8a89-e32b-d73c4d8b65ff)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,8f7dd564-36b6-593f-3c6f-687ad06cd40b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,20e44c22-1ccd-301a-220c-7b6837d09a52)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,39b7a1de-6276-cfc7-9b59-1d22f3bbb06b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,5dcaaf46-e6fb-2548-e763-272a7ab2c9b1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,801c95e3-b07e-7bfe-3789-a561c7beccd3,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4644ccb1-2adc-cf26-c654-04ed1dcc7090,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,618c87db-5959-338b-bcc7-6f5f4cc0b6c6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d00f4ba6-707e-4684-20af-69eb47587cc2,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,06c3e060-8133-4b58-9b53-a0fced25e056,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,3ade7817-ae27-259e-8e48-1570e7f932f6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,dfe2c02a-54b7-f7a2-39fc-c651da2f6ad8,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d8d4c0ea-3162-fa11-31e6-26675da44a38,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "urn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4fb670d5-3e19-9656-e684-74aa9729cf18,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "SubProject1" + }, + { + "id": "AbcJoinWorkbook" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity11,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity10,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity7,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.campaignstable,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.address,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.actor,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.task,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_request,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_req_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_cat_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sys_user_group,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.problem,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.incident,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.cmdb_ci,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.customer,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.payment,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.staff,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_mces_golden_deleted_stateful.json b/metadata-ingestion/tests/integration/tableau/tableau_mces_golden_deleted_stateful.json index fb9cca0b6a544c..b39f7a181f01bc 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_mces_golden_deleted_stateful.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_mces_golden_deleted_stateful.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -254,7 +269,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -269,7 +285,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -284,7 +301,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -299,7 +317,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -314,7 +333,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -329,7 +349,24 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": true + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -344,7 +381,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -359,7 +397,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -374,7 +413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -389,7 +429,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -404,7 +445,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -419,7 +461,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -434,7 +477,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -449,7 +493,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -464,7 +509,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -479,7 +525,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -494,7 +541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -509,7 +557,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -524,7 +573,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -539,7 +589,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -554,7 +605,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -569,7 +621,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -584,7 +637,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -599,7 +653,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -614,7 +669,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -629,7 +685,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -644,7 +701,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -659,7 +717,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -674,7 +733,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -689,7 +749,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -704,7 +765,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -719,7 +781,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -734,7 +797,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -749,7 +813,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -764,7 +829,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -779,7 +845,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -794,7 +861,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -809,7 +877,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -824,7 +893,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -839,7 +909,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -854,7 +925,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -869,7 +941,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -884,7 +957,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -899,7 +973,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -914,7 +989,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -929,7 +1005,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -944,7 +1021,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -959,7 +1037,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -974,7 +1053,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -989,7 +1069,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1004,7 +1085,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1019,7 +1101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1034,7 +1117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1049,7 +1133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1064,7 +1149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1079,7 +1165,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1094,7 +1181,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1109,7 +1197,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1124,7 +1213,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1139,7 +1229,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1154,7 +1245,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1169,7 +1261,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1184,7 +1277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1199,7 +1293,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1214,7 +1309,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1229,7 +1325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1244,7 +1341,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1259,7 +1357,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1274,7 +1373,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1289,7 +1389,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1304,7 +1405,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1319,7 +1421,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1334,7 +1437,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1349,7 +1453,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1364,7 +1469,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1379,7 +1485,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1394,7 +1501,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1409,7 +1517,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1424,7 +1533,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1439,7 +1549,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_nested_project_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_nested_project_mces_golden.json index 87cba090cee776..179ea937a5ef99 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_nested_project_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_nested_project_mces_golden.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -258,7 +273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -273,7 +289,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -288,7 +305,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -305,7 +323,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -320,7 +339,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -340,7 +360,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -361,7 +382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -376,7 +398,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -391,7 +414,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -408,7 +432,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -432,7 +457,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -447,7 +473,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -467,7 +494,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -488,7 +516,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -503,7 +532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -518,7 +548,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -535,7 +566,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -559,7 +591,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -578,7 +611,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -593,7 +627,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -613,7 +648,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -634,7 +670,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -649,7 +686,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -664,7 +702,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -681,7 +720,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -705,7 +745,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -720,7 +761,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -740,7 +782,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -761,7 +804,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -776,7 +820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -791,7 +836,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -808,7 +854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -832,7 +879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -847,7 +895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -867,7 +916,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -888,7 +938,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -903,7 +954,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -918,7 +970,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -935,7 +988,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -959,7 +1013,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -974,7 +1029,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -998,7 +1054,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1018,7 +1075,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1082,7 +1140,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1097,7 +1156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1269,7 +1329,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1293,7 +1354,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1355,7 +1417,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1370,7 +1433,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1828,7 +1892,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1852,7 +1917,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1914,7 +1980,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1929,7 +1996,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2439,7 +2507,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2463,7 +2532,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2525,7 +2595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2540,7 +2611,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2946,7 +3018,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2970,7 +3043,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3032,7 +3106,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3047,7 +3122,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3089,7 +3165,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3113,7 +3190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3175,7 +3253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3190,7 +3269,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3400,7 +3480,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3424,7 +3505,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3495,7 +3577,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3510,7 +3593,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3595,7 +3679,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3619,7 +3704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3684,7 +3770,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3699,7 +3786,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4094,7 +4182,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4118,7 +4207,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4183,7 +4273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4198,7 +4289,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4561,7 +4653,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4585,7 +4678,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4650,7 +4744,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4665,7 +4760,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5060,7 +5156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5084,7 +5181,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5146,7 +5244,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5161,7 +5260,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5440,7 +5540,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5464,7 +5565,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5529,7 +5631,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5544,7 +5647,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5800,7 +5904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5824,7 +5929,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5886,7 +5992,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5901,7 +6008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6183,7 +6291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6207,7 +6316,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6272,7 +6382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6287,7 +6398,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6621,7 +6733,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6645,7 +6758,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6710,7 +6824,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6725,7 +6840,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7033,7 +7149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7057,7 +7174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7119,7 +7237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7134,7 +7253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7361,7 +7481,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7385,7 +7506,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7450,7 +7572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7465,7 +7588,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7721,7 +7845,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7745,7 +7870,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7807,7 +7933,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7822,7 +7949,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7971,7 +8099,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7995,7 +8124,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8060,7 +8190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8075,7 +8206,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8409,7 +8541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8433,7 +8566,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8498,7 +8632,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8513,7 +8648,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8743,7 +8879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8767,7 +8904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8832,7 +8970,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8847,7 +8986,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9103,7 +9243,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9127,7 +9268,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9192,7 +9334,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9207,7 +9350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9489,7 +9633,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9513,7 +9658,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9575,7 +9721,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9590,7 +9737,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9927,7 +10075,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9951,7 +10100,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10013,7 +10163,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10028,7 +10179,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10113,7 +10265,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10137,7 +10290,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10157,7 +10311,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10223,7 +10378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10238,7 +10394,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10262,7 +10419,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10323,7 +10481,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10338,7 +10497,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10362,7 +10522,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10421,7 +10582,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10436,7 +10598,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10460,7 +10623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10533,7 +10697,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10548,7 +10713,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10572,7 +10738,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10915,7 +11082,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12705,7 +12873,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12722,7 +12891,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12737,7 +12907,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12761,7 +12932,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12777,7 +12949,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", "type": "TRANSFORMED" }, { @@ -12785,7 +12957,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", "type": "TRANSFORMED" } ], @@ -12861,7 +13033,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13103,7 +13276,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13120,7 +13294,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13135,7 +13310,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13159,7 +13335,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13191,7 +13368,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13605,7 +13783,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13622,7 +13801,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13637,7 +13817,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13661,7 +13842,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14095,7 +14277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14844,7 +15027,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14861,7 +15045,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14876,7 +15061,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14900,7 +15086,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -15045,7 +15232,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21662,7 +21850,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21679,7 +21868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21694,7 +21884,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21718,7 +21909,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21906,7 +22098,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25809,7 +26002,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25826,7 +26020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25841,7 +26036,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25865,7 +26061,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -26015,7 +26212,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31180,7 +31378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31197,7 +31396,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31212,7 +31412,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31236,7 +31437,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31358,7 +31560,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31587,7 +31790,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31604,7 +31808,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31619,7 +31824,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31643,7 +31849,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31746,7 +31953,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31760,469 +31968,18 @@ } }, { - "com.linkedin.pegasus2avro.common.BrowsePaths": { - "paths": [ - "/prod/tableau/default" - ] - } - }, - { - "com.linkedin.pegasus2avro.common.Ownership": { - "owners": [ - { - "owner": "urn:li:corpuser:jawadqu@gmail.com", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - { - "com.linkedin.pegasus2avro.dataset.DatasetProperties": { - "customProperties": {}, - "name": "test publish datasource", - "description": "description for test publish datasource", - "tags": [] - } - }, - { - "com.linkedin.pegasus2avro.schema.SchemaMetadata": { - "schemaName": "test", - "platform": "urn:li:dataPlatform:tableau", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.pegasus2avro.schema.OtherSchema": { - "rawSchema": "" - } - }, - "fields": [ - { - "fieldPath": "payment_date", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.TimeType": {} - } - }, - "nativeDataType": "DATETIME", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:YEAR" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "staff_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_id", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "INTEGER", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "amount", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "REAL", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "Published SQL Query", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.ArrayType": {} - } - }, - "nativeDataType": "TABLE", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ { - "fieldPath": "staff_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false + "tag": "urn:li:tag:tag on published datasource" } ] } - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Published Data Source" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", - "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "changeType": "UPSERT", - "aspectName": "upstreamLineage", - "aspect": { - "json": { - "upstreams": [ - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", - "type": "TRANSFORMED" - } - ], - "fineGrainedLineages": [ - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" - ], - "transformOperation": "GroupField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" - ], - "transformOperation": "BinField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" - ], - "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" - ], - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" - ], - "transformOperation": "SetField", - "confidenceScore": 1.0 - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "proposedSnapshot": { - "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { - "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "aspects": [ - { - "com.linkedin.pegasus2avro.common.DataPlatformInstance": { - "platform": "urn:li:dataPlatform:tableau" - } }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ - "/prod/tableau/Samples" + "/prod/tableau/default" ] } }, @@ -32243,8 +32000,473 @@ { "com.linkedin.pegasus2avro.dataset.DatasetProperties": { "customProperties": {}, - "name": "Superstore Datasource", - "description": "Description for Superstore dataset", + "name": "test publish datasource", + "description": "description for test publish datasource", + "tags": [] + } + }, + { + "com.linkedin.pegasus2avro.schema.SchemaMetadata": { + "schemaName": "test", + "platform": "urn:li:dataPlatform:tableau", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "hash": "", + "platformSchema": { + "com.linkedin.pegasus2avro.schema.OtherSchema": { + "rawSchema": "" + } + }, + "fields": [ + { + "fieldPath": "payment_date", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.TimeType": {} + } + }, + "nativeDataType": "DATETIME", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:YEAR" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_id", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "INTEGER", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "amount", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "REAL", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "Published SQL Query", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.ArrayType": {} + } + }, + "nativeDataType": "TABLE", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + } + ] + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Published Data Source" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "type": "TRANSFORMED" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" + ], + "transformOperation": "GroupField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" + ], + "transformOperation": "BinField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" + ], + "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" + ], + "transformOperation": "SetField", + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { + "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "aspects": [ + { + "com.linkedin.pegasus2avro.common.DataPlatformInstance": { + "platform": "urn:li:dataPlatform:tableau" + } + }, + { + "com.linkedin.pegasus2avro.common.BrowsePaths": { + "paths": [ + "/prod/tableau/Samples" + ] + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:jawadqu@gmail.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + { + "com.linkedin.pegasus2avro.dataset.DatasetProperties": { + "customProperties": {}, + "name": "Superstore Datasource", + "description": "Description for Superstore dataset", "tags": [] } }, @@ -33085,7 +33307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33102,7 +33325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33117,7 +33341,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33137,7 +33362,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33152,7 +33378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33184,7 +33411,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33318,7 +33546,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33336,7 +33565,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33360,7 +33590,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33400,7 +33631,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33546,7 +33778,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33564,7 +33797,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33586,7 +33820,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33665,7 +33900,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33683,7 +33919,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33846,7 +34083,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33871,7 +34109,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34106,7 +34345,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34131,7 +34371,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34342,7 +34583,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34367,7 +34609,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34530,7 +34773,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34555,7 +34799,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34706,7 +34951,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34731,7 +34977,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34870,7 +35117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34895,7 +35143,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34986,7 +35235,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35011,7 +35261,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35078,7 +35329,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35100,7 +35352,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35167,7 +35420,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35189,7 +35443,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35484,7 +35739,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35506,7 +35762,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36295,7 +36552,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36320,7 +36578,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37203,7 +37462,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37228,7 +37488,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38183,7 +38444,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38208,7 +38470,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39031,7 +39294,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39056,7 +39320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39363,7 +39628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39388,7 +39654,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40235,7 +40502,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40260,7 +40528,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41251,7 +41520,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41276,7 +41546,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42135,7 +42406,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42160,7 +42432,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42181,7 +42454,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42203,7 +42477,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42224,7 +42499,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42246,7 +42522,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42266,7 +42543,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42288,7 +42566,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42303,7 +42582,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42318,7 +42598,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42333,7 +42614,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42348,7 +42630,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42363,7 +42646,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42378,7 +42662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42393,7 +42678,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42408,7 +42694,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42423,7 +42710,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42438,7 +42726,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42453,7 +42742,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42468,7 +42758,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42483,7 +42774,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42498,7 +42790,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42513,7 +42806,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42528,7 +42822,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42543,7 +42838,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42558,7 +42854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42573,7 +42870,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42588,7 +42886,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42603,7 +42902,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42618,7 +42918,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42633,7 +42934,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42648,7 +42950,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42663,7 +42966,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42678,7 +42982,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42693,7 +42998,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42708,7 +43014,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42723,7 +43030,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42738,7 +43046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42753,7 +43062,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42768,7 +43078,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42783,7 +43094,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42798,7 +43110,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42813,7 +43126,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42828,7 +43142,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42843,7 +43158,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42858,7 +43174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42873,7 +43190,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42888,7 +43206,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42903,7 +43222,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42918,7 +43238,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42933,7 +43254,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42948,7 +43270,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42963,7 +43286,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42978,7 +43302,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42993,7 +43318,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43008,7 +43334,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43023,7 +43350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43038,7 +43366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43053,7 +43382,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43068,7 +43398,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43083,7 +43414,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43098,7 +43430,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43113,7 +43446,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43128,7 +43462,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43143,7 +43478,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43158,7 +43494,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43173,7 +43510,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43188,7 +43526,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43203,7 +43542,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43218,7 +43558,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43233,7 +43574,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43248,7 +43590,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43263,7 +43606,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43278,7 +43622,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43293,7 +43638,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43308,7 +43654,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43323,7 +43670,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43338,7 +43686,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43353,7 +43702,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43368,7 +43718,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43383,7 +43734,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43398,7 +43750,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43413,7 +43766,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43428,7 +43782,1720 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:252a054d4dd93cd657735aa46dd71370", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:595877512935338b94eac9e06cf20607", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce", + "urn": "urn:li:container:beaddce9d1e89ab503ae6408fb77d4ce" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,222d1406-de0e-cd8d-0b94-9b45a0007e59)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,38130558-4194-2e2a-3046-c0d887829cb4)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,692a2da4-2a82-32c1-f713-63b8e4325d86)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f4317efd-c3e6-6ace-8fe6-e71b590bbbcc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8a6a269a-d6de-fae4-5050-513255b40ffc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c57a5574-db47-46df-677f-0b708dab14db)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e604255e-0573-3951-6db7-05bee48116c1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,20fc5eb7-81eb-aa18-8c39-af501c62d085)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b5351c1-535d-4a4a-1339-c51ddd6abf8a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b73b9dd-4ec7-75ca-f2e9-fa1984ca8b72)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,373c6466-bb0c-b319-8752-632456349261)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,53b8dc2f-8ada-51f7-7422-fe82e9b803cc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,58af9ecf-b839-da50-65e1-2e1fa20e3362)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,618b3e76-75c1-cb31-0c61-3f4890b72c31)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,721c3c41-7a2b-16a8-3281-6f948a44be96)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7ef184c1-5a41-5ec8-723e-ae44c20aa335)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7fbc77ba-0ab6-3727-0db3-d8402a804da5)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8385ea9a-0749-754f-7ad9-824433de2120)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b207c2f2-b675-32e3-2663-17bb836a018b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b679da5e-7d03-f01e-b2ea-01fb3c1926dc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c14973c2-e1c3-563a-a9c1-8a408396d22a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e70a540d-55ed-b9cc-5a3c-01ebe81a1274)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f76d3570-23b8-f74b-d85c-cc5484c2079c)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,130496dc-29ca-8a89-e32b-d73c4d8b65ff)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,8f7dd564-36b6-593f-3c6f-687ad06cd40b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,20e44c22-1ccd-301a-220c-7b6837d09a52)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,39b7a1de-6276-cfc7-9b59-1d22f3bbb06b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,5dcaaf46-e6fb-2548-e763-272a7ab2c9b1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,801c95e3-b07e-7bfe-3789-a561c7beccd3,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4644ccb1-2adc-cf26-c654-04ed1dcc7090,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,618c87db-5959-338b-bcc7-6f5f4cc0b6c6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d00f4ba6-707e-4684-20af-69eb47587cc2,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,06c3e060-8133-4b58-9b53-a0fced25e056,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,3ade7817-ae27-259e-8e48-1570e7f932f6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,dfe2c02a-54b7-f7a2-39fc-c651da2f6ad8,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d8d4c0ea-3162-fa11-31e6-26675da44a38,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "urn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4fb670d5-3e19-9656-e684-74aa9729cf18,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity11,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity10,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity7,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.campaignstable,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.address,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.actor,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.task,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_request,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_req_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_cat_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sys_user_group,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.problem,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.incident,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.cmdb_ci,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.customer,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.payment,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.staff,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_signout_timeout_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_signout_timeout_mces_golden.json index 77ea484abfa0e8..9d0e8a14a1f8c0 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_signout_timeout_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_signout_timeout_mces_golden.json @@ -15,7 +15,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30,7 +31,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -45,7 +47,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -62,7 +65,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -77,7 +81,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -96,7 +101,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -111,7 +117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -126,7 +133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -143,7 +151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -158,7 +167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -177,7 +187,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -192,7 +203,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -207,7 +219,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -224,7 +237,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -239,7 +253,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -260,7 +275,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -275,7 +291,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -290,7 +307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -307,7 +325,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -331,7 +350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -346,7 +366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -366,7 +387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -387,7 +409,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -402,7 +425,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -417,7 +441,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -434,7 +459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -458,7 +484,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -477,7 +504,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -492,7 +520,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -512,7 +541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -533,7 +563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -548,7 +579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -563,7 +595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -580,7 +613,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -604,7 +638,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -619,7 +654,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -639,7 +675,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -660,7 +697,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -675,7 +713,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -690,7 +729,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -707,7 +747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -731,7 +772,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -746,7 +788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -766,7 +809,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -786,7 +830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -850,7 +895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -865,7 +911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1037,7 +1084,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1061,7 +1109,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1123,7 +1172,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1138,7 +1188,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1596,7 +1647,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1620,7 +1672,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1682,7 +1735,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -1697,7 +1751,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2207,7 +2262,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2231,7 +2287,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2293,7 +2350,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2308,7 +2366,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2714,7 +2773,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2738,7 +2798,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2800,7 +2861,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2815,7 +2877,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2857,7 +2920,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2881,7 +2945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2943,7 +3008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -2958,7 +3024,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3168,7 +3235,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3192,7 +3260,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3263,7 +3332,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3278,7 +3348,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3363,7 +3434,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3387,7 +3459,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3452,7 +3525,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3467,7 +3541,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3862,7 +3937,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3886,7 +3962,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3951,7 +4028,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -3966,7 +4044,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4329,7 +4408,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4353,7 +4433,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4418,7 +4499,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4433,7 +4515,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4828,7 +4911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4852,7 +4936,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4914,7 +4999,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -4929,7 +5015,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5208,7 +5295,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5232,7 +5320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5297,7 +5386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5312,7 +5402,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5568,7 +5659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5592,7 +5684,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5654,7 +5747,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5669,7 +5763,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5951,7 +6046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -5975,7 +6071,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6040,7 +6137,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6055,7 +6153,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6389,7 +6488,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6413,7 +6513,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6478,7 +6579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6493,7 +6595,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6801,7 +6904,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6825,7 +6929,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6887,7 +6992,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -6902,7 +7008,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7129,7 +7236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7153,7 +7261,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7218,7 +7327,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7233,7 +7343,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7489,7 +7600,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7513,7 +7625,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7575,7 +7688,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7590,7 +7704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7739,7 +7854,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7763,7 +7879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7828,7 +7945,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -7843,7 +7961,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8177,7 +8296,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8201,7 +8321,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8266,7 +8387,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8281,7 +8403,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8511,7 +8634,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8535,7 +8659,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8600,7 +8725,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8615,7 +8741,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8871,7 +8998,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8895,7 +9023,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8960,7 +9089,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -8975,7 +9105,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9257,7 +9388,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9281,7 +9413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9343,7 +9476,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9358,7 +9492,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9695,7 +9830,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9719,7 +9855,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9781,7 +9918,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9796,7 +9934,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9881,7 +10020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9905,7 +10045,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9925,7 +10066,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -9991,7 +10133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10006,7 +10149,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10030,7 +10174,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10091,7 +10236,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10106,7 +10252,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10130,7 +10277,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10189,7 +10337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10204,7 +10353,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10228,7 +10378,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10301,7 +10452,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10316,7 +10468,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10340,7 +10493,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -10683,7 +10837,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12473,7 +12628,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12490,7 +12646,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12505,7 +12662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12529,7 +12687,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12545,7 +12704,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", "type": "TRANSFORMED" }, { @@ -12553,7 +12712,7 @@ "time": 0, "actor": "urn:li:corpuser:unknown" }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "dataset": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", "type": "TRANSFORMED" } ], @@ -12629,7 +12788,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12871,7 +13031,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12888,7 +13049,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12903,7 +13065,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12927,7 +13090,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -12959,7 +13123,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13373,7 +13538,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13390,7 +13556,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13405,7 +13572,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13429,7 +13597,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -13863,7 +14032,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14612,7 +14782,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14629,7 +14800,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14644,7 +14816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14668,7 +14841,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -14813,7 +14987,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21430,7 +21605,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21447,7 +21623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21462,7 +21639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21486,7 +21664,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -21674,7 +21853,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25577,7 +25757,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25594,7 +25775,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25609,7 +25791,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25633,7 +25816,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -25783,7 +25967,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30948,7 +31133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30965,7 +31151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -30980,7 +31167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31004,7 +31192,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31126,7 +31315,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31355,7 +31545,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31372,7 +31563,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31387,7 +31579,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31411,7 +31604,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31514,7 +31708,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -31528,469 +31723,18 @@ } }, { - "com.linkedin.pegasus2avro.common.BrowsePaths": { - "paths": [ - "/prod/tableau/default" - ] - } - }, - { - "com.linkedin.pegasus2avro.common.Ownership": { - "owners": [ - { - "owner": "urn:li:corpuser:jawadqu@gmail.com", - "type": "DATAOWNER" - } - ], - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - } - } - }, - { - "com.linkedin.pegasus2avro.dataset.DatasetProperties": { - "customProperties": {}, - "name": "test publish datasource", - "description": "description for test publish datasource", - "tags": [] - } - }, - { - "com.linkedin.pegasus2avro.schema.SchemaMetadata": { - "schemaName": "test", - "platform": "urn:li:dataPlatform:tableau", - "version": 0, - "created": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "lastModified": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "hash": "", - "platformSchema": { - "com.linkedin.pegasus2avro.schema.OtherSchema": { - "rawSchema": "" - } - }, - "fields": [ - { - "fieldPath": "payment_date", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.TimeType": {} - } - }, - "nativeDataType": "DATETIME", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:YEAR" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "staff_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_id", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "INTEGER", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "amount", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.NumberType": {} - } - }, - "nativeDataType": "REAL", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:SUM" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "Published SQL Query", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.ArrayType": {} - } - }, - "nativeDataType": "TABLE", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:MEASURE" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, - { - "fieldPath": "customer_first_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false - }, + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ { - "fieldPath": "staff_last_name", - "nullable": false, - "description": "", - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.StringType": {} - } - }, - "nativeDataType": "STRING", - "recursive": false, - "globalTags": { - "tags": [ - { - "tag": "urn:li:tag:DIMENSION" - }, - { - "tag": "urn:li:tag:COLUMNFIELD" - }, - { - "tag": "urn:li:tag:COUNT" - } - ] - }, - "isPartOfKey": false + "tag": "urn:li:tag:tag on published datasource" } ] } - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "subTypes", - "aspect": { - "json": { - "typeNames": [ - "Published Data Source" - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "container", - "aspect": { - "json": { - "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", - "changeType": "UPSERT", - "aspectName": "browsePathsV2", - "aspect": { - "json": { - "path": [ - { - "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", - "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "entityType": "dataset", - "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "changeType": "UPSERT", - "aspectName": "upstreamLineage", - "aspect": { - "json": { - "upstreams": [ - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", - "type": "TRANSFORMED" - }, - { - "auditStamp": { - "time": 0, - "actor": "urn:li:corpuser:unknown" - }, - "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", - "type": "TRANSFORMED" - } - ], - "fineGrainedLineages": [ - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" - ], - "transformOperation": "GroupField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" - ], - "transformOperation": "HierarchyField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" - ], - "transformOperation": "BinField", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" - ], - "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" - ], - "confidenceScore": 1.0 - }, - { - "upstreamType": "FIELD_SET", - "upstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" - ], - "downstreamType": "FIELD", - "downstreams": [ - "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" - ], - "transformOperation": "SetField", - "confidenceScore": 1.0 - } - ] - } - }, - "systemMetadata": { - "lastObserved": 1638860400000, - "runId": "tableau-test" - } -}, -{ - "proposedSnapshot": { - "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { - "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", - "aspects": [ - { - "com.linkedin.pegasus2avro.common.DataPlatformInstance": { - "platform": "urn:li:dataPlatform:tableau" - } }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ - "/prod/tableau/Samples" + "/prod/tableau/default" ] } }, @@ -32011,8 +31755,473 @@ { "com.linkedin.pegasus2avro.dataset.DatasetProperties": { "customProperties": {}, - "name": "Superstore Datasource", - "description": "Description for Superstore dataset", + "name": "test publish datasource", + "description": "description for test publish datasource", + "tags": [] + } + }, + { + "com.linkedin.pegasus2avro.schema.SchemaMetadata": { + "schemaName": "test", + "platform": "urn:li:dataPlatform:tableau", + "version": 0, + "created": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "hash": "", + "platformSchema": { + "com.linkedin.pegasus2avro.schema.OtherSchema": { + "rawSchema": "" + } + }, + "fields": [ + { + "fieldPath": "payment_date", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.TimeType": {} + } + }, + "nativeDataType": "DATETIME", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:YEAR" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_id", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "INTEGER", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "amount", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "REAL", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:SUM" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "Published SQL Query", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.ArrayType": {} + } + }, + "nativeDataType": "TABLE", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:MEASURE" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "customer_first_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + }, + { + "fieldPath": "staff_last_name", + "nullable": false, + "description": "", + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "STRING", + "recursive": false, + "globalTags": { + "tags": [ + { + "tag": "urn:li:tag:DIMENSION" + }, + { + "tag": "urn:li:tag:COLUMNFIELD" + }, + { + "tag": "urn:li:tag:COUNT" + } + ] + }, + "isPartOfKey": false + } + ] + } + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "subTypes", + "aspect": { + "json": { + "typeNames": [ + "Published Data Source" + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "container", + "aspect": { + "json": { + "container": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "upstreamLineage", + "aspect": { + "json": { + "upstreams": [ + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "type": "TRANSFORMED" + }, + { + "auditStamp": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + }, + "dataset": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "type": "TRANSFORMED" + } + ], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),City)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Postal Code)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Country/Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Region)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),State)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Location)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)" + ], + "transformOperation": "GroupField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sub-Category)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Manufacturer)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product Name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Product)" + ], + "transformOperation": "HierarchyField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit %28bin%29)" + ], + "transformOperation": "BinField", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)", + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Sales)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit Ratio)" + ], + "transformOperation": "CalculatedFieldformula: SUM([Profit])/SUM([Sales])", + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD),Segment)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Segment)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Profit)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD),Top Customers by Profit)" + ], + "transformOperation": "SetField", + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": { + "urn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "aspects": [ + { + "com.linkedin.pegasus2avro.common.DataPlatformInstance": { + "platform": "urn:li:dataPlatform:tableau" + } + }, + { + "com.linkedin.pegasus2avro.common.BrowsePaths": { + "paths": [ + "/prod/tableau/Samples" + ] + } + }, + { + "com.linkedin.pegasus2avro.common.Ownership": { + "owners": [ + { + "owner": "urn:li:corpuser:jawadqu@gmail.com", + "type": "DATAOWNER" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + }, + { + "com.linkedin.pegasus2avro.dataset.DatasetProperties": { + "customProperties": {}, + "name": "Superstore Datasource", + "description": "Description for Superstore dataset", "tags": [] } }, @@ -32853,7 +33062,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32870,7 +33080,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32885,7 +33096,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32905,7 +33117,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32920,7 +33133,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -32952,7 +33166,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33086,7 +33301,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33104,7 +33320,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33128,7 +33345,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33168,7 +33386,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33314,7 +33533,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33332,7 +33552,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33354,7 +33575,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33440,7 +33662,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33458,7 +33681,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33480,7 +33704,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33643,7 +33868,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33668,7 +33894,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33903,7 +34130,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -33928,7 +34156,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34139,7 +34368,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34164,7 +34394,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34327,7 +34558,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34352,7 +34584,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34503,7 +34736,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34528,7 +34762,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34667,7 +34902,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34692,7 +34928,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34783,7 +35020,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34808,7 +35046,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34875,7 +35114,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34897,7 +35137,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34964,7 +35205,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -34986,7 +35228,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35281,7 +35524,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -35303,7 +35547,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36092,7 +36337,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -36117,7 +36363,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37000,7 +37247,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37025,7 +37273,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -37980,7 +38229,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38005,7 +38255,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38828,7 +39079,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -38853,7 +39105,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39160,7 +39413,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -39185,7 +39439,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40032,7 +40287,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -40057,7 +40313,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41048,7 +41305,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41073,7 +41331,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41932,7 +42191,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41957,7 +42217,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -41978,7 +42239,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42000,7 +42262,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42021,7 +42284,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42043,7 +42307,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42063,7 +42328,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42085,7 +42351,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42100,7 +42367,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42115,7 +42383,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42130,7 +42399,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42145,7 +42415,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42160,7 +42431,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42175,7 +42447,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42190,7 +42463,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42205,7 +42479,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42220,7 +42495,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42235,7 +42511,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42250,7 +42527,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42265,7 +42543,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42280,7 +42559,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42295,7 +42575,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42310,7 +42591,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42325,7 +42607,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42340,7 +42623,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42355,7 +42639,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42370,7 +42655,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42385,7 +42671,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42400,7 +42687,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42415,7 +42703,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42430,7 +42719,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42445,7 +42735,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42460,7 +42751,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42475,7 +42767,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42490,7 +42783,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42505,7 +42799,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42520,7 +42815,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42535,7 +42831,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42550,7 +42847,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42565,7 +42863,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42580,7 +42879,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42595,7 +42895,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42610,7 +42911,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42625,7 +42927,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42640,7 +42943,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42655,7 +42959,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42670,7 +42975,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42685,7 +42991,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42700,7 +43007,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42715,7 +43023,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42730,7 +43039,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42745,7 +43055,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42760,7 +43071,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42775,7 +43087,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42790,7 +43103,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42805,7 +43119,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42820,7 +43135,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42835,7 +43151,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42850,7 +43167,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42865,7 +43183,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42880,7 +43199,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42895,7 +43215,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42910,7 +43231,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42925,7 +43247,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42940,7 +43263,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42955,7 +43279,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42970,7 +43295,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -42985,7 +43311,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43000,7 +43327,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43015,7 +43343,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43030,7 +43359,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43045,7 +43375,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43060,7 +43391,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43075,7 +43407,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43090,7 +43423,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43105,7 +43439,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43120,7 +43455,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43135,7 +43471,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43150,7 +43487,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43165,7 +43503,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43180,7 +43519,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43195,7 +43535,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43210,7 +43551,8 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } }, { @@ -43225,7 +43567,1697 @@ }, "systemMetadata": { "lastObserved": 1638860400000, - "runId": "tableau-test" + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:252a054d4dd93cd657735aa46dd71370", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "container", + "entityUrn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,222d1406-de0e-cd8d-0b94-9b45a0007e59)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,38130558-4194-2e2a-3046-c0d887829cb4)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,692a2da4-2a82-32c1-f713-63b8e4325d86)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f4317efd-c3e6-6ace-8fe6-e71b590bbbcc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8a6a269a-d6de-fae4-5050-513255b40ffc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c57a5574-db47-46df-677f-0b708dab14db)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e604255e-0573-3951-6db7-05bee48116c1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,20fc5eb7-81eb-aa18-8c39-af501c62d085)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b5351c1-535d-4a4a-1339-c51ddd6abf8a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,2b73b9dd-4ec7-75ca-f2e9-fa1984ca8b72)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,373c6466-bb0c-b319-8752-632456349261)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,53b8dc2f-8ada-51f7-7422-fe82e9b803cc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,58af9ecf-b839-da50-65e1-2e1fa20e3362)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,618b3e76-75c1-cb31-0c61-3f4890b72c31)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,721c3c41-7a2b-16a8-3281-6f948a44be96)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7ef184c1-5a41-5ec8-723e-ae44c20aa335)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,7fbc77ba-0ab6-3727-0db3-d8402a804da5)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,8385ea9a-0749-754f-7ad9-824433de2120)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b207c2f2-b675-32e3-2663-17bb836a018b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,b679da5e-7d03-f01e-b2ea-01fb3c1926dc)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,c14973c2-e1c3-563a-a9c1-8a408396d22a)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,e70a540d-55ed-b9cc-5a3c-01ebe81a1274)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,f76d3570-23b8-f74b-d85c-cc5484c2079c)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "chart", + "entityUrn": "urn:li:chart:(tableau,130496dc-29ca-8a89-e32b-d73c4d8b65ff)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,8f7dd564-36b6-593f-3c6f-687ad06cd40b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,20e44c22-1ccd-301a-220c-7b6837d09a52)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,39b7a1de-6276-cfc7-9b59-1d22f3bbb06b)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dashboard", + "entityUrn": "urn:li:dashboard:(tableau,5dcaaf46-e6fb-2548-e763-272a7ab2c9b1)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,801c95e3-b07e-7bfe-3789-a561c7beccd3,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a", + "urn": "urn:li:container:008e111aa1d250dd52e0fd5d4b307b1a" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4644ccb1-2adc-cf26-c654-04ed1dcc7090,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,618c87db-5959-338b-bcc7-6f5f4cc0b6c6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d00f4ba6-707e-4684-20af-69eb47587cc2,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,06c3e060-8133-4b58-9b53-a0fced25e056,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,3ade7817-ae27-259e-8e48-1570e7f932f6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,dfe2c02a-54b7-f7a2-39fc-c651da2f6ad8,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d", + "urn": "urn:li:container:047691e9c16bec8fb08e1df0f5d71c4d" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,d8d4c0ea-3162-fa11-31e6-26675da44a38,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9", + "urn": "urn:li:container:94e6e84b66f9ee8c70c22f06cfbad6a9" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,00cce29f-b561-bb41-3557-8e19660bb5dd,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,6cbbeeb2-9f3a-00f6-2342-17139d6e97ae,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873", + "urn": "urn:li:container:d2dcd6bd1bb954d62f1cfc68332ee873" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,22b0b4c3-6b85-713d-a161-5a87fdd78f40,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b", + "urn": "urn:li:container:5ec314b9630974ec084f5dfd3849f87b" + }, + { + "id": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1", + "urn": "urn:li:container:fad3de4b86519c3edeb685215fe0bab1" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,4fb670d5-3e19-9656-e684-74aa9729cf18,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:tableau,10c6297d-0dbd-44f1-b1ba-458bea446513,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "SubProject1" + }, + { + "id": "AbcJoinWorkbook" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity6,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity11,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity10,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.activity7,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:marketo-marketo,marketo.campaignstable,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Email Performance by Campaign" + }, + { + "id": "Marketo" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.address,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.actor,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Dvdrental Workbook" + }, + { + "id": "actor+ (dvdrental)" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.people,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.returns,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:external,sample - superstore%2C %28new%29.xls.orders,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "Samples" + }, + { + "id": "Superstore Datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.task,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_request,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_req_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sc_cat_item,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Requests" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.sys_user_group,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.problem,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Problems" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.incident,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:webdata-direct:servicenowitsm-servicenowitsm,ven01911.cmdb_ci,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Executive Dashboard" + }, + { + "id": "Incidents" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.customer,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.payment,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "Customer Payment Query" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,demo_postgres_instance.dvdrental.public.staff,PROD)", + "changeType": "UPSERT", + "aspectName": "browsePathsV2", + "aspect": { + "json": { + "path": [ + { + "id": "default" + }, + { + "id": "test publish datasource" + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" } } ] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/tableau/tableau_with_platform_instance_mces_golden.json b/metadata-ingestion/tests/integration/tableau/tableau_with_platform_instance_mces_golden.json index f94cafb1073175..f3dedb2cc1ce35 100644 --- a/metadata-ingestion/tests/integration/tableau/tableau_with_platform_instance_mces_golden.json +++ b/metadata-ingestion/tests/integration/tableau/tableau_with_platform_instance_mces_golden.json @@ -31855,6 +31855,15 @@ "instance": "urn:li:dataPlatformInstance:(urn:li:dataPlatform:tableau,acryl_site1)" } }, + { + "com.linkedin.pegasus2avro.common.GlobalTags": { + "tags": [ + { + "tag": "urn:li:tag:tag on published datasource" + } + ] + } + }, { "com.linkedin.pegasus2avro.common.BrowsePaths": { "paths": [ @@ -43858,6 +43867,22 @@ "lastRunId": "no-run-id-provided" } }, +{ + "entityType": "tag", + "entityUrn": "urn:li:tag:tag on published datasource", + "changeType": "UPSERT", + "aspectName": "tagKey", + "aspect": { + "json": { + "name": "tag on published datasource" + } + }, + "systemMetadata": { + "lastObserved": 1638860400000, + "runId": "tableau-test", + "lastRunId": "no-run-id-provided" + } +}, { "entityType": "container", "entityUrn": "urn:li:container:66fa1e14620418276c85f3b552c7ec65", From 8559ebae200faafd84ccd35e5943590fc0de9b2a Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Tue, 30 Jan 2024 12:49:07 +0530 Subject: [PATCH 233/540] feat(ingest/okta): option to only ingest users from filtered groups (#9738) --- .../src/datahub/ingestion/source/identity/okta.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/identity/okta.py b/metadata-ingestion/src/datahub/ingestion/source/identity/okta.py index 5e8413bbb6f309..c9b0e4d7de4674 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/identity/okta.py +++ b/metadata-ingestion/src/datahub/ingestion/source/identity/okta.py @@ -140,6 +140,10 @@ class OktaConfig(StatefulIngestionConfigBase, ConfigModel): default=None, description="Okta search expression (not regex) for ingesting groups. Only one of `okta_groups_filter` and `okta_groups_search` can be set. See (https://developer.okta.com/docs/reference/api/groups/#list-groups-with-search) for more info.", ) + skip_users_without_a_group: bool = Field( + default=False, + description="Whether to only ingest users that are members of groups. If this is set to False, all users will be ingested regardless of group membership.", + ) # Configuration for stateful ingestion stateful_ingestion: Optional[StatefulStaleMetadataRemovalConfig] = Field( @@ -387,6 +391,15 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]: datahub_corp_user_snapshot.urn ] ) + if ( + self.config.skip_users_without_a_group + and len(datahub_group_membership.groups) == 0 + ): + logger.debug( + f"Filtering {datahub_corp_user_snapshot.urn} due to group filter" + ) + self.report.report_filtered(datahub_corp_user_snapshot.urn) + continue assert datahub_group_membership is not None datahub_corp_user_snapshot.aspects.append(datahub_group_membership) mce = MetadataChangeEvent(proposedSnapshot=datahub_corp_user_snapshot) From aa98f4897a358b6894d22bdfa9f8d4fea4538801 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Tue, 30 Jan 2024 17:54:09 +0530 Subject: [PATCH 234/540] feat(cli): add sibling helper CLI command (#9744) --- .../src/datahub/cli/specific/dataset_cli.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py b/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py index c702d0ec28961c..1c55651f4ff941 100644 --- a/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py +++ b/metadata-ingestion/src/datahub/cli/specific/dataset_cli.py @@ -1,12 +1,15 @@ import json import logging from pathlib import Path +from typing import Set, Tuple import click from click_default_group import DefaultGroup from datahub.api.entities.dataset.dataset import Dataset -from datahub.ingestion.graph.client import get_default_graph +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.graph.client import DataHubGraph, get_default_graph +from datahub.metadata.com.linkedin.pegasus2avro.common import Siblings from datahub.telemetry import telemetry from datahub.upgrade import upgrade @@ -65,3 +68,38 @@ def get(urn: str, to_file: str) -> None: click.secho(f"Dataset yaml written to {to_file}", fg="green") else: click.secho(f"Dataset {urn} does not exist") + + +@dataset.command() +@click.option("--urn", required=True, type=str, help="URN of primary sibling") +@click.option( + "--sibling-urns", + required=True, + type=str, + help="URN of secondary sibling(s)", + multiple=True, +) +@telemetry.with_telemetry() +def add_sibling(urn: str, sibling_urns: Tuple[str]) -> None: + all_urns = set() + all_urns.add(urn) + for sibling_urn in sibling_urns: + all_urns.add(sibling_urn) + with get_default_graph() as graph: + for _urn in all_urns: + _emit_sibling(graph, urn, _urn, all_urns) + + +def _emit_sibling( + graph: DataHubGraph, primary_urn: str, urn: str, all_urns: Set[str] +) -> None: + siblings = [] + for sibling_urn in all_urns: + if sibling_urn != urn: + siblings.append(sibling_urn) + graph.emit( + MetadataChangeProposalWrapper( + entityUrn=urn, + aspect=Siblings(primary=primary_urn == urn, siblings=sorted(siblings)), + ) + ) From 1af9b873266cd0cf25b817688188fcd0ec887e73 Mon Sep 17 00:00:00 2001 From: Davi Arnaut Date: Tue, 30 Jan 2024 04:16:31 -1000 Subject: [PATCH 235/540] chore(gms): allow adjusting the log level for gms debug log file (#9743) --- metadata-service/war/src/main/resources/logback.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-service/war/src/main/resources/logback.xml b/metadata-service/war/src/main/resources/logback.xml index 111ec627a6d430..2594f8c48ab535 100644 --- a/metadata-service/war/src/main/resources/logback.xml +++ b/metadata-service/war/src/main/resources/logback.xml @@ -47,7 +47,7 @@ 1 - DEBUG + ${logging.appender.debug_file.level:-DEBUG} ACCEPT DENY From 69d0ba181223c16ee2800bf093236d1acde5395b Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 30 Jan 2024 08:19:06 -0600 Subject: [PATCH 236/540] build(ingestion-smoke): fix smoke dockerfile (#9724) --- .../datahub-ingestion-base/smoke.Dockerfile | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docker/datahub-ingestion-base/smoke.Dockerfile b/docker/datahub-ingestion-base/smoke.Dockerfile index 5c6738720e05ec..34654faaad729d 100644 --- a/docker/datahub-ingestion-base/smoke.Dockerfile +++ b/docker/datahub-ingestion-base/smoke.Dockerfile @@ -1,6 +1,6 @@ FROM acryldata/datahub-ingestion-base as base -RUN apt-get update && apt-get install -y \ +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ sudo \ python3-dev \ libgtk2.0-0 \ @@ -13,14 +13,16 @@ RUN apt-get update && apt-get install -y \ libasound2 \ libxtst6 \ xauth \ - xvfb - -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y openjdk-17-jdk + xvfb \ + openjdk-17-jdk && \ + rm -rf /var/lib/apt/lists/* /var/cache/apk/* COPY . /datahub-src ARG RELEASE_VERSION -RUN cd /datahub-src/metadata-ingestion && \ - sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" src/datahub/__init__.py && \ - cat src/datahub/__init__.py && \ - cd ../ && \ - ./gradlew :metadata-ingestion:installAll +RUN cd /datahub-src && \ + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" metadata-ingestion/src/datahub/__init__.py && \ + sed -i.bak "s/__version__ = \"1\!0.0.0.dev0\"/__version__ = \"$(echo $RELEASE_VERSION|sed s/-/+/)\"/" metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/__init__.py && \ + cat metadata-ingestion/src/datahub/__init__.py && \ + ./gradlew :metadata-ingestion:codegen && \ + pip install file:metadata-ingestion-modules/airflow-plugin#egg=acryl-datahub-airflow-plugin file:metadata-ingestion#egg=acryl-datahub + From 3f9490d9018d4505eac413f92a9301cfca5c0ae7 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Wed, 31 Jan 2024 14:42:40 +0530 Subject: [PATCH 237/540] feat(cli): option to init via username password, add lint for smoke-test (#9675) Co-authored-by: Harshal Sheth --- .github/workflows/docker-unified.yml | 12 + docs/cli.md | 2 +- .../src/datahub/cli/cli_utils.py | 207 ++++++------ .../src/datahub/cli/config_utils.py | 102 ++++++ .../src/datahub/cli/docker_cli.py | 2 +- .../src/datahub/cli/env_utils.py | 11 + .../src/datahub/cli/ingest_cli.py | 19 +- .../src/datahub/cli/lite_cli.py | 2 +- .../src/datahub/emitter/mcp_patch_builder.py | 5 +- metadata-ingestion/src/datahub/entrypoints.py | 36 +- .../integrations/great_expectations/action.py | 2 +- .../src/datahub/specific/chart.py | 2 +- .../src/datahub/specific/dashboard.py | 2 +- .../src/datahub/specific/datajob.py | 2 +- .../src/datahub/specific/dataproduct.py | 1 - .../src/datahub/specific/dataset.py | 2 +- .../src/datahub/telemetry/telemetry.py | 3 +- .../tests/unit/test_cli_utils.py | 21 ++ smoke-test/.gitignore | 1 + smoke-test/build.gradle | 35 +- smoke-test/pyproject.toml | 46 +++ smoke-test/requirements.txt | 10 +- smoke-test/run-quickstart.sh | 4 +- .../timeseries/dataset_profile_gen.py | 8 +- .../tests/assertions/assertions_test.py | 40 ++- smoke-test/tests/browse/browse_test.py | 7 +- .../cli/{datahub-cli.py => datahub_cli.py} | 7 +- smoke-test/tests/cli/datahub_graph_test.py | 11 +- .../cli/delete_cmd/test_timeseries_delete.py | 8 +- .../ingest_cmd/test_timeseries_rollback.py | 2 - .../user_groups_cmd}/__init__.py | 0 .../cli/user_groups_cmd/test_group_cmd.py | 9 +- smoke-test/tests/consistency_utils.py | 2 +- .../tests/containers/containers_test.py | 9 +- smoke-test/tests/cypress/integration_test.py | 24 +- .../tests/dataproduct/test_dataproduct.py | 26 +- smoke-test/tests/delete/delete_test.py | 16 +- .../tests/deprecation/deprecation_test.py | 8 +- smoke-test/tests/domains/domains_test.py | 12 +- smoke-test/tests/lineage/test_lineage.py | 7 +- .../__init__.py | 0 .../managed_ingestion_test.py | 13 +- smoke-test/tests/patch/common_patch_tests.py | 86 ++--- .../tests/patch/test_datajob_patches.py | 23 +- .../tests/patch/test_dataset_patches.py | 42 +-- smoke-test/tests/policies/test_policies.py | 10 +- smoke-test/tests/privileges/__init__.py | 0 .../tests/privileges/test_privileges.py | 275 ++++++++------- smoke-test/tests/privileges/utils.py | 101 +++--- .../tests/read_only/test_services_up.py | 8 +- .../setup/lineage/ingest_data_job_change.py | 35 +- .../lineage/ingest_dataset_join_change.py | 27 +- .../lineage/ingest_input_datasets_change.py | 34 +- .../setup/lineage/ingest_time_lineage.py | 12 +- smoke-test/tests/setup/lineage/utils.py | 60 ++-- .../test_structured_properties.py | 315 +++++++++--------- smoke-test/tests/tags_and_terms/__init__.py | 0 .../data.json | 0 .../tags_and_terms_test.py | 12 +- smoke-test/tests/telemetry/telemetry_test.py | 4 +- smoke-test/tests/test_stateful_ingestion.py | 16 +- smoke-test/tests/tests/tests_test.py | 12 +- smoke-test/tests/timeline/timeline_test.py | 9 +- .../tokens/revokable_access_token_test.py | 60 ++-- smoke-test/tests/utilities/file_emitter.py | 11 +- smoke-test/tests/utils.py | 31 +- smoke-test/tests/views/views_test.py | 12 +- 67 files changed, 1161 insertions(+), 772 deletions(-) create mode 100644 metadata-ingestion/src/datahub/cli/config_utils.py create mode 100644 metadata-ingestion/src/datahub/cli/env_utils.py create mode 100644 smoke-test/pyproject.toml rename smoke-test/tests/cli/{datahub-cli.py => datahub_cli.py} (93%) rename smoke-test/tests/{managed-ingestion => cli/user_groups_cmd}/__init__.py (100%) rename smoke-test/tests/{tags-and-terms => managed_ingestion}/__init__.py (100%) rename smoke-test/tests/{managed-ingestion => managed_ingestion}/managed_ingestion_test.py (98%) create mode 100644 smoke-test/tests/privileges/__init__.py create mode 100644 smoke-test/tests/tags_and_terms/__init__.py rename smoke-test/tests/{tags-and-terms => tags_and_terms}/data.json (100%) rename smoke-test/tests/{tags-and-terms => tags_and_terms}/tags_and_terms_test.py (98%) diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index 5e9112726b010e..24490ef83ae5de 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -64,6 +64,18 @@ jobs: steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: "pip" + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 + - name: Run lint on smoke test + run: | + ./gradlew :smoke-test:lint - name: Compute Tag id: tag run: | diff --git a/docs/cli.md b/docs/cli.md index 927270b42259d4..3f67f1de6204d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -180,7 +180,7 @@ failure_log: ### init The init command is used to tell `datahub` about where your DataHub instance is located. The CLI will point to localhost DataHub by default. -Running `datahub init` will allow you to customize the datahub instance you are communicating with. +Running `datahub init` will allow you to customize the datahub instance you are communicating with. It has an optional `--use-password` option which allows to initialise the config using username, password. We foresee this mainly being used by admins as majority of organisations will be using SSO and there won't be any passwords to use. **_Note_**: Provide your GMS instance's host when the prompt asks you for the DataHub host. diff --git a/metadata-ingestion/src/datahub/cli/cli_utils.py b/metadata-ingestion/src/datahub/cli/cli_utils.py index 8ac9a101121be6..1bb3b01e078dd9 100644 --- a/metadata-ingestion/src/datahub/cli/cli_utils.py +++ b/metadata-ingestion/src/datahub/cli/cli_utils.py @@ -9,12 +9,11 @@ import click import requests -import yaml from deprecated import deprecated -from pydantic import BaseModel, ValidationError from requests.models import Response from requests.sessions import Session +from datahub.cli import config_utils from datahub.emitter.aspect import ASPECT_MAP, TIMESERIES_ASPECT_MAP from datahub.emitter.request_helper import make_curl_command from datahub.emitter.serialization_helper import post_json_transform @@ -23,13 +22,6 @@ log = logging.getLogger(__name__) -DEFAULT_GMS_HOST = "http://localhost:8080" -CONDENSED_DATAHUB_CONFIG_PATH = "~/.datahubenv" -DATAHUB_CONFIG_PATH = os.path.expanduser(CONDENSED_DATAHUB_CONFIG_PATH) - -DATAHUB_ROOT_FOLDER = os.path.expanduser("~/.datahub") - -ENV_SKIP_CONFIG = "DATAHUB_SKIP_CONFIG" ENV_METADATA_HOST_URL = "DATAHUB_GMS_URL" ENV_METADATA_HOST = "DATAHUB_GMS_HOST" ENV_METADATA_PORT = "DATAHUB_GMS_PORT" @@ -45,25 +37,6 @@ # For the methods that aren't duplicates, that logic should be moved to the client. -class GmsConfig(BaseModel): - server: str - token: Optional[str] = None - - -class DatahubConfig(BaseModel): - gms: GmsConfig - - -def get_boolean_env_variable(key: str, default: bool = False) -> bool: - value = os.environ.get(key) - if value is None: - return default - elif value.lower() in ("true", "1"): - return True - else: - return False - - def set_env_variables_override_config(url: str, token: Optional[str]) -> None: """Should be used to override the config when using rest emitter""" config_override[ENV_METADATA_HOST_URL] = url @@ -71,78 +44,6 @@ def set_env_variables_override_config(url: str, token: Optional[str]) -> None: config_override[ENV_METADATA_TOKEN] = token -def persist_datahub_config(config: dict) -> None: - with open(DATAHUB_CONFIG_PATH, "w+") as outfile: - yaml.dump(config, outfile, default_flow_style=False) - return None - - -def write_gms_config( - host: str, token: Optional[str], merge_with_previous: bool = True -) -> None: - config = DatahubConfig(gms=GmsConfig(server=host, token=token)) - if merge_with_previous: - try: - previous_config = get_client_config(as_dict=True) - assert isinstance(previous_config, dict) - except Exception as e: - # ok to fail on this - previous_config = {} - log.debug( - f"Failed to retrieve config from file {DATAHUB_CONFIG_PATH}: {e}. This isn't fatal." - ) - config_dict = {**previous_config, **config.dict()} - else: - config_dict = config.dict() - persist_datahub_config(config_dict) - - -def should_skip_config() -> bool: - return get_boolean_env_variable(ENV_SKIP_CONFIG, False) - - -def ensure_datahub_config() -> None: - if not os.path.isfile(DATAHUB_CONFIG_PATH): - click.secho( - f"No {CONDENSED_DATAHUB_CONFIG_PATH} file found, generating one for you...", - bold=True, - ) - write_gms_config(DEFAULT_GMS_HOST, None) - - -def get_client_config(as_dict: bool = False) -> Union[Optional[DatahubConfig], dict]: - with open(DATAHUB_CONFIG_PATH, "r") as stream: - try: - config_json = yaml.safe_load(stream) - if as_dict: - return config_json - try: - datahub_config = DatahubConfig.parse_obj(config_json) - return datahub_config - except ValidationError as e: - click.echo( - f"Received error, please check your {CONDENSED_DATAHUB_CONFIG_PATH}" - ) - click.echo(e, err=True) - sys.exit(1) - except yaml.YAMLError as exc: - click.secho(f"{DATAHUB_CONFIG_PATH} malformed, error: {exc}", bold=True) - return None - - -def get_details_from_config(): - datahub_config = get_client_config(as_dict=False) - assert isinstance(datahub_config, DatahubConfig) - if datahub_config is not None: - gms_config = datahub_config.gms - - gms_host = gms_config.server - gms_token = gms_config.token - return gms_host, gms_token - else: - return None, None - - def get_details_from_env() -> Tuple[Optional[str], Optional[str]]: host = os.environ.get(ENV_METADATA_HOST) port = os.environ.get(ENV_METADATA_PORT) @@ -178,12 +79,12 @@ def get_url_and_token(): if len(config_override.keys()) > 0: gms_host = config_override.get(ENV_METADATA_HOST_URL) gms_token = config_override.get(ENV_METADATA_TOKEN) - elif should_skip_config(): + elif config_utils.should_skip_config(): gms_host = gms_host_env gms_token = gms_token_env else: - ensure_datahub_config() - gms_host_conf, gms_token_conf = get_details_from_config() + config_utils.ensure_datahub_config() + gms_host_conf, gms_token_conf = config_utils.get_details_from_config() gms_host = first_non_null([gms_host_env, gms_host_conf]) gms_token = first_non_null([gms_token_env, gms_token_conf]) return gms_host, gms_token @@ -253,14 +154,18 @@ def parse_run_restli_response(response: requests.Response) -> dict: exit() if not isinstance(response_json, dict): - click.echo(f"Received error, please check your {CONDENSED_DATAHUB_CONFIG_PATH}") + click.echo( + f"Received error, please check your {config_utils.CONDENSED_DATAHUB_CONFIG_PATH}" + ) click.echo() click.echo(response_json) exit() summary = response_json.get("value") if not isinstance(summary, dict): - click.echo(f"Received error, please check your {CONDENSED_DATAHUB_CONFIG_PATH}") + click.echo( + f"Received error, please check your {config_utils.CONDENSED_DATAHUB_CONFIG_PATH}" + ) click.echo() click.echo(response_json) exit() @@ -686,3 +591,95 @@ def command(ctx: click.Context) -> None: ctx.exit(1) return command + + +def get_session_login_as( + username: str, password: str, frontend_url: str +) -> requests.Session: + session = requests.Session() + headers = { + "Content-Type": "application/json", + } + system_auth = get_system_auth() + if system_auth is not None: + session.headers.update({"Authorization": system_auth}) + else: + data = '{"username":"' + username + '", "password":"' + password + '"}' + response = session.post(f"{frontend_url}/logIn", headers=headers, data=data) + response.raise_for_status() + return session + + +def _ensure_valid_gms_url_acryl_cloud(url: str) -> str: + if "acryl.io" not in url: + return url + if url.startswith("http://"): + url = url.replace("http://", "https://") + if url.endswith("acryl.io"): + url = f"{url}/gms" + return url + + +def fixup_gms_url(url: str) -> str: + if url is None: + return "" + if url.endswith("/"): + url = url.rstrip("/") + url = _ensure_valid_gms_url_acryl_cloud(url) + return url + + +def guess_frontend_url_from_gms_url(gms_url: str) -> str: + gms_url = fixup_gms_url(gms_url) + url = gms_url + if url.endswith("/gms"): + url = gms_url.rstrip("/gms") + if url.endswith("8080"): + url = url[:-4] + "9002" + return url + + +def generate_access_token( + username: str, + password: str, + gms_url: str, + token_name: Optional[str] = None, + validity: str = "ONE_HOUR", +) -> Tuple[str, str]: + frontend_url = guess_frontend_url_from_gms_url(gms_url) + session = get_session_login_as( + username=username, + password=password, + frontend_url=frontend_url, + ) + now = datetime.now() + timestamp = now.astimezone().isoformat() + if token_name is None: + token_name = f"cli token {timestamp}" + json = { + "query": """mutation createAccessToken($input: CreateAccessTokenInput!) { + createAccessToken(input: $input) { + accessToken + metadata { + id + actorUrn + ownerUrn + name + description + } + } + }""", + "variables": { + "input": { + "type": "PERSONAL", + "actorUrn": f"urn:li:corpuser:{username}", + "duration": validity, + "name": token_name, + } + }, + } + response = session.post(f"{frontend_url}/api/v2/graphql", json=json) + response.raise_for_status() + return token_name, response.json().get("data", {}).get("createAccessToken", {}).get( + "accessToken", None + ) diff --git a/metadata-ingestion/src/datahub/cli/config_utils.py b/metadata-ingestion/src/datahub/cli/config_utils.py new file mode 100644 index 00000000000000..4b69aec8081ab3 --- /dev/null +++ b/metadata-ingestion/src/datahub/cli/config_utils.py @@ -0,0 +1,102 @@ +import logging +import os +import sys +from typing import Optional, Union + +import click +import yaml +from pydantic import BaseModel, ValidationError + +from datahub.cli.env_utils import get_boolean_env_variable + +__help__ = ( + "For helper methods to contain manipulation of the config file in local system." +) +log = logging.getLogger(__name__) + +DEFAULT_GMS_HOST = "http://localhost:8080" +CONDENSED_DATAHUB_CONFIG_PATH = "~/.datahubenv" +DATAHUB_CONFIG_PATH = os.path.expanduser(CONDENSED_DATAHUB_CONFIG_PATH) +DATAHUB_ROOT_FOLDER = os.path.expanduser("~/.datahub") +ENV_SKIP_CONFIG = "DATAHUB_SKIP_CONFIG" + + +class GmsConfig(BaseModel): + server: str + token: Optional[str] = None + + +class DatahubConfig(BaseModel): + gms: GmsConfig + + +def persist_datahub_config(config: dict) -> None: + with open(DATAHUB_CONFIG_PATH, "w+") as outfile: + yaml.dump(config, outfile, default_flow_style=False) + return None + + +def write_gms_config( + host: str, token: Optional[str], merge_with_previous: bool = True +) -> None: + config = DatahubConfig(gms=GmsConfig(server=host, token=token)) + if merge_with_previous: + try: + previous_config = get_client_config(as_dict=True) + assert isinstance(previous_config, dict) + except Exception as e: + # ok to fail on this + previous_config = {} + log.debug( + f"Failed to retrieve config from file {DATAHUB_CONFIG_PATH}: {e}. This isn't fatal." + ) + config_dict = {**previous_config, **config.dict()} + else: + config_dict = config.dict() + persist_datahub_config(config_dict) + + +def get_details_from_config(): + datahub_config = get_client_config(as_dict=False) + assert isinstance(datahub_config, DatahubConfig) + if datahub_config is not None: + gms_config = datahub_config.gms + + gms_host = gms_config.server + gms_token = gms_config.token + return gms_host, gms_token + else: + return None, None + + +def should_skip_config() -> bool: + return get_boolean_env_variable(ENV_SKIP_CONFIG, False) + + +def ensure_datahub_config() -> None: + if not os.path.isfile(DATAHUB_CONFIG_PATH): + click.secho( + f"No {CONDENSED_DATAHUB_CONFIG_PATH} file found, generating one for you...", + bold=True, + ) + write_gms_config(DEFAULT_GMS_HOST, None) + + +def get_client_config(as_dict: bool = False) -> Union[Optional[DatahubConfig], dict]: + with open(DATAHUB_CONFIG_PATH, "r") as stream: + try: + config_json = yaml.safe_load(stream) + if as_dict: + return config_json + try: + datahub_config = DatahubConfig.parse_obj(config_json) + return datahub_config + except ValidationError as e: + click.echo( + f"Received error, please check your {CONDENSED_DATAHUB_CONFIG_PATH}" + ) + click.echo(e, err=True) + sys.exit(1) + except yaml.YAMLError as exc: + click.secho(f"{DATAHUB_CONFIG_PATH} malformed, error: {exc}", bold=True) + return None diff --git a/metadata-ingestion/src/datahub/cli/docker_cli.py b/metadata-ingestion/src/datahub/cli/docker_cli.py index 0e0bc37c61573d..099f57d975bbb3 100644 --- a/metadata-ingestion/src/datahub/cli/docker_cli.py +++ b/metadata-ingestion/src/datahub/cli/docker_cli.py @@ -21,7 +21,7 @@ from expandvars import expandvars from requests_file import FileAdapter -from datahub.cli.cli_utils import DATAHUB_ROOT_FOLDER +from datahub.cli.config_utils import DATAHUB_ROOT_FOLDER from datahub.cli.docker_check import ( DATAHUB_COMPOSE_LEGACY_VOLUME_FILTERS, DATAHUB_COMPOSE_PROJECT_FILTER, diff --git a/metadata-ingestion/src/datahub/cli/env_utils.py b/metadata-ingestion/src/datahub/cli/env_utils.py new file mode 100644 index 00000000000000..8909036eab6cdf --- /dev/null +++ b/metadata-ingestion/src/datahub/cli/env_utils.py @@ -0,0 +1,11 @@ +import os + + +def get_boolean_env_variable(key: str, default: bool = False) -> bool: + value = os.environ.get(key) + if value is None: + return default + elif value.lower() in ("true", "1"): + return True + else: + return False diff --git a/metadata-ingestion/src/datahub/cli/ingest_cli.py b/metadata-ingestion/src/datahub/cli/ingest_cli.py index 9c55f52497c0ea..2e66b18e481453 100644 --- a/metadata-ingestion/src/datahub/cli/ingest_cli.py +++ b/metadata-ingestion/src/datahub/cli/ingest_cli.py @@ -15,12 +15,7 @@ import datahub as datahub_package from datahub.cli import cli_utils -from datahub.cli.cli_utils import ( - CONDENSED_DATAHUB_CONFIG_PATH, - format_aspect_summaries, - get_session_and_host, - post_rollback_endpoint, -) +from datahub.cli.config_utils import CONDENSED_DATAHUB_CONFIG_PATH from datahub.configuration.config_loader import load_config_file from datahub.ingestion.graph.client import get_default_graph from datahub.ingestion.run.connection import ConnectionManager @@ -436,7 +431,7 @@ def mcps(path: str) -> None: def list_runs(page_offset: int, page_size: int, include_soft_deletes: bool) -> None: """List recent ingestion runs to datahub""" - session, gms_host = get_session_and_host() + session, gms_host = cli_utils.get_session_and_host() url = f"{gms_host}/runs?action=list" @@ -485,7 +480,7 @@ def show( run_id: str, start: int, count: int, include_soft_deletes: bool, show_aspect: bool ) -> None: """Describe a provided ingestion run to datahub""" - session, gms_host = get_session_and_host() + session, gms_host = cli_utils.get_session_and_host() url = f"{gms_host}/runs?action=describe" @@ -504,7 +499,11 @@ def show( rows = parse_restli_response(response) if not show_aspect: click.echo( - tabulate(format_aspect_summaries(rows), RUN_TABLE_COLUMNS, tablefmt="grid") + tabulate( + cli_utils.format_aspect_summaries(rows), + RUN_TABLE_COLUMNS, + tablefmt="grid", + ) ) else: for row in rows: @@ -546,7 +545,7 @@ def rollback( aspects_affected, unsafe_entity_count, unsafe_entities, - ) = post_rollback_endpoint(payload_obj, "/runs?action=rollback") + ) = cli_utils.post_rollback_endpoint(payload_obj, "/runs?action=rollback") click.echo( "Rolling back deletes the entities created by a run and reverts the updated aspects" diff --git a/metadata-ingestion/src/datahub/cli/lite_cli.py b/metadata-ingestion/src/datahub/cli/lite_cli.py index 8636187a51d09f..7e2ad23a7753f4 100644 --- a/metadata-ingestion/src/datahub/cli/lite_cli.py +++ b/metadata-ingestion/src/datahub/cli/lite_cli.py @@ -9,7 +9,7 @@ from click.shell_completion import CompletionItem from click_default_group import DefaultGroup -from datahub.cli.cli_utils import ( +from datahub.cli.config_utils import ( DATAHUB_ROOT_FOLDER, DatahubConfig, get_client_config, diff --git a/metadata-ingestion/src/datahub/emitter/mcp_patch_builder.py b/metadata-ingestion/src/datahub/emitter/mcp_patch_builder.py index be68d46472a553..5a9eb074f1b096 100644 --- a/metadata-ingestion/src/datahub/emitter/mcp_patch_builder.py +++ b/metadata-ingestion/src/datahub/emitter/mcp_patch_builder.py @@ -12,6 +12,7 @@ MetadataChangeProposalClass, SystemMetadataClass, ) +from datahub.utilities.urns.urn import guess_entity_type def _recursive_to_obj(obj: Any) -> Any: @@ -47,13 +48,11 @@ class MetadataPatchProposal: def __init__( self, urn: str, - entity_type: str, system_metadata: Optional[SystemMetadataClass] = None, audit_header: Optional[KafkaAuditHeaderClass] = None, ) -> None: self.urn = urn - # TODO: Remove the entity_type parameter, as MCPW can infer it from the URN. - self.entity_type = entity_type + self.entity_type = guess_entity_type(urn) self.system_metadata = system_metadata self.audit_header = audit_header self.patches = defaultdict(list) diff --git a/metadata-ingestion/src/datahub/entrypoints.py b/metadata-ingestion/src/datahub/entrypoints.py index 1bf090a2e514ec..4f6c596b7bf20a 100644 --- a/metadata-ingestion/src/datahub/entrypoints.py +++ b/metadata-ingestion/src/datahub/entrypoints.py @@ -9,9 +9,13 @@ import datahub as datahub_package from datahub.cli.check_cli import check from datahub.cli.cli_utils import ( + fixup_gms_url, + generate_access_token, + make_shim_command, +) +from datahub.cli.config_utils import ( DATAHUB_CONFIG_PATH, get_boolean_env_variable, - make_shim_command, write_gms_config, ) from datahub.cli.delete_cli import delete @@ -99,8 +103,15 @@ def version() -> None: @datahub.command() +@click.option( + "--use-password", + type=bool, + is_flag=True, + default=False, + help="If passed then uses password to initialise token.", +) @telemetry.with_telemetry() -def init() -> None: +def init(use_password: bool = False) -> None: """Configure which datahub instance to connect to""" if os.path.isfile(DATAHUB_CONFIG_PATH): @@ -110,11 +121,22 @@ def init() -> None: host = click.prompt( "Enter your DataHub host", type=str, default="http://localhost:8080" ) - token = click.prompt( - "Enter your DataHub access token (Supports env vars via `{VAR_NAME}` syntax)", - type=str, - default="", - ) + host = fixup_gms_url(host) + if use_password: + username = click.prompt("Enter your DataHub username", type=str) + password = click.prompt( + "Enter your DataHub password", + type=str, + ) + _, token = generate_access_token( + username=username, password=password, gms_url=host + ) + else: + token = click.prompt( + "Enter your DataHub access token (Supports env vars via `{VAR_NAME}` syntax)", + type=str, + default="", + ) write_gms_config(host, token) click.echo(f"Written to {DATAHUB_CONFIG_PATH}") diff --git a/metadata-ingestion/src/datahub/integrations/great_expectations/action.py b/metadata-ingestion/src/datahub/integrations/great_expectations/action.py index 8b393a8f6f1c68..9890fea990c4ed 100644 --- a/metadata-ingestion/src/datahub/integrations/great_expectations/action.py +++ b/metadata-ingestion/src/datahub/integrations/great_expectations/action.py @@ -32,7 +32,7 @@ from sqlalchemy.engine.url import make_url import datahub.emitter.mce_builder as builder -from datahub.cli.cli_utils import get_boolean_env_variable +from datahub.cli.env_utils import get_boolean_env_variable from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.emitter.rest_emitter import DatahubRestEmitter from datahub.emitter.serialization_helper import pre_json_transform diff --git a/metadata-ingestion/src/datahub/specific/chart.py b/metadata-ingestion/src/datahub/specific/chart.py index 5dc394e8ebe0fb..47ea539491359d 100644 --- a/metadata-ingestion/src/datahub/specific/chart.py +++ b/metadata-ingestion/src/datahub/specific/chart.py @@ -40,7 +40,7 @@ def __init__( audit_header: The Kafka audit header of the chart (optional). """ super().__init__( - urn, "chart", system_metadata=system_metadata, audit_header=audit_header + urn, system_metadata=system_metadata, audit_header=audit_header ) self.custom_properties_patch_helper = CustomPropertiesPatchHelper( self, ChartInfo.ASPECT_NAME diff --git a/metadata-ingestion/src/datahub/specific/dashboard.py b/metadata-ingestion/src/datahub/specific/dashboard.py index 855dcc5685cea4..64f28a57cb1800 100644 --- a/metadata-ingestion/src/datahub/specific/dashboard.py +++ b/metadata-ingestion/src/datahub/specific/dashboard.py @@ -40,7 +40,7 @@ def __init__( audit_header: The Kafka audit header of the dashboard (optional). """ super().__init__( - urn, "dashboard", system_metadata=system_metadata, audit_header=audit_header + urn, system_metadata=system_metadata, audit_header=audit_header ) self.custom_properties_patch_helper = CustomPropertiesPatchHelper( self, DashboardInfo.ASPECT_NAME diff --git a/metadata-ingestion/src/datahub/specific/datajob.py b/metadata-ingestion/src/datahub/specific/datajob.py index 0338a1320c15b8..174749b3268bf2 100644 --- a/metadata-ingestion/src/datahub/specific/datajob.py +++ b/metadata-ingestion/src/datahub/specific/datajob.py @@ -41,7 +41,7 @@ def __init__( audit_header: The Kafka audit header of the data job (optional). """ super().__init__( - urn, "datajob", system_metadata=system_metadata, audit_header=audit_header + urn, system_metadata=system_metadata, audit_header=audit_header ) self.custom_properties_patch_helper = CustomPropertiesPatchHelper( self, DataJobInfo.ASPECT_NAME diff --git a/metadata-ingestion/src/datahub/specific/dataproduct.py b/metadata-ingestion/src/datahub/specific/dataproduct.py index 2c174e0c9a6cbb..c698c511fd9b58 100644 --- a/metadata-ingestion/src/datahub/specific/dataproduct.py +++ b/metadata-ingestion/src/datahub/specific/dataproduct.py @@ -30,7 +30,6 @@ def __init__( ) -> None: super().__init__( urn, - "dataProduct", system_metadata=system_metadata, audit_header=audit_header, ) diff --git a/metadata-ingestion/src/datahub/specific/dataset.py b/metadata-ingestion/src/datahub/specific/dataset.py index d3c3de36198e3f..c59cdb8ddfa382 100644 --- a/metadata-ingestion/src/datahub/specific/dataset.py +++ b/metadata-ingestion/src/datahub/specific/dataset.py @@ -98,7 +98,7 @@ def __init__( audit_header: Optional[KafkaAuditHeaderClass] = None, ) -> None: super().__init__( - urn, "dataset", system_metadata=system_metadata, audit_header=audit_header + urn, system_metadata=system_metadata, audit_header=audit_header ) self.custom_properties_patch_helper = CustomPropertiesPatchHelper( self, DatasetProperties.ASPECT_NAME diff --git a/metadata-ingestion/src/datahub/telemetry/telemetry.py b/metadata-ingestion/src/datahub/telemetry/telemetry.py index c399f2e1a27e55..a802125e76b4e1 100644 --- a/metadata-ingestion/src/datahub/telemetry/telemetry.py +++ b/metadata-ingestion/src/datahub/telemetry/telemetry.py @@ -13,7 +13,8 @@ from typing_extensions import ParamSpec import datahub as datahub_package -from datahub.cli.cli_utils import DATAHUB_ROOT_FOLDER, get_boolean_env_variable +from datahub.cli.config_utils import DATAHUB_ROOT_FOLDER +from datahub.cli.env_utils import get_boolean_env_variable from datahub.configuration.common import ExceptionWithProps from datahub.ingestion.graph.client import DataHubGraph from datahub.metadata.schema_classes import _custom_package_path diff --git a/metadata-ingestion/tests/unit/test_cli_utils.py b/metadata-ingestion/tests/unit/test_cli_utils.py index cb0b7c734ee0ac..bc1826d422e38a 100644 --- a/metadata-ingestion/tests/unit/test_cli_utils.py +++ b/metadata-ingestion/tests/unit/test_cli_utils.py @@ -59,3 +59,24 @@ def test_correct_url_when_gms_host_port_url_protocol_set(): ) def test_correct_url_when_url_set(): assert cli_utils.get_details_from_env() == ("https://example.com", None) + + +def test_fixup_gms_url(): + assert cli_utils.fixup_gms_url("http://localhost:8080") == "http://localhost:8080" + assert cli_utils.fixup_gms_url("http://localhost:8080/") == "http://localhost:8080" + assert cli_utils.fixup_gms_url("http://abc.acryl.io") == "https://abc.acryl.io/gms" + + +def test_guess_frontend_url_from_gms_url(): + assert ( + cli_utils.guess_frontend_url_from_gms_url("http://localhost:8080") + == "http://localhost:9002" + ) + assert ( + cli_utils.guess_frontend_url_from_gms_url("http://localhost:8080/") + == "http://localhost:9002" + ) + assert ( + cli_utils.guess_frontend_url_from_gms_url("https://abc.acryl.io/gms") + == "https://abc.acryl.io" + ) diff --git a/smoke-test/.gitignore b/smoke-test/.gitignore index 44d3f620a19372..b8af2eef535a0b 100644 --- a/smoke-test/.gitignore +++ b/smoke-test/.gitignore @@ -127,6 +127,7 @@ venv.bak/ .mypy_cache/ .dmypy.json dmypy.json +.ruff_cache/ # Pyre type checker .pyre/ diff --git a/smoke-test/build.gradle b/smoke-test/build.gradle index 1614a4b8527dc9..a6f3cd793ddd63 100644 --- a/smoke-test/build.gradle +++ b/smoke-test/build.gradle @@ -1,5 +1,10 @@ apply plugin: 'com.github.node-gradle.node' +ext { + python_executable = 'python3' + venv_name = 'venv' +} + node { // If true, it will download node using above parameters. @@ -38,4 +43,32 @@ task yarnInstall(type: YarnTask) { println "Root directory: ${project.rootDir}"; environment = ['NODE_OPTIONS': '--openssl-legacy-provider'] args = ['install', '--cwd', "${project.rootDir}/smoke-test/tests/cypress"] -} \ No newline at end of file +} + +task installDev(type: Exec) { + inputs.file file('pyproject.toml') + inputs.file file('requirements.txt') + outputs.file("${venv_name}/.build_install_dev_sentinel") + commandLine 'bash', '-x', '-c', + "${python_executable} -m venv ${venv_name} && " + + "${venv_name}/bin/pip install --upgrade pip wheel setuptools && " + + "${venv_name}/bin/pip install -r requirements.txt && " + + "touch ${venv_name}/.build_install_dev_sentinel" +} + +task lint(type: Exec, dependsOn: installDev) { + commandLine 'bash', '-c', + "source ${venv_name}/bin/activate && set -x && " + + "black --check --diff tests/ && " + + "isort --check --diff tests/ && " + + "ruff --statistics tests/ && " + + "mypy tests/" +} +task lintFix(type: Exec, dependsOn: installDev) { + commandLine 'bash', '-c', + "source ${venv_name}/bin/activate && set -x && " + + "black tests/ && " + + "isort tests/ && " + + "ruff --fix tests/ && " + + "mypy tests/" +} diff --git a/smoke-test/pyproject.toml b/smoke-test/pyproject.toml new file mode 100644 index 00000000000000..c7745d0e9a3640 --- /dev/null +++ b/smoke-test/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "smoke-test" +version = "0.0.0" +description = "" +authors = [ + { name="Acryl Data", email="eng@acryl.io" }, +] +requires-python = ">=3.9" + + +[tool.black] +extend-exclude = ''' +# A regex preceded with ^/ will apply only to files and directories +# in the root of the project. +tmp +venv +''' +include = '\.pyi?$' +target-version = ['py310'] + +[tool.isort] +profile = 'black' + +[tool.ruff] +ignore = [ + 'E501', # Ignore line length, since black handles that. + 'D203', # Ignore 1 blank line required before class docstring. +] + +[tool.mypy] +exclude = "^(venv/|build/|dist/)" +ignore_missing_imports = true +namespace_packages = false +check_untyped_defs = true +disallow_untyped_decorators = true +warn_unused_configs = true +# eventually we'd like to enable these +disallow_incomplete_defs = false +disallow_untyped_defs = false + +[tool.pyright] +extraPaths = ['tests'] diff --git a/smoke-test/requirements.txt b/smoke-test/requirements.txt index e37de9caddc696..c5d43163dff5d5 100644 --- a/smoke-test/requirements.txt +++ b/smoke-test/requirements.txt @@ -7,4 +7,12 @@ slack-sdk==3.18.1 aiohttp joblib pytest-xdist -networkx \ No newline at end of file +networkx +# libaries for linting below this +black==23.7.0 +isort==5.12.0 +mypy==1.5.1 +ruff==0.0.287 +# stub version are copied from metadata-ingestion/setup.py and that should be the source of truth +types-requests>=2.28.11.6,<=2.31.0.3 +types-PyYAML diff --git a/smoke-test/run-quickstart.sh b/smoke-test/run-quickstart.sh index cd747321ad6023..05c321566d54a6 100755 --- a/smoke-test/run-quickstart.sh +++ b/smoke-test/run-quickstart.sh @@ -4,10 +4,8 @@ set -euxo pipefail DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" cd "$DIR" -python3 -m venv venv +../gradlew :smoke-test:installDev source venv/bin/activate -pip install --upgrade pip wheel setuptools -pip install -r requirements.txt mkdir -p ~/.datahub/plugins/frontend/auth/ echo "test_user:test_pass" >> ~/.datahub/plugins/frontend/auth/user.props diff --git a/smoke-test/tests/aspect_generators/timeseries/dataset_profile_gen.py b/smoke-test/tests/aspect_generators/timeseries/dataset_profile_gen.py index bc22b74ed185cd..f808e7a58a3292 100644 --- a/smoke-test/tests/aspect_generators/timeseries/dataset_profile_gen.py +++ b/smoke-test/tests/aspect_generators/timeseries/dataset_profile_gen.py @@ -1,8 +1,10 @@ from typing import Iterable -from datahub.metadata.schema_classes import (DatasetFieldProfileClass, - DatasetProfileClass, - TimeWindowSizeClass) +from datahub.metadata.schema_classes import ( + DatasetFieldProfileClass, + DatasetProfileClass, + TimeWindowSizeClass, +) from tests.utils import get_timestampmillis_at_start_of_day diff --git a/smoke-test/tests/assertions/assertions_test.py b/smoke-test/tests/assertions/assertions_test.py index 48f3564e6cd971..78ba68a840f0d3 100644 --- a/smoke-test/tests/assertions/assertions_test.py +++ b/smoke-test/tests/assertions/assertions_test.py @@ -7,24 +7,30 @@ from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.common import PipelineContext, RecordEnvelope from datahub.ingestion.api.sink import NoopWriteCallback -from datahub.ingestion.sink.file import FileSink, FileSinkConfig -from datahub.metadata.com.linkedin.pegasus2avro.assertion import \ - AssertionStdAggregation -from datahub.metadata.schema_classes import (AssertionInfoClass, - AssertionResultClass, - AssertionResultTypeClass, - AssertionRunEventClass, - AssertionRunStatusClass, - AssertionStdOperatorClass, - AssertionTypeClass, - DatasetAssertionInfoClass, - DatasetAssertionScopeClass, - PartitionSpecClass, - PartitionTypeClass) +from datahub.ingestion.sink.file import FileSink +from datahub.metadata.com.linkedin.pegasus2avro.assertion import AssertionStdAggregation +from datahub.metadata.schema_classes import ( + AssertionInfoClass, + AssertionResultClass, + AssertionResultTypeClass, + AssertionRunEventClass, + AssertionRunStatusClass, + AssertionStdOperatorClass, + AssertionTypeClass, + DatasetAssertionInfoClass, + DatasetAssertionScopeClass, + PartitionSpecClass, + PartitionTypeClass, +) import requests_wrapper as requests -from tests.utils import (delete_urns_from_file, get_gms_url, get_sleep_info, - ingest_file_via_rest, wait_for_healthcheck_util) +from tests.utils import ( + delete_urns_from_file, + get_gms_url, + get_sleep_info, + ingest_file_via_rest, + wait_for_healthcheck_util, +) restli_default_headers = { "X-RestLi-Protocol-Version": "2.0.0", @@ -210,7 +216,7 @@ def create_test_data(test_file): ) fileSink: FileSink = FileSink.create( - FileSinkConfig(filename=test_file), ctx=PipelineContext(run_id="test-file") + {"filename": test_file}, ctx=PipelineContext(run_id="test-file") ) for mcp in [mcp1, mcp2, mcp3, mcp4, mcp5, mcp6, mcp7]: fileSink.write_record_async( diff --git a/smoke-test/tests/browse/browse_test.py b/smoke-test/tests/browse/browse_test.py index 550f0062d5a398..adeb6775a150d7 100644 --- a/smoke-test/tests/browse/browse_test.py +++ b/smoke-test/tests/browse/browse_test.py @@ -1,10 +1,6 @@ -import time - import pytest -import requests_wrapper as requests -from tests.utils import (delete_urns_from_file, get_frontend_url, - ingest_file_via_rest) +from tests.utils import delete_urns_from_file, get_frontend_url, ingest_file_via_rest TEST_DATASET_1_URN = "urn:li:dataset:(urn:li:dataPlatform:kafka,test-browse-1,PROD)" TEST_DATASET_2_URN = "urn:li:dataset:(urn:li:dataPlatform:kafka,test-browse-2,PROD)" @@ -29,7 +25,6 @@ def test_healthchecks(wait_for_healthchecks): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_get_browse_paths(frontend_session, ingest_cleanup_data): - # Iterate through each browse path, starting with the root get_browse_paths_query = """query browse($input: BrowseInput!) {\n diff --git a/smoke-test/tests/cli/datahub-cli.py b/smoke-test/tests/cli/datahub_cli.py similarity index 93% rename from smoke-test/tests/cli/datahub-cli.py rename to smoke-test/tests/cli/datahub_cli.py index c3db6028efceb8..81ae6a34264ad6 100644 --- a/smoke-test/tests/cli/datahub-cli.py +++ b/smoke-test/tests/cli/datahub_cli.py @@ -1,10 +1,7 @@ import json -from time import sleep import pytest -from datahub.cli.cli_utils import (get_aspects_for_entity, guess_entity_type, - post_entity) -from datahub.cli.ingest_cli import get_session_and_host, rollback +from datahub.cli.cli_utils import get_aspects_for_entity, get_session_and_host from tests.utils import ingest_file_via_rest, wait_for_writes_to_sync @@ -115,5 +112,5 @@ def test_rollback_editable(): ) # But first ingestion aspects should not be present assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, typed=False + entity_urn=dataset_urn, aspects=["browsePaths"], typed=False ) diff --git a/smoke-test/tests/cli/datahub_graph_test.py b/smoke-test/tests/cli/datahub_graph_test.py index 17c8924fb0998b..1e324477adb6b6 100644 --- a/smoke-test/tests/cli/datahub_graph_test.py +++ b/smoke-test/tests/cli/datahub_graph_test.py @@ -1,11 +1,14 @@ import pytest import tenacity from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph -from datahub.metadata.schema_classes import (KafkaSchemaClass, - SchemaMetadataClass) +from datahub.metadata.schema_classes import KafkaSchemaClass, SchemaMetadataClass -from tests.utils import (delete_urns_from_file, get_gms_url, get_sleep_info, - ingest_file_via_rest) +from tests.utils import ( + delete_urns_from_file, + get_gms_url, + get_sleep_info, + ingest_file_via_rest, +) sleep_sec, sleep_times = get_sleep_info() diff --git a/smoke-test/tests/cli/delete_cmd/test_timeseries_delete.py b/smoke-test/tests/cli/delete_cmd/test_timeseries_delete.py index 106da7cd8d71e5..cfbbacea1ed79e 100644 --- a/smoke-test/tests/cli/delete_cmd/test_timeseries_delete.py +++ b/smoke-test/tests/cli/delete_cmd/test_timeseries_delete.py @@ -2,7 +2,6 @@ import logging import sys import tempfile -import time from json import JSONDecodeError from typing import Any, Dict, List, Optional @@ -12,11 +11,8 @@ from datahub.entrypoints import datahub from datahub.metadata.schema_classes import DatasetProfileClass -import requests_wrapper as requests -from tests.aspect_generators.timeseries.dataset_profile_gen import \ - gen_dataset_profiles -from tests.utils import (get_strftime_from_timestamp_millis, - wait_for_writes_to_sync) +from tests.aspect_generators.timeseries.dataset_profile_gen import gen_dataset_profiles +from tests.utils import get_strftime_from_timestamp_millis, wait_for_writes_to_sync logger = logging.getLogger(__name__) diff --git a/smoke-test/tests/cli/ingest_cmd/test_timeseries_rollback.py b/smoke-test/tests/cli/ingest_cmd/test_timeseries_rollback.py index e962b1a5cafd6a..aa7c90cc6f988b 100644 --- a/smoke-test/tests/cli/ingest_cmd/test_timeseries_rollback.py +++ b/smoke-test/tests/cli/ingest_cmd/test_timeseries_rollback.py @@ -1,5 +1,4 @@ import json -import time from typing import Any, Dict, List, Optional import datahub.emitter.mce_builder as builder @@ -8,7 +7,6 @@ from datahub.entrypoints import datahub from datahub.metadata.schema_classes import DatasetProfileClass -import requests_wrapper as requests from tests.utils import ingest_file_via_rest, wait_for_writes_to_sync runner = CliRunner(mix_stderr=False) diff --git a/smoke-test/tests/managed-ingestion/__init__.py b/smoke-test/tests/cli/user_groups_cmd/__init__.py similarity index 100% rename from smoke-test/tests/managed-ingestion/__init__.py rename to smoke-test/tests/cli/user_groups_cmd/__init__.py diff --git a/smoke-test/tests/cli/user_groups_cmd/test_group_cmd.py b/smoke-test/tests/cli/user_groups_cmd/test_group_cmd.py index 7b986d3be0444d..555687c98ed3e2 100644 --- a/smoke-test/tests/cli/user_groups_cmd/test_group_cmd.py +++ b/smoke-test/tests/cli/user_groups_cmd/test_group_cmd.py @@ -1,16 +1,15 @@ import json import sys import tempfile -import time from typing import Any, Dict, Iterable, List +import pytest import yaml from click.testing import CliRunner, Result from datahub.api.entities.corpgroup.corpgroup import CorpGroup from datahub.entrypoints import datahub from datahub.ingestion.graph.client import DataHubGraph, get_default_graph -import requests_wrapper as requests from tests.utils import wait_for_writes_to_sync runner = CliRunner(mix_stderr=False) @@ -43,7 +42,6 @@ def gen_datahub_groups(num_groups: int) -> Iterable[CorpGroup]: description=f"The Group {i}", picture_link=f"https://images.google.com/group{i}.jpg", slack=f"@group{i}", - admins=["user1"], members=["user2"], ) yield group @@ -65,7 +63,7 @@ def get_group_ownership(user_urn: str) -> List[str]: graph = get_default_graph() entities = graph.get_related_entities( entity_urn=user_urn, - relationship_types="OwnedBy", + relationship_types=["OwnedBy"], direction=DataHubGraph.RelationshipDirection.INCOMING, ) return [entity.urn for entity in entities] @@ -75,12 +73,13 @@ def get_group_membership(user_urn: str) -> List[str]: graph = get_default_graph() entities = graph.get_related_entities( entity_urn=user_urn, - relationship_types="IsMemberOfGroup", + relationship_types=["IsMemberOfGroup"], direction=DataHubGraph.RelationshipDirection.OUTGOING, ) return [entity.urn for entity in entities] +@pytest.mark.skip(reason="Functionality and test needs to be validated for correctness") def test_group_upsert(wait_for_healthchecks: Any) -> None: num_groups: int = 10 for i, datahub_group in enumerate(gen_datahub_groups(num_groups)): diff --git a/smoke-test/tests/consistency_utils.py b/smoke-test/tests/consistency_utils.py index 607835bf3649c0..1af9399c2dc9ae 100644 --- a/smoke-test/tests/consistency_utils.py +++ b/smoke-test/tests/consistency_utils.py @@ -30,7 +30,7 @@ def wait_for_writes_to_sync(max_timeout_in_sec: int = 120) -> None: result = str(completed_process.stdout) lines = result.splitlines() - lag_values = [int(l) for l in lines if l != ""] + lag_values = [int(line) for line in lines if line != ""] maximum_lag = max(lag_values) if maximum_lag == 0: lag_zero = True diff --git a/smoke-test/tests/containers/containers_test.py b/smoke-test/tests/containers/containers_test.py index 227645a87d30ad..4997102702e57d 100644 --- a/smoke-test/tests/containers/containers_test.py +++ b/smoke-test/tests/containers/containers_test.py @@ -1,7 +1,6 @@ import pytest -from tests.utils import (delete_urns_from_file, get_frontend_url, - ingest_file_via_rest) +from tests.utils import delete_urns_from_file, get_frontend_url, ingest_file_via_rest @pytest.fixture(scope="module", autouse=False) @@ -21,12 +20,10 @@ def test_healthchecks(wait_for_healthchecks): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_get_full_container(frontend_session, ingest_cleanup_data): - container_urn = "urn:li:container:SCHEMA" container_name = "datahub_schema" container_description = "The DataHub schema" editable_container_description = "custom description" - dataset_urn = "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)" # Get a full container get_container_json = { @@ -129,7 +126,6 @@ def test_get_full_container(frontend_session, ingest_cleanup_data): @pytest.mark.dependency(depends=["test_healthchecks", "test_get_full_container"]) def test_get_parent_container(frontend_session, ingest_cleanup_data): - dataset_urn = "urn:li:dataset:(urn:li:dataPlatform:hive,SampleHiveDataset,PROD)" # Get count of existing secrets @@ -165,7 +161,6 @@ def test_get_parent_container(frontend_session, ingest_cleanup_data): @pytest.mark.dependency(depends=["test_healthchecks", "test_get_full_container"]) def test_update_container(frontend_session, ingest_cleanup_data): - container_urn = "urn:li:container:SCHEMA" new_tag = "urn:li:tag:Test" @@ -227,7 +222,7 @@ def test_update_container(frontend_session, ingest_cleanup_data): "ownerUrn": new_owner, "resourceUrn": container_urn, "ownerEntityType": "CORP_USER", - "ownershipTypeUrn": "urn:li:ownershipType:__system__technical_owner" + "ownershipTypeUrn": "urn:li:ownershipType:__system__technical_owner", } }, } diff --git a/smoke-test/tests/cypress/integration_test.py b/smoke-test/tests/cypress/integration_test.py index 4ad2bc53fa87d9..4124ced9994461 100644 --- a/smoke-test/tests/cypress/integration_test.py +++ b/smoke-test/tests/cypress/integration_test.py @@ -5,11 +5,17 @@ import pytest -from tests.setup.lineage.ingest_time_lineage import (get_time_lineage_urns, - ingest_time_lineage) -from tests.utils import (create_datahub_step_state_aspects, delete_urns, - delete_urns_from_file, get_admin_username, - ingest_file_via_rest) +from tests.setup.lineage.ingest_time_lineage import ( + get_time_lineage_urns, + ingest_time_lineage, +) +from tests.utils import ( + create_datahub_step_state_aspects, + delete_urns, + delete_urns_from_file, + get_admin_username, + ingest_file_via_rest, +) CYPRESS_TEST_DATA_DIR = "tests/cypress" @@ -178,8 +184,10 @@ def test_run_cypress(frontend_session, wait_for_healthchecks): print(f"test strategy is {test_strategy}") test_spec_arg = "" if test_strategy is not None: - specs = _get_spec_map(strategy_spec_map.get(test_strategy)) - test_spec_arg = f" --spec '{specs}' " + specs = strategy_spec_map.get(test_strategy) + assert specs is not None + specs_str = _get_spec_map(specs) + test_spec_arg = f" --spec '{specs_str}' " print("Running Cypress tests with command") command = f"NO_COLOR=1 npx cypress run {record_arg} {test_spec_arg} {tag_arg}" @@ -194,6 +202,8 @@ def test_run_cypress(frontend_session, wait_for_healthchecks): stderr=subprocess.PIPE, cwd=f"{CYPRESS_TEST_DATA_DIR}", ) + assert proc.stdout is not None + assert proc.stderr is not None stdout = proc.stdout.read() stderr = proc.stderr.read() return_code = proc.wait() diff --git a/smoke-test/tests/dataproduct/test_dataproduct.py b/smoke-test/tests/dataproduct/test_dataproduct.py index baef1cb1cb3ba0..0d0141e9111c0d 100644 --- a/smoke-test/tests/dataproduct/test_dataproduct.py +++ b/smoke-test/tests/dataproduct/test_dataproduct.py @@ -1,8 +1,6 @@ import logging import os -import subprocess import tempfile -import time from random import randint from typing import List @@ -14,17 +12,24 @@ from datahub.ingestion.api.sink import NoopWriteCallback from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph from datahub.ingestion.sink.file import FileSink, FileSinkConfig -from datahub.metadata.schema_classes import (DataProductPropertiesClass, - DatasetPropertiesClass, - DomainPropertiesClass, - DomainsClass) +from datahub.metadata.schema_classes import ( + DataProductPropertiesClass, + DatasetPropertiesClass, + DomainPropertiesClass, + DomainsClass, +) from datahub.utilities.urns.urn import Urn +from tests.utils import ( + delete_urns_from_file, + get_gms_url, + get_sleep_info, + ingest_file_via_rest, + wait_for_writes_to_sync, +) + logger = logging.getLogger(__name__) -import requests_wrapper as requests -from tests.utils import (delete_urns_from_file, get_gms_url, get_sleep_info, - ingest_file_via_rest, wait_for_writes_to_sync) start_index = randint(10, 10000) dataset_urns = [ @@ -82,7 +87,6 @@ def create_test_data(filename: str): @pytest.fixture(scope="module", autouse=False) def ingest_cleanup_data(request): - new_file, filename = tempfile.mkstemp() try: create_test_data(filename) @@ -160,7 +164,6 @@ def validate_relationships( ) @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_data_product(ingest_cleanup_data): - domain_urn = Urn("domain", [datahub_guid({"name": "Marketing"})]) graph: DataHubGraph = DataHubGraph(config=DatahubClientConfig(server=get_gms_url())) result = graph.execute_graphql( @@ -191,6 +194,7 @@ def test_create_data_product(ingest_cleanup_data): assert result["batchSetDataProduct"] is True data_product_props = graph.get_aspect(data_product_urn, DataProductPropertiesClass) assert data_product_props is not None + assert data_product_props.assets is not None assert data_product_props.description == "Test Description" assert data_product_props.name == "Test Data Product" assert len(data_product_props.assets) == len(dataset_urns) diff --git a/smoke-test/tests/delete/delete_test.py b/smoke-test/tests/delete/delete_test.py index d920faaf3a89a4..21833d0bd30a18 100644 --- a/smoke-test/tests/delete/delete_test.py +++ b/smoke-test/tests/delete/delete_test.py @@ -1,14 +1,16 @@ import json import os -from time import sleep import pytest -from datahub.cli.cli_utils import get_aspects_for_entity -from datahub.cli.ingest_cli import get_session_and_host - -from tests.utils import (delete_urns_from_file, get_datahub_graph, - ingest_file_via_rest, wait_for_healthcheck_util, - wait_for_writes_to_sync) +from datahub.cli.cli_utils import get_aspects_for_entity, get_session_and_host + +from tests.utils import ( + delete_urns_from_file, + get_datahub_graph, + ingest_file_via_rest, + wait_for_healthcheck_util, + wait_for_writes_to_sync, +) # Disable telemetry os.environ["DATAHUB_TELEMETRY_ENABLED"] = "false" diff --git a/smoke-test/tests/deprecation/deprecation_test.py b/smoke-test/tests/deprecation/deprecation_test.py index a8969804d03d7b..ae3890aeda9561 100644 --- a/smoke-test/tests/deprecation/deprecation_test.py +++ b/smoke-test/tests/deprecation/deprecation_test.py @@ -1,7 +1,11 @@ import pytest -from tests.utils import (delete_urns_from_file, get_frontend_url, get_root_urn, - ingest_file_via_rest) +from tests.utils import ( + delete_urns_from_file, + get_frontend_url, + get_root_urn, + ingest_file_via_rest, +) @pytest.fixture(scope="module", autouse=True) diff --git a/smoke-test/tests/domains/domains_test.py b/smoke-test/tests/domains/domains_test.py index fa8c918e3cbe16..1d83b032d7a8fe 100644 --- a/smoke-test/tests/domains/domains_test.py +++ b/smoke-test/tests/domains/domains_test.py @@ -1,8 +1,13 @@ import pytest import tenacity -from tests.utils import (delete_urns_from_file, get_frontend_url, get_gms_url, - get_sleep_info, ingest_file_via_rest) +from tests.utils import ( + delete_urns_from_file, + get_frontend_url, + get_gms_url, + get_sleep_info, + ingest_file_via_rest, +) sleep_sec, sleep_times = get_sleep_info() @@ -26,7 +31,6 @@ def test_healthchecks(wait_for_healthchecks): stop=tenacity.stop_after_attempt(sleep_times), wait=tenacity.wait_fixed(sleep_sec) ) def _ensure_more_domains(frontend_session, list_domains_json, before_count): - # Get new count of Domains response = frontend_session.post( f"{get_frontend_url()}/api/v2/graphql", json=list_domains_json @@ -47,7 +51,6 @@ def _ensure_more_domains(frontend_session, list_domains_json, before_count): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_list_get_domain(frontend_session): - # Setup: Delete the domain (if exists) response = frontend_session.post( f"{get_gms_url()}/entities?action=delete", json={"urn": "urn:li:domain:test id"} @@ -167,7 +170,6 @@ def test_create_list_get_domain(frontend_session): @pytest.mark.dependency(depends=["test_healthchecks", "test_create_list_get_domain"]) def test_set_unset_domain(frontend_session, ingest_cleanup_data): - # Set and Unset a Domain for a dataset. Note that this doesn't test for adding domains to charts, dashboards, charts, & jobs. dataset_urn = ( "urn:li:dataset:(urn:li:dataPlatform:kafka,test-tags-terms-sample-kafka,PROD)" diff --git a/smoke-test/tests/lineage/test_lineage.py b/smoke-test/tests/lineage/test_lineage.py index 52d61d666c7d9d..9cd98d1245bbbb 100644 --- a/smoke-test/tests/lineage/test_lineage.py +++ b/smoke-test/tests/lineage/test_lineage.py @@ -49,6 +49,7 @@ from datahub.utilities.urns.dataset_urn import DatasetUrn from datahub.utilities.urns.urn import Urn from pydantic import BaseModel, validator + from tests.utils import ingest_file_via_rest, wait_for_writes_to_sync logger = logging.getLogger(__name__) @@ -85,7 +86,6 @@ def ingest_tableau_cll_via_rest(wait_for_healthchecks) -> None: ingest_file_via_rest( "tests/lineage/tableau_cll_mcps.json", ) - yield def search_across_lineage( @@ -499,6 +499,7 @@ def get_lineage_mcps(self) -> Iterable[MetadataChangeProposalWrapper]: def get_lineage_mcps_for_hop( self, hop_index: int ) -> Iterable[MetadataChangeProposalWrapper]: + assert self.expectations is not None if self.lineage_style == Scenario.LineageStyle.DATASET_JOB_DATASET: fine_grained_lineage = FineGrainedLineage( upstreamType=FineGrainedLineageUpstreamType.FIELD_SET, @@ -722,11 +723,9 @@ def cleanup(self, delete_agent: DeleteAgent) -> None: for urn in self._generated_urns: delete_agent.delete_entity(urn) - def generate_expectation(self, query: ImpactQuery) -> LineageExpectation: - return self.expectations.generate_query_expectation_pairs(query) - def test_expectation(self, graph: DataHubGraph) -> bool: print("Testing expectation...") + assert self.expectations is not None try: for hop_index in range(self.num_hops): for dataset_urn in self.get_upstream_dataset_urns(hop_index): diff --git a/smoke-test/tests/tags-and-terms/__init__.py b/smoke-test/tests/managed_ingestion/__init__.py similarity index 100% rename from smoke-test/tests/tags-and-terms/__init__.py rename to smoke-test/tests/managed_ingestion/__init__.py diff --git a/smoke-test/tests/managed-ingestion/managed_ingestion_test.py b/smoke-test/tests/managed_ingestion/managed_ingestion_test.py similarity index 98% rename from smoke-test/tests/managed-ingestion/managed_ingestion_test.py rename to smoke-test/tests/managed_ingestion/managed_ingestion_test.py index 6d95f731f32b1d..4a4bdca2e45926 100644 --- a/smoke-test/tests/managed-ingestion/managed_ingestion_test.py +++ b/smoke-test/tests/managed_ingestion/managed_ingestion_test.py @@ -3,8 +3,7 @@ import pytest import tenacity -from tests.utils import (get_frontend_url, get_sleep_info, - wait_for_healthcheck_util) +from tests.utils import get_frontend_url, get_sleep_info, wait_for_healthcheck_util sleep_sec, sleep_times = get_sleep_info() @@ -206,7 +205,6 @@ def _ensure_execution_request_present(frontend_session, execution_request_urn): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_list_get_remove_secret(frontend_session): - # Get count of existing secrets json_q = { "query": """query listSecrets($input: ListSecretsInput!) {\n @@ -265,7 +263,13 @@ def test_create_list_get_remove_secret(frontend_session): "query": """mutation updateSecret($input: UpdateSecretInput!) {\n updateSecret(input: $input) }""", - "variables": {"input": {"urn": secret_urn, "name": "SMOKE_TEST", "value": "mytestvalue.updated"}}, + "variables": { + "input": { + "urn": secret_urn, + "name": "SMOKE_TEST", + "value": "mytestvalue.updated", + } + }, } response = frontend_session.post( @@ -333,7 +337,6 @@ def test_create_list_get_remove_secret(frontend_session): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_list_get_remove_ingestion_source(frontend_session): - # Get count of existing ingestion sources res_data = _get_ingestionSources(frontend_session) diff --git a/smoke-test/tests/patch/common_patch_tests.py b/smoke-test/tests/patch/common_patch_tests.py index f1d6abf5da794c..9530edb760c137 100644 --- a/smoke-test/tests/patch/common_patch_tests.py +++ b/smoke-test/tests/patch/common_patch_tests.py @@ -2,21 +2,26 @@ import uuid from typing import Dict, Optional, Type -from datahub.emitter.mce_builder import (make_tag_urn, make_term_urn, - make_user_urn) +from datahub.emitter.mce_builder import make_tag_urn, make_term_urn, make_user_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.emitter.mcp_patch_builder import MetadataPatchProposal from datahub.ingestion.graph.client import DataHubGraph, DataHubGraphConfig -from datahub.metadata.schema_classes import (AuditStampClass, GlobalTagsClass, - GlossaryTermAssociationClass, - GlossaryTermsClass, OwnerClass, - OwnershipClass, - OwnershipTypeClass, - TagAssociationClass, _Aspect) +from datahub.metadata.schema_classes import ( + AuditStampClass, + GlobalTagsClass, + GlossaryTermAssociationClass, + GlossaryTermsClass, + OwnerClass, + OwnershipClass, + OwnershipTypeClass, + TagAssociationClass, + _Aspect, +) def helper_test_entity_terms_patch( - test_entity_urn: str, patch_builder_class: Type[MetadataPatchProposal] + test_entity_urn: str, + patch_builder_class: Type[MetadataPatchProposal], ): def get_terms(graph, entity_urn): return graph.get_aspect( @@ -57,9 +62,9 @@ def get_terms(graph, entity_urn): assert terms_read.terms[1].urn == new_term.urn assert terms_read.terms[1].context is None - for patch_mcp in ( - patch_builder_class(test_entity_urn).remove_term(term_urn).build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "remove_term") + for patch_mcp in patch_builder.remove_term(term_urn).build(): graph.emit_mcp(patch_mcp) pass @@ -69,9 +74,9 @@ def get_terms(graph, entity_urn): def helper_test_dataset_tags_patch( - test_entity_urn: str, patch_builder_class: Type[MetadataPatchProposal] + test_entity_urn: str, + patch_builder_class: Type[MetadataPatchProposal], ): - tag_urn = make_tag_urn(tag=f"testTag-{uuid.uuid4()}") tag_association = TagAssociationClass(tag=tag_urn, context="test") @@ -80,10 +85,11 @@ def helper_test_dataset_tags_patch( with DataHubGraph(DataHubGraphConfig()) as graph: graph.emit_mcp(mcpw) - tags_read: GlobalTagsClass = graph.get_aspect( + tags_read = graph.get_aspect( entity_urn=test_entity_urn, aspect_type=GlobalTagsClass, ) + assert tags_read is not None assert tags_read.tags[0].tag == tag_urn assert tags_read.tags[0].context == "test" @@ -98,14 +104,15 @@ def helper_test_dataset_tags_patch( entity_urn=test_entity_urn, aspect_type=GlobalTagsClass, ) + assert tags_read is not None assert tags_read.tags[0].tag == tag_urn assert tags_read.tags[0].context == "test" assert tags_read.tags[1].tag == new_tag.tag assert tags_read.tags[1].context is None - for patch_mcp in ( - patch_builder_class(test_entity_urn).remove_tag(tag_urn).build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "remove_tag") + for patch_mcp in patch_builder.remove_tag(tag_urn).build(): graph.emit_mcp(patch_mcp) pass @@ -113,12 +120,14 @@ def helper_test_dataset_tags_patch( entity_urn=test_entity_urn, aspect_type=GlobalTagsClass, ) + assert tags_read is not None assert len(tags_read.tags) == 1 assert tags_read.tags[0].tag == new_tag.tag def helper_test_ownership_patch( - test_entity_urn: str, patch_builder_class: Type[MetadataPatchProposal] + test_entity_urn: str, + patch_builder_class: Type[MetadataPatchProposal], ): owner_to_set = OwnerClass( owner=make_user_urn("jdoe"), type=OwnershipTypeClass.DATAOWNER @@ -133,27 +142,26 @@ def helper_test_ownership_patch( ) with DataHubGraph(DataHubGraphConfig()) as graph: graph.emit_mcp(mcpw) - owner: OwnershipClass = graph.get_aspect( - entity_urn=test_entity_urn, aspect_type=OwnershipClass - ) + owner = graph.get_aspect(entity_urn=test_entity_urn, aspect_type=OwnershipClass) + assert owner is not None assert owner.owners[0].owner == make_user_urn("jdoe") - for patch_mcp in ( - patch_builder_class(test_entity_urn).add_owner(owner_to_add).build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "add_owner") + for patch_mcp in patch_builder.add_owner(owner_to_add).build(): graph.emit_mcp(patch_mcp) owner = graph.get_aspect(entity_urn=test_entity_urn, aspect_type=OwnershipClass) + assert owner is not None assert len(owner.owners) == 2 - for patch_mcp in ( - patch_builder_class(test_entity_urn) - .remove_owner(make_user_urn("gdoe")) - .build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "remove_owner") + for patch_mcp in patch_builder.remove_owner(make_user_urn("gdoe")).build(): graph.emit_mcp(patch_mcp) owner = graph.get_aspect(entity_urn=test_entity_urn, aspect_type=OwnershipClass) + assert owner is not None assert len(owner.owners) == 1 assert owner.owners[0].owner == make_user_urn("jdoe") @@ -172,6 +180,7 @@ def get_custom_properties( aspect_type=custom_properties_aspect_class, ) assert custom_properties_aspect + assert hasattr(custom_properties_aspect, "customProperties") return custom_properties_aspect.customProperties base_property_map = {"base_property": "base_property_value"} @@ -195,6 +204,7 @@ def get_custom_properties( } entity_patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(entity_patch_builder, "add_custom_property") for k, v in new_properties.items(): entity_patch_builder.add_custom_property(k, v) @@ -212,11 +222,9 @@ def get_custom_properties( assert custom_properties[k] == v # Remove property - for patch_mcp in ( - patch_builder_class(test_entity_urn) - .remove_custom_property("test_property") - .build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "remove_custom_property") + for patch_mcp in patch_builder.remove_custom_property("test_property").build(): graph.emit_mcp(patch_mcp) custom_properties = get_custom_properties(graph, test_entity_urn) @@ -230,11 +238,9 @@ def get_custom_properties( assert custom_properties[k] == v # Replace custom properties - for patch_mcp in ( - patch_builder_class(test_entity_urn) - .set_custom_properties(new_properties) - .build() - ): + patch_builder = patch_builder_class(test_entity_urn) + assert hasattr(patch_builder, "set_custom_properties") + for patch_mcp in patch_builder.set_custom_properties(new_properties).build(): graph.emit_mcp(patch_mcp) custom_properties = get_custom_properties(graph, test_entity_urn) diff --git a/smoke-test/tests/patch/test_datajob_patches.py b/smoke-test/tests/patch/test_datajob_patches.py index 342d5d683228a7..ce63d4571d6c81 100644 --- a/smoke-test/tests/patch/test_datajob_patches.py +++ b/smoke-test/tests/patch/test_datajob_patches.py @@ -3,14 +3,19 @@ from datahub.emitter.mce_builder import make_data_job_urn, make_dataset_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.graph.client import DataHubGraph, DataHubGraphConfig -from datahub.metadata.schema_classes import (DataJobInfoClass, - DataJobInputOutputClass, - EdgeClass) +from datahub.metadata.schema_classes import ( + DataJobInfoClass, + DataJobInputOutputClass, + EdgeClass, +) from datahub.specific.datajob import DataJobPatchBuilder from tests.patch.common_patch_tests import ( - helper_test_custom_properties_patch, helper_test_dataset_tags_patch, - helper_test_entity_terms_patch, helper_test_ownership_patch) + helper_test_custom_properties_patch, + helper_test_dataset_tags_patch, + helper_test_entity_terms_patch, + helper_test_ownership_patch, +) def _make_test_datajob_urn( @@ -76,10 +81,12 @@ def test_datajob_inputoutput_dataset_patch(wait_for_healthchecks): with DataHubGraph(DataHubGraphConfig()) as graph: graph.emit_mcp(mcpw) - inputoutput_lineage_read: DataJobInputOutputClass = graph.get_aspect( + inputoutput_lineage_read = graph.get_aspect( entity_urn=datajob_urn, aspect_type=DataJobInputOutputClass, ) + assert inputoutput_lineage_read is not None + assert inputoutput_lineage_read.inputDatasetEdges is not None assert ( inputoutput_lineage_read.inputDatasetEdges[0].destinationUrn == other_dataset_urn @@ -97,6 +104,8 @@ def test_datajob_inputoutput_dataset_patch(wait_for_healthchecks): entity_urn=datajob_urn, aspect_type=DataJobInputOutputClass, ) + assert inputoutput_lineage_read is not None + assert inputoutput_lineage_read.inputDatasetEdges is not None assert len(inputoutput_lineage_read.inputDatasetEdges) == 2 assert ( inputoutput_lineage_read.inputDatasetEdges[0].destinationUrn @@ -119,6 +128,8 @@ def test_datajob_inputoutput_dataset_patch(wait_for_healthchecks): entity_urn=datajob_urn, aspect_type=DataJobInputOutputClass, ) + assert inputoutput_lineage_read is not None + assert inputoutput_lineage_read.inputDatasetEdges is not None assert len(inputoutput_lineage_read.inputDatasetEdges) == 1 assert ( inputoutput_lineage_read.inputDatasetEdges[0].destinationUrn diff --git a/smoke-test/tests/patch/test_dataset_patches.py b/smoke-test/tests/patch/test_dataset_patches.py index 6704d19760fb9a..ec6b4a91fa6bed 100644 --- a/smoke-test/tests/patch/test_dataset_patches.py +++ b/smoke-test/tests/patch/test_dataset_patches.py @@ -1,29 +1,27 @@ -import time import uuid from typing import Dict, Optional -from datahub.emitter.mce_builder import (make_dataset_urn, make_tag_urn, - make_term_urn, make_user_urn) +from datahub.emitter.mce_builder import make_dataset_urn, make_tag_urn, make_term_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.graph.client import DataHubGraph, DataHubGraphConfig -from datahub.metadata.schema_classes import (AuditStampClass, - DatasetLineageTypeClass, - DatasetPropertiesClass, - EditableSchemaFieldInfoClass, - EditableSchemaMetadataClass, - GlobalTagsClass, - GlossaryTermAssociationClass, - GlossaryTermsClass, OwnerClass, - OwnershipClass, - OwnershipTypeClass, - TagAssociationClass, - UpstreamClass, - UpstreamLineageClass) +from datahub.metadata.schema_classes import ( + DatasetLineageTypeClass, + DatasetPropertiesClass, + EditableSchemaFieldInfoClass, + EditableSchemaMetadataClass, + GlossaryTermAssociationClass, + TagAssociationClass, + UpstreamClass, + UpstreamLineageClass, +) from datahub.specific.dataset import DatasetPatchBuilder from tests.patch.common_patch_tests import ( - helper_test_custom_properties_patch, helper_test_dataset_tags_patch, - helper_test_entity_terms_patch, helper_test_ownership_patch) + helper_test_custom_properties_patch, + helper_test_dataset_tags_patch, + helper_test_entity_terms_patch, + helper_test_ownership_patch, +) # Common Aspect Patch Tests @@ -135,7 +133,6 @@ def get_field_info( def test_field_terms_patch(wait_for_healthchecks): - dataset_urn = make_dataset_urn( platform="hive", name=f"SampleHiveDataset-{uuid.uuid4()}", env="PROD" ) @@ -174,6 +171,7 @@ def test_field_terms_patch(wait_for_healthchecks): assert field_info assert field_info.description == "This is a test field" + assert field_info.glossaryTerms is not None assert len(field_info.glossaryTerms.terms) == 1 assert field_info.glossaryTerms.terms[0].urn == new_term.urn @@ -191,11 +189,11 @@ def test_field_terms_patch(wait_for_healthchecks): assert field_info assert field_info.description == "This is a test field" + assert field_info.glossaryTerms is not None assert len(field_info.glossaryTerms.terms) == 0 def test_field_tags_patch(wait_for_healthchecks): - dataset_urn = make_dataset_urn( platform="hive", name=f"SampleHiveDataset-{uuid.uuid4()}", env="PROD" ) @@ -235,6 +233,7 @@ def test_field_tags_patch(wait_for_healthchecks): assert field_info assert field_info.description == "This is a test field" + assert field_info.globalTags is not None assert len(field_info.globalTags.tags) == 1 assert field_info.globalTags.tags[0].tag == new_tag.tag @@ -253,6 +252,7 @@ def test_field_tags_patch(wait_for_healthchecks): assert field_info assert field_info.description == "This is a test field" + assert field_info.globalTags is not None assert len(field_info.globalTags.tags) == 1 assert field_info.globalTags.tags[0].tag == new_tag.tag @@ -270,6 +270,7 @@ def test_field_tags_patch(wait_for_healthchecks): assert field_info assert field_info.description == "This is a test field" + assert field_info.globalTags is not None assert len(field_info.globalTags.tags) == 0 @@ -285,7 +286,6 @@ def get_custom_properties( def test_custom_properties_patch(wait_for_healthchecks): - dataset_urn = make_dataset_urn( platform="hive", name=f"SampleHiveDataset-{uuid.uuid4()}", env="PROD" ) diff --git a/smoke-test/tests/policies/test_policies.py b/smoke-test/tests/policies/test_policies.py index 67142181d2b960..186550482190c0 100644 --- a/smoke-test/tests/policies/test_policies.py +++ b/smoke-test/tests/policies/test_policies.py @@ -1,8 +1,13 @@ import pytest import tenacity -from tests.utils import (get_frontend_session, get_frontend_url, get_root_urn, - get_sleep_info, wait_for_healthcheck_util) +from tests.utils import ( + get_frontend_session, + get_frontend_url, + get_root_urn, + get_sleep_info, + wait_for_healthcheck_util, +) TEST_POLICY_NAME = "Updated Platform Policy" @@ -90,7 +95,6 @@ def _ensure_policy_present(frontend_session, new_urn): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_frontend_policy_operations(frontend_session): - json = { "query": """mutation createPolicy($input: PolicyUpdateInput!) {\n createPolicy(input: $input) }""", diff --git a/smoke-test/tests/privileges/__init__.py b/smoke-test/tests/privileges/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/smoke-test/tests/privileges/test_privileges.py b/smoke-test/tests/privileges/test_privileges.py index e1cb848cccf8eb..a4c607dac89f2e 100644 --- a/smoke-test/tests/privileges/test_privileges.py +++ b/smoke-test/tests/privileges/test_privileges.py @@ -1,14 +1,34 @@ import pytest import tenacity -from tests.utils import (get_frontend_session, wait_for_writes_to_sync, wait_for_healthcheck_util, - get_frontend_url, get_admin_credentials,get_sleep_info) -from tests.privileges.utils import * +from tests.privileges.utils import ( + assign_role, + assign_user_to_group, + create_group, + create_user, + create_user_policy, + remove_group, + remove_policy, + remove_user, + set_base_platform_privileges_policy_status, + set_view_dataset_sensitive_info_policy_status, + set_view_entity_profile_privileges_policy_status, +) +from tests.utils import ( + get_admin_credentials, + get_frontend_session, + get_frontend_url, + get_sleep_info, + login_as, + wait_for_healthcheck_util, + wait_for_writes_to_sync, +) pytestmark = pytest.mark.no_cypress_suite1 sleep_sec, sleep_times = get_sleep_info() + @pytest.fixture(scope="session") def wait_for_healthchecks(): wait_for_healthcheck_util() @@ -37,7 +57,7 @@ def privileges_and_test_user_setup(admin_session): # Sleep for eventual consistency wait_for_writes_to_sync() - # Create a new user + # Create a new user admin_session = create_user(admin_session, "user", "user") yield @@ -57,15 +77,16 @@ def privileges_and_test_user_setup(admin_session): @tenacity.retry( stop=tenacity.stop_after_attempt(sleep_times), wait=tenacity.wait_fixed(sleep_sec) ) -def _ensure_cant_perform_action(session, json,assertion_key): - action_response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json) +def _ensure_cant_perform_action(session, json, assertion_key): + action_response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) action_response.raise_for_status() action_data = action_response.json() - assert action_data["errors"][0]["extensions"]["code"] == 403, action_data["errors"][0] + assert action_data["errors"][0]["extensions"]["code"] == 403, action_data["errors"][ + 0 + ] assert action_data["errors"][0]["extensions"]["type"] == "UNAUTHORIZED" - assert action_data["data"][assertion_key] == None + assert action_data["data"][assertion_key] is None @tenacity.retry( @@ -73,7 +94,8 @@ def _ensure_cant_perform_action(session, json,assertion_key): ) def _ensure_can_create_secret(session, json, urn): create_secret_success = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json) + f"{get_frontend_url()}/api/v2/graphql", json=json + ) create_secret_success.raise_for_status() secret_data = create_secret_success.json() @@ -81,14 +103,15 @@ def _ensure_can_create_secret(session, json, urn): assert secret_data["data"] assert secret_data["data"]["createSecret"] assert secret_data["data"]["createSecret"] == urn - + @tenacity.retry( stop=tenacity.stop_after_attempt(10), wait=tenacity.wait_fixed(sleep_sec) ) def _ensure_can_create_ingestion_source(session, json): create_ingestion_success = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json) + f"{get_frontend_url()}/api/v2/graphql", json=json + ) create_ingestion_success.raise_for_status() ingestion_data = create_ingestion_success.json() @@ -98,14 +121,15 @@ def _ensure_can_create_ingestion_source(session, json): assert ingestion_data["data"]["createIngestionSource"] is not None return ingestion_data["data"]["createIngestionSource"] - + @tenacity.retry( stop=tenacity.stop_after_attempt(10), wait=tenacity.wait_fixed(sleep_sec) ) def _ensure_can_create_access_token(session, json): create_access_token_success = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=json) + f"{get_frontend_url()}/api/v2/graphql", json=json + ) create_access_token_success.raise_for_status() ingestion_data = create_access_token_success.json() @@ -128,50 +152,49 @@ def _ensure_can_create_user_policy(session, json): assert res_data["data"] assert res_data["data"]["createPolicy"] is not None - return res_data["data"]["createPolicy"] + return res_data["data"]["createPolicy"] @pytest.mark.dependency(depends=["test_healthchecks"]) def test_privilege_to_create_and_manage_secrets(): - (admin_user, admin_pass) = get_admin_credentials() admin_session = login_as(admin_user, admin_pass) user_session = login_as("user", "user") secret_urn = "urn:li:dataHubSecret:TestSecretName" # Verify new user can't create secrets - create_secret = { + create_secret = { "query": """mutation createSecret($input: CreateSecretInput!) {\n createSecret(input: $input)\n}""", "variables": { - "input":{ - "name":"TestSecretName", - "value":"Test Secret Value", - "description":"Test Secret Description" - } + "input": { + "name": "TestSecretName", + "value": "Test Secret Value", + "description": "Test Secret Description", + } }, } - _ensure_cant_perform_action(user_session, create_secret,"createSecret") - + _ensure_cant_perform_action(user_session, create_secret, "createSecret") # Assign privileges to the new user to manage secrets - policy_urn = create_user_policy("urn:li:corpuser:user", ["MANAGE_SECRETS"], admin_session) + policy_urn = create_user_policy( + "urn:li:corpuser:user", ["MANAGE_SECRETS"], admin_session + ) # Verify new user can create and manage secrets # Create a secret _ensure_can_create_secret(user_session, create_secret, secret_urn) - # Remove a secret - remove_secret = { + remove_secret = { "query": """mutation deleteSecret($urn: String!) {\n deleteSecret(urn: $urn)\n}""", - "variables": { - "urn": secret_urn - }, + "variables": {"urn": secret_urn}, } - remove_secret_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_secret) + remove_secret_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=remove_secret + ) remove_secret_response.raise_for_status() secret_data = remove_secret_response.json() @@ -180,28 +203,29 @@ def test_privilege_to_create_and_manage_secrets(): assert secret_data["data"]["deleteSecret"] assert secret_data["data"]["deleteSecret"] == secret_urn - # Remove the policy remove_policy(policy_urn, admin_session) # Ensure user can't create secret after policy is removed - _ensure_cant_perform_action(user_session, create_secret,"createSecret") + _ensure_cant_perform_action(user_session, create_secret, "createSecret") @pytest.mark.dependency(depends=["test_healthchecks"]) def test_privilege_to_create_and_manage_ingestion_source(): - (admin_user, admin_pass) = get_admin_credentials() admin_session = login_as(admin_user, admin_pass) user_session = login_as("user", "user") # Verify new user can't create ingestion source - create_ingestion_source = { + create_ingestion_source = { "query": """mutation createIngestionSource($input: UpdateIngestionSourceInput!) {\n createIngestionSource(input: $input)\n}""", - "variables": {"input":{"type":"snowflake","name":"test","config": - {"recipe": - """{\"source\":{\"type\":\"snowflake\",\"config\":{ + "variables": { + "input": { + "type": "snowflake", + "name": "test", + "config": { + "recipe": """{\"source\":{\"type\":\"snowflake\",\"config\":{ \"account_id\":null, \"include_table_lineage\":true, \"include_view_lineage\":true, @@ -209,25 +233,39 @@ def test_privilege_to_create_and_manage_ingestion_source(): \"include_views\":true, \"profiling\":{\"enabled\":true,\"profile_table_level_only\":true}, \"stateful_ingestion\":{\"enabled\":true}}}}""", - "executorId":"default","debugMode":False,"extraArgs":[]}}}, + "executorId": "default", + "debugMode": False, + "extraArgs": [], + }, + } + }, } - _ensure_cant_perform_action(user_session, create_ingestion_source, "createIngestionSource") + _ensure_cant_perform_action( + user_session, create_ingestion_source, "createIngestionSource" + ) + # Assign privileges to the new user to manage ingestion source + policy_urn = create_user_policy( + "urn:li:corpuser:user", ["MANAGE_INGESTION"], admin_session + ) - # Assign privileges to the new user to manage ingestion source - policy_urn = create_user_policy("urn:li:corpuser:user", ["MANAGE_INGESTION"], admin_session) - # Verify new user can create and manage ingestion source(edit, delete) - ingestion_source_urn = _ensure_can_create_ingestion_source(user_session, create_ingestion_source) + ingestion_source_urn = _ensure_can_create_ingestion_source( + user_session, create_ingestion_source + ) # Edit ingestion source - update_ingestion_source = { + update_ingestion_source = { "query": """mutation updateIngestionSource($urn: String!, $input: UpdateIngestionSourceInput!) {\n updateIngestionSource(urn: $urn, input: $input)\n}""", - "variables": {"urn":ingestion_source_urn, - "input":{"type":"snowflake","name":"test updated", - "config":{"recipe":"""{\"source\":{\"type\":\"snowflake\",\"config\":{ + "variables": { + "urn": ingestion_source_urn, + "input": { + "type": "snowflake", + "name": "test updated", + "config": { + "recipe": """{\"source\":{\"type\":\"snowflake\",\"config\":{ \"account_id\":null, \"include_table_lineage\":true, \"include_view_lineage\":true, @@ -235,11 +273,17 @@ def test_privilege_to_create_and_manage_ingestion_source(): \"include_views\":true, \"profiling\":{\"enabled\":true,\"profile_table_level_only\":true}, \"stateful_ingestion\":{\"enabled\":true}}}}""", - "executorId":"default","debugMode":False,"extraArgs":[]}}} + "executorId": "default", + "debugMode": False, + "extraArgs": [], + }, + }, + }, } update_ingestion_success = user_session.post( - f"{get_frontend_url()}/api/v2/graphql", json=update_ingestion_source) + f"{get_frontend_url()}/api/v2/graphql", json=update_ingestion_source + ) update_ingestion_success.raise_for_status() ingestion_data = update_ingestion_success.json() @@ -248,17 +292,16 @@ def test_privilege_to_create_and_manage_ingestion_source(): assert ingestion_data["data"]["updateIngestionSource"] assert ingestion_data["data"]["updateIngestionSource"] == ingestion_source_urn - # Delete ingestion source - remove_ingestion_source = { + remove_ingestion_source = { "query": """mutation deleteIngestionSource($urn: String!) {\n deleteIngestionSource(urn: $urn)\n}""", - "variables": { - "urn": ingestion_source_urn - }, + "variables": {"urn": ingestion_source_urn}, } - remove_ingestion_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_ingestion_source) + remove_ingestion_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=remove_ingestion_source + ) remove_ingestion_response.raise_for_status() ingestion_data = remove_ingestion_response.json() @@ -271,75 +314,81 @@ def test_privilege_to_create_and_manage_ingestion_source(): remove_policy(policy_urn, admin_session) # Ensure that user can't create ingestion source after policy is removed - _ensure_cant_perform_action(user_session, create_ingestion_source, "createIngestionSource") + _ensure_cant_perform_action( + user_session, create_ingestion_source, "createIngestionSource" + ) +@pytest.mark.skip(reason="Functionality and test needs to be validated for correctness") @pytest.mark.dependency(depends=["test_healthchecks"]) def test_privilege_to_create_and_manage_access_tokens(): - (admin_user, admin_pass) = get_admin_credentials() admin_session = login_as(admin_user, admin_pass) user_session = login_as("user", "user") - # Verify new user can't create access token - create_access_token = { + create_access_token = { "query": """mutation createAccessToken($input: CreateAccessTokenInput!) {\n createAccessToken(input: $input) {\n accessToken\n __typename\n }\n}\n""", - "variables": {"input":{"actorUrn":"urn:li:corpuser:user", - "type":"PERSONAL", - "duration":"ONE_MONTH", - "name":"test", - "description":"test"}} + "variables": { + "input": { + "actorUrn": "urn:li:corpuser:user", + "type": "PERSONAL", + "duration": "ONE_MONTH", + "name": "test", + "description": "test", + } + }, } - _ensure_cant_perform_action(user_session, create_access_token,"createAccessToken") - + _ensure_cant_perform_action(user_session, create_access_token, "createAccessToken") # Assign privileges to the new user to create and manage access tokens - policy_urn = create_user_policy("urn:li:corpuser:user", ["MANAGE_ACCESS_TOKENS"], admin_session) - + policy_urn = create_user_policy( + "urn:li:corpuser:user", ["MANAGE_ACCESS_TOKENS"], admin_session + ) # Verify new user can create and manage access token(create, revoke) # Create a access token _ensure_can_create_access_token(user_session, create_access_token) - # List access tokens first to get token id - list_access_tokens = { + list_access_tokens = { "query": """query listAccessTokens($input: ListAccessTokenInput!) {\n listAccessTokens(input: $input) {\n start\n count\n total\n tokens {\n urn\n type\n id\n name\n description\n actorUrn\n ownerUrn\n createdAt\n expiresAt\n __typename\n }\n __typename\n }\n}\n""", "variables": { - "input":{ - "start":0,"count":10,"filters":[{ - "field":"ownerUrn", - "values":["urn:li:corpuser:user"]}]} - } + "input": { + "start": 0, + "count": 10, + "filters": [{"field": "ownerUrn", "values": ["urn:li:corpuser:user"]}], + } + }, } - list_tokens_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=list_access_tokens) + list_tokens_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=list_access_tokens + ) list_tokens_response.raise_for_status() list_tokens_data = list_tokens_response.json() assert list_tokens_data assert list_tokens_data["data"] assert list_tokens_data["data"]["listAccessTokens"]["tokens"][0]["id"] is not None - - access_token_id = list_tokens_data["data"]["listAccessTokens"]["tokens"][0]["id"] + access_token_id = list_tokens_data["data"]["listAccessTokens"]["tokens"][0]["id"] # Revoke access token - revoke_access_token = { + revoke_access_token = { "query": "mutation revokeAccessToken($tokenId: String!) {\n revokeAccessToken(tokenId: $tokenId)\n}\n", - "variables": { - "tokenId": access_token_id - }, + "variables": {"tokenId": access_token_id}, } - revoke_token_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=revoke_access_token) + revoke_token_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=revoke_access_token + ) revoke_token_response.raise_for_status() revoke_token_data = revoke_token_response.json() @@ -348,22 +397,18 @@ def test_privilege_to_create_and_manage_access_tokens(): assert revoke_token_data["data"]["revokeAccessToken"] assert revoke_token_data["data"]["revokeAccessToken"] is True - # Remove the policy remove_policy(policy_urn, admin_session) - # Ensure that user can't create access token after policy is removed - _ensure_cant_perform_action(user_session, create_access_token,"createAccessToken") + _ensure_cant_perform_action(user_session, create_access_token, "createAccessToken") @pytest.mark.dependency(depends=["test_healthchecks"]) def test_privilege_to_create_and_manage_policies(): - (admin_user, admin_pass) = get_admin_credentials() admin_session = login_as(admin_user, admin_pass) user_session = login_as("user", "user") - # Verify new user can't create a policy create_policy = { @@ -376,7 +421,7 @@ def test_privilege_to_create_and_manage_policies(): "name": "Policy Name", "description": "Policy Description", "state": "ACTIVE", - "resources": {"filter":{"criteria":[]}}, + "resources": {"filter": {"criteria": []}}, "privileges": ["MANAGE_POLICIES"], "actors": { "users": [], @@ -388,19 +433,19 @@ def test_privilege_to_create_and_manage_policies(): }, } - _ensure_cant_perform_action(user_session, create_policy,"createPolicy") - + _ensure_cant_perform_action(user_session, create_policy, "createPolicy") # Assign privileges to the new user to create and manage policies - admin_policy_urn = create_user_policy("urn:li:corpuser:user", ["MANAGE_POLICIES"], admin_session) - + admin_policy_urn = create_user_policy( + "urn:li:corpuser:user", ["MANAGE_POLICIES"], admin_session + ) # Verify new user can create and manage policy(create, edit, delete) # Create a policy user_policy_urn = _ensure_can_create_user_policy(user_session, create_policy) # Edit a policy - edit_policy = { + edit_policy = { "query": """mutation updatePolicy($urn: String!, $input: PolicyUpdateInput!) {\n updatePolicy(urn: $urn, input: $input) }""", "variables": { @@ -422,7 +467,9 @@ def test_privilege_to_create_and_manage_policies(): }, }, } - edit_policy_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=edit_policy) + edit_policy_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=edit_policy + ) edit_policy_response.raise_for_status() res_data = edit_policy_response.json() @@ -431,12 +478,14 @@ def test_privilege_to_create_and_manage_policies(): assert res_data["data"]["updatePolicy"] == user_policy_urn # Delete a policy - remove_user_policy = { + remove_user_policy = { "query": "mutation deletePolicy($urn: String!) {\n deletePolicy(urn: $urn)\n}\n", - "variables":{"urn":user_policy_urn} + "variables": {"urn": user_policy_urn}, } - remove_policy_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_user_policy) + remove_policy_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=remove_user_policy + ) remove_policy_response.raise_for_status() res_data = remove_policy_response.json() @@ -444,18 +493,16 @@ def test_privilege_to_create_and_manage_policies(): assert res_data["data"] assert res_data["data"]["deletePolicy"] == user_policy_urn - # Remove the user privilege by admin remove_policy(admin_policy_urn, admin_session) - # Ensure that user can't create a policy after privilege is removed by admin - _ensure_cant_perform_action(user_session, create_policy,"createPolicy") + _ensure_cant_perform_action(user_session, create_policy, "createPolicy") +@pytest.mark.skip(reason="Functionality and test needs to be validated for correctness") @pytest.mark.dependency(depends=["test_healthchecks"]) def test_privilege_from_group_role_can_create_and_manage_secret(): - (admin_user, admin_pass) = get_admin_credentials() admin_session = login_as(admin_user, admin_pass) user_session = login_as("user", "user") @@ -466,20 +513,20 @@ def test_privilege_from_group_role_can_create_and_manage_secret(): "query": """mutation createSecret($input: CreateSecretInput!) {\n createSecret(input: $input)\n}""", "variables": { - "input":{ - "name":"TestSecretName", - "value":"Test Secret Value", - "description":"Test Secret Description" + "input": { + "name": "TestSecretName", + "value": "Test Secret Value", + "description": "Test Secret Description", } }, } - _ensure_cant_perform_action(user_session, create_secret,"createSecret") + _ensure_cant_perform_action(user_session, create_secret, "createSecret") # Create group and grant it the admin role. group_urn = create_group(admin_session, "Test Group") # Assign admin role to group - assign_role(admin_session,"urn:li:dataHubRole:Admin", [group_urn]) + assign_role(admin_session, "urn:li:dataHubRole:Admin", [group_urn]) # Assign user to group assign_user_to_group(admin_session, group_urn, ["urn:li:corpuser:user"]) @@ -492,12 +539,12 @@ def test_privilege_from_group_role_can_create_and_manage_secret(): remove_secret = { "query": """mutation deleteSecret($urn: String!) {\n deleteSecret(urn: $urn)\n}""", - "variables": { - "urn": secret_urn - }, + "variables": {"urn": secret_urn}, } - remove_secret_response = user_session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_secret) + remove_secret_response = user_session.post( + f"{get_frontend_url()}/api/v2/graphql", json=remove_secret + ) remove_secret_response.raise_for_status() secret_data = remove_secret_response.json() @@ -510,4 +557,4 @@ def test_privilege_from_group_role_can_create_and_manage_secret(): remove_group(admin_session, group_urn) # Ensure user can't create secret after policy is removed - _ensure_cant_perform_action(user_session, create_secret,"createSecret") + _ensure_cant_perform_action(user_session, create_secret, "createSecret") diff --git a/smoke-test/tests/privileges/utils.py b/smoke-test/tests/privileges/utils.py index eeb385a243a901..1e58ec4085b703 100644 --- a/smoke-test/tests/privileges/utils.py +++ b/smoke-test/tests/privileges/utils.py @@ -1,10 +1,9 @@ -import requests_wrapper as requests from tests.consistency_utils import wait_for_writes_to_sync -from tests.utils import (get_frontend_url, wait_for_writes_to_sync, get_admin_credentials) +from tests.utils import get_admin_credentials, get_frontend_url, login_as def set_base_platform_privileges_policy_status(status, session): - base_platform_privileges = { + base_platform_privileges = { "query": """mutation updatePolicy($urn: String!, $input: PolicyUpdateInput!) {\n updatePolicy(urn: $urn, input: $input) }""", "variables": { @@ -14,18 +13,20 @@ def set_base_platform_privileges_policy_status(status, session): "state": status, "name": "All Users - Base Platform Privileges", "description": "Grants base platform privileges to ALL users of DataHub. Change this policy to alter that behavior.", - "privileges": ["MANAGE_INGESTION", - "MANAGE_SECRETS", - "MANAGE_USERS_AND_GROUPS", - "VIEW_ANALYTICS", - "GENERATE_PERSONAL_ACCESS_TOKENS", - "MANAGE_DOMAINS", - "MANAGE_GLOBAL_ANNOUNCEMENTS", - "MANAGE_TESTS", - "MANAGE_GLOSSARIES", - "MANAGE_TAGS", - "MANAGE_GLOBAL_VIEWS", - "MANAGE_GLOBAL_OWNERSHIP_TYPES"], + "privileges": [ + "MANAGE_INGESTION", + "MANAGE_SECRETS", + "MANAGE_USERS_AND_GROUPS", + "VIEW_ANALYTICS", + "GENERATE_PERSONAL_ACCESS_TOKENS", + "MANAGE_DOMAINS", + "MANAGE_GLOBAL_ANNOUNCEMENTS", + "MANAGE_TESTS", + "MANAGE_GLOSSARIES", + "MANAGE_TAGS", + "MANAGE_GLOBAL_VIEWS", + "MANAGE_GLOBAL_OWNERSHIP_TYPES", + ], "actors": { "users": [], "groups": None, @@ -38,13 +39,15 @@ def set_base_platform_privileges_policy_status(status, session): }, } base_privileges_response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=base_platform_privileges) + f"{get_frontend_url()}/api/v2/graphql", json=base_platform_privileges + ) base_privileges_response.raise_for_status() base_res_data = base_privileges_response.json() assert base_res_data["data"]["updatePolicy"] == "urn:li:dataHubPolicy:7" + def set_view_dataset_sensitive_info_policy_status(status, session): - dataset_sensitive_information = { + dataset_sensitive_information = { "query": """mutation updatePolicy($urn: String!, $input: PolicyUpdateInput!) {\n updatePolicy(urn: $urn, input: $input) }""", "variables": { @@ -54,7 +57,7 @@ def set_view_dataset_sensitive_info_policy_status(status, session): "state": status, "name": "All Users - View Dataset Sensitive Information", "description": "Grants viewing privileges of usage and profile information of all datasets for all users", - "privileges": ["VIEW_DATASET_USAGE","VIEW_DATASET_PROFILE"], + "privileges": ["VIEW_DATASET_USAGE", "VIEW_DATASET_PROFILE"], "actors": { "users": [], "groups": None, @@ -67,13 +70,18 @@ def set_view_dataset_sensitive_info_policy_status(status, session): }, } sensitive_info_response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=dataset_sensitive_information) + f"{get_frontend_url()}/api/v2/graphql", json=dataset_sensitive_information + ) sensitive_info_response.raise_for_status() sens_info_data = sensitive_info_response.json() - assert sens_info_data["data"]["updatePolicy"] == "urn:li:dataHubPolicy:view-dataset-sensitive" + assert ( + sens_info_data["data"]["updatePolicy"] + == "urn:li:dataHubPolicy:view-dataset-sensitive" + ) + def set_view_entity_profile_privileges_policy_status(status, session): - view_entity_page = { + view_entity_page = { "query": """mutation updatePolicy($urn: String!, $input: PolicyUpdateInput!) {\n updatePolicy(urn: $urn, input: $input) }""", "variables": { @@ -83,12 +91,14 @@ def set_view_entity_profile_privileges_policy_status(status, session): "state": status, "name": "All Users - View Entity Page", "description": "Grants entity view to all users", - "privileges": ["VIEW_ENTITY_PAGE", - "SEARCH_PRIVILEGE", - "GET_COUNTS_PRIVILEGE", - "GET_TIMESERIES_ASPECT_PRIVILEGE", - "GET_ENTITY_PRIVILEGE", - "GET_TIMELINE_PRIVILEGE"], + "privileges": [ + "VIEW_ENTITY_PAGE", + "SEARCH_PRIVILEGE", + "GET_COUNTS_PRIVILEGE", + "GET_TIMESERIES_ASPECT_PRIVILEGE", + "GET_ENTITY_PRIVILEGE", + "GET_TIMELINE_PRIVILEGE", + ], "actors": { "users": [], "groups": None, @@ -101,10 +111,15 @@ def set_view_entity_profile_privileges_policy_status(status, session): }, } view_entity_response = session.post( - f"{get_frontend_url()}/api/v2/graphql", json=view_entity_page) + f"{get_frontend_url()}/api/v2/graphql", json=view_entity_page + ) view_entity_response.raise_for_status() view_entity_data = view_entity_response.json() - assert view_entity_data["data"]["updatePolicy"] == "urn:li:dataHubPolicy:view-entity-page-all" + assert ( + view_entity_data["data"]["updatePolicy"] + == "urn:li:dataHubPolicy:view-entity-page-all" + ) + def create_user(session, email, password): # Remove user if exists @@ -136,9 +151,7 @@ def create_user(session, email, password): "title": "Data Engineer", "inviteToken": invite_token, } - sign_up_response = session.post( - f"{get_frontend_url()}/signUp", json=sign_up_json - ) + sign_up_response = session.post(f"{get_frontend_url()}/signUp", json=sign_up_json) sign_up_response.raise_for_status() assert sign_up_response assert "error" not in sign_up_response @@ -149,16 +162,6 @@ def create_user(session, email, password): return admin_session -def login_as(username, password): - session = requests.Session() - headers = { - "Content-Type": "application/json", - } - data = '{"username":"' + username + '", "password":"' + password + '"}' - response = session.post(f"{get_frontend_url()}/logIn", headers=headers, data=data) - response.raise_for_status() - return session - def remove_user(session, urn): json = { "query": """mutation removeUser($urn: String!) {\n @@ -170,6 +173,7 @@ def remove_user(session, urn): response.raise_for_status() return response.json() + def create_group(session, name): json = { "query": """mutation createGroup($input: CreateGroupInput!) {\n @@ -185,6 +189,7 @@ def create_group(session, name): assert res_data["data"]["createGroup"] return res_data["data"]["createGroup"] + def remove_group(session, urn): json = { "query": """mutation removeGroup($urn: String!) {\n @@ -200,6 +205,7 @@ def remove_group(session, urn): assert res_data["data"]["removeGroup"] return res_data["data"]["removeGroup"] + def assign_user_to_group(session, group_urn, user_urns): json = { "query": """mutation addGroupMembers($groupUrn: String!, $userUrns: [String!]!) {\n @@ -215,6 +221,7 @@ def assign_user_to_group(session, group_urn, user_urns): assert res_data["data"]["addGroupMembers"] return res_data["data"]["addGroupMembers"] + def assign_role(session, role_urn, actor_urns): json = { "query": """mutation batchAssignRole($input: BatchAssignRoleInput!) {\n @@ -231,6 +238,7 @@ def assign_role(session, role_urn, actor_urns): assert res_data["data"]["batchAssignRole"] return res_data["data"]["batchAssignRole"] + def create_user_policy(user_urn, privileges, session): policy = { "query": """mutation createPolicy($input: PolicyUpdateInput!) {\n @@ -241,7 +249,7 @@ def create_user_policy(user_urn, privileges, session): "name": "Policy Name", "description": "Policy Description", "state": "ACTIVE", - "resources": {"filter":{"criteria":[]}}, + "resources": {"filter": {"criteria": []}}, "privileges": privileges, "actors": { "users": [user_urn], @@ -262,6 +270,7 @@ def create_user_policy(user_urn, privileges, session): assert res_data["data"]["createPolicy"] return res_data["data"]["createPolicy"] + def remove_policy(urn, session): remove_policy_json = { "query": """mutation deletePolicy($urn: String!) {\n @@ -269,11 +278,13 @@ def remove_policy(urn, session): "variables": {"urn": urn}, } - response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=remove_policy_json) + response = session.post( + f"{get_frontend_url()}/api/v2/graphql", json=remove_policy_json + ) response.raise_for_status() res_data = response.json() assert res_data assert res_data["data"] assert res_data["data"]["deletePolicy"] - assert res_data["data"]["deletePolicy"] == urn \ No newline at end of file + assert res_data["data"]["deletePolicy"] == urn diff --git a/smoke-test/tests/read_only/test_services_up.py b/smoke-test/tests/read_only/test_services_up.py index 792a5063d3f8b0..4e00f910ceb73b 100644 --- a/smoke-test/tests/read_only/test_services_up.py +++ b/smoke-test/tests/read_only/test_services_up.py @@ -1,8 +1,8 @@ import os +import re import pytest import requests -import re from tests.utils import get_gms_url, wait_for_healthcheck_util @@ -14,9 +14,11 @@ def test_services_up(): wait_for_healthcheck_util() + def looks_like_a_short_sha(sha: str) -> bool: return len(sha) == 7 and re.match(r"[0-9a-f]{7}", sha) is not None + @pytest.mark.read_only def test_gms_config_accessible() -> None: gms_config = requests.get(f"{get_gms_url()}/config").json() @@ -33,4 +35,6 @@ def test_gms_config_accessible() -> None: default_cli_version: str = gms_config["managedIngestion"]["defaultCliVersion"] print(f"Default CLI version: {default_cli_version}") assert not default_cli_version.startswith("@") - assert "." in default_cli_version or looks_like_a_short_sha(default_cli_version), "Default CLI version does not look like a version string" + assert "." in default_cli_version or looks_like_a_short_sha( + default_cli_version + ), "Default CLI version does not look like a version string" diff --git a/smoke-test/tests/setup/lineage/ingest_data_job_change.py b/smoke-test/tests/setup/lineage/ingest_data_job_change.py index 588a1625419bc2..2746baf89600e2 100644 --- a/smoke-test/tests/setup/lineage/ingest_data_job_change.py +++ b/smoke-test/tests/setup/lineage/ingest_data_job_change.py @@ -1,20 +1,31 @@ from typing import List -from datahub.emitter.mce_builder import (make_data_flow_urn, - make_data_job_urn_with_flow, - make_dataset_urn) +from datahub.emitter.mce_builder import ( + make_data_flow_urn, + make_data_job_urn_with_flow, + make_dataset_urn, +) from datahub.emitter.rest_emitter import DatahubRestEmitter -from datahub.metadata.schema_classes import (DateTypeClass, NumberTypeClass, - SchemaFieldDataTypeClass, - StringTypeClass) +from datahub.metadata.schema_classes import ( + DateTypeClass, + NumberTypeClass, + SchemaFieldDataTypeClass, + StringTypeClass, +) -from tests.setup.lineage.constants import (AIRFLOW_DATA_PLATFORM, - SNOWFLAKE_DATA_PLATFORM, - TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, - TIMESTAMP_MILLIS_ONE_DAY_AGO) +from tests.setup.lineage.constants import ( + AIRFLOW_DATA_PLATFORM, + SNOWFLAKE_DATA_PLATFORM, + TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, + TIMESTAMP_MILLIS_ONE_DAY_AGO, +) from tests.setup.lineage.helper_classes import Dataset, Field, Pipeline, Task -from tests.setup.lineage.utils import (create_edge, create_node, - create_nodes_and_edges, emit_mcps) +from tests.setup.lineage.utils import ( + create_edge, + create_node, + create_nodes_and_edges, + emit_mcps, +) # Constants for Case 2 DAILY_TEMPERATURE_DATASET_ID = "climate.daily_temperature" diff --git a/smoke-test/tests/setup/lineage/ingest_dataset_join_change.py b/smoke-test/tests/setup/lineage/ingest_dataset_join_change.py index bb9f51b6b5e9b7..4a8da1fcf0588c 100644 --- a/smoke-test/tests/setup/lineage/ingest_dataset_join_change.py +++ b/smoke-test/tests/setup/lineage/ingest_dataset_join_change.py @@ -2,17 +2,26 @@ from datahub.emitter.mce_builder import make_dataset_urn from datahub.emitter.rest_emitter import DatahubRestEmitter -from datahub.metadata.schema_classes import (NumberTypeClass, - SchemaFieldDataTypeClass, - StringTypeClass, UpstreamClass) +from datahub.metadata.schema_classes import ( + NumberTypeClass, + SchemaFieldDataTypeClass, + StringTypeClass, + UpstreamClass, +) -from tests.setup.lineage.constants import (DATASET_ENTITY_TYPE, - SNOWFLAKE_DATA_PLATFORM, - TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, - TIMESTAMP_MILLIS_ONE_DAY_AGO) +from tests.setup.lineage.constants import ( + DATASET_ENTITY_TYPE, + SNOWFLAKE_DATA_PLATFORM, + TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, + TIMESTAMP_MILLIS_ONE_DAY_AGO, +) from tests.setup.lineage.helper_classes import Dataset, Field -from tests.setup.lineage.utils import (create_node, create_upstream_edge, - create_upstream_mcp, emit_mcps) +from tests.setup.lineage.utils import ( + create_node, + create_upstream_edge, + create_upstream_mcp, + emit_mcps, +) # Constants for Case 3 GDP_DATASET_ID = "economic_data.gdp" diff --git a/smoke-test/tests/setup/lineage/ingest_input_datasets_change.py b/smoke-test/tests/setup/lineage/ingest_input_datasets_change.py index 6079d7a3d2b63b..143c65c0826566 100644 --- a/smoke-test/tests/setup/lineage/ingest_input_datasets_change.py +++ b/smoke-test/tests/setup/lineage/ingest_input_datasets_change.py @@ -1,20 +1,30 @@ from typing import List -from datahub.emitter.mce_builder import (make_data_flow_urn, - make_data_job_urn_with_flow, - make_dataset_urn) +from datahub.emitter.mce_builder import ( + make_data_flow_urn, + make_data_job_urn_with_flow, + make_dataset_urn, +) from datahub.emitter.rest_emitter import DatahubRestEmitter -from datahub.metadata.schema_classes import (NumberTypeClass, - SchemaFieldDataTypeClass, - StringTypeClass) +from datahub.metadata.schema_classes import ( + NumberTypeClass, + SchemaFieldDataTypeClass, + StringTypeClass, +) -from tests.setup.lineage.constants import (AIRFLOW_DATA_PLATFORM, - BQ_DATA_PLATFORM, - TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, - TIMESTAMP_MILLIS_ONE_DAY_AGO) +from tests.setup.lineage.constants import ( + AIRFLOW_DATA_PLATFORM, + BQ_DATA_PLATFORM, + TIMESTAMP_MILLIS_EIGHT_DAYS_AGO, + TIMESTAMP_MILLIS_ONE_DAY_AGO, +) from tests.setup.lineage.helper_classes import Dataset, Field, Pipeline, Task -from tests.setup.lineage.utils import (create_edge, create_node, - create_nodes_and_edges, emit_mcps) +from tests.setup.lineage.utils import ( + create_edge, + create_node, + create_nodes_and_edges, + emit_mcps, +) # Constants for Case 1 TRANSACTIONS_DATASET_ID = "transactions.transactions" diff --git a/smoke-test/tests/setup/lineage/ingest_time_lineage.py b/smoke-test/tests/setup/lineage/ingest_time_lineage.py index 3aec979707290d..116e6cd63dd9f5 100644 --- a/smoke-test/tests/setup/lineage/ingest_time_lineage.py +++ b/smoke-test/tests/setup/lineage/ingest_time_lineage.py @@ -4,11 +4,17 @@ from datahub.emitter.rest_emitter import DatahubRestEmitter from tests.setup.lineage.ingest_data_job_change import ( - get_data_job_change_urns, ingest_data_job_change) + get_data_job_change_urns, + ingest_data_job_change, +) from tests.setup.lineage.ingest_dataset_join_change import ( - get_dataset_join_change_urns, ingest_dataset_join_change) + get_dataset_join_change_urns, + ingest_dataset_join_change, +) from tests.setup.lineage.ingest_input_datasets_change import ( - get_input_datasets_change_urns, ingest_input_datasets_change) + get_input_datasets_change_urns, + ingest_input_datasets_change, +) SERVER = os.getenv("DATAHUB_SERVER") or "http://localhost:8080" TOKEN = os.getenv("DATAHUB_TOKEN") or "" diff --git a/smoke-test/tests/setup/lineage/utils.py b/smoke-test/tests/setup/lineage/utils.py index c72f6ccb89b7ad..d4c16ed3b7a211 100644 --- a/smoke-test/tests/setup/lineage/utils.py +++ b/smoke-test/tests/setup/lineage/utils.py @@ -1,29 +1,38 @@ -import datetime from typing import List -from datahub.emitter.mce_builder import (make_data_flow_urn, - make_data_job_urn_with_flow, - make_data_platform_urn, - make_dataset_urn) +from datahub.emitter.mce_builder import ( + make_data_flow_urn, + make_data_job_urn_with_flow, + make_data_platform_urn, + make_dataset_urn, +) from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.emitter.rest_emitter import DatahubRestEmitter from datahub.metadata.com.linkedin.pegasus2avro.dataset import UpstreamLineage -from datahub.metadata.schema_classes import (AuditStampClass, ChangeTypeClass, - DataFlowInfoClass, - DataJobInfoClass, - DataJobInputOutputClass, - DatasetLineageTypeClass, - DatasetPropertiesClass, EdgeClass, - MySqlDDLClass, SchemaFieldClass, - SchemaMetadataClass, - UpstreamClass) - -from tests.setup.lineage.constants import (DATA_FLOW_ENTITY_TYPE, - DATA_FLOW_INFO_ASPECT_NAME, - DATA_JOB_ENTITY_TYPE, - DATA_JOB_INFO_ASPECT_NAME, - DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, - DATASET_ENTITY_TYPE) +from datahub.metadata.com.linkedin.pegasus2avro.mxe import SystemMetadata +from datahub.metadata.schema_classes import ( + AuditStampClass, + ChangeTypeClass, + DataFlowInfoClass, + DataJobInfoClass, + DataJobInputOutputClass, + DatasetLineageTypeClass, + DatasetPropertiesClass, + EdgeClass, + MySqlDDLClass, + SchemaFieldClass, + SchemaMetadataClass, + UpstreamClass, +) + +from tests.setup.lineage.constants import ( + DATA_FLOW_ENTITY_TYPE, + DATA_FLOW_INFO_ASPECT_NAME, + DATA_JOB_ENTITY_TYPE, + DATA_JOB_INFO_ASPECT_NAME, + DATA_JOB_INPUT_OUTPUT_ASPECT_NAME, + DATASET_ENTITY_TYPE, +) from tests.setup.lineage.helper_classes import Dataset, Pipeline @@ -176,17 +185,16 @@ def create_upstream_mcp( run_id: str = "", ) -> MetadataChangeProposalWrapper: print(f"Creating upstreamLineage aspect for {entity_urn}") - timestamp_millis: int = int(datetime.datetime.now().timestamp() * 1000) mcp = MetadataChangeProposalWrapper( entityType=entity_type, entityUrn=entity_urn, changeType=ChangeTypeClass.UPSERT, aspectName="upstreamLineage", aspect=UpstreamLineage(upstreams=upstreams), - systemMetadata={ - "lastObserved": timestamp_millis, - "runId": run_id, - }, + systemMetadata=SystemMetadata( + lastObserved=timestamp_millis, + runId=run_id, + ), ) return mcp diff --git a/smoke-test/tests/structured_properties/test_structured_properties.py b/smoke-test/tests/structured_properties/test_structured_properties.py index 83994776076b05..de85d2af95e034 100644 --- a/smoke-test/tests/structured_properties/test_structured_properties.py +++ b/smoke-test/tests/structured_properties/test_structured_properties.py @@ -1,31 +1,39 @@ import logging import os -from datahub.ingestion.graph.filters import SearchFilterRule -from tests.consistency_utils import wait_for_writes_to_sync import tempfile from random import randint -from tests.utilities.file_emitter import FileEmitter from typing import Iterable, List, Optional, Union import pytest + # import tenacity from datahub.api.entities.dataset.dataset import Dataset -from datahub.api.entities.structuredproperties.structuredproperties import \ - StructuredProperties +from datahub.api.entities.structuredproperties.structuredproperties import ( + StructuredProperties, +) from datahub.emitter.mce_builder import make_dataset_urn, make_schema_field_urn from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph from datahub.metadata.schema_classes import ( - EntityTypeInfoClass, PropertyValueClass, StructuredPropertiesClass, - StructuredPropertyDefinitionClass, StructuredPropertyValueAssignmentClass) + EntityTypeInfoClass, + PropertyValueClass, + StructuredPropertiesClass, + StructuredPropertyDefinitionClass, + StructuredPropertyValueAssignmentClass, +) from datahub.specific.dataset import DatasetPatchBuilder -from datahub.utilities.urns.structured_properties_urn import \ - StructuredPropertyUrn +from datahub.utilities.urns.structured_properties_urn import StructuredPropertyUrn from datahub.utilities.urns.urn import Urn -from tests.utils import (delete_urns, delete_urns_from_file, get_gms_url, - get_sleep_info, ingest_file_via_rest, - wait_for_writes_to_sync) +from tests.consistency_utils import wait_for_writes_to_sync +from tests.utilities.file_emitter import FileEmitter +from tests.utils import ( + delete_urns, + delete_urns_from_file, + get_gms_url, + get_sleep_info, + ingest_file_via_rest, +) logger = logging.getLogger(__name__) @@ -36,8 +44,7 @@ ] schema_field_urns = [ - make_schema_field_urn(dataset_urn, "column_1") - for dataset_urn in dataset_urns + make_schema_field_urn(dataset_urn, "column_1") for dataset_urn in dataset_urns ] generated_urns = [d for d in dataset_urns] + [f for f in schema_field_urns] @@ -45,6 +52,7 @@ default_namespace = "io.acryl.privacy" + def create_logical_entity( entity_name: str, ) -> Iterable[MetadataChangeProposalWrapper]: @@ -66,14 +74,13 @@ def create_test_data(filename: str): file_emitter.close() wait_for_writes_to_sync() + sleep_sec, sleep_times = get_sleep_info() @pytest.fixture(scope="module", autouse=False) def graph() -> DataHubGraph: - graph: DataHubGraph = DataHubGraph( - config=DatahubClientConfig(server=get_gms_url()) - ) + graph: DataHubGraph = DataHubGraph(config=DatahubClientConfig(server=get_gms_url())) return graph @@ -132,7 +139,7 @@ def attach_property_to_entity( property_name: str, property_value: Union[str, float, List[str | float]], graph: DataHubGraph, - namespace: str = default_namespace + namespace: str = default_namespace, ): if isinstance(property_value, list): property_values: List[Union[str, float]] = property_value @@ -159,15 +166,12 @@ def get_property_from_entity( property_name: str, graph: DataHubGraph, ): - structured_properties: Optional[ - StructuredPropertiesClass - ] = graph.get_aspect(urn, StructuredPropertiesClass) + structured_properties: Optional[StructuredPropertiesClass] = graph.get_aspect( + urn, StructuredPropertiesClass + ) assert structured_properties is not None for property in structured_properties.properties: - if ( - property.propertyUrn - == f"urn:li:structuredProperty:{property_name}" - ): + if property.propertyUrn == f"urn:li:structuredProperty:{property_name}": return property.values return None @@ -181,16 +185,14 @@ def test_structured_property_string(ingest_cleanup_data, graph): property_name = "retentionPolicy" create_property_definition(property_name, graph) - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.retentionPolicy") - - attach_property_to_entity( - dataset_urns[0], property_name, ["30d"], graph=graph + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.retentionPolicy" ) + attach_property_to_entity(dataset_urns[0], property_name, ["30d"], graph=graph) + try: - attach_property_to_entity( - dataset_urns[0], property_name, 200030, graph=graph - ) + attach_property_to_entity(dataset_urns[0], property_name, 200030, graph=graph) raise AssertionError( "Should not be able to attach a number to a string property" ) @@ -208,12 +210,12 @@ def test_structured_property_string(ingest_cleanup_data, graph): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_structured_property_double(ingest_cleanup_data, graph): property_name = "expiryTime" - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.{property_name}" + ) create_property_definition(property_name, graph, value_type="number") - attach_property_to_entity( - dataset_urns[0], property_name, 2000034, graph=graph - ) + attach_property_to_entity(dataset_urns[0], property_name, 2000034, graph=graph) try: attach_property_to_entity( @@ -232,9 +234,7 @@ def test_structured_property_double(ingest_cleanup_data, graph): attach_property_to_entity( dataset_urns[0], property_name, [2000034, 2000035], graph=graph ) - raise AssertionError( - "Should not be able to attach a list to a number property" - ) + raise AssertionError("Should not be able to attach a list to a number property") except Exception as e: if not isinstance(e, AssertionError): pass @@ -249,15 +249,15 @@ def test_structured_property_double(ingest_cleanup_data, graph): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_structured_property_double_multiple(ingest_cleanup_data, graph): property_name = "versions" - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.{property_name}" + ) create_property_definition( property_name, graph, value_type="number", cardinality="MULTIPLE" ) - attach_property_to_entity( - dataset_urns[0], property_name, [1.0, 2.0], graph=graph - ) + attach_property_to_entity(dataset_urns[0], property_name, [1.0, 2.0], graph=graph) # @tenacity.retry( @@ -265,11 +265,11 @@ def test_structured_property_double_multiple(ingest_cleanup_data, graph): # wait=tenacity.wait_fixed(sleep_sec), # ) @pytest.mark.dependency(depends=["test_healthchecks"]) -def test_structured_property_string_allowed_values( - ingest_cleanup_data, graph -): +def test_structured_property_string_allowed_values(ingest_cleanup_data, graph): property_name = "enumProperty" - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.{property_name}" + ) create_property_definition( property_name, @@ -301,9 +301,7 @@ def test_structured_property_string_allowed_values( @pytest.mark.dependency(depends=["test_healthchecks"]) -def test_structured_property_definition_evolution( - ingest_cleanup_data, graph -): +def test_structured_property_definition_evolution(ingest_cleanup_data, graph): property_name = "enumProperty1234" create_property_definition( @@ -316,7 +314,9 @@ def test_structured_property_definition_evolution( PropertyValueClass(value="bar"), ], ) - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{property_name}") + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.{property_name}" + ) try: create_property_definition( @@ -345,9 +345,7 @@ def test_structured_property_definition_evolution( # ) @pytest.mark.dependency(depends=["test_healthchecks"]) def test_structured_property_schema_field(ingest_cleanup_data, graph): - property_name = ( - f"deprecationDate{randint(10, 10000)}" - ) + property_name = f"deprecationDate{randint(10, 10000)}" create_property_definition( property_name, @@ -356,26 +354,31 @@ def test_structured_property_schema_field(ingest_cleanup_data, graph): value_type="date", entity_types=["schemaField"], ) - generated_urns.append(f"urn:li:structuredProperty:io.datahubproject.test.{property_name}") + generated_urns.append( + f"urn:li:structuredProperty:io.datahubproject.test.{property_name}" + ) attach_property_to_entity( - schema_field_urns[0], property_name, "2020-10-01", graph=graph, namespace="io.datahubproject.test" + schema_field_urns[0], + property_name, + "2020-10-01", + graph=graph, + namespace="io.datahubproject.test", ) - assert ( - get_property_from_entity( - schema_field_urns[0], f"io.datahubproject.test.{property_name}", graph=graph - ) - == ["2020-10-01"] - ) + assert get_property_from_entity( + schema_field_urns[0], f"io.datahubproject.test.{property_name}", graph=graph + ) == ["2020-10-01"] try: attach_property_to_entity( - schema_field_urns[0], property_name, 200030, graph=graph, namespace="io.datahubproject.test" - ) - raise AssertionError( - "Should not be able to attach a number to a DATE property" + schema_field_urns[0], + property_name, + 200030, + graph=graph, + namespace="io.datahubproject.test", ) + raise AssertionError("Should not be able to attach a number to a DATE property") except Exception as e: if not isinstance(e, AssertionError): pass @@ -388,49 +391,38 @@ def test_dataset_yaml_loader(ingest_cleanup_data, graph): "tests/structured_properties/test_structured_properties.yaml" ) - for dataset in Dataset.from_yaml( - "tests/structured_properties/test_dataset.yaml" - ): + for dataset in Dataset.from_yaml("tests/structured_properties/test_dataset.yaml"): for mcp in dataset.generate_mcp(): graph.emit(mcp) wait_for_writes_to_sync() property_name = "io.acryl.dataManagement.deprecationDate" - assert ( - get_property_from_entity( - make_schema_field_urn( - make_dataset_urn("hive", "user.clicks"), "ip" - ), - property_name, - graph=graph, - ) - == ["2023-01-01"] - ) + assert get_property_from_entity( + make_schema_field_urn(make_dataset_urn("hive", "user.clicks"), "ip"), + property_name, + graph=graph, + ) == ["2023-01-01"] dataset = Dataset.from_datahub( graph=graph, urn="urn:li:dataset:(urn:li:dataPlatform:hive,user.clicks,PROD)", ) field_name = "ip" + assert dataset.schema_metadata is not None + assert dataset.schema_metadata.fields is not None matching_fields = [ f for f in dataset.schema_metadata.fields - if Dataset._simplify_field_path(f.id) == field_name + if f.id is not None and Dataset._simplify_field_path(f.id) == field_name ] assert len(matching_fields) == 1 - assert ( - matching_fields[0].structured_properties[ - Urn.make_structured_property_urn( - "io.acryl.dataManagement.deprecationDate" - ) - ] - == ["2023-01-01"] - ) + assert matching_fields[0].structured_properties is not None + assert matching_fields[0].structured_properties[ + Urn.make_structured_property_urn("io.acryl.dataManagement.deprecationDate") + ] == ["2023-01-01"] -def test_dataset_structured_property_validation( - ingest_cleanup_data, graph, caplog -): +def test_dataset_structured_property_validation(ingest_cleanup_data, graph, caplog): from datahub.api.entities.dataset.dataset import Dataset property_name = "replicationSLA" @@ -440,7 +432,9 @@ def test_dataset_structured_property_validation( create_property_definition( property_name=property_name, graph=graph, value_type=value_type ) - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.replicationSLA") + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.replicationSLA" + ) attach_property_to_entity( dataset_urns[0], property_name, [property_value], graph=graph @@ -453,21 +447,15 @@ def test_dataset_structured_property_validation( float(property_value), ) - assert ( - Dataset.validate_structured_property("testName", "testValue") is None - ) + assert Dataset.validate_structured_property("testName", "testValue") is None bad_property_value = "2023-09-20" assert ( - Dataset.validate_structured_property( - property_name, bad_property_value - ) - is None + Dataset.validate_structured_property(property_name, bad_property_value) is None ) - -def test_structured_property_search(ingest_cleanup_data, graph: DataHubGraph, caplog): +def test_structured_property_search(ingest_cleanup_data, graph: DataHubGraph, caplog): def to_es_name(property_name, namespace=default_namespace): namespace_field = namespace.replace(".", "_") return f"structuredProperties.{namespace_field}_{property_name}" @@ -478,88 +466,116 @@ def to_es_name(property_name, namespace=default_namespace): create_property_definition( namespace="io.datahubproject.test", property_name=field_property_name, - graph=graph, value_type="date", entity_types=["schemaField"] + graph=graph, + value_type="date", + entity_types=["schemaField"], + ) + generated_urns.append( + f"urn:li:structuredProperty:io.datahubproject.test.{field_property_name}" ) - generated_urns.append(f"urn:li:structuredProperty:io.datahubproject.test.{field_property_name}") attach_property_to_entity( - schema_field_urns[0], field_property_name, "2020-10-01", graph=graph, namespace="io.datahubproject.test" + schema_field_urns[0], + field_property_name, + "2020-10-01", + graph=graph, + namespace="io.datahubproject.test", ) dataset_property_name = "replicationSLA" property_value = 30 value_type = "number" - create_property_definition(property_name=dataset_property_name, graph=graph, value_type=value_type) - generated_urns.append(f"urn:li:structuredProperty:{default_namespace}.{dataset_property_name}") + create_property_definition( + property_name=dataset_property_name, graph=graph, value_type=value_type + ) + generated_urns.append( + f"urn:li:structuredProperty:{default_namespace}.{dataset_property_name}" + ) - attach_property_to_entity(dataset_urns[0], dataset_property_name, [property_value], graph=graph) + attach_property_to_entity( + dataset_urns[0], dataset_property_name, [property_value], graph=graph + ) # [] = default entities which includes datasets, does not include fields - entity_urns = list(graph.get_urns_by_filter(extraFilters=[ - { - "field": to_es_name(dataset_property_name), - "negated": "false", - "condition": "EXISTS", - } - ])) + entity_urns = list( + graph.get_urns_by_filter( + extraFilters=[ + { + "field": to_es_name(dataset_property_name), + "negated": "false", + "condition": "EXISTS", + } + ] + ) + ) assert len(entity_urns) == 1 assert entity_urns[0] == dataset_urns[0] # Search over schema field specifically - field_structured_prop = graph.get_aspect(entity_urn=schema_field_urns[0], aspect_type=StructuredPropertiesClass) + field_structured_prop = graph.get_aspect( + entity_urn=schema_field_urns[0], aspect_type=StructuredPropertiesClass + ) assert field_structured_prop == StructuredPropertiesClass( properties=[ StructuredPropertyValueAssignmentClass( propertyUrn=f"urn:li:structuredProperty:io.datahubproject.test.{field_property_name}", - values=["2020-10-01"] + values=["2020-10-01"], ) ] ) # Search over entities that do not include the field - field_urns = list(graph.get_urns_by_filter(entity_types=["tag"], - extraFilters=[ - { - "field": to_es_name(field_property_name, - namespace="io.datahubproject.test"), - "negated": "false", - "condition": "EXISTS", - } - ])) + field_urns = list( + graph.get_urns_by_filter( + entity_types=["tag"], + extraFilters=[ + { + "field": to_es_name( + field_property_name, namespace="io.datahubproject.test" + ), + "negated": "false", + "condition": "EXISTS", + } + ], + ) + ) assert len(field_urns) == 0 # OR the two properties together to return both results - field_urns = list(graph.get_urns_by_filter(entity_types=["dataset", "tag"], - extraFilters=[ - { - "field": to_es_name(dataset_property_name), - "negated": "false", - "condition": "EXISTS", - } - ])) + field_urns = list( + graph.get_urns_by_filter( + entity_types=["dataset", "tag"], + extraFilters=[ + { + "field": to_es_name(dataset_property_name), + "negated": "false", + "condition": "EXISTS", + } + ], + ) + ) assert len(field_urns) == 1 assert dataset_urns[0] in field_urns -def test_dataset_structured_property_patch( - ingest_cleanup_data, graph, caplog -): +@pytest.mark.skip(reason="Functionality and test needs to be validated for correctness") +def test_dataset_structured_property_patch(ingest_cleanup_data, graph, caplog): property_name = "replicationSLA" property_value = 30 value_type = "number" create_property_definition( - property_name=property_name, - graph=graph, - value_type=value_type + property_name=property_name, graph=graph, value_type=value_type ) - dataset_patcher: DatasetPatchBuilder = DatasetPatchBuilder( - urn=dataset_urns[0] - ) + dataset_patcher: DatasetPatchBuilder = DatasetPatchBuilder(urn=dataset_urns[0]) - dataset_patcher.set_structured_property(StructuredPropertyUrn.make_structured_property_urn( - f"{default_namespace}.{property_name}"), property_value) + dataset_patcher.set_structured_property( + StructuredPropertyUrn.make_structured_property_urn( + f"{default_namespace}.{property_name}" + ), + property_value, + ) for mcp in dataset_patcher.build(): graph.emit(mcp) @@ -567,11 +583,12 @@ def test_dataset_structured_property_patch( dataset = Dataset.from_datahub(graph=graph, urn=dataset_urns[0]) assert dataset.structured_properties is not None - assert ( - [int(float(k)) for k in dataset.structured_properties[ + assert isinstance(dataset.structured_properties, list) + assert [ + int(float(k)) + for k in dataset.structured_properties[ StructuredPropertyUrn.make_structured_property_urn( f"{default_namespace}.{property_name}" ) - ]] - == [property_value] - ) + ] + ] == [property_value] diff --git a/smoke-test/tests/tags_and_terms/__init__.py b/smoke-test/tests/tags_and_terms/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/smoke-test/tests/tags-and-terms/data.json b/smoke-test/tests/tags_and_terms/data.json similarity index 100% rename from smoke-test/tests/tags-and-terms/data.json rename to smoke-test/tests/tags_and_terms/data.json diff --git a/smoke-test/tests/tags-and-terms/tags_and_terms_test.py b/smoke-test/tests/tags_and_terms/tags_and_terms_test.py similarity index 98% rename from smoke-test/tests/tags-and-terms/tags_and_terms_test.py rename to smoke-test/tests/tags_and_terms/tags_and_terms_test.py index 6ac75765286f00..34404a1ddff594 100644 --- a/smoke-test/tests/tags-and-terms/tags_and_terms_test.py +++ b/smoke-test/tests/tags_and_terms/tags_and_terms_test.py @@ -1,16 +1,20 @@ import pytest -from tests.utils import (delete_urns_from_file, get_frontend_url, - ingest_file_via_rest, wait_for_healthcheck_util) +from tests.utils import ( + delete_urns_from_file, + get_frontend_url, + ingest_file_via_rest, + wait_for_healthcheck_util, +) @pytest.fixture(scope="module", autouse=True) def ingest_cleanup_data(request): print("ingesting test data") - ingest_file_via_rest("tests/tags-and-terms/data.json") + ingest_file_via_rest("tests/tags_and_terms/data.json") yield print("removing test data") - delete_urns_from_file("tests/tags-and-terms/data.json") + delete_urns_from_file("tests/tags_and_terms/data.json") @pytest.fixture(scope="session") diff --git a/smoke-test/tests/telemetry/telemetry_test.py b/smoke-test/tests/telemetry/telemetry_test.py index b7cd6fa0517df7..963d85baef3bb7 100644 --- a/smoke-test/tests/telemetry/telemetry_test.py +++ b/smoke-test/tests/telemetry/telemetry_test.py @@ -5,7 +5,9 @@ def test_no_client_id(): client_id_urn = "urn:li:telemetry:clientId" - aspect = ["clientId"] # this is checking for the removal of the invalid aspect RemoveClientIdAspectStep.java + aspect = [ + "clientId" + ] # this is checking for the removal of the invalid aspect RemoveClientIdAspectStep.java res_data = json.dumps( get_aspects_for_entity(entity_urn=client_id_urn, aspects=aspect, typed=False) diff --git a/smoke-test/tests/test_stateful_ingestion.py b/smoke-test/tests/test_stateful_ingestion.py index c6adb402e5d510..5eac25059ec62c 100644 --- a/smoke-test/tests/test_stateful_ingestion.py +++ b/smoke-test/tests/test_stateful_ingestion.py @@ -4,15 +4,19 @@ from datahub.ingestion.run.pipeline import Pipeline from datahub.ingestion.source.sql.mysql import MySQLConfig, MySQLSource from datahub.ingestion.source.state.checkpoint import Checkpoint -from datahub.ingestion.source.state.entity_removal_state import \ - GenericCheckpointState -from datahub.ingestion.source.state.stale_entity_removal_handler import \ - StaleEntityRemovalHandler +from datahub.ingestion.source.state.entity_removal_state import GenericCheckpointState +from datahub.ingestion.source.state.stale_entity_removal_handler import ( + StaleEntityRemovalHandler, +) from sqlalchemy import create_engine from sqlalchemy.sql import text -from tests.utils import (get_gms_url, get_mysql_password, get_mysql_url, - get_mysql_username) +from tests.utils import ( + get_gms_url, + get_mysql_password, + get_mysql_url, + get_mysql_username, +) def test_stateful_ingestion(wait_for_healthchecks): diff --git a/smoke-test/tests/tests/tests_test.py b/smoke-test/tests/tests/tests_test.py index 213a2ea087b7a1..28005c8397d0d1 100644 --- a/smoke-test/tests/tests/tests_test.py +++ b/smoke-test/tests/tests/tests_test.py @@ -1,9 +1,13 @@ import pytest import tenacity -from tests.utils import (delete_urns_from_file, get_frontend_url, - get_sleep_info, ingest_file_via_rest, - wait_for_healthcheck_util) +from tests.utils import ( + delete_urns_from_file, + get_frontend_url, + get_sleep_info, + ingest_file_via_rest, + wait_for_healthcheck_util, +) sleep_sec, sleep_times = get_sleep_info() @@ -37,7 +41,6 @@ def test_healthchecks(wait_for_healthchecks): def create_test(frontend_session): - # Create new Test create_test_json = { "query": """mutation createTest($input: CreateTestInput!) {\n @@ -84,7 +87,6 @@ def delete_test(frontend_session, test_urn): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_test(frontend_session, wait_for_healthchecks): - test_urn = create_test(frontend_session) # Get the test diff --git a/smoke-test/tests/timeline/timeline_test.py b/smoke-test/tests/timeline/timeline_test.py index c075d981487dbd..f8a0e425c37816 100644 --- a/smoke-test/tests/timeline/timeline_test.py +++ b/smoke-test/tests/timeline/timeline_test.py @@ -1,15 +1,14 @@ import json -import pytest -from time import sleep +import pytest from datahub.cli import timeline_cli from datahub.cli.cli_utils import guess_entity_type, post_entity -from tests.utils import (get_datahub_graph, ingest_file_via_rest, - wait_for_writes_to_sync) +from tests.utils import get_datahub_graph, ingest_file_via_rest, wait_for_writes_to_sync pytestmark = pytest.mark.no_cypress_suite1 + def test_all(): platform = "urn:li:dataPlatform:kafka" dataset_name = "test-timeline-sample-kafka" @@ -184,7 +183,7 @@ def put(urn: str, aspect: str, aspect_data: str) -> None: entity_type = guess_entity_type(urn) with open(aspect_data) as fp: aspect_obj = json.load(fp) - status = post_entity( + post_entity( urn=urn, aspect_name=aspect, entity_type=entity_type, diff --git a/smoke-test/tests/tokens/revokable_access_token_test.py b/smoke-test/tests/tokens/revokable_access_token_test.py index 55f3de594af4e2..10332b32b9cafc 100644 --- a/smoke-test/tests/tokens/revokable_access_token_test.py +++ b/smoke-test/tests/tokens/revokable_access_token_test.py @@ -1,11 +1,14 @@ import os -from time import sleep import pytest -import requests -from tests.utils import (get_admin_credentials, get_frontend_url, - wait_for_healthcheck_util, wait_for_writes_to_sync) +from tests.utils import ( + get_admin_credentials, + get_frontend_url, + login_as, + wait_for_healthcheck_util, + wait_for_writes_to_sync, +) # Disable telemetry os.environ["DATAHUB_TELEMETRY_ENABLED"] = "false" @@ -29,7 +32,7 @@ def test_healthchecks(wait_for_healthchecks): @pytest.fixture(scope="class", autouse=True) def custom_user_setup(): """Fixture to execute setup before and tear down after all tests are run""" - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) res_data = removeUser(admin_session, "urn:li:corpuser:user") assert res_data @@ -77,7 +80,7 @@ def custom_user_setup(): # signUp will override the session cookie to the new user to be signed up. admin_session.cookies.clear() - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) # Make user created user is there. res_data = listUsers(admin_session) @@ -91,7 +94,7 @@ def custom_user_setup(): res_data = removeUser(admin_session, "urn:li:corpuser:user") assert res_data assert res_data["data"] - assert res_data["data"]["removeUser"] == True + assert res_data["data"]["removeUser"] is True # Sleep for eventual consistency wait_for_writes_to_sync() @@ -106,7 +109,7 @@ def custom_user_setup(): @pytest.fixture(autouse=True) def access_token_setup(): """Fixture to execute asserts before and after a test is run""" - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) res_data = listAccessTokens(admin_session) assert res_data @@ -127,7 +130,7 @@ def access_token_setup(): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) # Using a super account, there should be no tokens res_data = listAccessTokens(admin_session) @@ -170,7 +173,7 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert res_data assert res_data["data"] assert res_data["data"]["revokeAccessToken"] - assert res_data["data"]["revokeAccessToken"] == True + assert res_data["data"]["revokeAccessToken"] is True # Sleep for eventual consistency wait_for_writes_to_sync() @@ -184,7 +187,7 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks): - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) # Using a super account, there should be no tokens res_data = listAccessTokens(admin_session) @@ -227,7 +230,7 @@ def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks assert res_data assert res_data["data"] assert res_data["data"]["revokeAccessToken"] - assert res_data["data"]["revokeAccessToken"] == True + assert res_data["data"]["revokeAccessToken"] is True # Sleep for eventual consistency wait_for_writes_to_sync() @@ -241,7 +244,7 @@ def test_admin_can_create_and_revoke_tokens_for_other_user(wait_for_healthchecks @pytest.mark.dependency(depends=["test_healthchecks"]) def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): - user_session = loginAs("user", "user") + user_session = login_as("user", "user") # Normal user should be able to generate token for himself. res_data = generateAccessToken_v2(user_session, "urn:li:corpuser:user") @@ -280,7 +283,7 @@ def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): assert res_data assert res_data["data"] assert res_data["data"]["revokeAccessToken"] - assert res_data["data"]["revokeAccessToken"] == True + assert res_data["data"]["revokeAccessToken"] is True # Sleep for eventual consistency wait_for_writes_to_sync() @@ -296,7 +299,7 @@ def test_non_admin_can_create_list_revoke_tokens(wait_for_healthchecks): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) # Using a super account, there should be no tokens res_data = listAccessTokens(admin_session) @@ -306,7 +309,7 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 admin_session.cookies.clear() - user_session = loginAs("user", "user") + user_session = login_as("user", "user") res_data = generateAccessToken_v2(user_session, "urn:li:corpuser:user") assert res_data assert res_data["data"] @@ -326,7 +329,7 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): # Admin should be able to list other tokens user_session.cookies.clear() - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) res_data = listAccessTokens( admin_session, [{"field": "ownerUrn", "values": ["urn:li:corpuser:user"]}] ) @@ -346,18 +349,18 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): # Admin can delete token created by someone else. admin_session.cookies.clear() - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) res_data = revokeAccessToken(admin_session, user_tokenId) assert res_data assert res_data["data"] assert res_data["data"]["revokeAccessToken"] - assert res_data["data"]["revokeAccessToken"] == True + assert res_data["data"]["revokeAccessToken"] is True # Sleep for eventual consistency wait_for_writes_to_sync() # Using a normal account, check that all its tokens where removed. user_session.cookies.clear() - user_session = loginAs("user", "user") + user_session = login_as("user", "user") res_data = listAccessTokens( user_session, [{"field": "ownerUrn", "values": ["urn:li:corpuser:user"]}] ) @@ -367,7 +370,7 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 # Using the super account, check that all tokens where removed. - admin_session = loginAs(admin_user, admin_pass) + admin_session = login_as(admin_user, admin_pass) res_data = listAccessTokens( admin_session, [{"field": "ownerUrn", "values": ["urn:li:corpuser:user"]}] ) @@ -379,7 +382,7 @@ def test_admin_can_manage_tokens_generated_by_other_user(wait_for_healthchecks): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_non_admin_can_not_generate_tokens_for_others(wait_for_healthchecks): - user_session = loginAs("user", "user") + user_session = login_as("user", "user") # Normal user should not be able to generate token for another user res_data = generateAccessToken_v2(user_session, f"urn:li:corpuser:{admin_user}") assert res_data @@ -467,19 +470,6 @@ def revokeAccessToken(session, tokenId): return response.json() -def loginAs(username, password): - session = requests.Session() - - headers = { - "Content-Type": "application/json", - } - data = '{"username":"' + username + '", "password":"' + password + '"}' - response = session.post(f"{get_frontend_url()}/logIn", headers=headers, data=data) - response.raise_for_status() - - return session - - def removeUser(session, urn): # Remove user json = { diff --git a/smoke-test/tests/utilities/file_emitter.py b/smoke-test/tests/utilities/file_emitter.py index 27a91c360af8a8..ddbcff8db31d8b 100644 --- a/smoke-test/tests/utilities/file_emitter.py +++ b/smoke-test/tests/utilities/file_emitter.py @@ -1,11 +1,14 @@ -from datahub.ingestion.sink.file import FileSink, FileSinkConfig +import time + from datahub.ingestion.api.common import PipelineContext, RecordEnvelope from datahub.ingestion.api.sink import NoopWriteCallback -import time +from datahub.ingestion.sink.file import FileSink, FileSinkConfig class FileEmitter: - def __init__(self, filename: str, run_id: str = f"test_{int(time.time()*1000.0)}") -> None: + def __init__( + self, filename: str, run_id: str = f"test_{int(time.time()*1000.0)}" + ) -> None: self.sink: FileSink = FileSink( ctx=PipelineContext(run_id=run_id), config=FileSinkConfig(filename=filename), @@ -18,4 +21,4 @@ def emit(self, event): ) def close(self): - self.sink.close() \ No newline at end of file + self.sink.close() diff --git a/smoke-test/tests/utils.py b/smoke-test/tests/utils.py index bd75b13d1910f6..29b956bde9ab89 100644 --- a/smoke-test/tests/utils.py +++ b/smoke-test/tests/utils.py @@ -2,14 +2,10 @@ import json import logging import os -import subprocess -import time from datetime import datetime, timedelta, timezone -from time import sleep from typing import Any, Dict, List, Tuple -from datahub.cli import cli_utils -from datahub.cli.cli_utils import get_system_auth +from datahub.cli import cli_utils, env_utils from datahub.ingestion.graph.client import DatahubClientConfig, DataHubGraph from datahub.ingestion.run.pipeline import Pipeline from joblib import Parallel, delayed @@ -22,23 +18,14 @@ def get_frontend_session(): - session = requests.Session() + username, password = get_admin_credentials() + return login_as(username, password) - headers = { - "Content-Type": "application/json", - } - system_auth = get_system_auth() - if system_auth is not None: - session.headers.update({"Authorization": system_auth}) - else: - username, password = get_admin_credentials() - data = '{"username":"' + username + '", "password":"' + password + '"}' - response = session.post( - f"{get_frontend_url()}/logIn", headers=headers, data=data - ) - response.raise_for_status() - return session +def login_as(username: str, password: str): + return cli_utils.get_session_login_as( + username=username, password=password, frontend_url=get_frontend_url() + ) def get_admin_username() -> str: @@ -146,7 +133,7 @@ def delete_urns(urns: List[str]) -> None: def delete_urns_from_file(filename: str, shared_data: bool = False) -> None: - if not cli_utils.get_boolean_env_variable("CLEANUP_DATA", True): + if not env_utils.get_boolean_env_variable("CLEANUP_DATA", True): print("Not cleaning data to save time") return session = requests.Session() @@ -223,7 +210,7 @@ def create_datahub_step_state_aspect( def create_datahub_step_state_aspects( - username: str, onboarding_ids: str, onboarding_filename + username: str, onboarding_ids: List[str], onboarding_filename: str ) -> None: """ For a specific user, creates dataHubStepState aspects for each onboarding id in the list diff --git a/smoke-test/tests/views/views_test.py b/smoke-test/tests/views/views_test.py index 685c3bd80b04d8..a99f1f0dbb2451 100644 --- a/smoke-test/tests/views/views_test.py +++ b/smoke-test/tests/views/views_test.py @@ -1,10 +1,7 @@ -import time - import pytest import tenacity -from tests.utils import (delete_urns_from_file, get_frontend_url, get_gms_url, - get_sleep_info, ingest_file_via_rest) +from tests.utils import get_frontend_url, get_sleep_info sleep_sec, sleep_times = get_sleep_info() @@ -19,7 +16,6 @@ def test_healthchecks(wait_for_healthchecks): stop=tenacity.stop_after_attempt(sleep_times), wait=tenacity.wait_fixed(sleep_sec) ) def _ensure_more_views(frontend_session, list_views_json, query_name, before_count): - # Get new count of Views response = frontend_session.post( f"{get_frontend_url()}/api/v2/graphql", json=list_views_json @@ -43,7 +39,6 @@ def _ensure_more_views(frontend_session, list_views_json, query_name, before_cou stop=tenacity.stop_after_attempt(sleep_times), wait=tenacity.wait_fixed(sleep_sec) ) def _ensure_less_views(frontend_session, list_views_json, query_name, before_count): - # Get new count of Views response = frontend_session.post( f"{get_frontend_url()}/api/v2/graphql", json=list_views_json @@ -64,7 +59,6 @@ def _ensure_less_views(frontend_session, list_views_json, query_name, before_cou @pytest.mark.dependency(depends=["test_healthchecks"]) def test_create_list_delete_global_view(frontend_session): - # Get count of existing views list_global_views_json = { "query": """query listGlobalViews($input: ListGlobalViewsInput!) {\n @@ -161,8 +155,6 @@ def test_create_list_delete_global_view(frontend_session): before_count=before_count, ) - delete_json = {"urn": view_urn} - # Delete the View delete_view_json = { "query": """mutation deleteView($urn: String!) {\n @@ -190,7 +182,6 @@ def test_create_list_delete_global_view(frontend_session): depends=["test_healthchecks", "test_create_list_delete_global_view"] ) def test_create_list_delete_personal_view(frontend_session): - # Get count of existing views list_my_views_json = { "query": """query listMyViews($input: ListMyViewsInput!) {\n @@ -314,7 +305,6 @@ def test_create_list_delete_personal_view(frontend_session): depends=["test_healthchecks", "test_create_list_delete_personal_view"] ) def test_update_global_view(frontend_session): - # First create a view new_view_name = "Test View" new_view_description = "Test Description" From 54f85503f9feda7404aa6911c2d2220f69ae63b2 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Wed, 31 Jan 2024 10:15:34 -0500 Subject: [PATCH 238/540] feat(forms) Update form model to include searchable on actor assignment (#9747) --- .../com/linkedin/form/FormActorAssignment.pdl | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl b/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl index e58eb4c7c56a81..f0ea0f4988298b 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/form/FormActorAssignment.pdl @@ -7,15 +7,31 @@ record FormActorAssignment { * Whether the form should be assigned to the owners of assets that it is applied to. * This is the default. */ + @Searchable = { + "fieldName": "isOwnershipForm", + "fieldType": "BOOLEAN", + } owners: boolean = true /** * Optional: Specific set of groups that are targeted by this form assignment. */ + @Searchable = { + "/*": { + "fieldName": "assignedGroups", + "fieldType": "URN" + } + } groups: optional array[Urn] /** * Optional: Specific set of users that are targeted by this form assignment. */ + @Searchable = { + "/*": { + "fieldName": "assignedUsers", + "fieldType": "URN" + } + } users: optional array[Urn] } \ No newline at end of file From ad2df22d7843f69d90191dbfe5bb64708167c6e5 Mon Sep 17 00:00:00 2001 From: sid-acryl <155424659+sid-acryl@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:08:26 +0530 Subject: [PATCH 239/540] feat(ingestion/dbt): multiple node owner separated by comma (#9740) Co-authored-by: Aseem Bansal --- .../src/datahub/utilities/mapping.py | 75 +++++++++++++++---- metadata-ingestion/tests/unit/test_mapping.py | 11 ++- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/metadata-ingestion/src/datahub/utilities/mapping.py b/metadata-ingestion/src/datahub/utilities/mapping.py index 00f7d370d16765..5d26c3af54d5ef 100644 --- a/metadata-ingestion/src/datahub/utilities/mapping.py +++ b/metadata-ingestion/src/datahub/utilities/mapping.py @@ -31,6 +31,23 @@ def _get_best_match(the_match: Match, group_name: str) -> str: return the_match.group(0) +def _make_owner_category_list( + owner_type: OwnerType, + owner_category: Any, + owner_category_urn: Optional[str], + owner_ids: List[str], +) -> List[Dict]: + + return [ + { + "urn": mce_builder.make_owner_urn(owner_id, owner_type), + "category": owner_category, + "categoryUrn": owner_category_urn, + } + for owner_id in owner_ids + ] + + _match_regexp = re.compile(r"{{\s*\$match\s*}}", flags=re.MULTILINE) @@ -149,13 +166,26 @@ def process(self, raw_props: Mapping[str, Any]) -> Dict[str, Any]: operation = self.get_operation_value( operation_key, operation_type, operation_config, maybe_match ) + if operation_type == Constants.ADD_TERMS_OPERATION: # add_terms operation is a special case where the operation value is a list of terms. # We want to aggregate these values with the add_term operation. operation_type = Constants.ADD_TERM_OPERATION if operation: - if isinstance(operation, (str, list)): + if ( + isinstance(operation, list) + and operation_type == Constants.ADD_OWNER_OPERATION + ): + operation_value_list = operations_map.get( + operation_type, list() + ) + cast(List, operation_value_list).extend( + operation + ) # cast to silent the lint + operations_map[operation_type] = operation_value_list + + elif isinstance(operation, (str, list)): operations_value_set = operations_map.get( operation_type, set() ) @@ -184,8 +214,11 @@ def convert_to_aspects( tag_aspect = mce_builder.make_global_tag_aspect_with_tag_list( sorted(operation_map[Constants.ADD_TAG_OPERATION]) ) + aspect_map[Constants.ADD_TAG_OPERATION] = tag_aspect + if Constants.ADD_OWNER_OPERATION in operation_map: + owner_aspect = OwnershipClass( owners=[ OwnerClass( @@ -202,6 +235,7 @@ def convert_to_aspects( ) ] ) + aspect_map[Constants.ADD_OWNER_OPERATION] = owner_aspect if Constants.ADD_TERM_OPERATION in operation_map: @@ -262,7 +296,7 @@ def get_operation_value( operation_type: str, operation_config: Dict, match: Match, - ) -> Optional[Union[str, Dict, List[str]]]: + ) -> Optional[Union[str, Dict, List[str], List[Dict]]]: if ( operation_type == Constants.ADD_TAG_OPERATION and operation_config[Constants.TAG] @@ -278,30 +312,39 @@ def get_operation_value( and operation_config[Constants.OWNER_TYPE] ): owner_id = _get_best_match(match, "owner") + + owner_ids: List[str] = [_id.strip() for _id in owner_id.split(",")] + owner_category = ( operation_config.get(Constants.OWNER_CATEGORY) or OwnershipTypeClass.DATAOWNER ) - owner_category_urn = None + owner_category_urn: Optional[str] = None if owner_category.startswith("urn:li:"): owner_category_urn = owner_category owner_category = OwnershipTypeClass.DATAOWNER else: owner_category = owner_category.upper() + if self.strip_owner_email_id: - owner_id = self.sanitize_owner_ids(owner_id) - if operation_config[Constants.OWNER_TYPE] == Constants.USER_OWNER: - return { - "urn": mce_builder.make_owner_urn(owner_id, OwnerType.USER), - "category": owner_category, - "categoryUrn": owner_category_urn, - } - elif operation_config[Constants.OWNER_TYPE] == Constants.GROUP_OWNER: - return { - "urn": mce_builder.make_owner_urn(owner_id, OwnerType.GROUP), - "category": owner_category, - "categoryUrn": owner_category_urn, - } + owner_ids = [ + self.sanitize_owner_ids(owner_id) for owner_id in owner_ids + ] + + owner_type_mapping: Dict[str, OwnerType] = { + Constants.USER_OWNER: OwnerType.USER, + Constants.GROUP_OWNER: OwnerType.GROUP, + } + if operation_config[Constants.OWNER_TYPE] in owner_type_mapping: + return _make_owner_category_list( + owner_ids=owner_ids, + owner_category=owner_category, + owner_category_urn=owner_category_urn, + owner_type=owner_type_mapping[ + operation_config[Constants.OWNER_TYPE] + ], + ) + elif ( operation_type == Constants.ADD_TERM_OPERATION and operation_config[Constants.TERM] diff --git a/metadata-ingestion/tests/unit/test_mapping.py b/metadata-ingestion/tests/unit/test_mapping.py index de35451c9ec4b5..755a62fa329123 100644 --- a/metadata-ingestion/tests/unit/test_mapping.py +++ b/metadata-ingestion/tests/unit/test_mapping.py @@ -25,6 +25,11 @@ def get_operation_defs() -> Dict[str, Any]: "operation": "add_owner", "config": {"owner_type": "user"}, }, + "multi_user": { + "match": ".*", + "operation": "add_owner", + "config": {"owner_type": "user"}, + }, "group.owner": { "match": ".*", "operation": "add_owner", @@ -78,6 +83,7 @@ def test_operation_processor_not_matching(): def test_operation_processor_matching(): raw_props = { "user_owner": "test_user@abc.com", + "multi_user": "sales_member1@abc.com, sales_member2@abc.com", "user_owner_2": "test_user_2", "group.owner": "test.group@abc.co.in", "governance.team_owner": "Finance", @@ -86,6 +92,7 @@ def test_operation_processor_matching(): "double_property": 2.5, "tag": "Finance", } + processor = OperationProcessor( operation_defs=get_operation_defs(), owner_source_type="SOURCE_CONTROL", @@ -116,11 +123,13 @@ def test_operation_processor_matching(): ) ownership_aspect: OwnershipClass = aspect_map["add_owner"] - assert len(ownership_aspect.owners) == 3 + assert len(ownership_aspect.owners) == 5 owner_set = { "urn:li:corpuser:test_user", "urn:li:corpuser:test_user_2", "urn:li:corpGroup:test.group", + "urn:li:corpuser:sales_member1", + "urn:li:corpuser:sales_member2", } for single_owner in ownership_aspect.owners: assert single_owner.owner in owner_set From f3afdf9e0c19f0d18e426d1a4c188b70c94b86ab Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Wed, 31 Jan 2024 10:52:42 -0600 Subject: [PATCH 240/540] fix(mceConsumer): prevent health endpoint authentication failure (#9748) --- .../metadata/restli/RestliServletConfig.java | 17 +++++++++-------- .../HealthStatusAuthenticator.java | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java index b41e6bc75af19c..269b9a41a89a9b 100644 --- a/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java +++ b/metadata-jobs/mce-consumer-job/src/main/java/com/linkedin/metadata/restli/RestliServletConfig.java @@ -3,6 +3,7 @@ import com.datahub.auth.authentication.filter.AuthenticationFilter; import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; import com.linkedin.restli.server.RestliHandlerServlet; +import java.util.Collections; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -41,19 +42,19 @@ public RestliHandlerServlet restliHandlerServlet() { @Bean public FilterRegistrationBean authenticationFilterRegistrationBean( - @Qualifier("restliServletRegistration") - ServletRegistrationBean servlet) { + @Qualifier("restliServletRegistration") ServletRegistrationBean servlet, + AuthenticationFilter authenticationFilter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.addServletRegistrationBeans(servlet); + registrationBean.setServletRegistrationBeans(Collections.singletonList(servlet)); + registrationBean.setUrlPatterns(Collections.singletonList("/gms/*")); + registrationBean.setServletNames(Collections.singletonList(servlet.getServletName())); registrationBean.setOrder(1); + registrationBean.setFilter(authenticationFilter); return registrationBean; } @Bean - public AuthenticationFilter authenticationFilter( - FilterRegistrationBean filterReg) { - AuthenticationFilter filter = new AuthenticationFilter(); - filterReg.setFilter(filter); - return filter; + public AuthenticationFilter authenticationFilter() { + return new AuthenticationFilter(); } } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/authenticator/HealthStatusAuthenticator.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/authenticator/HealthStatusAuthenticator.java index 65581f1d5b6352..017ab25bc7b7c3 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/authenticator/HealthStatusAuthenticator.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/authenticator/HealthStatusAuthenticator.java @@ -24,7 +24,8 @@ */ @Slf4j public class HealthStatusAuthenticator implements Authenticator { - private static final Set HEALTH_ENDPOINTS = Set.of("/openapi/check/", "/openapi/up/"); + private static final Set HEALTH_ENDPOINTS = + Set.of("/openapi/check/", "/openapi/up/", "/actuator/health", "/health"); private String systemClientId; @Override From 874c683f2a1132e89498cda458a0e050ee4b73af Mon Sep 17 00:00:00 2001 From: sid-acryl <155424659+sid-acryl@users.noreply.github.com> Date: Thu, 1 Feb 2024 03:55:08 +0530 Subject: [PATCH 241/540] fix(ingest/transformer): generate ownership aspect from handle_end_of_stream (#9720) --- .../ingestion/transformer/base_transformer.py | 4 +- .../extract_ownership_from_tags.py | 46 ++++++++++++++----- .../tests/unit/test_transform_dataset.py | 25 ++++++++-- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py index e8e25a061a665e..fb776ca8d23281 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/base_transformer.py @@ -20,8 +20,8 @@ def _update_work_unit_id( envelope: RecordEnvelope, urn: str, aspect_name: str ) -> Dict[Any, Any]: - structured_urn = Urn.create_from_string(urn) - simple_name = "-".join(structured_urn.get_entity_id()) + structured_urn = Urn.from_string(urn) + simple_name = "-".join(structured_urn.entity_ids) record_metadata = envelope.metadata.copy() record_metadata.update({"workunit_id": f"txform-{simple_name}-{aspect_name}"}) return record_metadata diff --git a/metadata-ingestion/src/datahub/ingestion/transformer/extract_ownership_from_tags.py b/metadata-ingestion/src/datahub/ingestion/transformer/extract_ownership_from_tags.py index 76f883b629d505..6266e0bca6c614 100644 --- a/metadata-ingestion/src/datahub/ingestion/transformer/extract_ownership_from_tags.py +++ b/metadata-ingestion/src/datahub/ingestion/transformer/extract_ownership_from_tags.py @@ -1,11 +1,14 @@ +import logging import re from functools import lru_cache -from typing import List, Optional, cast +from typing import List, Optional, Sequence, Union, cast from datahub.configuration.common import TransformerSemanticsConfigModel from datahub.emitter.mce_builder import Aspect +from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.common import PipelineContext from datahub.ingestion.transformer.dataset_transformer import DatasetTagsTransformer +from datahub.metadata._schema_classes import MetadataChangeProposalClass from datahub.metadata.schema_classes import ( GlobalTagsClass, OwnerClass, @@ -16,6 +19,8 @@ from datahub.utilities.urns.corpuser_urn import CorpuserUrn from datahub.utilities.urns.tag_urn import TagUrn +logger = logging.getLogger(__name__) + class ExtractOwnersFromTagsConfig(TransformerSemanticsConfigModel): tag_prefix: str @@ -38,11 +43,13 @@ class ExtractOwnersFromTagsTransformer(DatasetTagsTransformer): ctx: PipelineContext config: ExtractOwnersFromTagsConfig + owner_mcps: List[MetadataChangeProposalWrapper] def __init__(self, config: ExtractOwnersFromTagsConfig, ctx: PipelineContext): super().__init__() self.ctx = ctx self.config = config + self.owner_mcps = [] @classmethod def create( @@ -56,6 +63,12 @@ def get_owner_urn(self, owner_str: str) -> str: return owner_str + "@" + self.config.email_domain return owner_str + def handle_end_of_stream( + self, + ) -> Sequence[Union[MetadataChangeProposalWrapper, MetadataChangeProposalClass]]: + + return self.owner_mcps + def transform_aspect( self, entity_urn: str, aspect_name: str, aspect: Optional[Aspect] ) -> Optional[Aspect]: @@ -64,28 +77,39 @@ def transform_aspect( return None tags = in_tags_aspect.tags owners: List[OwnerClass] = [] + for tag_class in tags: tag_urn = TagUrn.from_string(tag_class.tag) - tag_str = tag_urn.get_entity_id()[0] + tag_str = tag_urn.entity_ids[0] re_match = re.search(self.config.tag_prefix, tag_str) if re_match: owner_str = tag_str[re_match.end() :].strip() owner_urn_str = self.get_owner_urn(owner_str) if self.config.is_user: - owner_urn = str(CorpuserUrn.create_from_id(owner_urn_str)) + owner_urn = str(CorpuserUrn(owner_urn_str)) else: - owner_urn = str(CorpGroupUrn.create_from_id(owner_urn_str)) + owner_urn = str(CorpGroupUrn(owner_urn_str)) owner_type = get_owner_type(self.config.owner_type) if owner_type == OwnershipTypeClass.CUSTOM: assert ( self.config.owner_type_urn is not None ), "owner_type_urn must be set if owner_type is CUSTOM" - owner = OwnerClass( - owner=owner_urn, - type=owner_type, - typeUrn=self.config.owner_type_urn, + + owners.append( + OwnerClass( + owner=owner_urn, + type=owner_type, + typeUrn=self.config.owner_type_urn, + ) ) - owners.append(owner) - owner_aspect = OwnershipClass(owners=owners) - return cast(Aspect, owner_aspect) + self.owner_mcps.append( + MetadataChangeProposalWrapper( + entityUrn=entity_urn, + aspect=OwnershipClass( + owners=owners, + ), + ) + ) + + return None diff --git a/metadata-ingestion/tests/unit/test_transform_dataset.py b/metadata-ingestion/tests/unit/test_transform_dataset.py index 5152f406ed3ce0..2a6176906a0c3e 100644 --- a/metadata-ingestion/tests/unit/test_transform_dataset.py +++ b/metadata-ingestion/tests/unit/test_transform_dataset.py @@ -648,22 +648,35 @@ def _test_owner( ) ] ) + transformer = ExtractOwnersFromTagsTransformer.create( config, PipelineContext(run_id="test"), ) - transformed = list( + + record_envelops: List[RecordEnvelope] = list( transformer.transform( [ RecordEnvelope(dataset, metadata={}), + RecordEnvelope(record=EndOfStream(), metadata={}), ] ) ) - owners_aspect = transformed[0].record.proposedSnapshot.aspects[0] + + assert len(record_envelops) == 3 + + mcp: MetadataChangeProposalWrapper = record_envelops[1].record + + owners_aspect = cast(OwnershipClass, mcp.aspect) + owners = owners_aspect.owners + owner = owners[0] - if expected_owner_type is not None: - assert owner.type == expected_owner_type + + assert expected_owner_type is not None + + assert owner.type == expected_owner_type + assert owner.owner == expected_owner _test_owner( @@ -672,6 +685,7 @@ def _test_owner( "tag_prefix": "owner:", }, expected_owner="urn:li:corpuser:foo", + expected_owner_type=OwnershipTypeClass.TECHNICAL_OWNER, ) _test_owner( tag="abcdef-owner:foo", @@ -679,6 +693,7 @@ def _test_owner( "tag_prefix": ".*owner:", }, expected_owner="urn:li:corpuser:foo", + expected_owner_type=OwnershipTypeClass.TECHNICAL_OWNER, ) _test_owner( tag="owner:foo", @@ -687,6 +702,7 @@ def _test_owner( "is_user": False, }, expected_owner="urn:li:corpGroup:foo", + expected_owner_type=OwnershipTypeClass.TECHNICAL_OWNER, ) _test_owner( tag="owner:foo", @@ -695,6 +711,7 @@ def _test_owner( "email_domain": "example.com", }, expected_owner="urn:li:corpuser:foo@example.com", + expected_owner_type=OwnershipTypeClass.TECHNICAL_OWNER, ) _test_owner( tag="owner:foo", From ff0818bad722df4b618e106b523f78b7ef9242e0 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:28:35 -0600 Subject: [PATCH 242/540] fix(aspects): fix default aspect generation for non-restli locations (#9746) --- .../upgrade/nocode/DataMigrationStep.java | 3 +- .../steps/BackfillBrowsePathsV2Step.java | 4 +- .../metadata/aspect/batch/AspectsBatch.java | 5 +- .../metadata/aspect/batch/BatchItem.java | 10 + .../metadata/aspect/batch/MCLBatchItem.java | 7 +- .../metadata/aspect/batch/UpsertItem.java | 2 - .../datahub/spark/TestCoalesceJobLineage.java | 4 +- .../datahub/spark/TestSparkJobsLineage.java | 4 +- .../aspect/utils/DefaultAspectsUtil.java | 312 ++++++++++ .../metadata/client/JavaEntityClient.java | 8 +- .../linkedin/metadata/entity/AspectDao.java | 4 +- .../metadata/entity/EntityServiceImpl.java | 531 ++++++------------ .../metadata/entity/ebean/EbeanAspectDao.java | 125 ++++- .../entity/ebean/batch/AspectsBatchImpl.java | 3 +- .../entity/ebean/batch/MCLBatchItemImpl.java | 4 +- .../entity/ebean/batch/MCPPatchBatchItem.java | 10 +- .../ebean/batch/MCPUpsertBatchItem.java | 24 +- .../search/utils/BrowsePathUtils.java | 3 +- .../search/utils/BrowsePathV2Utils.java | 6 +- .../service/UpdateIndicesService.java | 6 +- .../metadata/AspectIngestionUtils.java | 6 +- .../utils/DefaultAspectsUtilTest.java} | 50 +- .../CassandraAspectMigrationsDaoTest.java | 3 +- .../entity/CassandraEntityServiceTest.java | 3 +- .../entity/DeleteEntityServiceTest.java | 3 +- .../entity/EbeanAspectMigrationsDaoTest.java | 6 +- .../entity/EbeanEntityServiceTest.java | 30 +- .../metadata/entity/EntityServiceTest.java | 82 ++- .../CassandraTimelineServiceTest.java | 3 +- .../timeline/EbeanTimelineServiceTest.java | 6 +- .../io/datahubproject/test/DataGenerator.java | 45 +- .../SampleDataFixtureConfiguration.java | 3 +- .../SearchLineageFixtureConfiguration.java | 2 +- .../CustomDataQualityRulesMCPSideEffect.java | 2 +- .../token/StatefulTokenService.java | 13 +- .../metadata/config/EbeanConfiguration.java | 46 ++ .../src/main/resources/application.yml | 4 + .../factory/config/ConfigurationProvider.java | 4 + .../entity/EntityAspectDaoFactory.java | 6 +- .../EntityAspectMigrationsDaoFactory.java | 6 +- .../factory/entity/EntityServiceFactory.java | 6 +- .../boot/steps/BackfillBrowsePathsV2Step.java | 4 +- .../IngestDataPlatformInstancesStep.java | 2 +- .../boot/steps/IngestDataPlatformsStep.java | 2 +- .../boot/steps/IngestDataTypesStep.java | 39 +- .../boot/steps/IngestEntityTypesStep.java | 56 +- .../steps/UpgradeDefaultBrowsePathsStep.java | 3 +- .../steps/BackfillBrowsePathsV2StepTest.java | 26 +- .../IngestDataPlatformInstancesStepTest.java | 4 +- .../boot/steps/IngestDataTypesStepTest.java | 9 +- .../UpgradeDefaultBrowsePathsStepTest.java | 4 - .../openapi/util/MappingUtil.java | 11 +- .../v2/controller/EntityController.java | 2 +- .../java/entities/EntitiesControllerTest.java | 13 +- .../src/test/java/mock/MockEntityService.java | 2 +- .../resources/entity/AspectResource.java | 17 +- .../resources/entity/AspectResourceTest.java | 8 +- .../linkedin/metadata/entity/AspectUtils.java | 101 ---- .../metadata/entity/EntityService.java | 42 -- smoke-test/tests/cli/datahub_cli.py | 20 +- .../cypress/cypress/e2e/browse/browseV2.js | 2 + smoke-test/tests/delete/delete_test.py | 12 +- 62 files changed, 1013 insertions(+), 770 deletions(-) create mode 100644 metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtil.java rename metadata-io/src/test/java/com/linkedin/metadata/{AspectUtilsTest.java => aspect/utils/DefaultAspectsUtilTest.java} (56%) create mode 100644 metadata-service/configuration/src/main/java/com/linkedin/metadata/config/EbeanConfiguration.java diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/DataMigrationStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/DataMigrationStep.java index ac56e5e91c72be..9f41daf02d2093 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/DataMigrationStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/nocode/DataMigrationStep.java @@ -10,6 +10,7 @@ import com.linkedin.datahub.upgrade.UpgradeStepResult; import com.linkedin.datahub.upgrade.impl.DefaultUpgradeStepResult; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ebean.EbeanAspectV1; import com.linkedin.metadata.entity.ebean.EbeanAspectV2; @@ -170,7 +171,7 @@ public Function executable() { // Emit a browse path aspect. final BrowsePaths browsePaths; try { - browsePaths = _entityService.buildDefaultBrowsePath(urn); + browsePaths = DefaultAspectsUtil.buildDefaultBrowsePath(urn, _entityService); final AuditStamp browsePathsStamp = new AuditStamp(); browsePathsStamp.setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)); diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java index 9a426369cfb026..601ce4d25493c1 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java @@ -15,6 +15,7 @@ import com.linkedin.datahub.upgrade.impl.DefaultUpgradeStepResult; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Condition; @@ -181,7 +182,8 @@ private Filter backfillDefaultBrowsePathsV2Filter() { } private void ingestBrowsePathsV2(Urn urn, AuditStamp auditStamp) throws Exception { - BrowsePathsV2 browsePathsV2 = _entityService.buildDefaultBrowsePathV2(urn, true); + BrowsePathsV2 browsePathsV2 = + DefaultAspectsUtil.buildDefaultBrowsePathV2(urn, true, _entityService); log.debug(String.format("Adding browse path v2 for urn %s with value %s", urn, browsePathsV2)); MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(urn); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java index 806fd47c721ec8..3d803d238b4f92 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java @@ -3,6 +3,7 @@ import com.linkedin.metadata.aspect.plugins.validation.AspectRetriever; import com.linkedin.mxe.SystemMetadata; import com.linkedin.util.Pair; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -17,14 +18,14 @@ * SystemMetadata} and record/message created time */ public interface AspectsBatch { - List getItems(); + Collection getItems(); /** * Returns MCP items. Can be patch, upsert, etc. * * @return batch items */ - default List getMCPItems() { + default Collection getMCPItems() { return getItems().stream() .filter(item -> item instanceof MCPBatchItem) .map(item -> (MCPBatchItem) item) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java index a4c0624150532c..60033cd6919d60 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java @@ -2,11 +2,13 @@ import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.mxe.SystemMetadata; import javax.annotation.Nonnull; +import javax.annotation.Nullable; public interface BatchItem { /** @@ -63,4 +65,12 @@ default String getAspectName() { */ @Nonnull AspectSpec getAspectSpec(); + + /** + * The aspect's record template. Null when patch + * + * @return record template if it exists + */ + @Nullable + RecordTemplate getRecordTemplate(); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCLBatchItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCLBatchItem.java index 30e882705da453..17a910b125a34f 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCLBatchItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/MCLBatchItem.java @@ -26,7 +26,7 @@ default String getAspectName() { if (getMetadataChangeLog().getAspectName() != null) { return getMetadataChangeLog().getAspectName(); } else { - return getAspect().schema().getName(); + return getRecordTemplate().schema().getName(); } } @@ -40,10 +40,7 @@ default SystemMetadata getPreviousSystemMetadata() { } @Nullable - RecordTemplate getPreviousAspect(); - - @Nonnull - RecordTemplate getAspect(); + RecordTemplate getPreviousRecordTemplate(); @Override @Nonnull diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java index c337e4f848e5c7..c64105637dfcc6 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/UpsertItem.java @@ -11,8 +11,6 @@ * related data stored along with the aspect */ public abstract class UpsertItem extends MCPBatchItem { - public abstract RecordTemplate getAspect(); - public abstract SystemAspect toLatestEntityAspect(); public abstract void validatePreCommit( diff --git a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java index 053055716eaa07..17aea13dbb94e3 100644 --- a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java +++ b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestCoalesceJobLineage.java @@ -99,7 +99,9 @@ public static void resetBaseExpectations() { @BeforeClass public static void initMockServer() { - mockServer = startClientAndServer(GMS_PORT); + if (mockServer == null) { + mockServer = startClientAndServer(GMS_PORT); + } resetBaseExpectations(); } diff --git a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java index a4eb035b0abce5..885be6d00fee85 100644 --- a/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java +++ b/metadata-integration/java/spark-lineage/src/test/java/datahub/spark/TestSparkJobsLineage.java @@ -138,7 +138,9 @@ public static void resetBaseExpectations() { @BeforeClass public static void init() { - mockServer = startClientAndServer(GMS_PORT); + if (mockServer == null) { + mockServer = startClientAndServer(GMS_PORT); + } resetBaseExpectations(); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtil.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtil.java new file mode 100644 index 00000000000000..a3711afb753dc8 --- /dev/null +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtil.java @@ -0,0 +1,312 @@ +package com.linkedin.metadata.aspect.utils; + +import static com.linkedin.metadata.Constants.BROWSE_PATHS_ASPECT_NAME; +import static com.linkedin.metadata.Constants.BROWSE_PATHS_V2_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME; +import static com.linkedin.metadata.search.utils.BrowsePathUtils.buildDataPlatformUrn; +import static com.linkedin.metadata.search.utils.BrowsePathUtils.getDefaultBrowsePath; +import static com.linkedin.metadata.search.utils.BrowsePathV2Utils.getDefaultBrowsePathV2; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.BrowsePaths; +import com.linkedin.common.BrowsePathsV2; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.data.template.StringArray; +import com.linkedin.dataplatform.DataPlatformInfo; +import com.linkedin.entity.EntityResponse; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.batch.AspectsBatch; +import com.linkedin.metadata.aspect.batch.BatchItem; +import com.linkedin.metadata.aspect.batch.MCPBatchItem; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.EntityUtils; +import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.utils.DataPlatformInstanceUtils; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.GenericAspect; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.util.Pair; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** Consolidates logic for default aspects */ +@Slf4j +public class DefaultAspectsUtil { + private DefaultAspectsUtil() {} + + public static final Set SUPPORTED_TYPES = + Set.of(ChangeType.UPSERT, ChangeType.CREATE, ChangeType.PATCH); + + public static List getAdditionalChanges( + @Nonnull AspectsBatch batch, @Nonnull EntityService entityService, boolean browsePathV2) { + + Map> itemsByUrn = + batch.getMCPItems().stream() + .filter(item -> SUPPORTED_TYPES.contains(item.getChangeType())) + .collect(Collectors.groupingBy(BatchItem::getUrn)); + + Set urnsWithExistingKeyAspects = entityService.exists(itemsByUrn.keySet()); + + // create default aspects when key aspect is missing + return itemsByUrn.entrySet().stream() + .filter(aspectsEntry -> !urnsWithExistingKeyAspects.contains(aspectsEntry.getKey())) + .flatMap( + aspectsEntry -> { + // Exclude aspects already in the batch + Set currentBatchAspectNames = + aspectsEntry.getValue().stream() + .map(BatchItem::getAspectName) + .collect(Collectors.toSet()); + + // Generate key aspect and defaults + List> defaultAspects = + generateDefaultAspects( + entityService, aspectsEntry.getKey(), currentBatchAspectNames, browsePathV2); + + // First is the key aspect + RecordTemplate entityKeyAspect = defaultAspects.get(0).getSecond(); + + // pick the first item as a template (use entity information) + MCPBatchItem templateItem = aspectsEntry.getValue().get(0); + + // generate default aspects (including key aspect, always upserts) + return defaultAspects.stream() + .map( + entry -> + MCPUpsertBatchItem.MCPUpsertBatchItemBuilder.build( + getProposalFromAspect( + entry.getKey(), entry.getValue(), entityKeyAspect, templateItem), + templateItem.getAuditStamp(), + entityService)) + .filter(Objects::nonNull); + }) + .collect(Collectors.toList()); + } + + /** + * Generate default aspects + * + * @param entityService entity service + * @param urn entity urn + * @return a list of aspect name/aspect pairs to be written + */ + public static List> generateDefaultAspects( + @Nonnull EntityService entityService, + @Nonnull final Urn urn, + @Nonnull Set currentBatchAspectNames, + boolean browsePathV2) { + + final List> defaultAspects = new LinkedList<>(); + + // Key Aspect + final String keyAspectName = entityService.getKeyAspectName(urn); + defaultAspects.add( + Pair.of(keyAspectName, EntityUtils.buildKeyAspect(entityService.getEntityRegistry(), urn))); + + // Other Aspects + defaultAspects.addAll( + generateDefaultAspectsIfMissing( + entityService, + urn, + defaultAspects.get(0).getSecond(), + currentBatchAspectNames, + browsePathV2)); + + return defaultAspects; + } + + /** + * Generate default aspects if the aspect is NOT in the database. + * + *

    Does not automatically create key aspects. + * + * @see #generateDefaultAspectsIfMissing if key aspects need autogeneration + * @param entityService + * @param urn entity urn + * @param entityKeyAspect entity's key aspect + * @return additional aspects to be written + */ + private static List> generateDefaultAspectsIfMissing( + @Nonnull EntityService entityService, + @Nonnull final Urn urn, + RecordTemplate entityKeyAspect, + @Nonnull Set currentAspectNames, + boolean browsePathV2) { + EntityRegistry entityRegistry = entityService.getEntityRegistry(); + + Set fetchAspects = + Stream.of( + BROWSE_PATHS_ASPECT_NAME, + BROWSE_PATHS_V2_ASPECT_NAME, + DATA_PLATFORM_INSTANCE_ASPECT_NAME) + // If browsePathV2 then exclude v1 + .filter(aspectName -> !(BROWSE_PATHS_ASPECT_NAME.equals(aspectName) && browsePathV2)) + // Exclude currently ingesting aspects + .filter(aspectName -> !currentAspectNames.contains(aspectName)) + // Exclude in case when we have limited test entity registry which doesn't include these + .filter( + aspectName -> + entityRegistry + .getEntitySpec(urn.getEntityType()) + .getAspectSpecMap() + .containsKey(aspectName)) + .collect(Collectors.toSet()); + + if (!fetchAspects.isEmpty()) { + + Set latestAspects = entityService.getLatestAspectsForUrn(urn, fetchAspects).keySet(); + + return fetchAspects.stream() + .filter(aspectName -> !latestAspects.contains(aspectName)) + .map( + aspectName -> { + switch (aspectName) { + case BROWSE_PATHS_ASPECT_NAME: + return Pair.of( + BROWSE_PATHS_ASPECT_NAME, + (RecordTemplate) buildDefaultBrowsePath(urn, entityService)); + case BROWSE_PATHS_V2_ASPECT_NAME: + return Pair.of( + BROWSE_PATHS_V2_ASPECT_NAME, + (RecordTemplate) buildDefaultBrowsePathV2(urn, false, entityService)); + case DATA_PLATFORM_INSTANCE_ASPECT_NAME: + return DataPlatformInstanceUtils.buildDataPlatformInstance( + urn.getEntityType(), entityKeyAspect) + .map( + aspect -> + Pair.of( + DATA_PLATFORM_INSTANCE_ASPECT_NAME, (RecordTemplate) aspect)) + .orElse(null); + default: + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + return Collections.emptyList(); + } + + /** + * Builds the default browse path aspects for a subset of well-supported entities. + * + *

    This method currently supports datasets, charts, dashboards, data flows, data jobs, and + * glossary terms. + */ + @Nonnull + public static BrowsePaths buildDefaultBrowsePath( + final @Nonnull Urn urn, EntityService entityService) { + Character dataPlatformDelimiter = getDataPlatformDelimiter(urn, entityService); + String defaultBrowsePath = + getDefaultBrowsePath(urn, entityService.getEntityRegistry(), dataPlatformDelimiter); + StringArray browsePaths = new StringArray(); + browsePaths.add(defaultBrowsePath); + BrowsePaths browsePathAspect = new BrowsePaths(); + browsePathAspect.setPaths(browsePaths); + return browsePathAspect; + } + + /** + * Builds the default browse path V2 aspects for all entities. + * + *

    This method currently supports datasets, charts, dashboards, and data jobs best. Everything + * else will have a basic "Default" folder added to their browsePathV2. + */ + @Nonnull + public static BrowsePathsV2 buildDefaultBrowsePathV2( + final @Nonnull Urn urn, boolean useContainerPaths, EntityService entityService) { + Character dataPlatformDelimiter = getDataPlatformDelimiter(urn, entityService); + return getDefaultBrowsePathV2( + urn, + entityService.getEntityRegistry(), + dataPlatformDelimiter, + entityService, + useContainerPaths); + } + + /** Returns a delimiter on which the name of an asset may be split. */ + private static Character getDataPlatformDelimiter(Urn urn, EntityService entityService) { + // Attempt to construct the appropriate Data Platform URN + Urn dataPlatformUrn = buildDataPlatformUrn(urn, entityService.getEntityRegistry()); + if (dataPlatformUrn != null) { + // Attempt to resolve the delimiter from Data Platform Info + DataPlatformInfo dataPlatformInfo = getDataPlatformInfo(dataPlatformUrn, entityService); + if (dataPlatformInfo != null && dataPlatformInfo.hasDatasetNameDelimiter()) { + return dataPlatformInfo.getDatasetNameDelimiter().charAt(0); + } + } + // Else, fallback to a default delimiter (period) if one cannot be resolved. + return '.'; + } + + @Nullable + private static DataPlatformInfo getDataPlatformInfo(Urn urn, EntityService entityService) { + try { + final EntityResponse entityResponse = + entityService.getEntityV2( + Constants.DATA_PLATFORM_ENTITY_NAME, + urn, + ImmutableSet.of(Constants.DATA_PLATFORM_INFO_ASPECT_NAME)); + if (entityResponse != null + && entityResponse.hasAspects() + && entityResponse.getAspects().containsKey(Constants.DATA_PLATFORM_INFO_ASPECT_NAME)) { + return new DataPlatformInfo( + entityResponse + .getAspects() + .get(Constants.DATA_PLATFORM_INFO_ASPECT_NAME) + .getValue() + .data()); + } + } catch (Exception e) { + log.warn(String.format("Failed to find Data Platform Info for urn %s", urn)); + } + return null; + } + + private static MetadataChangeProposal getProposalFromAspect( + String aspectName, + RecordTemplate aspect, + RecordTemplate entityKeyAspect, + MCPBatchItem templateItem) { + MetadataChangeProposal proposal = new MetadataChangeProposal(); + GenericAspect genericAspect = GenericRecordUtils.serializeAspect(aspect); + + // Set net new fields + proposal.setAspect(genericAspect); + proposal.setAspectName(aspectName); + + // Set fields determined from original + // Additional changes should never be set as PATCH, if a PATCH is coming across it should be an + // UPSERT + proposal.setChangeType(templateItem.getChangeType()); + if (ChangeType.PATCH.equals(proposal.getChangeType())) { + proposal.setChangeType(ChangeType.UPSERT); + } + + if (templateItem.getSystemMetadata() != null) { + proposal.setSystemMetadata(templateItem.getSystemMetadata()); + } + if (templateItem.getUrn() != null) { + proposal.setEntityUrn(templateItem.getUrn()); + } + if (entityKeyAspect != null) { + proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(entityKeyAspect)); + } + proposal.setEntityType(templateItem.getUrn().getEntityType()); + + return proposal; + } +} diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java index 0ebe9ed1d1b666..9a3bc9e319d2bd 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java @@ -26,7 +26,6 @@ import com.linkedin.metadata.aspect.batch.AspectsBatch; import com.linkedin.metadata.browse.BrowseResult; import com.linkedin.metadata.browse.BrowseResultV2; -import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.DeleteEntityService; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.IngestResult; @@ -67,7 +66,6 @@ import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; -import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.RequiredArgsConstructor; @@ -706,14 +704,10 @@ public String ingestProposal( : Constants.UNKNOWN_ACTOR; final AuditStamp auditStamp = new AuditStamp().setTime(_clock.millis()).setActor(UrnUtils.getUrn(actorUrnStr)); - final List additionalChanges = - AspectUtils.getAdditionalChanges(metadataChangeProposal, _entityService); - Stream proposalStream = - Stream.concat(Stream.of(metadataChangeProposal), additionalChanges.stream()); AspectsBatch batch = AspectsBatchImpl.builder() - .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) + .mcps(List.of(metadataChangeProposal), auditStamp, _entityService) .build(); IngestResult one = _entityService.ingestProposal(batch, async).stream().findFirst().get(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectDao.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectDao.java index e00a696a095a15..b031377842176b 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectDao.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/AspectDao.java @@ -148,11 +148,11 @@ T runInTransactionWithRetry( @Nonnull final Function block, final int maxTransactionRetry); @Nonnull - default T runInTransactionWithRetry( + default List runInTransactionWithRetry( @Nonnull final Function block, AspectsBatch batch, final int maxTransactionRetry) { - return runInTransactionWithRetry(block, maxTransactionRetry); + return List.of(runInTransactionWithRetry(block, maxTransactionRetry)); } default void incrementWriteMetrics(String aspectName, long count, long bytes) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index e6e69c96c15421..7f15e3a7fd8fc9 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -2,16 +2,11 @@ import static com.linkedin.metadata.Constants.APP_SOURCE; import static com.linkedin.metadata.Constants.ASPECT_LATEST_VERSION; -import static com.linkedin.metadata.Constants.BROWSE_PATHS_ASPECT_NAME; -import static com.linkedin.metadata.Constants.BROWSE_PATHS_V2_ASPECT_NAME; -import static com.linkedin.metadata.Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME; import static com.linkedin.metadata.Constants.DEFAULT_RUN_ID; import static com.linkedin.metadata.Constants.FORCE_INDEXING_KEY; import static com.linkedin.metadata.Constants.STATUS_ASPECT_NAME; import static com.linkedin.metadata.Constants.SYSTEM_ACTOR; import static com.linkedin.metadata.Constants.UI_SOURCE; -import static com.linkedin.metadata.search.utils.BrowsePathUtils.buildDataPlatformUrn; -import static com.linkedin.metadata.search.utils.BrowsePathUtils.getDefaultBrowsePath; import static com.linkedin.metadata.utils.GenericRecordUtils.entityResponseToAspectMap; import static com.linkedin.metadata.utils.PegasusUtils.constructMCL; import static com.linkedin.metadata.utils.PegasusUtils.getDataTemplateClassFromSchema; @@ -25,8 +20,6 @@ import com.google.common.collect.Iterators; import com.google.common.collect.Streams; import com.linkedin.common.AuditStamp; -import com.linkedin.common.BrowsePaths; -import com.linkedin.common.BrowsePathsV2; import com.linkedin.common.Status; import com.linkedin.common.UrnArray; import com.linkedin.common.VersionedUrn; @@ -38,10 +31,8 @@ import com.linkedin.data.template.GetMode; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.SetMode; -import com.linkedin.data.template.StringArray; import com.linkedin.data.template.StringMap; import com.linkedin.data.template.UnionTemplate; -import com.linkedin.dataplatform.DataPlatformInfo; import com.linkedin.entity.AspectType; import com.linkedin.entity.Entity; import com.linkedin.entity.EntityResponse; @@ -57,6 +48,7 @@ import com.linkedin.metadata.aspect.batch.SystemAspect; import com.linkedin.metadata.aspect.batch.UpsertItem; import com.linkedin.metadata.aspect.plugins.validation.AspectValidationException; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.ebean.EbeanAspectV2; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; @@ -72,10 +64,8 @@ import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.ListUrnsResult; import com.linkedin.metadata.run.AspectRowSummary; -import com.linkedin.metadata.search.utils.BrowsePathV2Utils; import com.linkedin.metadata.service.UpdateIndicesService; import com.linkedin.metadata.snapshot.Snapshot; -import com.linkedin.metadata.utils.DataPlatformInstanceUtils; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.metadata.utils.PegasusUtils; @@ -166,6 +156,7 @@ public class EntityServiceImpl implements EntityService { protected static final int MAX_KEYS_PER_QUERY = 500; private final Integer ebeanMaxTransactionRetry; + private final boolean enableBrowseV2; public EntityServiceImpl( @Nonnull final AspectDao aspectDao, @@ -173,7 +164,8 @@ public EntityServiceImpl( @Nonnull final EntityRegistry entityRegistry, final boolean alwaysEmitChangeLog, @Nullable final UpdateIndicesService updateIndicesService, - final PreProcessHooks preProcessHooks) { + final PreProcessHooks preProcessHooks, + final boolean enableBrowsePathV2) { this( aspectDao, producer, @@ -181,7 +173,8 @@ public EntityServiceImpl( alwaysEmitChangeLog, updateIndicesService, preProcessHooks, - DEFAULT_MAX_TRANSACTION_RETRY); + DEFAULT_MAX_TRANSACTION_RETRY, + enableBrowsePathV2); } public EntityServiceImpl( @@ -191,7 +184,8 @@ public EntityServiceImpl( final boolean alwaysEmitChangeLog, @Nullable final UpdateIndicesService updateIndicesService, final PreProcessHooks preProcessHooks, - @Nullable final Integer retry) { + @Nullable final Integer retry, + final boolean enableBrowseV2) { _aspectDao = aspectDao; _producer = producer; @@ -204,6 +198,7 @@ public EntityServiceImpl( } _preProcessHooks = preProcessHooks; ebeanMaxTransactionRetry = retry != null ? retry : DEFAULT_MAX_TRANSACTION_RETRY; + this.enableBrowseV2 = enableBrowseV2; } @Override @@ -622,7 +617,7 @@ public List ingestAspects( MCPUpsertBatchItem.builder() .urn(entityUrn) .aspectName(pair.getKey()) - .aspect(pair.getValue()) + .recordTemplate(pair.getValue()) .systemMetadata(systemMetadata) .auditStamp(auditStamp) .build(this)) @@ -670,162 +665,166 @@ private List ingestAspectsToLocalDB( log.warn(String.format("Batch contains duplicates: %s", aspectsBatch)); } - return _aspectDao.runInTransactionWithRetry( - (tx) -> { - // Read before write is unfortunate, however batch it - final Map> urnAspects = aspectsBatch.getUrnAspectsMap(); - // read #1 - final Map> latestAspects = - toSystemEntityAspects(_aspectDao.getLatestAspects(urnAspects)); - // read #2 - final Map> nextVersions = - _aspectDao.getNextVersions(urnAspects); - - // 1. Convert patches to full upserts - // 2. Run any entity/aspect level hooks - Pair>, List> updatedItems = - aspectsBatch.toUpsertBatchItems(latestAspects, this); - - // Fetch additional information if needed - final Map> updatedLatestAspects; - final Map> updatedNextVersions; - if (!updatedItems.getFirst().isEmpty()) { - Map> newLatestAspects = - toSystemEntityAspects(_aspectDao.getLatestAspects(updatedItems.getFirst())); - Map> newNextVersions = - _aspectDao.getNextVersions(updatedItems.getFirst()); - // merge - updatedLatestAspects = aspectsBatch.merge(latestAspects, newLatestAspects); - updatedNextVersions = aspectsBatch.merge(nextVersions, newNextVersions); - } else { - updatedLatestAspects = latestAspects; - updatedNextVersions = nextVersions; - } - - // do final pre-commit checks with previous aspect value - updatedItems - .getSecond() - .forEach( - item -> { - SystemAspect previousAspect = - updatedLatestAspects - .getOrDefault(item.getUrn().toString(), Map.of()) - .get(item.getAspectSpec().getName()); - try { - item.validatePreCommit( - previousAspect == null - ? null - : previousAspect.getRecordTemplate(_entityRegistry), - this); - } catch (AspectValidationException e) { - throw new RuntimeException(e); - } - }); + return _aspectDao + .runInTransactionWithRetry( + (tx) -> { + // Read before write is unfortunate, however batch it + final Map> urnAspects = aspectsBatch.getUrnAspectsMap(); + // read #1 + final Map> latestAspects = + toSystemEntityAspects(_aspectDao.getLatestAspects(urnAspects)); + // read #2 + final Map> nextVersions = + _aspectDao.getNextVersions(urnAspects); + + // 1. Convert patches to full upserts + // 2. Run any entity/aspect level hooks + Pair>, List> updatedItems = + aspectsBatch.toUpsertBatchItems(latestAspects, this); + + // Fetch additional information if needed + final Map> updatedLatestAspects; + final Map> updatedNextVersions; + if (!updatedItems.getFirst().isEmpty()) { + Map> newLatestAspects = + toSystemEntityAspects(_aspectDao.getLatestAspects(updatedItems.getFirst())); + Map> newNextVersions = + _aspectDao.getNextVersions(updatedItems.getFirst()); + // merge + updatedLatestAspects = aspectsBatch.merge(latestAspects, newLatestAspects); + updatedNextVersions = aspectsBatch.merge(nextVersions, newNextVersions); + } else { + updatedLatestAspects = latestAspects; + updatedNextVersions = nextVersions; + } - // Database Upsert results - List upsertResults = - updatedItems.getSecond().stream() - .map( + // do final pre-commit checks with previous aspect value + updatedItems + .getSecond() + .forEach( item -> { - final String urnStr = item.getUrn().toString(); - final SystemAspect latest = + SystemAspect previousAspect = updatedLatestAspects - .getOrDefault(urnStr, Map.of()) - .get(item.getAspectName()); - final long nextVersion = - updatedNextVersions - .getOrDefault(urnStr, Map.of()) - .getOrDefault(item.getAspectName(), 0L); - - final UpdateAspectResult result; - if (overwrite || latest == null) { - result = - ingestAspectToLocalDB( - tx, - item.getUrn(), - item.getAspectName(), - item.getAspect(), - item.getAuditStamp(), - item.getSystemMetadata(), - latest == null - ? null - : ((EntityAspect.EntitySystemAspect) latest).asRaw(), - nextVersion) - .toBuilder() - .request(item) - .build(); - - // support inner-batch upserts - latestAspects - .computeIfAbsent(urnStr, key -> new HashMap<>()) - .put(item.getAspectName(), item.toLatestEntityAspect()); - nextVersions - .computeIfAbsent(urnStr, key -> new HashMap<>()) - .put(item.getAspectName(), nextVersion + 1); - } else { - RecordTemplate oldValue = latest.getRecordTemplate(_entityRegistry); - SystemMetadata oldMetadata = latest.getSystemMetadata(); - result = - UpdateAspectResult.builder() - .urn(item.getUrn()) - .request(item) - .oldValue(oldValue) - .newValue(oldValue) - .oldSystemMetadata(oldMetadata) - .newSystemMetadata(oldMetadata) - .operation(MetadataAuditOperation.UPDATE) - .auditStamp(item.getAuditStamp()) - .maxVersion(latest.getVersion()) - .build(); + .getOrDefault(item.getUrn().toString(), Map.of()) + .get(item.getAspectSpec().getName()); + try { + item.validatePreCommit( + previousAspect == null + ? null + : previousAspect.getRecordTemplate(_entityRegistry), + this); + } catch (AspectValidationException e) { + throw new RuntimeException(e); } + }); - return result; - }) - .collect(Collectors.toList()); + // Database Upsert results + List upsertResults = + updatedItems.getSecond().stream() + .map( + item -> { + final String urnStr = item.getUrn().toString(); + final SystemAspect latest = + updatedLatestAspects + .getOrDefault(urnStr, Map.of()) + .get(item.getAspectName()); + final long nextVersion = + updatedNextVersions + .getOrDefault(urnStr, Map.of()) + .getOrDefault(item.getAspectName(), 0L); + + final UpdateAspectResult result; + if (overwrite || latest == null) { + result = + ingestAspectToLocalDB( + tx, + item.getUrn(), + item.getAspectName(), + item.getRecordTemplate(), + item.getAuditStamp(), + item.getSystemMetadata(), + latest == null + ? null + : ((EntityAspect.EntitySystemAspect) latest).asRaw(), + nextVersion) + .toBuilder() + .request(item) + .build(); + + // support inner-batch upserts + latestAspects + .computeIfAbsent(urnStr, key -> new HashMap<>()) + .put(item.getAspectName(), item.toLatestEntityAspect()); + nextVersions + .computeIfAbsent(urnStr, key -> new HashMap<>()) + .put(item.getAspectName(), nextVersion + 1); + } else { + RecordTemplate oldValue = latest.getRecordTemplate(_entityRegistry); + SystemMetadata oldMetadata = latest.getSystemMetadata(); + result = + UpdateAspectResult.builder() + .urn(item.getUrn()) + .request(item) + .oldValue(oldValue) + .newValue(oldValue) + .oldSystemMetadata(oldMetadata) + .newSystemMetadata(oldMetadata) + .operation(MetadataAuditOperation.UPDATE) + .auditStamp(item.getAuditStamp()) + .maxVersion(latest.getVersion()) + .build(); + } + + return result; + }) + .collect(Collectors.toList()); - // commit upserts prior to retention or kafka send, if supported by impl - if (tx != null) { - tx.commitAndContinue(); - } + // commit upserts prior to retention or kafka send, if supported by impl + if (tx != null) { + tx.commitAndContinue(); + } - // Retention optimization and tx - if (_retentionService != null) { - List retentionBatch = - upsertResults.stream() - // Only consider retention when there was a previous version - .filter( - result -> - latestAspects.containsKey(result.getUrn().toString()) - && latestAspects - .get(result.getUrn().toString()) - .containsKey(result.getRequest().getAspectName())) - .filter( - result -> { - RecordTemplate oldAspect = result.getOldValue(); - RecordTemplate newAspect = result.getNewValue(); - // Apply retention policies if there was an update to existing aspect - // value - return oldAspect != newAspect - && oldAspect != null - && _retentionService != null; - }) - .map( - result -> - RetentionService.RetentionContext.builder() - .urn(result.getUrn()) - .aspectName(result.getRequest().getAspectName()) - .maxVersion(Optional.of(result.getMaxVersion())) - .build()) - .collect(Collectors.toList()); - _retentionService.applyRetentionWithPolicyDefaults(retentionBatch); - } else { - log.warn("Retention service is missing!"); - } + // Retention optimization and tx + if (_retentionService != null) { + List retentionBatch = + upsertResults.stream() + // Only consider retention when there was a previous version + .filter( + result -> + latestAspects.containsKey(result.getUrn().toString()) + && latestAspects + .get(result.getUrn().toString()) + .containsKey(result.getRequest().getAspectName())) + .filter( + result -> { + RecordTemplate oldAspect = result.getOldValue(); + RecordTemplate newAspect = result.getNewValue(); + // Apply retention policies if there was an update to existing aspect + // value + return oldAspect != newAspect + && oldAspect != null + && _retentionService != null; + }) + .map( + result -> + RetentionService.RetentionContext.builder() + .urn(result.getUrn()) + .aspectName(result.getRequest().getAspectName()) + .maxVersion(Optional.of(result.getMaxVersion())) + .build()) + .collect(Collectors.toList()); + _retentionService.applyRetentionWithPolicyDefaults(retentionBatch); + } else { + log.warn("Retention service is missing!"); + } - return upsertResults; - }, - aspectsBatch, - DEFAULT_MAX_TRANSACTION_RETRY); + return upsertResults; + }, + aspectsBatch, + DEFAULT_MAX_TRANSACTION_RETRY) + .stream() + .flatMap(List::stream) + .collect(Collectors.toList()); } /** @@ -921,7 +920,7 @@ public RecordTemplate ingestAspectIfNotPresent( MCPUpsertBatchItem.builder() .urn(urn) .aspectName(aspectName) - .aspect(newValue) + .recordTemplate(newValue) .systemMetadata(systemMetadata) .auditStamp(auditStamp) .build(this)) @@ -965,7 +964,6 @@ public IngestResult ingestProposal( */ @Override public Set ingestProposal(AspectsBatch aspectsBatch, final boolean async) { - Stream timeseriesIngestResults = ingestTimeseriesProposal(aspectsBatch); Stream nonTimeseriesIngestResults = async ? ingestProposalAsync(aspectsBatch) : ingestProposalSync(aspectsBatch); @@ -1005,7 +1003,7 @@ private Stream ingestTimeseriesProposal(AspectsBatch aspectsBatch) conditionallyProduceMCLAsync( null, null, - item.getAspect(), + item.getRecordTemplate(), item.getSystemMetadata(), item.getMetadataChangeProposal(), item.getUrn(), @@ -1082,10 +1080,17 @@ private Stream ingestProposalAsync(AspectsBatch aspectsBatch) { } private Stream ingestProposalSync(AspectsBatch aspectsBatch) { + Set items = new HashSet<>(aspectsBatch.getItems()); + + // Generate additional items as needed + items.addAll(DefaultAspectsUtil.getAdditionalChanges(aspectsBatch, this, enableBrowseV2)); + + AspectsBatch withDefaults = AspectsBatchImpl.builder().items(items).build(); + AspectsBatchImpl nonTimeseries = AspectsBatchImpl.builder() .items( - aspectsBatch.getItems().stream() + withDefaults.getItems().stream() .filter(item -> !item.getAspectSpec().isTimeseries()) .collect(Collectors.toList())) .build(); @@ -1542,116 +1547,6 @@ protected Map> getLatestAspectUnions( .collect(Collectors.toList()))); } - /** - * Returns true if entityType should have some aspect as per its definition but aspects given does - * not have that aspect - */ - private boolean isAspectMissing(String entityType, String aspectName, Set aspects) { - return _entityRegistry.getEntitySpec(entityType).getAspectSpecMap().containsKey(aspectName) - && !aspects.contains(aspectName); - } - - @Override - public Pair>> generateDefaultAspectsOnFirstWrite( - @Nonnull final Urn urn, Map includedAspects) { - List> returnAspects = new ArrayList<>(); - - final String keyAspectName = getKeyAspectName(urn); - final Map latestAspects = - new HashMap<>(getLatestAspectsForUrn(urn, Set.of(keyAspectName))); - - // key aspect: does not exist in database && is being written - boolean generateDefaults = - !latestAspects.containsKey(keyAspectName) && includedAspects.containsKey(keyAspectName); - - // conditionally generate defaults - if (generateDefaults) { - String entityType = urnToEntityName(urn); - Set aspectsToGet = new HashSet<>(); - - boolean shouldCheckBrowsePath = - isAspectMissing(entityType, BROWSE_PATHS_ASPECT_NAME, includedAspects.keySet()); - if (shouldCheckBrowsePath) { - aspectsToGet.add(BROWSE_PATHS_ASPECT_NAME); - } - - boolean shouldCheckBrowsePathV2 = - isAspectMissing(entityType, BROWSE_PATHS_V2_ASPECT_NAME, includedAspects.keySet()); - if (shouldCheckBrowsePathV2) { - aspectsToGet.add(BROWSE_PATHS_V2_ASPECT_NAME); - } - - boolean shouldCheckDataPlatform = - isAspectMissing(entityType, DATA_PLATFORM_INSTANCE_ASPECT_NAME, includedAspects.keySet()); - if (shouldCheckDataPlatform) { - aspectsToGet.add(DATA_PLATFORM_INSTANCE_ASPECT_NAME); - } - - // fetch additional aspects - latestAspects.putAll(getLatestAspectsForUrn(urn, aspectsToGet)); - - if (shouldCheckBrowsePath - && latestAspects.get(BROWSE_PATHS_ASPECT_NAME) == null - && !includedAspects.containsKey(BROWSE_PATHS_ASPECT_NAME)) { - try { - BrowsePaths generatedBrowsePath = buildDefaultBrowsePath(urn); - returnAspects.add(Pair.of(BROWSE_PATHS_ASPECT_NAME, generatedBrowsePath)); - } catch (URISyntaxException e) { - log.error("Failed to parse urn: {}", urn); - } - } - - if (shouldCheckBrowsePathV2 - && latestAspects.get(BROWSE_PATHS_V2_ASPECT_NAME) == null - && !includedAspects.containsKey(BROWSE_PATHS_V2_ASPECT_NAME)) { - try { - BrowsePathsV2 generatedBrowsePathV2 = buildDefaultBrowsePathV2(urn, false); - returnAspects.add(Pair.of(BROWSE_PATHS_V2_ASPECT_NAME, generatedBrowsePathV2)); - } catch (URISyntaxException e) { - log.error("Failed to parse urn: {}", urn); - } - } - - if (shouldCheckDataPlatform - && latestAspects.get(DATA_PLATFORM_INSTANCE_ASPECT_NAME) == null - && !includedAspects.containsKey(DATA_PLATFORM_INSTANCE_ASPECT_NAME)) { - RecordTemplate keyAspect = includedAspects.get(keyAspectName); - DataPlatformInstanceUtils.buildDataPlatformInstance(entityType, keyAspect) - .ifPresent( - aspect -> returnAspects.add(Pair.of(DATA_PLATFORM_INSTANCE_ASPECT_NAME, aspect))); - } - } - - return Pair.of(latestAspects.containsKey(keyAspectName), returnAspects); - } - - @Override - public List> generateDefaultAspectsIfMissing( - @Nonnull final Urn urn, Map includedAspects) { - - final String keyAspectName = getKeyAspectName(urn); - - if (includedAspects.containsKey(keyAspectName)) { - return generateDefaultAspectsOnFirstWrite(urn, includedAspects).getValue(); - } else { - // No key aspect being written, generate it and potentially suggest writing it later - HashMap includedWithKeyAspect = new HashMap<>(includedAspects); - Pair keyAspect = - Pair.of(keyAspectName, EntityUtils.buildKeyAspect(_entityRegistry, urn)); - includedWithKeyAspect.put(keyAspect.getKey(), keyAspect.getValue()); - - Pair>> returnAspects = - generateDefaultAspectsOnFirstWrite(urn, includedWithKeyAspect); - - // missing key aspect in database, add it - if (!returnAspects.getFirst()) { - returnAspects.getValue().add(keyAspect); - } - - return returnAspects.getValue(); - } - } - private void ingestSnapshotUnion( @Nonnull final Snapshot snapshotUnion, @Nonnull final AuditStamp auditStamp, @@ -1664,10 +1559,11 @@ private void ingestSnapshotUnion( log.info("INGEST urn {} with system metadata {}", urn.toString(), systemMetadata.toString()); aspectRecordsToIngest.addAll( - generateDefaultAspectsIfMissing( + DefaultAspectsUtil.generateDefaultAspects( + this, urn, - aspectRecordsToIngest.stream() - .collect(Collectors.toMap(Pair::getKey, Pair::getValue)))); + aspectRecordsToIngest.stream().map(Pair::getFirst).collect(Collectors.toSet()), + enableBrowseV2)); AspectsBatchImpl aspectsBatch = AspectsBatchImpl.builder() @@ -1678,7 +1574,7 @@ private void ingestSnapshotUnion( MCPUpsertBatchItem.builder() .urn(urn) .aspectName(pair.getKey()) - .aspect(pair.getValue()) + .recordTemplate(pair.getValue()) .auditStamp(auditStamp) .systemMetadata(systemMetadata) .build(this)) @@ -2397,79 +2293,6 @@ private UpdateAspectResult ingestAspectToLocalDB( .build(); } - /** - * Builds the default browse path aspects for a subset of well-supported entities. - * - *

    This method currently supports datasets, charts, dashboards, data flows, data jobs, and - * glossary terms. - */ - @Nonnull - @Override - public BrowsePaths buildDefaultBrowsePath(final @Nonnull Urn urn) throws URISyntaxException { - Character dataPlatformDelimiter = getDataPlatformDelimiter(urn); - String defaultBrowsePath = - getDefaultBrowsePath(urn, this.getEntityRegistry(), dataPlatformDelimiter); - StringArray browsePaths = new StringArray(); - browsePaths.add(defaultBrowsePath); - BrowsePaths browsePathAspect = new BrowsePaths(); - browsePathAspect.setPaths(browsePaths); - return browsePathAspect; - } - - /** - * Builds the default browse path V2 aspects for all entities. - * - *

    This method currently supports datasets, charts, dashboards, and data jobs best. Everything - * else will have a basic "Default" folder added to their browsePathV2. - */ - @Nonnull - @Override - public BrowsePathsV2 buildDefaultBrowsePathV2(final @Nonnull Urn urn, boolean useContainerPaths) - throws URISyntaxException { - Character dataPlatformDelimiter = getDataPlatformDelimiter(urn); - return BrowsePathV2Utils.getDefaultBrowsePathV2( - urn, this.getEntityRegistry(), dataPlatformDelimiter, this, useContainerPaths); - } - - /** Returns a delimiter on which the name of an asset may be split. */ - private Character getDataPlatformDelimiter(Urn urn) { - // Attempt to construct the appropriate Data Platform URN - Urn dataPlatformUrn = buildDataPlatformUrn(urn, this.getEntityRegistry()); - if (dataPlatformUrn != null) { - // Attempt to resolve the delimiter from Data Platform Info - DataPlatformInfo dataPlatformInfo = getDataPlatformInfo(dataPlatformUrn); - if (dataPlatformInfo != null && dataPlatformInfo.hasDatasetNameDelimiter()) { - return dataPlatformInfo.getDatasetNameDelimiter().charAt(0); - } - } - // Else, fallback to a default delimiter (period) if one cannot be resolved. - return '.'; - } - - @Nullable - private DataPlatformInfo getDataPlatformInfo(Urn urn) { - try { - final EntityResponse entityResponse = - getEntityV2( - Constants.DATA_PLATFORM_ENTITY_NAME, - urn, - ImmutableSet.of(Constants.DATA_PLATFORM_INFO_ASPECT_NAME)); - if (entityResponse != null - && entityResponse.hasAspects() - && entityResponse.getAspects().containsKey(Constants.DATA_PLATFORM_INFO_ASPECT_NAME)) { - return new DataPlatformInfo( - entityResponse - .getAspects() - .get(Constants.DATA_PLATFORM_INFO_ASPECT_NAME) - .getValue() - .data()); - } - } catch (Exception e) { - log.warn(String.format("Failed to find Data Platform Info for urn %s", urn)); - } - return null; - } - private static boolean shouldAspectEmitChangeLog(@Nonnull final AspectSpec aspectSpec) { final List relationshipFieldSpecs = aspectSpec.getRelationshipFieldSpecs(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java index 176a99d8d3a498..3342d4632f642e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java @@ -5,14 +5,20 @@ import com.codahale.metrics.MetricRegistry; import com.datahub.util.exception.ModelConversionException; import com.datahub.util.exception.RetryLimitReached; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.metadata.aspect.batch.AspectsBatch; +import com.linkedin.metadata.aspect.batch.MCPBatchItem; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.entity.AspectDao; import com.linkedin.metadata.entity.AspectMigrationsDao; import com.linkedin.metadata.entity.EntityAspect; import com.linkedin.metadata.entity.EntityAspectIdentifier; import com.linkedin.metadata.entity.ListResult; +import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; @@ -21,6 +27,7 @@ import com.linkedin.metadata.query.ListResultMetadata; import com.linkedin.metadata.search.utils.QueryUtils; import com.linkedin.metadata.utils.metrics.MetricUtils; +import com.linkedin.util.Pair; import io.ebean.Database; import io.ebean.DuplicateKeyException; import io.ebean.ExpressionList; @@ -39,9 +46,14 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -69,8 +81,29 @@ public class EbeanAspectDao implements AspectDao, AspectMigrationsDao { // more testing. private int _queryKeysCount = 375; // 0 means no pagination on keys - public EbeanAspectDao(@Nonnull final Database server) { + /** + * Used to control write concurrency when an entity key aspect is present. If a batch contains an + * entity key aspect, only allow a single execution per URN + */ + private final LoadingCache locks; + + public EbeanAspectDao(@Nonnull final Database server, EbeanConfiguration ebeanConfiguration) { _server = server; + if (ebeanConfiguration.getLocking().isEnabled()) { + this.locks = + CacheBuilder.newBuilder() + .maximumSize(ebeanConfiguration.getLocking().getMaximumLocks()) + .expireAfterWrite( + ebeanConfiguration.getLocking().getDurationSeconds(), TimeUnit.SECONDS) + .build( + new CacheLoader<>() { + public Lock load(String key) { + return new ReentrantLock(true); + } + }); + } else { + this.locks = null; + } } @Override @@ -588,15 +621,70 @@ public ListResult listLatestAspectMetadata( @Nonnull public T runInTransactionWithRetry( @Nonnull final Function block, final int maxTransactionRetry) { - return runInTransactionWithRetry(block, null, maxTransactionRetry); + return runInTransactionWithRetry(block, null, maxTransactionRetry).get(0); } @Override @Nonnull - public T runInTransactionWithRetry( + public List runInTransactionWithRetry( @Nonnull final Function block, @Nullable AspectsBatch batch, final int maxTransactionRetry) { + + LinkedList result = new LinkedList<>(); + + if (locks != null && batch != null) { + Set urnsWithKeyAspects = + batch.getMCPItems().stream() + .filter(i -> i.getEntitySpec().getKeyAspectSpec().equals(i.getAspectSpec())) + .map(MCPBatchItem::getUrn) + .collect(Collectors.toSet()); + + if (!urnsWithKeyAspects.isEmpty()) { + + // Split into batches by urn with key aspect, remaining aspects in the pair's second + Pair, AspectsBatch> splitBatches = splitByUrn(batch, urnsWithKeyAspects); + + // Run non-key aspect `other` batch per normal + if (!splitBatches.getSecond().getItems().isEmpty()) { + result.add( + runInTransactionWithRetryUnlocked( + block, splitBatches.getSecond(), maxTransactionRetry)); + } + + // For each key aspect batch + for (AspectsBatch splitBatch : splitBatches.getFirst()) { + try { + Lock lock = + locks.get(splitBatch.getMCPItems().stream().findFirst().get().getUrn().toString()); + lock.lock(); + try { + result.add(runInTransactionWithRetryUnlocked(block, splitBatch, maxTransactionRetry)); + } finally { + lock.unlock(); + } + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + } else { + // No key aspects found, run per normal + result.add(runInTransactionWithRetryUnlocked(block, batch, maxTransactionRetry)); + } + } else { + // locks disabled or null batch + result.add(runInTransactionWithRetryUnlocked(block, batch, maxTransactionRetry)); + } + + return result; + } + + @Nonnull + public T runInTransactionWithRetryUnlocked( + @Nonnull final Function block, + @Nullable AspectsBatch batch, + final int maxTransactionRetry) { + validateConnection(); int retryCount = 0; Exception lastException = null; @@ -804,4 +892,35 @@ private static String buildMetricName( MetricUtils.DELIMITER, List.of(entitySpec.getName(), aspectSpec.getName(), status.toLowerCase())); } + + /** + * Split batches by the set of Urns, all remaining items go into an `other` batch in the second of + * the pair + * + * @param batch the input batch + * @param urns urns for batch + * @return separated batches + */ + private static Pair, AspectsBatch> splitByUrn( + AspectsBatch batch, Set urns) { + Map> itemsByUrn = + batch.getMCPItems().stream().collect(Collectors.groupingBy(MCPBatchItem::getUrn)); + + AspectsBatch other = + AspectsBatchImpl.builder() + .items( + itemsByUrn.entrySet().stream() + .filter(entry -> !urns.contains(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .collect(Collectors.toList())) + .build(); + + List nonEmptyBatches = + urns.stream() + .map(urn -> AspectsBatchImpl.builder().items(itemsByUrn.get(urn)).build()) + .filter(b -> !b.getItems().isEmpty()) + .collect(Collectors.toList()); + + return Pair.of(nonEmptyBatches, other); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java index 80fb4e3e1b940e..1718bd835dc31f 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java @@ -11,6 +11,7 @@ import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; import com.linkedin.util.Pair; +import java.util.Collection; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -26,7 +27,7 @@ @Builder(toBuilder = true) public class AspectsBatchImpl implements AspectsBatch { - private final List items; + private final Collection items; /** * Convert patches to upserts, apply hooks at the aspect and batch level. diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java index 6563765657d6d0..a2ed2eb18fe6a3 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLBatchItemImpl.java @@ -30,9 +30,9 @@ public class MCLBatchItemImpl implements MCLBatchItem { @Nonnull private final MetadataChangeLog metadataChangeLog; - @Nullable private final RecordTemplate aspect; + @Nullable private final RecordTemplate recordTemplate; - @Nullable private final RecordTemplate previousAspect; + @Nullable private final RecordTemplate previousRecordTemplate; // derived private final EntitySpec entitySpec; diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java index be333af2f75398..d0cb2a4cc59b8a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPPatchBatchItem.java @@ -31,6 +31,7 @@ import java.nio.charset.StandardCharsets; import java.util.Objects; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Builder; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -72,6 +73,12 @@ public ChangeType getChangeType() { return ChangeType.PATCH; } + @Nullable + @Override + public RecordTemplate getRecordTemplate() { + return null; + } + public MCPUpsertBatchItem applyPatch( RecordTemplate recordTemplate, AspectRetriever aspectRetriever) { MCPUpsertBatchItem.MCPUpsertBatchItemBuilder builder = @@ -100,7 +107,8 @@ public MCPUpsertBatchItem applyPatch( } try { - builder.aspect(aspectTemplateEngine.applyPatch(currentValue, getPatch(), getAspectSpec())); + builder.recordTemplate( + aspectTemplateEngine.applyPatch(currentValue, getPatch(), getAspectSpec())); } catch (JsonProcessingException | JsonPatchException e) { throw new RuntimeException(e); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java index 89209c44f10c77..b9d5f24e7ce084 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCPUpsertBatchItem.java @@ -58,7 +58,7 @@ public static MCPUpsertBatchItem fromPatch( recordTemplate != null ? recordTemplate : genericPatchTemplate.getDefault(); try { - builder.aspect(genericPatchTemplate.applyPatch(currentValue)); + builder.recordTemplate(genericPatchTemplate.applyPatch(currentValue)); } catch (JsonPatchException | IOException e) { throw new RuntimeException(e); } @@ -72,7 +72,7 @@ public static MCPUpsertBatchItem fromPatch( // aspectName name of the aspect being inserted @Nonnull private final String aspectName; - @Nonnull private final RecordTemplate aspect; + @Nonnull private final RecordTemplate recordTemplate; @Nonnull private final SystemMetadata systemMetadata; @@ -104,7 +104,7 @@ public void applyMutationHooks( entitySpec, aspectSpec, oldAspectValue, - aspect, + recordTemplate, oldSystemMetadata, systemMetadata, auditStamp, @@ -116,7 +116,7 @@ public void applyMutationHooks( public SystemAspect toLatestEntityAspect() { EntityAspect latest = new EntityAspect(); latest.setAspect(getAspectName()); - latest.setMetadata(EntityUtils.toJsonAspect(getAspect())); + latest.setMetadata(EntityUtils.toJsonAspect(getRecordTemplate())); latest.setUrn(getUrn().toString()); latest.setVersion(ASPECT_LATEST_VERSION); latest.setCreatedOn(new Timestamp(auditStamp.getTime())); @@ -135,7 +135,7 @@ public void validatePreCommit( .getAspectPayloadValidators( getChangeType(), entitySpec.getName(), aspectSpec.getName())) { validator.validatePreCommit( - getChangeType(), urn, getAspectSpec(), previous, this.aspect, aspectRetriever); + getChangeType(), urn, getAspectSpec(), previous, this.recordTemplate, aspectRetriever); } } @@ -167,13 +167,13 @@ public MCPUpsertBatchItem build(AspectRetriever aspectRetriever) { this.entitySpec, this.aspectSpec, this.urn, - this.aspect, + this.recordTemplate, aspectRetriever); return new MCPUpsertBatchItem( this.urn, this.aspectName, - this.aspect, + this.recordTemplate, SystemMetadataUtils.generateSystemMetadataIfEmpty(this.systemMetadata), this.auditStamp, this.metadataChangeProposal, @@ -213,7 +213,7 @@ public static MCPUpsertBatchItem build( SystemMetadataUtils.generateSystemMetadataIfEmpty(mcp.getSystemMetadata())) .metadataChangeProposal(mcp) .auditStamp(auditStamp) - .aspect(convertToRecordTemplate(mcp, aspectSpec)) + .recordTemplate(convertToRecordTemplate(mcp, aspectSpec)) .build(aspectRetriever); } @@ -258,12 +258,12 @@ public boolean equals(Object o) { return urn.equals(that.urn) && aspectName.equals(that.aspectName) && Objects.equals(systemMetadata, that.systemMetadata) - && aspect.equals(that.aspect); + && recordTemplate.equals(that.recordTemplate); } @Override public int hashCode() { - return Objects.hash(urn, aspectName, systemMetadata, aspect); + return Objects.hash(urn, aspectName, systemMetadata, recordTemplate); } @Override @@ -276,8 +276,8 @@ public String toString() { + '\'' + ", systemMetadata=" + systemMetadata - + ", aspect=" - + aspect + + ", recordTemplate=" + + recordTemplate + '}'; } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathUtils.java index af0f537de86292..4152122c381dab 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathUtils.java @@ -29,8 +29,7 @@ public class BrowsePathUtils { public static String getDefaultBrowsePath( @Nonnull Urn urn, @Nonnull EntityRegistry entityRegistry, - @Nonnull Character dataPlatformDelimiter) - throws URISyntaxException { + @Nonnull Character dataPlatformDelimiter) { switch (urn.getEntityType()) { case Constants.DATASET_ENTITY_NAME: diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java index 961167663e11f7..a531c268ed7d29 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/BrowsePathV2Utils.java @@ -16,7 +16,6 @@ import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.utils.EntityKeyUtils; -import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -46,9 +45,8 @@ public static BrowsePathsV2 getDefaultBrowsePathV2( @Nonnull Urn urn, @Nonnull EntityRegistry entityRegistry, @Nonnull Character dataPlatformDelimiter, - @Nonnull EntityService entityService, - boolean useContainerPaths) - throws URISyntaxException { + @Nonnull EntityService entityService, + boolean useContainerPaths) { BrowsePathsV2 result = new BrowsePathsV2(); BrowsePathEntryArray browsePathEntries = new BrowsePathEntryArray(); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java index ed633b063afb21..3c73d1acab5c25 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/service/UpdateIndicesService.java @@ -160,8 +160,8 @@ private void handleUpdateChangeEvent(@Nonnull final MCLBatchItem event) throws I final AspectSpec aspectSpec = event.getAspectSpec(); final Urn urn = event.getUrn(); - RecordTemplate aspect = event.getAspect(); - RecordTemplate previousAspect = event.getPreviousAspect(); + RecordTemplate aspect = event.getRecordTemplate(); + RecordTemplate previousAspect = event.getPreviousRecordTemplate(); // Step 0. If the aspect is timeseries, add to its timeseries index. if (aspectSpec.isTimeseries()) { @@ -264,7 +264,7 @@ private void handleDeleteChangeEvent(@Nonnull final MCLBatchItem event) { urn.getEntityType(), event.getAspectName())); } - RecordTemplate aspect = event.getAspect(); + RecordTemplate aspect = event.getRecordTemplate(); Boolean isDeletingKey = event.getAspectName().equals(entitySpec.getKeyAspectName()); if (!aspectSpec.isTimeseries()) { diff --git a/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java b/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java index 451b7327224986..72bbc794171ff9 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/AspectIngestionUtils.java @@ -38,7 +38,7 @@ public static Map ingestCorpUserKeyAspects( MCPUpsertBatchItem.builder() .urn(urn) .aspectName(aspectName) - .aspect(aspect) + .recordTemplate(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) .build(entityService)); @@ -68,7 +68,7 @@ public static Map ingestCorpUserInfoAspects( MCPUpsertBatchItem.builder() .urn(urn) .aspectName(aspectName) - .aspect(aspect) + .recordTemplate(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) .build(entityService)); @@ -99,7 +99,7 @@ public static Map ingestChartInfoAspects( MCPUpsertBatchItem.builder() .urn(urn) .aspectName(aspectName) - .aspect(aspect) + .recordTemplate(aspect) .auditStamp(AspectGenerationUtils.createAuditStamp()) .systemMetadata(AspectGenerationUtils.createSystemMetadata()) .build(entityService)); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/AspectUtilsTest.java b/metadata-io/src/test/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtilTest.java similarity index 56% rename from metadata-io/src/test/java/com/linkedin/metadata/AspectUtilsTest.java rename to metadata-io/src/test/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtilTest.java index 258b40cac63715..308832a9c63ef0 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/AspectUtilsTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/aspect/utils/DefaultAspectsUtilTest.java @@ -1,31 +1,35 @@ -package com.linkedin.metadata; +package com.linkedin.metadata.aspect.utils; import static org.mockito.Mockito.*; +import com.linkedin.common.AuditStamp; import com.linkedin.common.FabricType; import com.linkedin.common.urn.DataPlatformUrn; import com.linkedin.common.urn.DatasetUrn; -import com.linkedin.dataset.DatasetProperties; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.EbeanTestUtils; +import com.linkedin.metadata.aspect.batch.MCPBatchItem; +import com.linkedin.metadata.aspect.patch.builder.DatasetPropertiesPatchBuilder; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.PreProcessHooks; -import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.EntityServiceImpl; import com.linkedin.metadata.entity.TestEntityRegistry; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; +import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.event.EventProducer; import com.linkedin.metadata.models.registry.ConfigEntityRegistry; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.models.registry.EntityRegistryException; import com.linkedin.metadata.models.registry.MergedEntityRegistry; import com.linkedin.metadata.snapshot.Snapshot; -import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import io.ebean.Database; import java.util.List; +import java.util.stream.Collectors; import org.testng.Assert; import org.testng.annotations.Test; -public class AspectUtilsTest { +public class DefaultAspectsUtilTest { protected final EntityRegistry _snapshotEntityRegistry = new TestEntityRegistry(); protected final EntityRegistry _configEntityRegistry = @@ -34,31 +38,41 @@ public class AspectUtilsTest { protected final EntityRegistry _testEntityRegistry = new MergedEntityRegistry(_snapshotEntityRegistry).apply(_configEntityRegistry); - public AspectUtilsTest() throws EntityRegistryException {} + public DefaultAspectsUtilTest() throws EntityRegistryException {} @Test public void testAdditionalChanges() { - Database server = EbeanTestUtils.createTestServer(AspectUtilsTest.class.getSimpleName()); - EbeanAspectDao aspectDao = new EbeanAspectDao(server); + Database server = EbeanTestUtils.createTestServer(DefaultAspectsUtilTest.class.getSimpleName()); + EbeanAspectDao aspectDao = new EbeanAspectDao(server, EbeanConfiguration.testDefault); aspectDao.setConnectionValidated(true); EventProducer mockProducer = mock(EventProducer.class); PreProcessHooks preProcessHooks = new PreProcessHooks(); preProcessHooks.setUiEnabled(true); EntityServiceImpl entityServiceImpl = new EntityServiceImpl( - aspectDao, mockProducer, _testEntityRegistry, true, null, preProcessHooks); + aspectDao, mockProducer, _testEntityRegistry, true, null, preProcessHooks, false); - MetadataChangeProposal proposal1 = new MetadataChangeProposal(); - proposal1.setEntityUrn( - new DatasetUrn(new DataPlatformUrn("platform"), "name", FabricType.PROD)); - proposal1.setAspectName("datasetProperties"); - DatasetProperties datasetProperties = new DatasetProperties().setName("name"); - proposal1.setAspect(GenericRecordUtils.serializeAspect(datasetProperties)); - proposal1.setEntityType("dataset"); - proposal1.setChangeType(ChangeType.PATCH); + MetadataChangeProposal proposal1 = + new DatasetPropertiesPatchBuilder() + .urn(new DatasetUrn(new DataPlatformUrn("platform"), "name", FabricType.PROD)) + .setDescription("something") + .setName("name") + .addCustomProperty("prop1", "propVal1") + .addCustomProperty("prop2", "propVal2") + .build(); + + Assert.assertEquals(proposal1.getChangeType(), ChangeType.PATCH); List proposalList = - AspectUtils.getAdditionalChanges(proposal1, entityServiceImpl); + DefaultAspectsUtil.getAdditionalChanges( + AspectsBatchImpl.builder() + .mcps(List.of(proposal1), new AuditStamp(), entityServiceImpl) + .build(), + entityServiceImpl, + false) + .stream() + .map(MCPBatchItem::getMetadataChangeProposal) + .collect(Collectors.toList()); // proposals for key aspect, browsePath, browsePathV2, dataPlatformInstance Assert.assertEquals(proposalList.size(), 4); Assert.assertEquals(proposalList.get(0).getChangeType(), ChangeType.UPSERT); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraAspectMigrationsDaoTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraAspectMigrationsDaoTest.java index d94de604bf44de..d191ea2b9fa971 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraAspectMigrationsDaoTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraAspectMigrationsDaoTest.java @@ -54,7 +54,8 @@ private void configureComponents() { _testEntityRegistry, true, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); _retentionService = new CassandraRetentionService(_entityServiceImpl, session, 1000); _entityServiceImpl.setRetentionService(_retentionService); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraEntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraEntityServiceTest.java index bad47f9acf507c..8d30fb02915c70 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraEntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/CassandraEntityServiceTest.java @@ -77,7 +77,8 @@ private void configureComponents() { _testEntityRegistry, false, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); _retentionService = new CassandraRetentionService(_entityServiceImpl, session, 1000); _entityServiceImpl.setRetentionService(_retentionService); } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/DeleteEntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/DeleteEntityServiceTest.java index 496744770dba8a..42fa2acb542375 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/DeleteEntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/DeleteEntityServiceTest.java @@ -59,7 +59,8 @@ public DeleteEntityServiceTest() { _entityRegistry, true, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); _deleteEntityService = new DeleteEntityService(_entityServiceImpl, _graphService); } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanAspectMigrationsDaoTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanAspectMigrationsDaoTest.java index 2430ebb1f94bec..d241fb3b9581b4 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanAspectMigrationsDaoTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanAspectMigrationsDaoTest.java @@ -7,6 +7,7 @@ import com.linkedin.common.urn.Urn; import com.linkedin.metadata.AspectIngestionUtils; import com.linkedin.metadata.EbeanTestUtils; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; import com.linkedin.metadata.entity.ebean.EbeanRetentionService; @@ -32,7 +33,7 @@ public void setupTest() { Database server = EbeanTestUtils.createTestServer(EbeanAspectMigrationsDaoTest.class.getSimpleName()); _mockProducer = mock(EventProducer.class); - EbeanAspectDao dao = new EbeanAspectDao(server); + EbeanAspectDao dao = new EbeanAspectDao(server, EbeanConfiguration.testDefault); dao.setConnectionValidated(true); _mockUpdateIndicesService = mock(UpdateIndicesService.class); PreProcessHooks preProcessHooks = new PreProcessHooks(); @@ -44,7 +45,8 @@ public void setupTest() { _testEntityRegistry, true, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); _retentionService = new EbeanRetentionService(_entityServiceImpl, server, 1000); _entityServiceImpl.setRetentionService(_retentionService); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java index c45306e5f022bb..1e2cf4d4255d2e 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java @@ -13,6 +13,7 @@ import com.linkedin.metadata.AspectGenerationUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.EbeanTestUtils; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; import com.linkedin.metadata.entity.ebean.EbeanRetentionService; @@ -63,7 +64,7 @@ public void setupTest() { Database server = EbeanTestUtils.createTestServer(EbeanEntityServiceTest.class.getSimpleName()); _mockProducer = mock(EventProducer.class); - _aspectDao = new EbeanAspectDao(server); + _aspectDao = new EbeanAspectDao(server, EbeanConfiguration.testDefault); _mockUpdateIndicesService = mock(UpdateIndicesService.class); PreProcessHooks preProcessHooks = new PreProcessHooks(); @@ -75,7 +76,8 @@ public void setupTest() { _testEntityRegistry, false, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); _retentionService = new EbeanRetentionService(_entityServiceImpl, server, 1000); _entityServiceImpl.setRetentionService(_retentionService); } @@ -121,21 +123,21 @@ public void testIngestListLatestAspects() throws AssertionError { MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(aspectName) - .aspect(writeAspect1) + .recordTemplate(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) - .aspect(writeAspect2) + .recordTemplate(writeAspect2) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) - .aspect(writeAspect3) + .recordTemplate(writeAspect3) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl)); @@ -190,21 +192,21 @@ public void testIngestListUrns() throws AssertionError { MCPUpsertBatchItem.builder() .urn(entityUrn1) .aspectName(aspectName) - .aspect(writeAspect1) + .recordTemplate(writeAspect1) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn2) .aspectName(aspectName) - .aspect(writeAspect2) + .recordTemplate(writeAspect2) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl), MCPUpsertBatchItem.builder() .urn(entityUrn3) .aspectName(aspectName) - .aspect(writeAspect3) + .recordTemplate(writeAspect3) .systemMetadata(metadata1) .auditStamp(TEST_AUDIT_STAMP) .build(_entityServiceImpl)); @@ -311,6 +313,12 @@ public void multiThreadingTest() { Set> additions = actualAspectIds.stream() .filter(id -> !generatedAspectIds.contains(id)) + // Exclude default aspects + .filter( + id -> + !Set.of("browsePaths", "browsePathsV2", "dataPlatformInstance") + .contains(id.getMiddle())) + .filter(id -> !id.getMiddle().endsWith("Key")) .collect(Collectors.toSet()); assertEquals( additions.size(), 0, String.format("Expected no additional aspects. Found: %s", additions)); @@ -361,6 +369,12 @@ public void singleThreadingTest() { Set> additions = actualAspectIds.stream() .filter(id -> !generatedAspectIds.contains(id)) + // Exclude default aspects + .filter( + id -> + !Set.of("browsePaths", "browsePathsV2", "dataPlatformInstance") + .contains(id.getMiddle())) + .filter(id -> !id.getMiddle().endsWith("Key")) .collect(Collectors.toSet()); assertEquals( additions.size(), 0, String.format("Expected no additional aspects. Found: %s", additions)); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java index db749f3575a064..ea4e97d264bca3 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java @@ -108,6 +108,8 @@ public abstract class EntityServiceTest captor = ArgumentCaptor.forClass(MetadataChangeLog.class); - verify(_mockProducer, times(1)) - .produceMetadataChangeLog(Mockito.eq(entityUrn), Mockito.any(), captor.capture()); + ArgumentCaptor aspectSpecCaptor = ArgumentCaptor.forClass(AspectSpec.class); + verify(_mockProducer, times(4)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), aspectSpecCaptor.capture(), captor.capture()); assertEquals(UI_SOURCE, captor.getValue().getSystemMetadata().getProperties().get(APP_SOURCE)); + assertEquals( + aspectSpecCaptor.getAllValues().stream() + .map(AspectSpec::getName) + .collect(Collectors.toSet()), + Set.of( + "browsePathsV2", + "editableDatasetProperties", + // "browsePaths", + "dataPlatformInstance", + "datasetKey")); } @Test @@ -1673,12 +1688,17 @@ public void testStructuredPropertyIngestProposal() throws Exception { genericAspect.setContentType("application/json"); gmce.setAspect(genericAspect); _entityServiceImpl.ingestProposal(gmce, TEST_AUDIT_STAMP, false); + ArgumentCaptor captor = ArgumentCaptor.forClass(MetadataChangeLog.class); verify(_mockProducer, times(1)) - .produceMetadataChangeLog(Mockito.eq(firstPropertyUrn), Mockito.any(), captor.capture()); + .produceMetadataChangeLog( + Mockito.eq(firstPropertyUrn), + Mockito.eq(structuredPropertiesDefinitionAspect), + captor.capture()); assertEquals( _entityServiceImpl.getAspect(firstPropertyUrn, definitionAspectName, 0), structuredPropertyDefinition); + Urn secondPropertyUrn = UrnUtils.getUrn("urn:li:structuredProperty:secondStructuredProperty"); assertNull(_entityServiceImpl.getAspect(secondPropertyUrn, definitionAspectName, 0)); assertEquals( @@ -1752,7 +1772,9 @@ public void testStructuredPropertyIngestProposal() throws Exception { ArgumentCaptor.forClass(MetadataChangeLog.class); verify(_mockProducer, times(1)) .produceMetadataChangeLog( - Mockito.eq(secondPropertyUrn), Mockito.any(), secondCaptor.capture()); + Mockito.eq(secondPropertyUrn), + Mockito.eq(structuredPropertiesDefinitionAspect), + secondCaptor.capture()); assertEquals( _entityServiceImpl.getAspect(firstPropertyUrn, definitionAspectName, 0), structuredPropertyDefinition); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/timeline/CassandraTimelineServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/timeline/CassandraTimelineServiceTest.java index 921fbac12df854..552cb0b52994f9 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/timeline/CassandraTimelineServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/timeline/CassandraTimelineServiceTest.java @@ -61,7 +61,8 @@ private void configureComponents() { _testEntityRegistry, true, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); } /** diff --git a/metadata-io/src/test/java/com/linkedin/metadata/timeline/EbeanTimelineServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/timeline/EbeanTimelineServiceTest.java index 4e47e596dddc26..5d7137a52eb21e 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/timeline/EbeanTimelineServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/timeline/EbeanTimelineServiceTest.java @@ -3,6 +3,7 @@ import static org.mockito.Mockito.mock; import com.linkedin.metadata.EbeanTestUtils; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.EntityServiceImpl; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; @@ -29,7 +30,7 @@ public EbeanTimelineServiceTest() throws EntityRegistryException {} public void setupTest() { Database server = EbeanTestUtils.createTestServer(EbeanTimelineServiceTest.class.getSimpleName()); - _aspectDao = new EbeanAspectDao(server); + _aspectDao = new EbeanAspectDao(server, EbeanConfiguration.testDefault); _aspectDao.setConnectionValidated(true); _entityTimelineService = new TimelineServiceImpl(_aspectDao, _testEntityRegistry); _mockProducer = mock(EventProducer.class); @@ -42,7 +43,8 @@ public void setupTest() { _testEntityRegistry, true, _mockUpdateIndicesService, - preProcessHooks); + preProcessHooks, + true); } /** diff --git a/metadata-io/src/test/java/io/datahubproject/test/DataGenerator.java b/metadata-io/src/test/java/io/datahubproject/test/DataGenerator.java index 29c64abdc4d0d0..eb4c85209ce422 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/DataGenerator.java +++ b/metadata-io/src/test/java/io/datahubproject/test/DataGenerator.java @@ -1,5 +1,6 @@ package io.datahubproject.test; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; import com.linkedin.common.AuditStamp; @@ -10,15 +11,18 @@ import com.linkedin.common.urn.GlossaryTermUrn; import com.linkedin.common.urn.TagUrn; import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.glossary.GlossaryTermInfo; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.batch.MCPBatchItem; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.AspectDao; -import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityServiceImpl; +import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.event.EventProducer; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; @@ -51,11 +55,17 @@ public class DataGenerator { private static final Faker FAKER = new Faker(); private final EntityRegistry entityRegistry; - private final EntityService entityService; + private final EntityService entityService; + private final boolean generateDefaultAspects; - public DataGenerator(EntityService entityService) { + public DataGenerator(EntityService entityService) { + this(entityService, false); + } + + public DataGenerator(EntityService entityService, Boolean generateDefaultAspects) { this.entityService = entityService; this.entityRegistry = entityService.getEntityRegistry(); + this.generateDefaultAspects = generateDefaultAspects != null ? generateDefaultAspects : false; } public static DataGenerator build(EntityRegistry entityRegistry) { @@ -66,7 +76,8 @@ public static DataGenerator build(EntityRegistry entityRegistry) { entityRegistry, false, mock(UpdateIndicesService.class), - mock(PreProcessHooks.class)); + mock(PreProcessHooks.class), + anyBoolean()); return new DataGenerator(mockEntityServiceImpl); } @@ -81,10 +92,15 @@ public List generateTags(long count) { public Stream> generateMCPs( String entityName, long count, List aspects) { EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); + AuditStamp auditStamp = + new AuditStamp() + .setActor(UrnUtils.getUrn(Constants.DATAHUB_ACTOR)) + .setTime(System.currentTimeMillis()); // Prevent duplicate tags and terms generated as secondary entities Set secondaryUrns = new HashSet<>(); + // Expand with default aspects per normal return LongStream.range(0, count) .mapToObj( idx -> { @@ -145,11 +161,22 @@ public Stream> generateMCPs( }) .map( mcp -> { - // Expand with default aspects per normal - return Stream.concat( - Stream.of(mcp), - AspectUtils.getAdditionalChanges(mcp, entityService, true).stream()) - .collect(Collectors.toList()); + if (generateDefaultAspects) { + // Expand with default aspects instead of relying on default generation + return Stream.concat( + Stream.of(mcp), + DefaultAspectsUtil.getAdditionalChanges( + AspectsBatchImpl.builder() + .mcps(List.of(mcp), auditStamp, entityService) + .build(), + entityService, + true) + .stream() + .map(MCPBatchItem::getMetadataChangeProposal)) + .collect(Collectors.toList()); + } else { + return List.of(mcp); + } }); } diff --git a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java index 84433a2b439f41..b42cd89131f51f 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java +++ b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SampleDataFixtureConfiguration.java @@ -296,7 +296,8 @@ private EntityClient entityClientHelper( PreProcessHooks preProcessHooks = new PreProcessHooks(); preProcessHooks.setUiEnabled(true); return new JavaEntityClient( - new EntityServiceImpl(mockAspectDao, null, entityRegistry, true, null, preProcessHooks), + new EntityServiceImpl( + mockAspectDao, null, entityRegistry, true, null, preProcessHooks, true), null, entitySearchService, cachingEntitySearchService, diff --git a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SearchLineageFixtureConfiguration.java b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SearchLineageFixtureConfiguration.java index 978471b53faada..07d27245222b9e 100644 --- a/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SearchLineageFixtureConfiguration.java +++ b/metadata-io/src/test/java/io/datahubproject/test/fixtures/search/SearchLineageFixtureConfiguration.java @@ -234,7 +234,7 @@ protected EntityClient entityClient( PreProcessHooks preProcessHooks = new PreProcessHooks(); preProcessHooks.setUiEnabled(true); return new JavaEntityClient( - new EntityServiceImpl(null, null, entityRegistry, true, null, preProcessHooks), + new EntityServiceImpl(null, null, entityRegistry, true, null, preProcessHooks, true), null, entitySearchService, cachingEntitySearchService, diff --git a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java index d2041c443503ed..c21b64c8a4fc00 100644 --- a/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java +++ b/metadata-models-custom/src/main/java/com/linkedin/metadata/aspect/plugins/hooks/CustomDataQualityRulesMCPSideEffect.java @@ -24,7 +24,7 @@ protected Stream applyMCPSideEffect( MCPUpsertBatchItem.builder() .urn(mirror) .aspectName(input.getAspectName()) - .aspect(input.getAspect()) + .recordTemplate(input.getRecordTemplate()) .auditStamp(input.getAuditStamp()) .systemMetadata(input.getSystemMetadata()) .build(aspectRetriever)); diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java index e072a59ae77ffd..0d1da4a7687bae 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java @@ -10,7 +10,6 @@ import com.linkedin.common.urn.UrnUtils; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; -import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.key.DataHubAccessTokenKey; @@ -20,12 +19,11 @@ import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; @@ -146,15 +144,8 @@ public String generateAccessToken( final AuditStamp auditStamp = AuditStampUtils.createDefaultAuditStamp().setActor(UrnUtils.getUrn(actorUrn)); - Stream proposalStream = - Stream.concat( - Stream.of(proposal), - AspectUtils.getAdditionalChanges(proposal, _entityService).stream()); - _entityService.ingestProposal( - AspectsBatchImpl.builder() - .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) - .build(), + AspectsBatchImpl.builder().mcps(List.of(proposal), auditStamp, _entityService).build(), false); return accessToken; diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/EbeanConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/EbeanConfiguration.java new file mode 100644 index 00000000000000..47b406e695a3fb --- /dev/null +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/EbeanConfiguration.java @@ -0,0 +1,46 @@ +package com.linkedin.metadata.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class EbeanConfiguration { + private String username; + private String password; + private String url; + private String driver; + private long minConnections; + private long maxConnections; + private long maxInactiveTimeSeconds; + private long maxAgeMinutes; + private long leakTimeMinutes; + private long waitTimeoutMillis; + private boolean autoCreateDdl; + private boolean postgresUseIamAuth; + private LockingConfiguration locking; + + public static final EbeanConfiguration testDefault = + EbeanConfiguration.builder().locking(LockingConfiguration.testDefault).build(); + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class LockingConfiguration { + private boolean enabled; + private long durationSeconds; + private long maximumLocks; + + public static final LockingConfiguration testDefault = + LockingConfiguration.builder() + .enabled(true) + .durationSeconds(60) + .maximumLocks(10000) + .build(); + } +} diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index a7222f2adc3c60..d4c11d4aa53bd0 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -141,6 +141,10 @@ ebean: waitTimeoutMillis: ${EBEAN_WAIT_TIMEOUT_MILLIS:1000} autoCreateDdl: ${EBEAN_AUTOCREATE:false} postgresUseIamAuth: ${EBEAN_POSTGRES_USE_AWS_IAM_AUTH:false} + locking: + enabled: ${EBEAN_LOCKING_ENABLED:true} + durationSeconds: ${EBEAN_LOCKING_DURATION_SECONDS:60} + maximumLocks: ${EBEAN_LOCKING_MAXIMUM_LOCKS:20000} # Only required if entityService.impl is cassandra cassandra: diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java index e969793fac1ef4..62bfcfa2cbf93d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/config/ConfigurationProvider.java @@ -4,6 +4,7 @@ import com.datahub.authorization.AuthorizationConfiguration; import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.metadata.config.DataHubConfiguration; +import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.IngestionConfiguration; import com.linkedin.metadata.config.SystemUpdateConfiguration; import com.linkedin.metadata.config.TestsConfiguration; @@ -71,4 +72,7 @@ public class ConfigurationProvider { /** Structured properties related configurations */ private StructuredPropertiesConfiguration structuredProperties; + + /** Ebean related configuration */ + private EbeanConfiguration ebean; } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectDaoFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectDaoFactory.java index 94aebb2a39efa3..22eced4fd5acf0 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectDaoFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectDaoFactory.java @@ -1,6 +1,7 @@ package com.linkedin.gms.factory.entity; import com.datastax.oss.driver.api.core.CqlSession; +import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.entity.AspectDao; import com.linkedin.metadata.entity.cassandra.CassandraAspectDao; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; @@ -18,8 +19,9 @@ public class EntityAspectDaoFactory { @DependsOn({"gmsEbeanServiceConfig"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull - protected AspectDao createEbeanInstance(Database server) { - return new EbeanAspectDao(server); + protected AspectDao createEbeanInstance( + Database server, final ConfigurationProvider configurationProvider) { + return new EbeanAspectDao(server, configurationProvider.getEbean()); } @Bean(name = "entityAspectDao") diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectMigrationsDaoFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectMigrationsDaoFactory.java index 9123714de5bc8d..2bf9804030b49c 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectMigrationsDaoFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityAspectMigrationsDaoFactory.java @@ -1,6 +1,7 @@ package com.linkedin.gms.factory.entity; import com.datastax.oss.driver.api.core.CqlSession; +import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.entity.AspectMigrationsDao; import com.linkedin.metadata.entity.cassandra.CassandraAspectDao; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; @@ -18,8 +19,9 @@ public class EntityAspectMigrationsDaoFactory { @DependsOn({"gmsEbeanServiceConfig"}) @ConditionalOnProperty(name = "entityService.impl", havingValue = "ebean", matchIfMissing = true) @Nonnull - protected AspectMigrationsDao createEbeanInstance(Database server) { - return new EbeanAspectDao(server); + protected AspectMigrationsDao createEbeanInstance( + Database server, final ConfigurationProvider configurationProvider) { + return new EbeanAspectDao(server, configurationProvider.getEbean()); } @Bean(name = "entityAspectMigrationsDao") diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java index 5fd64b02d08a8d..871f16d97be33c 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java @@ -43,7 +43,8 @@ protected EntityService createInstance( @Qualifier("entityAspectDao") AspectDao aspectDao, EntityRegistry entityRegistry, ConfigurationProvider configurationProvider, - UpdateIndicesService updateIndicesService) { + UpdateIndicesService updateIndicesService, + @Value("${featureFlags.showBrowseV2}") final boolean enableBrowsePathV2) { final KafkaEventProducer eventProducer = new KafkaEventProducer(producer, convention, kafkaHealthChecker); @@ -56,6 +57,7 @@ protected EntityService createInstance( featureFlags.isAlwaysEmitChangeLog(), updateIndicesService, featureFlags.getPreProcessHooks(), - _ebeanMaxTransactionRetry); + _ebeanMaxTransactionRetry, + enableBrowsePathV2); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java index 80e139dcd5c65b..49a86406c1ecd5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2Step.java @@ -9,6 +9,7 @@ import com.linkedin.common.urn.Urn; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.boot.UpgradeStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.query.filter.Condition; @@ -128,7 +129,8 @@ private String backfillBrowsePathsV2(String entityType, AuditStamp auditStamp, S } private void ingestBrowsePathsV2(Urn urn, AuditStamp auditStamp) throws Exception { - BrowsePathsV2 browsePathsV2 = _entityService.buildDefaultBrowsePathV2(urn, true); + BrowsePathsV2 browsePathsV2 = + DefaultAspectsUtil.buildDefaultBrowsePathV2(urn, true, _entityService); log.debug(String.format("Adding browse path v2 for urn %s with value %s", urn, browsePathsV2)); MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(urn); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java index 716ae292338ed2..19efa5e9c4de20 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStep.java @@ -79,7 +79,7 @@ public void execute() throws Exception { MCPUpsertBatchItem.builder() .urn(urn) .aspectName(DATA_PLATFORM_INSTANCE_ASPECT_NAME) - .aspect(dataPlatformInstance.get()) + .recordTemplate(dataPlatformInstance.get()) .auditStamp(aspectAuditStamp) .build(_entityService)); } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java index 89ed493e162ccf..d2bb61ad7ade5d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataPlatformsStep.java @@ -86,7 +86,7 @@ public void execute() throws IOException, URISyntaxException { return MCPUpsertBatchItem.builder() .urn(urn) .aspectName(PLATFORM_ASPECT_NAME) - .aspect(info) + .recordTemplate(info) .auditStamp( new AuditStamp() .setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)) diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java index 6f3a415b521e41..1487257d6d830e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestDataTypesStep.java @@ -11,12 +11,12 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.boot.BootstrapStep; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.models.AspectSpec; -import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; -import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.MetadataChangeProposal; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; +import java.util.Set; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; @@ -62,30 +62,29 @@ public void execute() throws Exception { log.info("Ingesting {} data types types", dataTypesObj.size()); int numIngested = 0; + + Map urnDataTypesMap = new HashMap<>(); for (final JsonNode roleObj : dataTypesObj) { final Urn urn = Urn.createFromString(roleObj.get("urn").asText()); - final DataTypeInfo info = - RecordUtils.toRecordTemplate(DataTypeInfo.class, roleObj.get("info").toString()); - log.info(String.format("Ingesting default data type with urn %s", urn)); - ingestDataType(urn, info); - numIngested++; + urnDataTypesMap.put(urn, roleObj); + } + + Set existingUrns = _entityService.exists(urnDataTypesMap.keySet()); + + for (final Map.Entry entry : urnDataTypesMap.entrySet()) { + if (!existingUrns.contains(entry.getKey())) { + final DataTypeInfo info = + RecordUtils.toRecordTemplate( + DataTypeInfo.class, entry.getValue().get("info").toString()); + log.info(String.format("Ingesting default data type with urn %s", entry.getKey())); + ingestDataType(entry.getKey(), info); + numIngested++; + } } log.info("Ingested {} new data types", numIngested); } private void ingestDataType(final Urn dataTypeUrn, final DataTypeInfo info) throws Exception { - // Write key - final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); - final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(dataTypeUrn.getEntityType()); - GenericAspect keyAspect = - GenericRecordUtils.serializeAspect( - EntityKeyUtils.convertUrnToEntityKey(dataTypeUrn, keyAspectSpec)); - keyAspectProposal.setAspect(keyAspect); - keyAspectProposal.setAspectName(keyAspectSpec.getName()); - keyAspectProposal.setEntityType(DATA_TYPE_ENTITY_NAME); - keyAspectProposal.setChangeType(ChangeType.UPSERT); - keyAspectProposal.setEntityUrn(dataTypeUrn); - final MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(dataTypeUrn); proposal.setEntityType(DATA_TYPE_ENTITY_NAME); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java index b2213eda71cae1..4067b4f34fb361 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/IngestEntityTypesStep.java @@ -9,13 +9,14 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.boot.BootstrapStep; import com.linkedin.metadata.entity.EntityService; -import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; -import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; -import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.util.Pair; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -42,34 +43,39 @@ public void execute() throws Exception { log.info( "Ingesting {} entity types", _entityService.getEntityRegistry().getEntitySpecs().size()); int numIngested = 0; - for (final EntitySpec spec : _entityService.getEntityRegistry().getEntitySpecs().values()) { - final Urn entityTypeUrn = - UrnUtils.getUrn( - String.format("urn:li:entityType:%s.%s", DATAHUB_NAMESPACE, spec.getName())); - final EntityTypeInfo info = - new EntityTypeInfo() - .setDisplayName(spec.getName()) // TODO: Support display name in the entity registry. - .setQualifiedName(entityTypeUrn.getId()); - log.info(String.format("Ingesting entity type with urn %s", entityTypeUrn)); - ingestEntityType(entityTypeUrn, info); - numIngested++; + + Map urnEntitySpecMap = + _entityService.getEntityRegistry().getEntitySpecs().values().stream() + .map( + spec -> + Pair.of( + UrnUtils.getUrn( + String.format( + "urn:li:entityType:%s.%s", DATAHUB_NAMESPACE, spec.getName())), + spec)) + .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); + + Set existingUrns = _entityService.exists(urnEntitySpecMap.keySet()); + + for (final Map.Entry entry : urnEntitySpecMap.entrySet()) { + if (!existingUrns.contains(entry.getKey())) { + final EntityTypeInfo info = + new EntityTypeInfo() + .setDisplayName( + entry + .getValue() + .getName()) // TODO: Support display name in the entity registry. + .setQualifiedName(entry.getKey().getId()); + log.info(String.format("Ingesting entity type with urn %s", entry.getKey())); + ingestEntityType(entry.getKey(), info); + numIngested++; + } } log.info("Ingested {} new entity types", numIngested); } private void ingestEntityType(final Urn entityTypeUrn, final EntityTypeInfo info) throws Exception { - // Write key - final MetadataChangeProposal keyAspectProposal = new MetadataChangeProposal(); - final AspectSpec keyAspectSpec = _entityService.getKeyAspectSpec(entityTypeUrn.getEntityType()); - GenericAspect keyAspect = - GenericRecordUtils.serializeAspect( - EntityKeyUtils.convertUrnToEntityKey(entityTypeUrn, keyAspectSpec)); - keyAspectProposal.setAspect(keyAspect); - keyAspectProposal.setAspectName(keyAspectSpec.getName()); - keyAspectProposal.setEntityType(ENTITY_TYPE_ENTITY_NAME); - keyAspectProposal.setChangeType(ChangeType.UPSERT); - keyAspectProposal.setEntityUrn(entityTypeUrn); final MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(entityTypeUrn); diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java index 3eedbb48aaecaf..f28e9ad4e9ed8c 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStep.java @@ -9,6 +9,7 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; import com.linkedin.metadata.boot.UpgradeStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.ListResult; @@ -126,7 +127,7 @@ private int getAndMigrateBrowsePaths(String entityType, int start, AuditStamp au } private void migrateBrowsePath(Urn urn, AuditStamp auditStamp) throws Exception { - BrowsePaths newPaths = _entityService.buildDefaultBrowsePath(urn); + BrowsePaths newPaths = DefaultAspectsUtil.buildDefaultBrowsePath(urn, _entityService); log.debug(String.format("Updating browse path for urn %s to value %s", urn, newPaths)); MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(urn); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java index 06571415620893..0858736e39021a 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/BackfillBrowsePathsV2StepTest.java @@ -1,12 +1,10 @@ package com.linkedin.metadata.boot.steps; import static com.linkedin.metadata.Constants.CONTAINER_ASPECT_NAME; +import static org.mockito.ArgumentMatchers.any; import com.google.common.collect.ImmutableList; import com.linkedin.common.AuditStamp; -import com.linkedin.common.BrowsePathEntry; -import com.linkedin.common.BrowsePathEntryArray; -import com.linkedin.common.BrowsePathsV2; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.entity.Aspect; @@ -93,9 +91,9 @@ public void testExecuteNoExistingBrowsePaths() throws Exception { Mockito.verify(mockSearchService, Mockito.times(9)) .scrollAcrossEntities( - Mockito.any(), + any(), Mockito.eq("*"), - Mockito.any(Filter.class), + any(Filter.class), Mockito.eq(null), Mockito.eq(null), Mockito.eq("5m"), @@ -104,8 +102,7 @@ public void testExecuteNoExistingBrowsePaths() throws Exception { // Verify that 11 aspects are ingested, 2 for the upgrade request / result, 9 for ingesting 1 of // each entity type Mockito.verify(mockService, Mockito.times(11)) - .ingestProposal( - Mockito.any(MetadataChangeProposal.class), Mockito.any(), Mockito.eq(false)); + .ingestProposal(any(MetadataChangeProposal.class), any(), Mockito.eq(false)); } @Test @@ -135,9 +132,7 @@ public void testDoesNotRunWhenAlreadyExecuted() throws Exception { Mockito.verify(mockService, Mockito.times(0)) .ingestProposal( - Mockito.any(MetadataChangeProposal.class), - Mockito.any(AuditStamp.class), - Mockito.anyBoolean()); + any(MetadataChangeProposal.class), any(AuditStamp.class), Mockito.anyBoolean()); } private EntityService initMockService() throws URISyntaxException { @@ -146,16 +141,9 @@ private EntityService initMockService() throws URISyntaxException { Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); for (int i = 0; i < ENTITY_TYPES.size(); i++) { - Mockito.when( - mockService.buildDefaultBrowsePathV2( - Mockito.eq(ENTITY_URNS.get(i)), Mockito.eq(true))) - .thenReturn( - new BrowsePathsV2() - .setPath(new BrowsePathEntryArray(new BrowsePathEntry().setId("test")))); - Mockito.when( mockService.getEntityV2( - Mockito.any(), + any(), Mockito.eq(ENTITY_URNS.get(i)), Mockito.eq(Collections.singleton(CONTAINER_ASPECT_NAME)))) .thenReturn(null); @@ -172,7 +160,7 @@ private SearchService initMockSearchService() { mockSearchService.scrollAcrossEntities( Mockito.eq(ImmutableList.of(ENTITY_TYPES.get(i))), Mockito.eq("*"), - Mockito.any(Filter.class), + any(Filter.class), Mockito.eq(null), Mockito.eq(null), Mockito.eq("5m"), diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java index 1ac0f2f4f914a6..5617d7e9714b08 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataPlatformInstancesStepTest.java @@ -122,7 +122,7 @@ public void testExecuteWhenSomeEntitiesShouldReceiveDataPlatformInstance() throw item.getUrn().getEntityType().equals("chart") && item.getAspectName() .equals(DATA_PLATFORM_INSTANCE_ASPECT_NAME) - && ((MCPUpsertBatchItem) item).getAspect() + && ((MCPUpsertBatchItem) item).getRecordTemplate() instanceof DataPlatformInstance)), anyBoolean(), anyBoolean()); @@ -136,7 +136,7 @@ public void testExecuteWhenSomeEntitiesShouldReceiveDataPlatformInstance() throw item.getUrn().getEntityType().equals("chart") && item.getAspectName() .equals(DATA_PLATFORM_INSTANCE_ASPECT_NAME) - && ((MCPUpsertBatchItem) item).getAspect() + && ((MCPUpsertBatchItem) item).getRecordTemplate() instanceof DataPlatformInstance)), anyBoolean(), anyBoolean()); diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java index 2bbd06c8a61a43..c5539b001e9e35 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/IngestDataTypesStepTest.java @@ -13,6 +13,8 @@ import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; +import java.util.Collection; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.mockito.Mockito; import org.testng.Assert; @@ -51,14 +53,17 @@ public void testExecuteValidDataTypesNoExistingDataTypes() throws Exception { @Test public void testExecuteInvalidJson() throws Exception { final EntityService entityService = mock(EntityService.class); + when(entityService.exists(any(Collection.class))).thenAnswer(args -> Set.of()); final IngestDataTypesStep step = new IngestDataTypesStep(entityService, "./boot/test_data_types_invalid.json"); Assert.assertThrows(RuntimeException.class, step::execute); - // Verify no interactions - verifyNoInteractions(entityService); + verify(entityService, times(1)).exists(any()); + + // Verify no additional interactions + verifyNoMoreInteractions(entityService); } private static MetadataChangeProposal buildUpdateDataTypeProposal(final DataTypeInfo info) { diff --git a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java index 024ad7b16a8447..605d9d1c5e5d89 100644 --- a/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java +++ b/metadata-service/factories/src/test/java/com/linkedin/metadata/boot/steps/UpgradeDefaultBrowsePathsStepTest.java @@ -107,10 +107,6 @@ public void testExecuteFirstTime() throws Exception { final EntityService mockService = Mockito.mock(EntityService.class); final EntityRegistry registry = new TestEntityRegistry(); Mockito.when(mockService.getEntityRegistry()).thenReturn(registry); - Mockito.when(mockService.buildDefaultBrowsePath(Mockito.eq(testUrn1))) - .thenReturn(new BrowsePaths().setPaths(new StringArray(ImmutableList.of("/prod/kafka")))); - Mockito.when(mockService.buildDefaultBrowsePath(Mockito.eq(testUrn2))) - .thenReturn(new BrowsePaths().setPaths(new StringArray(ImmutableList.of("/prod/kafka")))); final Urn upgradeEntityUrn = Urn.createFromString(UPGRADE_URN); Mockito.when( diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java index a7e88966e4f874..13d2e501abf09f 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/util/MappingUtil.java @@ -26,7 +26,6 @@ import com.linkedin.entity.Aspect; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.batch.AspectsBatch; -import com.linkedin.metadata.entity.AspectUtils; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.IngestResult; import com.linkedin.metadata.entity.RollbackRunResult; @@ -452,20 +451,12 @@ public static Pair ingestProposal( .setTime(System.currentTimeMillis()) .setActor(UrnUtils.getUrn(actorUrn)); - final List additionalChanges = - AspectUtils.getAdditionalChanges(serviceProposal, entityService); - log.info("Proposal: {}", serviceProposal); Throwable exceptionally = null; try { - Stream proposalStream = - Stream.concat( - Stream.of(serviceProposal), - AspectUtils.getAdditionalChanges(serviceProposal, entityService).stream()); - AspectsBatch batch = AspectsBatchImpl.builder() - .mcps(proposalStream.collect(Collectors.toList()), auditStamp, entityService) + .mcps(List.of(serviceProposal), auditStamp, entityService) .build(); Set proposalResult = entityService.ingestProposal(batch, async); diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java index 503330fdc8a2e5..44202c20ca6db7 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v2/controller/EntityController.java @@ -481,7 +481,7 @@ private UpsertItem toUpsertItem( .urn(entityUrn) .aspectName(aspectSpec.getName()) .auditStamp(AuditStampUtils.createAuditStamp(actor.toUrnStr())) - .aspect( + .recordTemplate( GenericRecordUtils.deserializeAspect( ByteString.copyString(jsonAspect, StandardCharsets.UTF_8), GenericRecordUtils.JSON, diff --git a/metadata-service/openapi-servlet/src/test/java/entities/EntitiesControllerTest.java b/metadata-service/openapi-servlet/src/test/java/entities/EntitiesControllerTest.java index 17be5a60816d30..20862bbc7f000d 100644 --- a/metadata-service/openapi-servlet/src/test/java/entities/EntitiesControllerTest.java +++ b/metadata-service/openapi-servlet/src/test/java/entities/EntitiesControllerTest.java @@ -11,6 +11,7 @@ import com.datahub.authorization.AuthorizationResult; import com.datahub.authorization.AuthorizerChain; import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.metadata.aspect.batch.AspectsBatch; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.AspectDao; import com.linkedin.metadata.entity.UpdateAspectResult; @@ -67,13 +68,15 @@ public void setup() IllegalAccessException { EntityRegistry mockEntityRegistry = new MockEntityRegistry(); AspectDao aspectDao = Mockito.mock(AspectDao.class); - Mockito.when( - aspectDao.runInTransactionWithRetry( - ArgumentMatchers.>any(), any(), anyInt())) + when(aspectDao.runInTransactionWithRetry( + ArgumentMatchers.>>any(), + any(AspectsBatch.class), + anyInt())) .thenAnswer( i -> - ((Function) i.getArgument(0)) - .apply(Mockito.mock(Transaction.class))); + List.of( + ((Function>) i.getArgument(0)) + .apply(Mockito.mock(Transaction.class)))); EventProducer mockEntityEventProducer = Mockito.mock(EventProducer.class); UpdateIndicesService mockUpdateIndicesService = mock(UpdateIndicesService.class); diff --git a/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java b/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java index b082a735e8084b..be5f99bed8e630 100644 --- a/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java +++ b/metadata-service/openapi-servlet/src/test/java/mock/MockEntityService.java @@ -59,7 +59,7 @@ public MockEntityService( @Nonnull EntityRegistry entityRegistry, @Nonnull UpdateIndicesService updateIndicesService, PreProcessHooks preProcessHooks) { - super(aspectDao, producer, entityRegistry, true, updateIndicesService, preProcessHooks); + super(aspectDao, producer, entityRegistry, true, updateIndicesService, preProcessHooks, true); } @Override diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java index ffa3abe6806f99..21a9f47a13f738 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java @@ -248,20 +248,9 @@ public Task ingestProposal( return RestliUtil.toTask(() -> { log.debug("Proposal: {}", metadataChangeProposal); try { - final AspectsBatch batch; - if (asyncBool) { - // if async we'll expand the getAdditionalChanges later, no need to do this early - batch = AspectsBatchImpl.builder() - .mcps(List.of(metadataChangeProposal), auditStamp, _entityService) - .build(); - } else { - Stream proposalStream = Stream.concat(Stream.of(metadataChangeProposal), - AspectUtils.getAdditionalChanges(metadataChangeProposal, _entityService).stream()); - - batch = AspectsBatchImpl.builder() - .mcps(proposalStream.collect(Collectors.toList()), auditStamp, _entityService) - .build(); - } + final AspectsBatch batch = AspectsBatchImpl.builder() + .mcps(List.of(metadataChangeProposal), auditStamp, _entityService) + .build(); Set results = _entityService.ingestProposal(batch, asyncBool); diff --git a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java index d6130e05b77bd3..1678fe92ec70e3 100644 --- a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java +++ b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java @@ -53,7 +53,7 @@ public void setup() { _updateIndicesService = mock(UpdateIndicesService.class); _preProcessHooks = mock(PreProcessHooks.class); _entityService = new EntityServiceImpl(_aspectDao, _producer, _entityRegistry, false, - _updateIndicesService, _preProcessHooks); + _updateIndicesService, _preProcessHooks, true); _authorizer = mock(Authorizer.class); _aspectResource.setAuthorizer(_authorizer); _aspectResource.setEntityService(_entityService); @@ -84,13 +84,13 @@ public void testAsyncDefaultAspects() throws URISyntaxException { MCPUpsertBatchItem req = MCPUpsertBatchItem.builder() .urn(urn) .aspectName(mcp.getAspectName()) - .aspect(mcp.getAspect()) + .recordTemplate(mcp.getAspect()) .auditStamp(new AuditStamp()) .metadataChangeProposal(mcp) .build(_entityService); when(_aspectDao.runInTransactionWithRetry(any(), any(), anyInt())) .thenReturn( - List.of( + List.of(List.of( UpdateAspectResult.builder() .urn(urn) .newValue(new DatasetProperties().setName("name1")) @@ -120,7 +120,7 @@ public void testAsyncDefaultAspects() throws URISyntaxException { .newValue(new DatasetProperties().setName("name5")) .auditStamp(new AuditStamp()) .request(req) - .build())); + .build()))); _aspectResource.ingestProposal(mcp, "false"); verify(_producer, times(5)) .produceMetadataChangeLog(eq(urn), any(AspectSpec.class), any(MetadataChangeLog.class)); diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java index 2c1596474fb21e..55373730e7b673 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/AspectUtils.java @@ -1,7 +1,6 @@ package com.linkedin.metadata.entity; import com.datahub.authentication.Authentication; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; @@ -12,18 +11,12 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; -import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; -import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.MetadataChangeLog; import com.linkedin.mxe.MetadataChangeProposal; -import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.joda.time.DateTimeUtils; @@ -33,66 +26,6 @@ public class AspectUtils { private AspectUtils() {} - public static final Set SUPPORTED_TYPES = - Set.of(ChangeType.UPSERT, ChangeType.CREATE, ChangeType.PATCH); - - public static List getAdditionalChanges( - @Nonnull MetadataChangeProposal metadataChangeProposal, - @Nonnull EntityService entityService, - boolean onPrimaryKeyInsertOnly) { - - // No additional changes for unsupported operations - if (!SUPPORTED_TYPES.contains(metadataChangeProposal.getChangeType())) { - return Collections.emptyList(); - } - - final Urn urn = - EntityKeyUtils.getUrnFromProposal( - metadataChangeProposal, - entityService.getKeyAspectSpec(metadataChangeProposal.getEntityType())); - - final Map includedAspects; - if (metadataChangeProposal.getChangeType() != ChangeType.PATCH) { - RecordTemplate aspectRecord = - GenericRecordUtils.deserializeAspect( - metadataChangeProposal.getAspect().getValue(), - metadataChangeProposal.getAspect().getContentType(), - entityService - .getEntityRegistry() - .getEntitySpec(urn.getEntityType()) - .getAspectSpec(metadataChangeProposal.getAspectName())); - includedAspects = ImmutableMap.of(metadataChangeProposal.getAspectName(), aspectRecord); - } else { - includedAspects = ImmutableMap.of(); - } - - if (onPrimaryKeyInsertOnly) { - return entityService - .generateDefaultAspectsOnFirstWrite(urn, includedAspects) - .getValue() - .stream() - .map( - entry -> - getProposalFromAspect(entry.getKey(), entry.getValue(), metadataChangeProposal)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } else { - return entityService.generateDefaultAspectsIfMissing(urn, includedAspects).stream() - .map( - entry -> - getProposalFromAspect(entry.getKey(), entry.getValue(), metadataChangeProposal)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } - } - - public static List getAdditionalChanges( - @Nonnull MetadataChangeProposal metadataChangeProposal, - @Nonnull EntityService entityService) { - - return getAdditionalChanges(metadataChangeProposal, entityService, false); - } - public static Map batchGetLatestAspect( String entity, Set urns, @@ -112,40 +45,6 @@ public static Map batchGetLatestAspect( return finalResult; } - private static MetadataChangeProposal getProposalFromAspect( - String aspectName, RecordTemplate aspect, MetadataChangeProposal original) { - MetadataChangeProposal proposal = new MetadataChangeProposal(); - GenericAspect genericAspect = GenericRecordUtils.serializeAspect(aspect); - // Set net new fields - proposal.setAspect(genericAspect); - proposal.setAspectName(aspectName); - - // Set fields determined from original - // Additional changes should never be set as PATCH, if a PATCH is coming across it should be an - // UPSERT - proposal.setChangeType(original.getChangeType()); - if (ChangeType.PATCH.equals(proposal.getChangeType())) { - proposal.setChangeType(ChangeType.UPSERT); - } - - if (original.getSystemMetadata() != null) { - proposal.setSystemMetadata(original.getSystemMetadata()); - } - if (original.getEntityUrn() != null) { - proposal.setEntityUrn(original.getEntityUrn()); - } - if (original.getEntityKeyAspect() != null) { - proposal.setEntityKeyAspect(original.getEntityKeyAspect()); - } - if (original.getAuditHeader() != null) { - proposal.setAuditHeader(original.getAuditHeader()); - } - - proposal.setEntityType(original.getEntityType()); - - return proposal; - } - public static MetadataChangeProposal buildMetadataChangeProposal( @Nonnull Urn urn, @Nonnull String aspectName, @Nonnull RecordTemplate aspect) { final MetadataChangeProposal proposal = new MetadataChangeProposal(); diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java index 94ab69e895920f..d9b0f4b73d5805 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/entity/EntityService.java @@ -1,8 +1,6 @@ package com.linkedin.metadata.entity; import com.linkedin.common.AuditStamp; -import com.linkedin.common.BrowsePaths; -import com.linkedin.common.BrowsePathsV2; import com.linkedin.common.VersionedUrn; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; @@ -255,34 +253,6 @@ Optional getAspectSpec( String getKeyAspectName(@Nonnull final Urn urn); - /** - * Generate default aspects if not present in the database. - * - * @param urn entity urn - * @param includedAspects aspects being written - * @return additional aspects to be written - */ - List> generateDefaultAspectsIfMissing( - @Nonnull final Urn urn, Map includedAspects); - - /** - * Generate default aspects if the entity key aspect is NOT in the database **AND** the key aspect - * is being written, present in `includedAspects`. - * - *

    Does not automatically create key aspects. - * - * @see EntityService#generateDefaultAspectsIfMissing if key aspects need autogeneration - *

    This version is more efficient in that it only generates additional writes when a new - * entity is being minted for the first time. The drawback is that it will not automatically - * add key aspects, in case the producer is not bothering to ensure that the entity exists - * before writing non-key aspects. - * @param urn entity urn - * @param includedAspects aspects being written - * @return whether key aspect exists in database and the additional aspects to be written - */ - Pair>> generateDefaultAspectsOnFirstWrite( - @Nonnull final Urn urn, Map includedAspects); - AspectSpec getKeyAspectSpec(@Nonnull final String entityName); Set getEntityAspectNames(final String entityName); @@ -339,17 +309,5 @@ default boolean exists(@Nonnull Urn urn, boolean includeSoftDelete) { void setWritable(boolean canWrite); - BrowsePaths buildDefaultBrowsePath(final @Nonnull Urn urn) throws URISyntaxException; - - /** - * Builds the default browse path V2 aspects for all entities. - * - *

    This method currently supports datasets, charts, dashboards, and data jobs best. Everything - * else will have a basic "Default" folder added to their browsePathV2. - */ - @Nonnull - BrowsePathsV2 buildDefaultBrowsePathV2(final @Nonnull Urn urn, boolean useContainerPaths) - throws URISyntaxException; - RecordTemplate getLatestAspect(@Nonnull final Urn urn, @Nonnull final String aspectName); } diff --git a/smoke-test/tests/cli/datahub_cli.py b/smoke-test/tests/cli/datahub_cli.py index 81ae6a34264ad6..d1620d03c88b24 100644 --- a/smoke-test/tests/cli/datahub_cli.py +++ b/smoke-test/tests/cli/datahub_cli.py @@ -24,8 +24,8 @@ def test_setup(): session, gms_host = get_session_and_host() - assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "browsePathsV2" not in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["browsePathsV2"], typed=False ) assert "editableDatasetProperties" not in get_aspects_for_entity( entity_urn=dataset_urn, aspects=["editableDatasetProperties"], typed=False @@ -36,8 +36,8 @@ def test_setup(): ).config.run_id print("Setup ingestion id: " + ingested_dataset_run_id) - assert "browsePaths" in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "browsePathsV2" in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["browsePathsV2"], typed=False ) yield @@ -58,8 +58,8 @@ def test_setup(): ), ) - assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "browsePathsV2" not in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["browsePathsV2"], typed=False ) assert "editableDatasetProperties" not in get_aspects_for_entity( entity_urn=dataset_urn, aspects=["editableDatasetProperties"], typed=False @@ -79,8 +79,8 @@ def test_rollback_editable(): print("Ingested dataset id:", ingested_dataset_run_id) # Assert that second data ingestion worked - assert "browsePaths" in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "browsePathsV2" in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["browsePathsV2"], typed=False ) # Make editable change @@ -111,6 +111,6 @@ def test_rollback_editable(): entity_urn=dataset_urn, aspects=["editableDatasetProperties"], typed=False ) # But first ingestion aspects should not be present - assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "browsePathsV2" not in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["browsePathsV2"], typed=False ) diff --git a/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js b/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js index f45edc5fa04819..d951b15d4a5920 100644 --- a/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js +++ b/smoke-test/tests/cypress/cypress/e2e/browse/browseV2.js @@ -111,6 +111,7 @@ describe("search", () => { ); }); + /* Legacy Browse Path Disabled when showBrowseV2 = `true` it("should take you to the old browse experience when clicking on browse path from entity profile page when browse flag is off", () => { setBrowseFeatureFlag(false); cy.login(); @@ -122,6 +123,7 @@ describe("search", () => { }); cy.url().should("include", "/browse/dataset/prod/bigquery/cypress_project"); }); +*/ it("should take you to the unified search and browse experience when clicking entity type on home page with the browse flag on", () => { setBrowseFeatureFlag(true); diff --git a/smoke-test/tests/delete/delete_test.py b/smoke-test/tests/delete/delete_test.py index 21833d0bd30a18..3a80e05d0cc4b5 100644 --- a/smoke-test/tests/delete/delete_test.py +++ b/smoke-test/tests/delete/delete_test.py @@ -41,8 +41,8 @@ def test_setup(): session, gms_host = get_session_and_host() try: - assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "institutionalMemory" not in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["institutionalMemory"], typed=False ) assert "editableDatasetProperties" not in get_aspects_for_entity( entity_urn=dataset_urn, aspects=["editableDatasetProperties"], typed=False @@ -55,8 +55,8 @@ def test_setup(): "tests/delete/cli_test_data.json" ).config.run_id - assert "browsePaths" in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "institutionalMemory" in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["institutionalMemory"], typed=False ) yield @@ -70,8 +70,8 @@ def test_setup(): wait_for_writes_to_sync() - assert "browsePaths" not in get_aspects_for_entity( - entity_urn=dataset_urn, aspects=["browsePaths"], typed=False + assert "institutionalMemory" not in get_aspects_for_entity( + entity_urn=dataset_urn, aspects=["institutionalMemory"], typed=False ) assert "editableDatasetProperties" not in get_aspects_for_entity( entity_urn=dataset_urn, aspects=["editableDatasetProperties"], typed=False From 88b5c063a4e85283f7a4a447b392809cbca7f2f6 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Thu, 1 Feb 2024 07:04:20 -0600 Subject: [PATCH 243/540] chore(compose): mysql compose healthcheck (#9754) --- docker/profiles/docker-compose.prerequisites.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/profiles/docker-compose.prerequisites.yml b/docker/profiles/docker-compose.prerequisites.yml index b587f811128fe5..47df17f3c5e46d 100644 --- a/docker/profiles/docker-compose.prerequisites.yml +++ b/docker/profiles/docker-compose.prerequisites.yml @@ -117,10 +117,10 @@ services: restart: on-failure healthcheck: test: mysqladmin ping -h mysql -u $$MYSQL_USER --password=$$MYSQL_PASSWORD - start_period: 10s - interval: 1s - retries: 3 - timeout: 5s + start_period: 20s + interval: 2s + timeout: 10s + retries: 5 volumes: - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql - mysqldata:/var/lib/mysql From d52818d489baf8e837bd8d748ac30e6c1d900db1 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Thu, 1 Feb 2024 07:44:01 -0600 Subject: [PATCH 244/540] chore(gradle): datahub-frontend docker dependency avoid yarnInstall (#9758) --- datahub-frontend/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datahub-frontend/build.gradle b/datahub-frontend/build.gradle index 1174c5c5cfd5d1..ab4ce405a55411 100644 --- a/datahub-frontend/build.gradle +++ b/datahub-frontend/build.gradle @@ -87,7 +87,7 @@ docker { } } -task unversionZip(type: Copy, dependsOn: [':datahub-web-react:build', dist]) { +task unversionZip(type: Copy, dependsOn: [':datahub-web-react:distZip', dist]) { from ("${buildDir}/distributions") include "datahub-frontend-${version}.zip" into "${buildDir}/docker/" From eb97120469693a3a78239825f0ad091eada015d1 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Thu, 1 Feb 2024 12:57:19 -0600 Subject: [PATCH 245/540] fix(search): fix default entities for aggregation filters (#9761) --- .../elasticsearch/query/ESSearchDAO.java | 6 +- .../request/AggregationQueryBuilder.java | 420 +++++++++++++++++- .../query/request/SearchRequestHandler.java | 391 +--------------- ...ySearchAggregationCandidateSourceTest.java | 15 +- .../request/AggregationQueryBuilderTest.java | 24 +- .../DomainsCandidateSourceFactory.java | 5 +- .../TopTagsCandidateSourceFactory.java | 5 +- .../TopTermsCandidateSourceFactory.java | 5 +- .../DomainsCandidateSource.java | 6 +- .../EntitySearchAggregationSource.java | 16 +- .../candidatesource/TopPlatformsSource.java | 7 +- .../candidatesource/TopTagsSource.java | 5 +- .../candidatesource/TopTermsSource.java | 5 +- .../metadata/search/utils/QueryUtils.java | 21 + 14 files changed, 510 insertions(+), 421 deletions(-) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java index 7de2770626ae34..76153a8d2adb3f 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESSearchDAO.java @@ -22,9 +22,11 @@ import com.linkedin.metadata.search.FilterValueArray; import com.linkedin.metadata.search.ScrollResult; import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.elasticsearch.query.request.AggregationQueryBuilder; import com.linkedin.metadata.search.elasticsearch.query.request.AutocompleteRequestHandler; import com.linkedin.metadata.search.elasticsearch.query.request.SearchAfterWrapper; import com.linkedin.metadata.search.elasticsearch.query.request.SearchRequestHandler; +import com.linkedin.metadata.search.utils.QueryUtils; import com.linkedin.metadata.utils.elasticsearch.IndexConvention; import com.linkedin.metadata.utils.metrics.MetricUtils; import io.opentelemetry.extension.annotations.WithSpan; @@ -317,7 +319,7 @@ public Map aggregateByValue( int limit) { List entitySpecs; if (entityNames == null || entityNames.isEmpty()) { - entitySpecs = new ArrayList<>(entityRegistry.getEntitySpecs().values()); + entitySpecs = QueryUtils.getQueryByDefaultEntitySpecs(entityRegistry); } else { entitySpecs = entityNames.stream().map(entityRegistry::getEntitySpec).collect(Collectors.toList()); @@ -341,7 +343,7 @@ public Map aggregateByValue( MetricUtils.timer(this.getClass(), "aggregateByValue_search").time()) { final SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT); // extract results, validated against document model as well - return SearchRequestHandler.extractAggregationsFromResponse(searchResponse, field); + return AggregationQueryBuilder.extractAggregationsFromResponse(searchResponse, field); } catch (Exception e) { log.error("Aggregation query failed", e); throw new ESQueryException("Aggregation query failed:", e); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java index bdc0332b040df9..887d4b22f37e24 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/AggregationQueryBuilder.java @@ -1,36 +1,71 @@ package com.linkedin.metadata.search.elasticsearch.query.request; import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.search.utils.ESUtils.toFacetField; import static com.linkedin.metadata.utils.SearchUtil.*; +import com.linkedin.data.template.LongMap; import com.linkedin.metadata.config.search.SearchConfiguration; +import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.StructuredPropertyUtils; import com.linkedin.metadata.models.annotation.SearchableAnnotation; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.search.AggregationMetadata; +import com.linkedin.metadata.search.FilterValueArray; import com.linkedin.metadata.search.utils.ESUtils; +import com.linkedin.metadata.utils.SearchUtil; +import com.linkedin.util.Pair; +import io.opentelemetry.extension.annotations.WithSpan; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.BinaryOperator; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.search.aggregations.Aggregation; import org.opensearch.search.aggregations.AggregationBuilder; import org.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.missing.ParsedMissing; +import org.opensearch.search.aggregations.bucket.terms.ParsedTerms; +import org.opensearch.search.aggregations.bucket.terms.Terms; @Slf4j public class AggregationQueryBuilder { + private static final String URN_FILTER = "urn"; - private final SearchConfiguration _configs; - private final Set _defaultFacetFields; - private final Set _allFacetFields; + private final SearchConfiguration configs; + private final Set defaultFacetFields; + private final Set allFacetFields; + private final Map> entitySearchAnnotations; + + private Map filtersToDisplayName; public AggregationQueryBuilder( @Nonnull final SearchConfiguration configs, - @Nonnull final List annotations) { - this._configs = Objects.requireNonNull(configs, "configs must not be null"); - this._defaultFacetFields = getDefaultFacetFields(annotations); - this._allFacetFields = getAllFacetFields(annotations); + @Nonnull Map> entitySearchAnnotations) { + this.configs = Objects.requireNonNull(configs, "configs must not be null"); + this.entitySearchAnnotations = entitySearchAnnotations; + + List annotations = + this.entitySearchAnnotations.values().stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + this.defaultFacetFields = getDefaultFacetFields(annotations); + this.allFacetFields = getAllFacetFields(annotations); } /** Get the set of default aggregations, across all facets. */ @@ -48,7 +83,7 @@ public List getAggregations(@Nullable List facets) { facetsToAggregate = facets.stream().filter(this::isValidAggregate).collect(Collectors.toSet()); } else { - facetsToAggregate = _defaultFacetFields; + facetsToAggregate = defaultFacetFields; } return facetsToAggregate.stream() .map(this::facetToAggregationBuilder) @@ -79,13 +114,13 @@ private boolean isValidAggregate(final String inputFacet) { !facets.isEmpty() && ((facets.size() == 1 && facets.get(0).startsWith(STRUCTURED_PROPERTY_MAPPING_FIELD + ".")) - || _allFacetFields.containsAll(facets)); + || allFacetFields.containsAll(facets)); if (!isValid) { log.warn( String.format( "Requested facet for search filter aggregations that isn't part of the filters. " + "Provided: %s; Available: %s", - inputFacet, _allFacetFields)); + inputFacet, allFacetFields)); } return isValid; } @@ -122,11 +157,11 @@ private AggregationBuilder facetToAggregationBuilder(final String inputFacet) { facet.equalsIgnoreCase(INDEX_VIRTUAL_FIELD) ? AggregationBuilders.terms(inputFacet) .field(getAggregationField("_index")) - .size(_configs.getMaxTermBucketSize()) + .size(configs.getMaxTermBucketSize()) .minDocCount(0) : AggregationBuilders.terms(inputFacet) .field(getAggregationField(facet)) - .size(_configs.getMaxTermBucketSize()); + .size(configs.getMaxTermBucketSize()); } if (lastAggBuilder != null) { aggBuilder = aggBuilder.subAggregation(lastAggBuilder); @@ -173,4 +208,365 @@ List getAllFacetFieldsFromAnnotation(final SearchableAnnotation annotati } return facetsFromAnnotation; } + + private String computeDisplayName(String name) { + if (getFacetToDisplayNames().containsKey(name)) { + return getFacetToDisplayNames().get(name); + } else if (name.contains(AGGREGATION_SEPARATOR_CHAR)) { + return Arrays.stream(name.split(AGGREGATION_SEPARATOR_CHAR)) + .map(i -> getFacetToDisplayNames().get(i)) + .collect(Collectors.joining(AGGREGATION_SEPARATOR_CHAR)); + } + return name; + } + + List extractAggregationMetadata( + @Nonnull SearchResponse searchResponse, @Nullable Filter filter) { + final List aggregationMetadataList = new ArrayList<>(); + if (searchResponse.getAggregations() == null) { + return addFiltersToAggregationMetadata(aggregationMetadataList, filter); + } + for (Map.Entry entry : + searchResponse.getAggregations().getAsMap().entrySet()) { + if (entry.getValue() instanceof ParsedTerms) { + processTermAggregations(entry, aggregationMetadataList); + } + if (entry.getValue() instanceof ParsedMissing) { + processMissingAggregations(entry, aggregationMetadataList); + } + } + return addFiltersToAggregationMetadata(aggregationMetadataList, filter); + } + + private void processTermAggregations( + final Map.Entry entry, + final List aggregationMetadataList) { + final Map oneTermAggResult = + extractTermAggregations( + (ParsedTerms) entry.getValue(), entry.getKey().equals(INDEX_VIRTUAL_FIELD)); + if (oneTermAggResult.isEmpty()) { + return; + } + final AggregationMetadata aggregationMetadata = + new AggregationMetadata() + .setName(entry.getKey()) + .setDisplayName(computeDisplayName(entry.getKey())) + .setAggregations(new LongMap(oneTermAggResult)) + .setFilterValues( + new FilterValueArray( + SearchUtil.convertToFilters(oneTermAggResult, Collections.emptySet()))); + aggregationMetadataList.add(aggregationMetadata); + } + + /** + * Adds nested sub-aggregation values to the aggregated results + * + * @param aggs The aggregations to traverse. Could be null (base case) + * @return A map from names to aggregation count values + */ + @Nonnull + private static Map recursivelyAddNestedSubAggs(@Nullable Aggregations aggs) { + final Map aggResult = new HashMap<>(); + + if (aggs != null) { + for (Map.Entry entry : aggs.getAsMap().entrySet()) { + if (entry.getValue() instanceof ParsedTerms) { + recurseTermsAgg((ParsedTerms) entry.getValue(), aggResult, false); + } else if (entry.getValue() instanceof ParsedMissing) { + recurseMissingAgg((ParsedMissing) entry.getValue(), aggResult); + } else { + throw new UnsupportedOperationException( + "Unsupported aggregation type: " + entry.getValue().getClass().getName()); + } + } + } + return aggResult; + } + + private static void recurseTermsAgg( + ParsedTerms terms, Map aggResult, boolean includeZeroes) { + List bucketList = terms.getBuckets(); + bucketList.forEach(bucket -> processTermBucket(bucket, aggResult, includeZeroes)); + } + + private static void processTermBucket( + Terms.Bucket bucket, Map aggResult, boolean includeZeroes) { + String key = bucket.getKeyAsString(); + // Gets filtered sub aggregation doc count if exist + Map subAggs = recursivelyAddNestedSubAggs(bucket.getAggregations()); + subAggs.forEach( + (entryKey, entryValue) -> + aggResult.put( + String.format("%s%s%s", key, AGGREGATION_SEPARATOR_CHAR, entryKey), entryValue)); + long docCount = bucket.getDocCount(); + if (includeZeroes || docCount > 0) { + aggResult.put(key, docCount); + } + } + + private static void recurseMissingAgg(ParsedMissing missing, Map aggResult) { + Map subAggs = recursivelyAddNestedSubAggs(missing.getAggregations()); + subAggs.forEach( + (key, value) -> + aggResult.put( + String.format("%s%s%s", missing.getName(), AGGREGATION_SEPARATOR_CHAR, key), + value)); + long docCount = missing.getDocCount(); + if (docCount > 0) { + aggResult.put(missing.getName(), docCount); + } + } + + /** + * Extracts term aggregations give a parsed term. + * + * @param terms an abstract parse term, input can be either ParsedStringTerms ParsedLongTerms + * @return a map with aggregation key and corresponding doc counts + */ + @Nonnull + private static Map extractTermAggregations( + @Nonnull ParsedTerms terms, boolean includeZeroes) { + + final Map aggResult = new HashMap<>(); + recurseTermsAgg(terms, aggResult, includeZeroes); + + return aggResult; + } + + /** Injects the missing conjunctive filters into the aggregations list. */ + public List addFiltersToAggregationMetadata( + @Nonnull final List originalMetadata, @Nullable final Filter filter) { + if (filter == null) { + return originalMetadata; + } + if (filter.getOr() != null) { + addOrFiltersToAggregationMetadata(filter.getOr(), originalMetadata); + } else if (filter.getCriteria() != null) { + addCriteriaFiltersToAggregationMetadata(filter.getCriteria(), originalMetadata); + } + return originalMetadata; + } + + void addOrFiltersToAggregationMetadata( + @Nonnull final ConjunctiveCriterionArray or, + @Nonnull final List originalMetadata) { + for (ConjunctiveCriterion conjunction : or) { + // For each item in the conjunction, inject an empty aggregation if necessary + addCriteriaFiltersToAggregationMetadata(conjunction.getAnd(), originalMetadata); + } + } + + private void addCriteriaFiltersToAggregationMetadata( + @Nonnull final CriterionArray criteria, + @Nonnull final List originalMetadata) { + for (Criterion criterion : criteria) { + addCriterionFiltersToAggregationMetadata(criterion, originalMetadata); + } + } + + private void addCriterionFiltersToAggregationMetadata( + @Nonnull final Criterion criterion, + @Nonnull final List aggregationMetadata) { + + // We should never see duplicate aggregation for the same field in aggregation metadata list. + final Map aggregationMetadataMap = + aggregationMetadata.stream() + .collect(Collectors.toMap(AggregationMetadata::getName, agg -> agg)); + + // Map a filter criterion to a facet field (e.g. domains.keyword -> domains) + final String finalFacetField = toFacetField(criterion.getField()); + + if (finalFacetField == null) { + log.warn( + String.format( + "Found invalid filter field for entity search. Invalid or unrecognized facet %s", + criterion.getField())); + return; + } + + // We don't want to add urn filters to the aggregations we return as a sidecar to search + // results. + // They are automatically added by searchAcrossLineage and we dont need them to show up in the + // filter panel. + if (finalFacetField.equals(URN_FILTER)) { + return; + } + + if (aggregationMetadataMap.containsKey(finalFacetField)) { + /* + * If we already have aggregations for the facet field, simply inject any missing values counts into the set. + * If there are no results for a particular facet value, it will NOT be in the original aggregation set returned by + * Elasticsearch. + */ + AggregationMetadata originalAggMetadata = aggregationMetadataMap.get(finalFacetField); + if (criterion.hasValues()) { + criterion + .getValues() + .forEach( + value -> + addMissingAggregationValueToAggregationMetadata(value, originalAggMetadata)); + } else { + addMissingAggregationValueToAggregationMetadata(criterion.getValue(), originalAggMetadata); + } + } else { + /* + * If we do not have ANY aggregation for the facet field, then inject a new aggregation metadata object for the + * facet field. + * If there are no results for a particular facet, it will NOT be in the original aggregation set returned by + * Elasticsearch. + */ + aggregationMetadata.add( + buildAggregationMetadata( + finalFacetField, + getFacetToDisplayNames().getOrDefault(finalFacetField, finalFacetField), + new LongMap( + criterion.getValues().stream().collect(Collectors.toMap(i -> i, i -> 0L))), + new FilterValueArray( + criterion.getValues().stream() + .map(value -> createFilterValue(value, 0L, true)) + .collect(Collectors.toList())))); + } + } + + private void addMissingAggregationValueToAggregationMetadata( + @Nonnull final String value, @Nonnull final AggregationMetadata originalMetadata) { + if (originalMetadata.getAggregations().entrySet().stream() + .noneMatch(entry -> value.equals(entry.getKey())) + || originalMetadata.getFilterValues().stream() + .noneMatch(entry -> entry.getValue().equals(value))) { + // No aggregation found for filtered value -- inject one! + originalMetadata.getAggregations().put(value, 0L); + originalMetadata.getFilterValues().add(createFilterValue(value, 0L, true)); + } + } + + private AggregationMetadata buildAggregationMetadata( + @Nonnull final String facetField, + @Nonnull final String displayName, + @Nonnull final LongMap aggValues, + @Nonnull final FilterValueArray filterValues) { + return new AggregationMetadata() + .setName(facetField) + .setDisplayName(displayName) + .setAggregations(aggValues) + .setFilterValues(filterValues); + } + + private List>> getFacetFieldDisplayNameFromAnnotation( + @Nonnull EntitySpec entitySpec, @Nonnull final SearchableAnnotation annotation) { + final List>> facetsFromAnnotation = new ArrayList<>(); + // Case 1: Default Keyword field + if (annotation.isAddToFilters()) { + facetsFromAnnotation.add( + Pair.of( + annotation.getFieldName(), + Pair.of(entitySpec.getName(), annotation.getFilterName()))); + } + // Case 2: HasX boolean field + if (annotation.isAddHasValuesToFilters() && annotation.getHasValuesFieldName().isPresent()) { + facetsFromAnnotation.add( + Pair.of( + annotation.getHasValuesFieldName().get(), + Pair.of(entitySpec.getName(), annotation.getHasValuesFilterName()))); + } + return facetsFromAnnotation; + } + + @WithSpan + public static Map extractAggregationsFromResponse( + @Nonnull SearchResponse searchResponse, @Nonnull String aggregationName) { + if (searchResponse.getAggregations() == null) { + return Collections.emptyMap(); + } + + Aggregation aggregation = searchResponse.getAggregations().get(aggregationName); + if (aggregation == null) { + return Collections.emptyMap(); + } + if (aggregation instanceof ParsedTerms) { + return extractTermAggregations( + (ParsedTerms) aggregation, aggregationName.equals("_entityType")); + } else if (aggregation instanceof ParsedMissing) { + return Collections.singletonMap( + aggregation.getName(), ((ParsedMissing) aggregation).getDocCount()); + } + throw new UnsupportedOperationException( + "Unsupported aggregation type: " + aggregation.getClass().getName()); + } + + /** + * Only used in aggregation queries, lazy load + * + * @return map of field name to facet display names + */ + private Map getFacetToDisplayNames() { + if (filtersToDisplayName == null) { + // Validate field names + Map>>> validateFieldMap = + entitySearchAnnotations.entrySet().stream() + .flatMap( + entry -> + entry.getValue().stream() + .flatMap( + annotation -> + getFacetFieldDisplayNameFromAnnotation(entry.getKey(), annotation) + .stream())) + .collect(Collectors.groupingBy(Pair::getFirst, Collectors.toSet())); + for (Map.Entry>>> entry : + validateFieldMap.entrySet()) { + if (entry.getValue().stream().map(i -> i.getSecond().getSecond()).distinct().count() > 1) { + Map>> displayNameEntityMap = + entry.getValue().stream() + .map(Pair::getSecond) + .collect(Collectors.groupingBy(Pair::getSecond, Collectors.toSet())); + throw new IllegalStateException( + String.format( + "Facet field collision on field `%s`. Incompatible Display Name across entities. Multiple Display Names detected: %s", + entry.getKey(), displayNameEntityMap)); + } + } + + filtersToDisplayName = + entitySearchAnnotations.entrySet().stream() + .flatMap( + entry -> + entry.getValue().stream() + .flatMap( + annotation -> + getFacetFieldDisplayNameFromAnnotation(entry.getKey(), annotation) + .stream())) + .collect( + Collectors.toMap(Pair::getFirst, p -> p.getSecond().getSecond(), mapMerger())); + filtersToDisplayName.put(INDEX_VIRTUAL_FIELD, "Type"); + } + + return filtersToDisplayName; + } + + private void processMissingAggregations( + final Map.Entry entry, + final List aggregationMetadataList) { + ParsedMissing parsedMissing = (ParsedMissing) entry.getValue(); + Long docCount = parsedMissing.getDocCount(); + LongMap longMap = new LongMap(); + longMap.put(entry.getKey(), docCount); + final AggregationMetadata aggregationMetadata = + new AggregationMetadata() + .setName(entry.getKey()) + .setDisplayName(computeDisplayName(entry.getKey())) + .setAggregations(longMap) + .setFilterValues( + new FilterValueArray(SearchUtil.convertToFilters(longMap, Collections.emptySet()))); + aggregationMetadataList.add(aggregationMetadata); + } + + // If values are not equal, throw error + private BinaryOperator mapMerger() { + return (s1, s2) -> { + if (!StringUtils.equals(s1, s2)) { + throw new IllegalStateException(String.format("Unable to merge values %s and %s", s1, s2)); + } + return s1; + }; + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java index 277e15e1334d56..3ac05ed122cd70 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchRequestHandler.java @@ -1,7 +1,6 @@ package com.linkedin.metadata.search.elasticsearch.query.request; import static com.linkedin.metadata.search.utils.ESUtils.NAME_SUGGESTION; -import static com.linkedin.metadata.search.utils.ESUtils.toFacetField; import static com.linkedin.metadata.search.utils.SearchUtils.applyDefaultSearchFlags; import static com.linkedin.metadata.utils.SearchUtil.*; @@ -10,22 +9,16 @@ import com.google.common.collect.ImmutableMap; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.DoubleMap; -import com.linkedin.data.template.LongMap; import com.linkedin.metadata.config.search.SearchConfiguration; import com.linkedin.metadata.config.search.custom.CustomSearchConfiguration; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.SearchableFieldSpec; import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.query.SearchFlags; -import com.linkedin.metadata.query.filter.ConjunctiveCriterion; -import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; -import com.linkedin.metadata.query.filter.Criterion; -import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.search.AggregationMetadata; import com.linkedin.metadata.search.AggregationMetadataArray; -import com.linkedin.metadata.search.FilterValueArray; import com.linkedin.metadata.search.MatchedField; import com.linkedin.metadata.search.MatchedFieldArray; import com.linkedin.metadata.search.ScrollResult; @@ -37,13 +30,11 @@ import com.linkedin.metadata.search.SearchSuggestionArray; import com.linkedin.metadata.search.features.Features; import com.linkedin.metadata.search.utils.ESUtils; -import com.linkedin.metadata.utils.SearchUtil; import com.linkedin.util.Pair; import io.opentelemetry.extension.annotations.WithSpan; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -51,13 +42,11 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BinaryOperator; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang.StringUtils; import org.opensearch.action.search.SearchRequest; import org.opensearch.action.search.SearchResponse; import org.opensearch.common.text.Text; @@ -66,12 +55,7 @@ import org.opensearch.index.query.QueryBuilder; import org.opensearch.index.query.QueryBuilders; import org.opensearch.search.SearchHit; -import org.opensearch.search.aggregations.Aggregation; import org.opensearch.search.aggregations.AggregationBuilders; -import org.opensearch.search.aggregations.Aggregations; -import org.opensearch.search.aggregations.bucket.missing.ParsedMissing; -import org.opensearch.search.aggregations.bucket.terms.ParsedTerms; -import org.opensearch.search.aggregations.bucket.terms.Terms; import org.opensearch.search.builder.SearchSourceBuilder; import org.opensearch.search.fetch.subphase.highlight.HighlightBuilder; import org.opensearch.search.fetch.subphase.highlight.HighlightField; @@ -88,11 +72,9 @@ public class SearchRequestHandler { .setSkipHighlighting(false); private static final Map, SearchRequestHandler> REQUEST_HANDLER_BY_ENTITY_NAME = new ConcurrentHashMap<>(); - private static final String URN_FILTER = "urn"; private final List _entitySpecs; private final Set _defaultQueryFieldNames; private final HighlightBuilder _highlights; - private final Map _filtersToDisplayName; private final SearchConfiguration _configs; private final SearchQueryBuilder _searchQueryBuilder; @@ -111,16 +93,16 @@ private SearchRequestHandler( @Nonnull SearchConfiguration configs, @Nullable CustomSearchConfiguration customSearchConfiguration) { _entitySpecs = entitySpecs; - List annotations = getSearchableAnnotations(); + Map> entitySearchAnnotations = + getSearchableAnnotations(); + List annotations = + entitySearchAnnotations.values().stream() + .flatMap(List::stream) + .collect(Collectors.toList()); _defaultQueryFieldNames = getDefaultQueryFieldNames(annotations); - _filtersToDisplayName = - annotations.stream() - .flatMap(annotation -> getFacetFieldDisplayNameFromAnnotation(annotation).stream()) - .collect(Collectors.toMap(Pair::getFirst, Pair::getSecond, mapMerger())); - _filtersToDisplayName.put(INDEX_VIRTUAL_FIELD, "Type"); _highlights = getHighlights(); _searchQueryBuilder = new SearchQueryBuilder(configs, customSearchConfiguration); - _aggregationQueryBuilder = new AggregationQueryBuilder(configs, annotations); + _aggregationQueryBuilder = new AggregationQueryBuilder(configs, entitySearchAnnotations); _configs = configs; searchableFieldTypes = _entitySpecs.stream() @@ -153,12 +135,16 @@ public static SearchRequestHandler getBuilder( k -> new SearchRequestHandler(entitySpecs, configs, customSearchConfiguration)); } - private List getSearchableAnnotations() { + private Map> getSearchableAnnotations() { return _entitySpecs.stream() - .map(EntitySpec::getSearchableFieldSpecs) - .flatMap(List::stream) - .map(SearchableFieldSpec::getSearchableAnnotation) - .collect(Collectors.toList()); + .map( + spec -> + Pair.of( + spec, + spec.getSearchableFieldSpecs().stream() + .map(SearchableFieldSpec::getSearchableAnnotation) + .collect(Collectors.toList()))) + .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); } @VisibleForTesting @@ -171,16 +157,6 @@ private Set getDefaultQueryFieldNames(List annotat .collect(Collectors.toSet()); } - // If values are not equal, throw error - private BinaryOperator mapMerger() { - return (s1, s2) -> { - if (!StringUtils.equals(s1, s2)) { - throw new IllegalStateException(String.format("Unable to merge values %s and %s", s1, s2)); - } - return s1; - }; - } - public BoolQueryBuilder getFilterQuery(@Nullable Filter filter) { return getFilterQuery(filter, searchableFieldTypes); } @@ -327,42 +303,6 @@ public SearchRequest getFilterRequest( return searchRequest; } - /** - * Returns a {@link SearchRequest} given filters to be applied to search query and sort criterion - * to be applied to search results. - * - *

    TODO: Used in batch ingestion from ingestion scheduler - * - * @param filters {@link Filter} list of conditions with fields and values - * @param sortCriterion {@link SortCriterion} to be applied to the search results - * @param sort sort values from last result of previous request - * @param pitId the Point In Time Id of the previous request - * @param keepAlive string representation of time to keep point in time alive - * @param size the number of search hits to return - * @return {@link SearchRequest} that contains the filtered query - */ - @Nonnull - public SearchRequest getFilterRequest( - @Nullable Filter filters, - @Nullable SortCriterion sortCriterion, - @Nullable Object[] sort, - @Nullable String pitId, - @Nonnull String keepAlive, - int size) { - SearchRequest searchRequest = new SearchRequest(); - - BoolQueryBuilder filterQuery = getFilterQuery(filters); - final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - searchSourceBuilder.query(filterQuery); - searchSourceBuilder.size(size); - - ESUtils.setSearchAfter(searchSourceBuilder, sort, pitId, keepAlive); - ESUtils.buildSortOrder(searchSourceBuilder, sortCriterion, _entitySpecs); - searchRequest.source(searchSourceBuilder); - - return searchRequest; - } - /** * Get search request to aggregate and get document counts per field value * @@ -558,7 +498,7 @@ private SearchResultMetadata extractSearchResultMetadata( new SearchResultMetadata().setAggregations(new AggregationMetadataArray()); final List aggregationMetadataList = - extractAggregationMetadata(searchResponse, filter); + _aggregationQueryBuilder.extractAggregationMetadata(searchResponse, filter); searchResultMetadata.setAggregations(new AggregationMetadataArray(aggregationMetadataList)); final List searchSuggestions = extractSearchSuggestions(searchResponse); @@ -588,301 +528,4 @@ private List extractSearchSuggestions(@Nonnull SearchResponse } return searchSuggestions; } - - private String computeDisplayName(String name) { - if (_filtersToDisplayName.containsKey(name)) { - return _filtersToDisplayName.get(name); - } else if (name.contains(AGGREGATION_SEPARATOR_CHAR)) { - return Arrays.stream(name.split(AGGREGATION_SEPARATOR_CHAR)) - .map(_filtersToDisplayName::get) - .collect(Collectors.joining(AGGREGATION_SEPARATOR_CHAR)); - } - return name; - } - - private List extractAggregationMetadata( - @Nonnull SearchResponse searchResponse, @Nullable Filter filter) { - final List aggregationMetadataList = new ArrayList<>(); - if (searchResponse.getAggregations() == null) { - return addFiltersToAggregationMetadata(aggregationMetadataList, filter); - } - for (Map.Entry entry : - searchResponse.getAggregations().getAsMap().entrySet()) { - if (entry.getValue() instanceof ParsedTerms) { - processTermAggregations(entry, aggregationMetadataList); - } - if (entry.getValue() instanceof ParsedMissing) { - processMissingAggregations(entry, aggregationMetadataList); - } - } - return addFiltersToAggregationMetadata(aggregationMetadataList, filter); - } - - private void processTermAggregations( - final Map.Entry entry, - final List aggregationMetadataList) { - final Map oneTermAggResult = - extractTermAggregations( - (ParsedTerms) entry.getValue(), entry.getKey().equals(INDEX_VIRTUAL_FIELD)); - if (oneTermAggResult.isEmpty()) { - return; - } - final AggregationMetadata aggregationMetadata = - new AggregationMetadata() - .setName(entry.getKey()) - .setDisplayName(computeDisplayName(entry.getKey())) - .setAggregations(new LongMap(oneTermAggResult)) - .setFilterValues( - new FilterValueArray( - SearchUtil.convertToFilters(oneTermAggResult, Collections.emptySet()))); - aggregationMetadataList.add(aggregationMetadata); - } - - private void processMissingAggregations( - final Map.Entry entry, - final List aggregationMetadataList) { - ParsedMissing parsedMissing = (ParsedMissing) entry.getValue(); - Long docCount = parsedMissing.getDocCount(); - LongMap longMap = new LongMap(); - longMap.put(entry.getKey(), docCount); - final AggregationMetadata aggregationMetadata = - new AggregationMetadata() - .setName(entry.getKey()) - .setDisplayName(computeDisplayName(entry.getKey())) - .setAggregations(longMap) - .setFilterValues( - new FilterValueArray(SearchUtil.convertToFilters(longMap, Collections.emptySet()))); - aggregationMetadataList.add(aggregationMetadata); - } - - @WithSpan - public static Map extractAggregationsFromResponse( - @Nonnull SearchResponse searchResponse, @Nonnull String aggregationName) { - if (searchResponse.getAggregations() == null) { - return Collections.emptyMap(); - } - - Aggregation aggregation = searchResponse.getAggregations().get(aggregationName); - if (aggregation == null) { - return Collections.emptyMap(); - } - if (aggregation instanceof ParsedTerms) { - return extractTermAggregations( - (ParsedTerms) aggregation, aggregationName.equals("_entityType")); - } else if (aggregation instanceof ParsedMissing) { - return Collections.singletonMap( - aggregation.getName(), ((ParsedMissing) aggregation).getDocCount()); - } - throw new UnsupportedOperationException( - "Unsupported aggregation type: " + aggregation.getClass().getName()); - } - - /** - * Adds nested sub-aggregation values to the aggregated results - * - * @param aggs The aggregations to traverse. Could be null (base case) - * @return A map from names to aggregation count values - */ - @Nonnull - private static Map recursivelyAddNestedSubAggs(@Nullable Aggregations aggs) { - final Map aggResult = new HashMap<>(); - - if (aggs != null) { - for (Map.Entry entry : aggs.getAsMap().entrySet()) { - if (entry.getValue() instanceof ParsedTerms) { - recurseTermsAgg((ParsedTerms) entry.getValue(), aggResult, false); - } else if (entry.getValue() instanceof ParsedMissing) { - recurseMissingAgg((ParsedMissing) entry.getValue(), aggResult); - } else { - throw new UnsupportedOperationException( - "Unsupported aggregation type: " + entry.getValue().getClass().getName()); - } - } - } - return aggResult; - } - - private static void recurseTermsAgg( - ParsedTerms terms, Map aggResult, boolean includeZeroes) { - List bucketList = terms.getBuckets(); - bucketList.forEach(bucket -> processTermBucket(bucket, aggResult, includeZeroes)); - } - - private static void processTermBucket( - Terms.Bucket bucket, Map aggResult, boolean includeZeroes) { - String key = bucket.getKeyAsString(); - // Gets filtered sub aggregation doc count if exist - Map subAggs = recursivelyAddNestedSubAggs(bucket.getAggregations()); - subAggs.forEach( - (entryKey, entryValue) -> - aggResult.put( - String.format("%s%s%s", key, AGGREGATION_SEPARATOR_CHAR, entryKey), entryValue)); - long docCount = bucket.getDocCount(); - if (includeZeroes || docCount > 0) { - aggResult.put(key, docCount); - } - } - - private static void recurseMissingAgg(ParsedMissing missing, Map aggResult) { - Map subAggs = recursivelyAddNestedSubAggs(missing.getAggregations()); - subAggs.forEach( - (key, value) -> - aggResult.put( - String.format("%s%s%s", missing.getName(), AGGREGATION_SEPARATOR_CHAR, key), - value)); - long docCount = missing.getDocCount(); - if (docCount > 0) { - aggResult.put(missing.getName(), docCount); - } - } - - /** - * Extracts term aggregations give a parsed term. - * - * @param terms an abstract parse term, input can be either ParsedStringTerms ParsedLongTerms - * @return a map with aggregation key and corresponding doc counts - */ - @Nonnull - private static Map extractTermAggregations( - @Nonnull ParsedTerms terms, boolean includeZeroes) { - - final Map aggResult = new HashMap<>(); - recurseTermsAgg(terms, aggResult, includeZeroes); - - return aggResult; - } - - /** Injects the missing conjunctive filters into the aggregations list. */ - public List addFiltersToAggregationMetadata( - @Nonnull final List originalMetadata, @Nullable final Filter filter) { - if (filter == null) { - return originalMetadata; - } - if (filter.getOr() != null) { - addOrFiltersToAggregationMetadata(filter.getOr(), originalMetadata); - } else if (filter.getCriteria() != null) { - addCriteriaFiltersToAggregationMetadata(filter.getCriteria(), originalMetadata); - } - return originalMetadata; - } - - void addOrFiltersToAggregationMetadata( - @Nonnull final ConjunctiveCriterionArray or, - @Nonnull final List originalMetadata) { - for (ConjunctiveCriterion conjunction : or) { - // For each item in the conjunction, inject an empty aggregation if necessary - addCriteriaFiltersToAggregationMetadata(conjunction.getAnd(), originalMetadata); - } - } - - private void addCriteriaFiltersToAggregationMetadata( - @Nonnull final CriterionArray criteria, - @Nonnull final List originalMetadata) { - for (Criterion criterion : criteria) { - addCriterionFiltersToAggregationMetadata(criterion, originalMetadata); - } - } - - private void addCriterionFiltersToAggregationMetadata( - @Nonnull final Criterion criterion, - @Nonnull final List aggregationMetadata) { - - // We should never see duplicate aggregation for the same field in aggregation metadata list. - final Map aggregationMetadataMap = - aggregationMetadata.stream() - .collect(Collectors.toMap(AggregationMetadata::getName, agg -> agg)); - - // Map a filter criterion to a facet field (e.g. domains.keyword -> domains) - final String finalFacetField = toFacetField(criterion.getField()); - - if (finalFacetField == null) { - log.warn( - String.format( - "Found invalid filter field for entity search. Invalid or unrecognized facet %s", - criterion.getField())); - return; - } - - // We don't want to add urn filters to the aggregations we return as a sidecar to search - // results. - // They are automatically added by searchAcrossLineage and we dont need them to show up in the - // filter panel. - if (finalFacetField.equals(URN_FILTER)) { - return; - } - - if (aggregationMetadataMap.containsKey(finalFacetField)) { - /* - * If we already have aggregations for the facet field, simply inject any missing values counts into the set. - * If there are no results for a particular facet value, it will NOT be in the original aggregation set returned by - * Elasticsearch. - */ - AggregationMetadata originalAggMetadata = aggregationMetadataMap.get(finalFacetField); - if (criterion.hasValues()) { - criterion - .getValues() - .forEach( - value -> - addMissingAggregationValueToAggregationMetadata(value, originalAggMetadata)); - } else { - addMissingAggregationValueToAggregationMetadata(criterion.getValue(), originalAggMetadata); - } - } else { - /* - * If we do not have ANY aggregation for the facet field, then inject a new aggregation metadata object for the - * facet field. - * If there are no results for a particular facet, it will NOT be in the original aggregation set returned by - * Elasticsearch. - */ - aggregationMetadata.add( - buildAggregationMetadata( - finalFacetField, - _filtersToDisplayName.getOrDefault(finalFacetField, finalFacetField), - new LongMap( - criterion.getValues().stream().collect(Collectors.toMap(i -> i, i -> 0L))), - new FilterValueArray( - criterion.getValues().stream() - .map(value -> createFilterValue(value, 0L, true)) - .collect(Collectors.toList())))); - } - } - - private void addMissingAggregationValueToAggregationMetadata( - @Nonnull final String value, @Nonnull final AggregationMetadata originalMetadata) { - if (originalMetadata.getAggregations().entrySet().stream() - .noneMatch(entry -> value.equals(entry.getKey())) - || originalMetadata.getFilterValues().stream() - .noneMatch(entry -> entry.getValue().equals(value))) { - // No aggregation found for filtered value -- inject one! - originalMetadata.getAggregations().put(value, 0L); - originalMetadata.getFilterValues().add(createFilterValue(value, 0L, true)); - } - } - - private AggregationMetadata buildAggregationMetadata( - @Nonnull final String facetField, - @Nonnull final String displayName, - @Nonnull final LongMap aggValues, - @Nonnull final FilterValueArray filterValues) { - return new AggregationMetadata() - .setName(facetField) - .setDisplayName(displayName) - .setAggregations(aggValues) - .setFilterValues(filterValues); - } - - private List> getFacetFieldDisplayNameFromAnnotation( - @Nonnull final SearchableAnnotation annotation) { - final List> facetsFromAnnotation = new ArrayList<>(); - // Case 1: Default Keyword field - if (annotation.isAddToFilters()) { - facetsFromAnnotation.add(Pair.of(annotation.getFieldName(), annotation.getFilterName())); - } - // Case 2: HasX boolean field - if (annotation.isAddHasValuesToFilters() && annotation.getHasValuesFieldName().isPresent()) { - facetsFromAnnotation.add( - Pair.of(annotation.getHasValuesFieldName().get(), annotation.getHasValuesFilterName())); - } - return facetsFromAnnotation; - } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationCandidateSourceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationCandidateSourceTest.java index dcc59d06329544..2d60f3202b69f5 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationCandidateSourceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationCandidateSourceTest.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.recommendation.candidatesource; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.assertEquals; @@ -11,6 +12,7 @@ import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.TestEntityUrn; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Criterion; import com.linkedin.metadata.recommendation.RecommendationContent; import com.linkedin.metadata.recommendation.RecommendationParams; @@ -29,6 +31,7 @@ public class EntitySearchAggregationCandidateSourceTest { private EntitySearchService _entitySearchService = Mockito.mock(EntitySearchService.class); + private EntityRegistry entityRegistry = Mockito.mock(EntityRegistry.class); private EntitySearchAggregationSource _valueBasedCandidateSource; private EntitySearchAggregationSource _urnBasedCandidateSource; @@ -45,7 +48,7 @@ public void setup() { private EntitySearchAggregationSource buildCandidateSource( String identifier, boolean isValueUrn) { - return new EntitySearchAggregationSource(_entitySearchService) { + return new EntitySearchAggregationSource(_entitySearchService, entityRegistry) { @Override protected String getSearchFieldName() { return identifier; @@ -98,8 +101,7 @@ public void testWhenSearchServiceReturnsEmpty() { @Test public void testWhenSearchServiceReturnsValueResults() { // One result - Mockito.when( - _entitySearchService.aggregateByValue(eq(null), eq("testValue"), eq(null), anyInt())) + Mockito.when(_entitySearchService.aggregateByValue(any(), eq("testValue"), eq(null), anyInt())) .thenReturn(ImmutableMap.of("value1", 1L)); List candidates = _valueBasedCandidateSource.getRecommendations(USER, CONTEXT); @@ -120,8 +122,7 @@ public void testWhenSearchServiceReturnsValueResults() { assertTrue(_valueBasedCandidateSource.getRecommendationModule(USER, CONTEXT).isPresent()); // Multiple result - Mockito.when( - _entitySearchService.aggregateByValue(eq(null), eq("testValue"), eq(null), anyInt())) + Mockito.when(_entitySearchService.aggregateByValue(any(), eq("testValue"), eq(null), anyInt())) .thenReturn(ImmutableMap.of("value1", 1L, "value2", 2L, "value3", 3L)); candidates = _valueBasedCandidateSource.getRecommendations(USER, CONTEXT); assertEquals(candidates.size(), 2); @@ -160,7 +161,7 @@ public void testWhenSearchServiceReturnsUrnResults() { Urn testUrn1 = new TestEntityUrn("testUrn1", "testUrn1", "testUrn1"); Urn testUrn2 = new TestEntityUrn("testUrn2", "testUrn2", "testUrn2"); Urn testUrn3 = new TestEntityUrn("testUrn3", "testUrn3", "testUrn3"); - Mockito.when(_entitySearchService.aggregateByValue(eq(null), eq("testUrn"), eq(null), anyInt())) + Mockito.when(_entitySearchService.aggregateByValue(any(), eq("testUrn"), eq(null), anyInt())) .thenReturn(ImmutableMap.of(testUrn1.toString(), 1L)); List candidates = _urnBasedCandidateSource.getRecommendations(USER, CONTEXT); @@ -181,7 +182,7 @@ public void testWhenSearchServiceReturnsUrnResults() { assertTrue(_urnBasedCandidateSource.getRecommendationModule(USER, CONTEXT).isPresent()); // Multiple result - Mockito.when(_entitySearchService.aggregateByValue(eq(null), eq("testUrn"), eq(null), anyInt())) + Mockito.when(_entitySearchService.aggregateByValue(any(), eq("testUrn"), eq(null), anyInt())) .thenReturn( ImmutableMap.of( testUrn1.toString(), 1L, testUrn2.toString(), 2L, testUrn3.toString(), 3L)); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java index 9e8855622ced4b..ed4c9db5db6430 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/search/query/request/AggregationQueryBuilderTest.java @@ -1,10 +1,13 @@ package com.linkedin.metadata.search.query.request; import static com.linkedin.metadata.utils.SearchUtil.*; +import static org.mockito.Mockito.mock; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.linkedin.metadata.config.search.SearchConfiguration; +import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.search.elasticsearch.query.request.AggregationQueryBuilder; import java.util.Collections; @@ -42,7 +45,8 @@ public void testGetDefaultAggregationsHasFields() { config.setMaxTermBucketSize(25); AggregationQueryBuilder builder = - new AggregationQueryBuilder(config, ImmutableList.of(annotation)); + new AggregationQueryBuilder( + config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of(annotation))); List aggs = builder.getAggregations(); @@ -73,7 +77,8 @@ public void testGetDefaultAggregationsFields() { config.setMaxTermBucketSize(25); AggregationQueryBuilder builder = - new AggregationQueryBuilder(config, ImmutableList.of(annotation)); + new AggregationQueryBuilder( + config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of(annotation))); List aggs = builder.getAggregations(); @@ -120,7 +125,9 @@ public void testGetSpecificAggregationsHasFields() { config.setMaxTermBucketSize(25); AggregationQueryBuilder builder = - new AggregationQueryBuilder(config, ImmutableList.of(annotation1, annotation2)); + new AggregationQueryBuilder( + config, + ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of(annotation1, annotation2))); // Case 1: Ask for fields that should exist. List aggs = @@ -139,7 +146,9 @@ public void testAggregateOverStructuredProperty() { SearchConfiguration config = new SearchConfiguration(); config.setMaxTermBucketSize(25); - AggregationQueryBuilder builder = new AggregationQueryBuilder(config, List.of()); + AggregationQueryBuilder builder = + new AggregationQueryBuilder( + config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of())); List aggs = builder.getAggregations(List.of("structuredProperties.ab.fgh.ten")); @@ -202,7 +211,9 @@ public void testAggregateOverFieldsAndStructProp() { config.setMaxTermBucketSize(25); AggregationQueryBuilder builder = - new AggregationQueryBuilder(config, ImmutableList.of(annotation1, annotation2)); + new AggregationQueryBuilder( + config, + ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of(annotation1, annotation2))); // Aggregate over fields and structured properties List aggs = @@ -252,7 +263,8 @@ public void testMissingAggregation() { config.setMaxTermBucketSize(25); AggregationQueryBuilder builder = - new AggregationQueryBuilder(config, ImmutableList.of(annotation)); + new AggregationQueryBuilder( + config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of(annotation))); List aggs = builder.getAggregations(); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/DomainsCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/DomainsCandidateSourceFactory.java index fbfd80f85ff4d2..a7c2dde8b7d25e 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/DomainsCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/DomainsCandidateSourceFactory.java @@ -1,6 +1,7 @@ package com.linkedin.gms.factory.recommendation.candidatesource; import com.linkedin.gms.factory.search.EntitySearchServiceFactory; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.recommendation.candidatesource.DomainsCandidateSource; import com.linkedin.metadata.search.EntitySearchService; import javax.annotation.Nonnull; @@ -20,7 +21,7 @@ public class DomainsCandidateSourceFactory { @Bean(name = "domainsCandidateSource") @Nonnull - protected DomainsCandidateSource getInstance() { - return new DomainsCandidateSource(entitySearchService); + protected DomainsCandidateSource getInstance(final EntityRegistry entityRegistry) { + return new DomainsCandidateSource(entitySearchService, entityRegistry); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTagsCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTagsCandidateSourceFactory.java index fe5c2d03d19071..bc2520c2b4617d 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTagsCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTagsCandidateSourceFactory.java @@ -1,6 +1,7 @@ package com.linkedin.gms.factory.recommendation.candidatesource; import com.linkedin.gms.factory.search.EntitySearchServiceFactory; +import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.recommendation.candidatesource.TopTagsSource; import com.linkedin.metadata.search.EntitySearchService; import javax.annotation.Nonnull; @@ -20,7 +21,7 @@ public class TopTagsCandidateSourceFactory { @Bean(name = "topTagsCandidateSource") @Nonnull - protected TopTagsSource getInstance() { - return new TopTagsSource(entitySearchService); + protected TopTagsSource getInstance(final EntityService entityService) { + return new TopTagsSource(entitySearchService, entityService); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTermsCandidateSourceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTermsCandidateSourceFactory.java index 36c53936094ff5..c8ad276eb3d862 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTermsCandidateSourceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/recommendation/candidatesource/TopTermsCandidateSourceFactory.java @@ -1,6 +1,7 @@ package com.linkedin.gms.factory.recommendation.candidatesource; import com.linkedin.gms.factory.search.EntitySearchServiceFactory; +import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.recommendation.candidatesource.TopTermsSource; import com.linkedin.metadata.search.EntitySearchService; import javax.annotation.Nonnull; @@ -20,7 +21,7 @@ public class TopTermsCandidateSourceFactory { @Bean(name = "topTermsCandidateSource") @Nonnull - protected TopTermsSource getInstance() { - return new TopTermsSource(entitySearchService); + protected TopTermsSource getInstance(final EntityService entityService) { + return new TopTermsSource(entitySearchService, entityService); } } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/DomainsCandidateSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/DomainsCandidateSource.java index 9392f50b4749eb..e34fa8ff1bde57 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/DomainsCandidateSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/DomainsCandidateSource.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.recommendation.candidatesource; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -13,8 +14,9 @@ public class DomainsCandidateSource extends EntitySearchAggregationSource { private static final String DOMAINS = "domains"; - public DomainsCandidateSource(EntitySearchService entitySearchService) { - super(entitySearchService); + public DomainsCandidateSource( + EntitySearchService entitySearchService, EntityRegistry entityRegistry) { + super(entitySearchService, entityRegistry); } @Override diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationSource.java index a19909576d25ba..8d6ccb22660fb2 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/EntitySearchAggregationSource.java @@ -2,6 +2,8 @@ import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Criterion; import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.recommendation.ContentParams; @@ -10,6 +12,7 @@ import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.SearchParams; import com.linkedin.metadata.search.EntitySearchService; +import com.linkedin.metadata.search.utils.QueryUtils; import io.opentelemetry.extension.annotations.WithSpan; import java.net.URISyntaxException; import java.util.Collections; @@ -35,7 +38,8 @@ @Slf4j @RequiredArgsConstructor public abstract class EntitySearchAggregationSource implements RecommendationSource { - private final EntitySearchService _entitySearchService; + private final EntitySearchService entitySearchService; + private final EntityRegistry entityRegistry; /** Field to aggregate on */ protected abstract String getSearchFieldName(); @@ -69,8 +73,8 @@ protected boolean isValidCandidate(T candidate) { public List getRecommendations( @Nonnull Urn userUrn, @Nullable RecommendationRequestContext requestContext) { Map aggregationResult = - _entitySearchService.aggregateByValue( - getEntityNames(), getSearchFieldName(), null, getMaxContent()); + entitySearchService.aggregateByValue( + getEntityNames(entityRegistry), getSearchFieldName(), null, getMaxContent()); if (aggregationResult.isEmpty()) { return Collections.emptyList(); @@ -110,9 +114,11 @@ public List getRecommendations( .collect(Collectors.toList()); } - protected List getEntityNames() { + protected List getEntityNames(EntityRegistry entityRegistry) { // By default, no list is applied which means searching across entities. - return null; + return QueryUtils.getQueryByDefaultEntitySpecs(entityRegistry).stream() + .map(EntitySpec::getName) + .collect(Collectors.toList()); } // Get top K entries with the most count diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopPlatformsSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopPlatformsSource.java index 3012e35baa607a..aecd9bbbf769c3 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopPlatformsSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopPlatformsSource.java @@ -37,11 +37,12 @@ public class TopPlatformsSource extends EntitySearchAggregationSource { Constants.CONTAINER_ENTITY_NAME, Constants.NOTEBOOK_ENTITY_NAME); - private final EntityService _entityService; + private final EntityService _entityService; private static final String PLATFORM = "platform"; - public TopPlatformsSource(EntityService entityService, EntitySearchService entitySearchService) { - super(entitySearchService); + public TopPlatformsSource( + EntityService entityService, EntitySearchService entitySearchService) { + super(entitySearchService, entityService.getEntityRegistry()); _entityService = entityService; } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTagsSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTagsSource.java index 317f956e1ca8ab..0897d441335fac 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTagsSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTagsSource.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.recommendation.candidatesource; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -13,8 +14,8 @@ public class TopTagsSource extends EntitySearchAggregationSource { private static final String TAGS = "tags"; - public TopTagsSource(EntitySearchService entitySearchService) { - super(entitySearchService); + public TopTagsSource(EntitySearchService entitySearchService, EntityService entityService) { + super(entitySearchService, entityService.getEntityRegistry()); } @Override diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTermsSource.java b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTermsSource.java index 6cdb5fdb659113..0fab9a28b51ea4 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTermsSource.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/recommendation/candidatesource/TopTermsSource.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.recommendation.candidatesource; import com.linkedin.common.urn.Urn; +import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.recommendation.RecommendationRenderType; import com.linkedin.metadata.recommendation.RecommendationRequestContext; import com.linkedin.metadata.recommendation.ScenarioType; @@ -13,8 +14,8 @@ public class TopTermsSource extends EntitySearchAggregationSource { private static final String TERMS = "glossaryTerms"; - public TopTermsSource(EntitySearchService entitySearchService) { - super(entitySearchService); + public TopTermsSource(EntitySearchService entitySearchService, EntityService entityService) { + super(entitySearchService, entityService.getEntityRegistry()); } @Override diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java index 842cc51e117775..a148a45b20e0c7 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java @@ -7,6 +7,10 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringArray; import com.linkedin.metadata.aspect.AspectVersion; +import com.linkedin.metadata.models.EntitySpec; +import com.linkedin.metadata.models.SearchableFieldSpec; +import com.linkedin.metadata.models.annotation.SearchableAnnotation; +import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; @@ -15,6 +19,7 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.RelationshipDirection; import com.linkedin.metadata.query.filter.RelationshipFilter; +import com.linkedin.util.Pair; import java.util.Collections; import java.util.List; import java.util.Map; @@ -174,4 +179,20 @@ public static Filter getFilterFromCriteria(List criteria) { new ConjunctiveCriterionArray( new ConjunctiveCriterion().setAnd(new CriterionArray(criteria)))); } + + public static List getQueryByDefaultEntitySpecs(EntityRegistry entityRegistry) { + return entityRegistry.getEntitySpecs().values().stream() + .map( + spec -> + Pair.of( + spec, + spec.getSearchableFieldSpecs().stream() + .map(SearchableFieldSpec::getSearchableAnnotation) + .collect(Collectors.toList()))) + .filter( + specPair -> + specPair.getSecond().stream().anyMatch(SearchableAnnotation::isQueryByDefault)) + .map(Pair::getFirst) + .collect(Collectors.toList()); + } } From 533130408a28c036f4bdf4c2d7289311d28bf906 Mon Sep 17 00:00:00 2001 From: sid-acryl <155424659+sid-acryl@users.noreply.github.com> Date: Fri, 2 Feb 2024 02:17:09 +0530 Subject: [PATCH 246/540] feat(ingestion/redshift): collapse lineage to permanent table (#9704) Co-authored-by: Harshal Sheth Co-authored-by: treff7es --- .../src/datahub/emitter/mce_builder.py | 1 + .../src/datahub/ingestion/api/common.py | 2 +- .../ingestion/source/redshift/config.py | 17 +- .../ingestion/source/redshift/lineage.py | 554 ++++++++++++++- .../ingestion/source/redshift/query.py | 136 +++- .../source/redshift/redshift_schema.py | 76 +- .../ingestion/source/redshift/report.py | 2 + .../source/snowflake/snowflake_config.py | 11 +- .../src/datahub/utilities/sqlglot_lineage.py | 8 + .../tests/unit/redshift_query_mocker.py | 104 +++ .../tests/unit/test_redshift_lineage.py | 663 +++++++++++++++++- 11 files changed, 1515 insertions(+), 59 deletions(-) create mode 100644 metadata-ingestion/tests/unit/redshift_query_mocker.py diff --git a/metadata-ingestion/src/datahub/emitter/mce_builder.py b/metadata-ingestion/src/datahub/emitter/mce_builder.py index 9da1b0ab56f890..fe9ecee8f80d0b 100644 --- a/metadata-ingestion/src/datahub/emitter/mce_builder.py +++ b/metadata-ingestion/src/datahub/emitter/mce_builder.py @@ -1,4 +1,5 @@ """Convenience functions for creating MCEs""" + import hashlib import json import logging diff --git a/metadata-ingestion/src/datahub/ingestion/api/common.py b/metadata-ingestion/src/datahub/ingestion/api/common.py index a6761a3c77d5e8..097859939cfea5 100644 --- a/metadata-ingestion/src/datahub/ingestion/api/common.py +++ b/metadata-ingestion/src/datahub/ingestion/api/common.py @@ -64,7 +64,7 @@ def _set_dataset_urn_to_lower_if_needed(self) -> None: # TODO: Get rid of this function once lower-casing is the standard. if self.graph: server_config = self.graph.get_config() - if server_config and server_config.get("datasetUrnNameCasing"): + if server_config and server_config.get("datasetUrnNameCasing") is True: set_dataset_urn_to_lower(True) def register_checkpointer(self, committable: Committable) -> None: diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/config.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/config.py index 540adbf4bfd15a..fe66ef006ec692 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/config.py @@ -94,10 +94,10 @@ class RedshiftConfig( description="The default schema to use if the sql parser fails to parse the schema with `sql_based` lineage collector", ) - include_table_lineage: Optional[bool] = Field( + include_table_lineage: bool = Field( default=True, description="Whether table lineage should be ingested." ) - include_copy_lineage: Optional[bool] = Field( + include_copy_lineage: bool = Field( default=True, description="Whether lineage should be collected from copy commands", ) @@ -107,17 +107,15 @@ class RedshiftConfig( description="Generate usage statistic. email_domain config parameter needs to be set if enabled", ) - include_unload_lineage: Optional[bool] = Field( + include_unload_lineage: bool = Field( default=True, description="Whether lineage should be collected from unload commands", ) - capture_lineage_query_parser_failures: Optional[bool] = Field( - hide_from_schema=True, + include_table_rename_lineage: bool = Field( default=False, - description="Whether to capture lineage query parser errors with dataset properties for debugging", + description="Whether we should follow `alter table ... rename to` statements when computing lineage. ", ) - table_lineage_mode: Optional[LineageMode] = Field( default=LineageMode.STL_SCAN_BASED, description="Which table lineage collector mode to use. Available modes are: [stl_scan_based, sql_based, mixed]", @@ -139,6 +137,11 @@ class RedshiftConfig( description="When enabled, emits lineage as incremental to existing lineage already in DataHub. When disabled, re-states lineage on each run. This config works with rest-sink only.", ) + resolve_temp_table_in_lineage: bool = Field( + default=False, + description="Whether to resolve temp table appear in lineage to upstream permanent tables.", + ) + @root_validator(pre=True) def check_email_is_set_on_usage(cls, values): if values.get("include_usage_statistics"): diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py index 3efef58737c6e9..898e6db0b14b06 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/lineage.py @@ -4,11 +4,12 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union, cast from urllib.parse import urlparse import humanfriendly import redshift_connector +import sqlglot import datahub.emitter.mce_builder as builder import datahub.utilities.sqlglot_lineage as sqlglot_l @@ -24,17 +25,24 @@ RedshiftSchema, RedshiftTable, RedshiftView, + TempTableRow, ) from datahub.ingestion.source.redshift.report import RedshiftReport from datahub.ingestion.source.state.redundant_run_skip_handler import ( RedundantLineageRunSkipHandler, ) +from datahub.metadata._schema_classes import SchemaFieldDataTypeClass from datahub.metadata.com.linkedin.pegasus2avro.dataset import ( FineGrainedLineage, FineGrainedLineageDownstreamType, FineGrainedLineageUpstreamType, UpstreamLineage, ) +from datahub.metadata.com.linkedin.pegasus2avro.schema import ( + OtherSchema, + SchemaField, + SchemaMetadata, +) from datahub.metadata.schema_classes import ( DatasetLineageTypeClass, UpstreamClass, @@ -111,6 +119,34 @@ def merge_lineage( self.cll = self.cll or None +def parse_alter_table_rename(default_schema: str, query: str) -> Tuple[str, str, str]: + """ + Parses an ALTER TABLE ... RENAME TO ... query and returns the schema, previous table name, and new table name. + """ + + parsed_query = sqlglot.parse_one(query, dialect="redshift") + assert isinstance(parsed_query, sqlglot.exp.AlterTable) + prev_name = parsed_query.this.name + rename_clause = parsed_query.args["actions"][0] + assert isinstance(rename_clause, sqlglot.exp.RenameTable) + new_name = rename_clause.this.name + + schema = parsed_query.this.db or default_schema + + return schema, prev_name, new_name + + +def split_qualified_table_name(urn: str) -> Tuple[str, str, str]: + qualified_table_name = dataset_urn.DatasetUrn.create_from_string( + urn + ).get_entity_id()[1] + + # -3 because platform instance is optional and that can cause the split to have more than 3 elements + db, schema, table = qualified_table_name.split(".")[-3:] + + return db, schema, table + + class RedshiftLineageExtractor: def __init__( self, @@ -130,6 +166,95 @@ def __init__( self.report.lineage_end_time, ) = self.get_time_window() + self.temp_tables: Dict[str, TempTableRow] = {} + + def _init_temp_table_schema( + self, database: str, temp_tables: List[TempTableRow] + ) -> None: + if self.context.graph is None: # to silent lint + return + + schema_resolver: sqlglot_l.SchemaResolver = ( + self.context.graph._make_schema_resolver( + platform=LineageDatasetPlatform.REDSHIFT.value, + platform_instance=self.config.platform_instance, + env=self.config.env, + ) + ) + + dataset_vs_columns: Dict[str, List[SchemaField]] = {} + # prepare dataset_urn vs List of schema fields + for table in temp_tables: + logger.debug( + f"Processing temp table: {table.create_command} with query text {table.query_text}" + ) + result = sqlglot_l.create_lineage_sql_parsed_result( + platform=LineageDatasetPlatform.REDSHIFT.value, + platform_instance=self.config.platform_instance, + env=self.config.env, + default_db=database, + default_schema=self.config.default_schema, + query=table.query_text, + graph=self.context.graph, + ) + + if ( + result is None + or result.column_lineage is None + or result.query_type != sqlglot_l.QueryType.CREATE + or not result.out_tables + ): + logger.debug(f"Unsupported temp table query found: {table.query_text}") + continue + + table.parsed_result = result + if result.column_lineage[0].downstream.table: + table.urn = result.column_lineage[0].downstream.table + + self.temp_tables[result.out_tables[0]] = table + + for table in self.temp_tables.values(): + if ( + table.parsed_result is None + or table.parsed_result.column_lineage is None + ): + continue + for column_lineage in table.parsed_result.column_lineage: + if column_lineage.downstream.table not in dataset_vs_columns: + dataset_vs_columns[cast(str, column_lineage.downstream.table)] = [] + # Initialise the temp table urn, we later need this to merge CLL + + dataset_vs_columns[cast(str, column_lineage.downstream.table)].append( + SchemaField( + fieldPath=column_lineage.downstream.column, + type=cast( + SchemaFieldDataTypeClass, + column_lineage.downstream.column_type, + ), + nativeDataType=cast( + str, column_lineage.downstream.native_column_type + ), + ) + ) + + # Add datasets, and it's respective fields in schema_resolver, so that later schema_resolver would be able + # correctly generates the upstreams for temporary tables + for urn in dataset_vs_columns: + db, schema, table_name = split_qualified_table_name(urn) + schema_resolver.add_schema_metadata( + urn=urn, + schema_metadata=SchemaMetadata( + schemaName=table_name, + platform=builder.make_data_platform_urn( + LineageDatasetPlatform.REDSHIFT.value + ), + version=0, + hash="", + platformSchema=OtherSchema(rawSchema=""), + fields=dataset_vs_columns[urn], + ), + ) + def get_time_window(self) -> Tuple[datetime, datetime]: if self.redundant_run_skip_handler: self.report.stateful_lineage_ingestion_enabled = True @@ -157,25 +282,32 @@ def _get_s3_path(self, path: str) -> str: return path def _get_sources_from_query( - self, db_name: str, query: str + self, + db_name: str, + query: str, + parsed_result: Optional[sqlglot_l.SqlParsingResult] = None, ) -> Tuple[List[LineageDataset], Optional[List[sqlglot_l.ColumnLineageInfo]]]: sources: List[LineageDataset] = list() - parsed_result: Optional[ - sqlglot_l.SqlParsingResult - ] = sqlglot_l.create_lineage_sql_parsed_result( - query=query, - platform=LineageDatasetPlatform.REDSHIFT.value, - platform_instance=self.config.platform_instance, - default_db=db_name, - default_schema=str(self.config.default_schema), - graph=self.context.graph, - env=self.config.env, - ) + if parsed_result is None: + parsed_result = sqlglot_l.create_lineage_sql_parsed_result( + query=query, + platform=LineageDatasetPlatform.REDSHIFT.value, + platform_instance=self.config.platform_instance, + default_db=db_name, + default_schema=str(self.config.default_schema), + graph=self.context.graph, + env=self.config.env, + ) if parsed_result is None: logger.debug(f"native query parsing failed for {query}") return sources, None + elif parsed_result.debug_info.table_error: + logger.debug( + f"native query parsing failed for {query} with error: {parsed_result.debug_info.table_error}" + ) + return sources, None logger.debug(f"parsed_result = {parsed_result}") @@ -277,7 +409,7 @@ def _populate_lineage_map( database: str, lineage_type: LineageCollectorType, connection: redshift_connector.Connection, - all_tables: Dict[str, Dict[str, List[Union[RedshiftView, RedshiftTable]]]], + all_tables_set: Dict[str, Dict[str, Set[str]]], ) -> None: """ This method generate table level lineage based with the given query. @@ -292,7 +424,10 @@ def _populate_lineage_map( return: The method does not return with anything as it directly modify the self._lineage_map property. :rtype: None """ + + logger.info(f"Extracting {lineage_type.name} lineage for db {database}") try: + logger.debug(f"Processing lineage query: {query}") cll: Optional[List[sqlglot_l.ColumnLineageInfo]] = None raw_db_name = database alias_db_name = self.config.database @@ -301,11 +436,18 @@ def _populate_lineage_map( conn=connection, query=query ): target = self._get_target_lineage( - alias_db_name, lineage_row, lineage_type + alias_db_name, + lineage_row, + lineage_type, + all_tables_set=all_tables_set, ) if not target: continue + logger.debug( + f"Processing {lineage_type.name} lineage row: {lineage_row}" + ) + sources, cll = self._get_sources( lineage_type, alias_db_name, @@ -318,9 +460,12 @@ def _populate_lineage_map( target.upstreams.update( self._get_upstream_lineages( sources=sources, - all_tables=all_tables, + target_table=target.dataset.urn, + target_dataset_cll=cll, + all_tables_set=all_tables_set, alias_db_name=alias_db_name, raw_db_name=raw_db_name, + connection=connection, ) ) target.cll = cll @@ -344,21 +489,50 @@ def _populate_lineage_map( ) self.report_status(f"extract-{lineage_type.name}", False) + def _update_lineage_map_for_table_renames( + self, table_renames: Dict[str, str] + ) -> None: + if not table_renames: + return + + logger.info(f"Updating lineage map for {len(table_renames)} table renames") + for new_table_urn, prev_table_urn in table_renames.items(): + # This table was renamed from some other name, copy in the lineage + # for the previous name as well. + prev_table_lineage = self._lineage_map.get(prev_table_urn) + if prev_table_lineage: + logger.debug( + f"including lineage for {prev_table_urn} in {new_table_urn} due to table rename" + ) + self._lineage_map[new_table_urn].merge_lineage( + upstreams=prev_table_lineage.upstreams, + cll=prev_table_lineage.cll, + ) + def _get_target_lineage( self, alias_db_name: str, lineage_row: LineageRow, lineage_type: LineageCollectorType, + all_tables_set: Dict[str, Dict[str, Set[str]]], ) -> Optional[LineageItem]: if ( lineage_type != LineageCollectorType.UNLOAD and lineage_row.target_schema and lineage_row.target_table ): - if not self.config.schema_pattern.allowed( - lineage_row.target_schema - ) or not self.config.table_pattern.allowed( - f"{alias_db_name}.{lineage_row.target_schema}.{lineage_row.target_table}" + if ( + not self.config.schema_pattern.allowed(lineage_row.target_schema) + or not self.config.table_pattern.allowed( + f"{alias_db_name}.{lineage_row.target_schema}.{lineage_row.target_table}" + ) + ) and not ( + # We also check the all_tables_set, since this might be a renamed table + # that we don't want to drop lineage for. + alias_db_name in all_tables_set + and lineage_row.target_schema in all_tables_set[alias_db_name] + and lineage_row.target_table + in all_tables_set[alias_db_name][lineage_row.target_schema] ): return None # Target @@ -400,18 +574,19 @@ def _get_target_lineage( def _get_upstream_lineages( self, sources: List[LineageDataset], - all_tables: Dict[str, Dict[str, List[Union[RedshiftView, RedshiftTable]]]], + target_table: str, + all_tables_set: Dict[str, Dict[str, Set[str]]], alias_db_name: str, raw_db_name: str, + connection: redshift_connector.Connection, + target_dataset_cll: Optional[List[sqlglot_l.ColumnLineageInfo]], ) -> List[LineageDataset]: - targe_source = [] + target_source = [] + probable_temp_tables: List[str] = [] + for source in sources: if source.platform == LineageDatasetPlatform.REDSHIFT: - qualified_table_name = dataset_urn.DatasetUrn.create_from_string( - source.urn - ).get_entity_id()[1] - # -3 because platform instance is optional and that can cause the split to have more than 3 elements - db, schema, table = qualified_table_name.split(".")[-3:] + db, schema, table = split_qualified_table_name(source.urn) if db == raw_db_name: db = alias_db_name path = f"{db}.{schema}.{table}" @@ -427,19 +602,40 @@ def _get_upstream_lineages( # Filtering out tables which does not exist in Redshift # It was deleted in the meantime or query parser did not capture well the table name + # Or it might be a temp table if ( - db not in all_tables - or schema not in all_tables[db] - or not any(table == t.name for t in all_tables[db][schema]) + db not in all_tables_set + or schema not in all_tables_set[db] + or table not in all_tables_set[db][schema] ): logger.debug( - f"{source.urn} missing table, dropping from lineage.", + f"{source.urn} missing table. Adding it to temp table list for target table {target_table}.", ) + probable_temp_tables.append(f"{schema}.{table}") self.report.num_lineage_tables_dropped += 1 continue - targe_source.append(source) - return targe_source + target_source.append(source) + + if probable_temp_tables and self.config.resolve_temp_table_in_lineage: + self.report.num_lineage_processed_temp_tables += len(probable_temp_tables) + # Generate lineage dataset from temporary tables + number_of_permanent_dataset_found: int = ( + self.update_table_and_column_lineage( + db_name=raw_db_name, + connection=connection, + temp_table_names=probable_temp_tables, + target_source_dataset=target_source, + target_dataset_cll=target_dataset_cll, + ) + ) + + logger.debug( + f"Number of permanent datasets found for {target_table} = {number_of_permanent_dataset_found} in " + f"temp tables {probable_temp_tables}" + ) + + return target_source def populate_lineage( self, @@ -447,8 +643,27 @@ def populate_lineage( connection: redshift_connector.Connection, all_tables: Dict[str, Dict[str, List[Union[RedshiftView, RedshiftTable]]]], ) -> None: + if self.config.resolve_temp_table_in_lineage: + self._init_temp_table_schema( + database=database, + temp_tables=self.get_temp_tables(connection=connection), + ) + populate_calls: List[Tuple[str, LineageCollectorType]] = [] + all_tables_set: Dict[str, Dict[str, Set[str]]] = { + db: {schema: {t.name for t in tables} for schema, tables in schemas.items()} + for db, schemas in all_tables.items() + } + + table_renames: Dict[str, str] = {} + if self.config.include_table_rename_lineage: + table_renames, all_tables_set = self._process_table_renames( + database=database, + connection=connection, + all_tables=all_tables_set, + ) + if self.config.table_lineage_mode in { LineageMode.STL_SCAN_BASED, LineageMode.MIXED, @@ -504,9 +719,12 @@ def populate_lineage( database=database, lineage_type=lineage_type, connection=connection, - all_tables=all_tables, + all_tables_set=all_tables_set, ) + # Handling for alter table statements. + self._update_lineage_map_for_table_renames(table_renames=table_renames) + self.report.lineage_mem_size[self.config.database] = humanfriendly.format_size( memory_footprint.total_size(self._lineage_map) ) @@ -613,3 +831,271 @@ def get_lineage( def report_status(self, step: str, status: bool) -> None: if self.redundant_run_skip_handler: self.redundant_run_skip_handler.report_current_run_status(step, status) + + def _process_table_renames( + self, + database: str, + connection: redshift_connector.Connection, + all_tables: Dict[str, Dict[str, Set[str]]], + ) -> Tuple[Dict[str, str], Dict[str, Dict[str, Set[str]]]]: + logger.info(f"Processing table renames for db {database}") + + # new urn -> prev urn + table_renames: Dict[str, str] = {} + + query = RedshiftQuery.alter_table_rename_query( + db_name=database, + start_time=self.start_time, + end_time=self.end_time, + ) + + for rename_row in RedshiftDataDictionary.get_alter_table_commands( + connection, query + ): + schema, prev_name, new_name = parse_alter_table_rename( + default_schema=self.config.default_schema, + query=rename_row.query_text, + ) + + prev_urn = make_dataset_urn_with_platform_instance( + platform=LineageDatasetPlatform.REDSHIFT.value, + platform_instance=self.config.platform_instance, + name=f"{database}.{schema}.{prev_name}", + env=self.config.env, + ) + new_urn = make_dataset_urn_with_platform_instance( + platform=LineageDatasetPlatform.REDSHIFT.value, + platform_instance=self.config.platform_instance, + name=f"{database}.{schema}.{new_name}", + env=self.config.env, + ) + + table_renames[new_urn] = prev_urn + + # We want to generate lineage for the previous name too. + all_tables[database][schema].add(prev_name) + + logger.info(f"Discovered {len(table_renames)} table renames") + return table_renames, all_tables + + def get_temp_tables( + self, connection: redshift_connector.Connection + ) -> List[TempTableRow]: + ddl_query: str = RedshiftQuery.temp_table_ddl_query( + start_time=self.config.start_time, + end_time=self.config.end_time, + ) + + logger.debug(f"Temporary table ddl query = {ddl_query}") + + temp_table_rows: List[TempTableRow] = [] + + for row in RedshiftDataDictionary.get_temporary_rows( + conn=connection, + query=ddl_query, + ): + temp_table_rows.append(row) + + return temp_table_rows + + def find_temp_tables( + self, temp_table_rows: List[TempTableRow], temp_table_names: List[str] + ) -> List[TempTableRow]: + matched_temp_tables: List[TempTableRow] = [] + + for table_name in temp_table_names: + prefixes = RedshiftQuery.get_temp_table_clause(table_name) + prefixes.extend( + RedshiftQuery.get_temp_table_clause(table_name.split(".")[-1]) + ) + + for row in temp_table_rows: + if any( + row.create_command.lower().startswith(prefix) for prefix in prefixes + ): + matched_temp_tables.append(row) + + return matched_temp_tables + + def resolve_column_refs( + self, column_refs: List[sqlglot_l.ColumnRef], depth: int = 0 + ) -> List[sqlglot_l.ColumnRef]: + """ + This method resolves the column reference to the original column reference. + For example, if the column reference is to a temporary table, it will be resolved to the original column + reference. + """ + max_depth = 10 + + resolved_column_refs: List[sqlglot_l.ColumnRef] = [] + if not column_refs: + return column_refs + + if depth >= max_depth: + logger.warning( + f"Max depth reached for resolving temporary columns: {column_refs}" + ) + self.report.num_unresolved_temp_columns += 1 + return column_refs + + for ref in column_refs: + resolved = False + if ref.table in self.temp_tables: + table = self.temp_tables[ref.table] + if table.parsed_result and table.parsed_result.column_lineage: + for column_lineage in table.parsed_result.column_lineage: + if ( + column_lineage.downstream.table == ref.table + and column_lineage.downstream.column == ref.column + ): + resolved_column_refs.extend( + self.resolve_column_refs( + column_lineage.upstreams, depth=depth + 1 + ) + ) + resolved = True + break + # If we reach here, it means that we were not able to resolve the column reference. + if resolved is False: + logger.warning( + f"Unable to resolve column reference {ref} to a permanent table" + ) + else: + logger.debug( + f"Resolved column reference {ref} is not resolved because referenced table {ref.table} is not a temp table or not found. Adding reference as non-temp table. This is normal." + ) + resolved_column_refs.append(ref) + return resolved_column_refs + + def _update_target_dataset_cll( + self, + temp_table_urn: str, + target_dataset_cll: List[sqlglot_l.ColumnLineageInfo], + source_dataset_cll: List[sqlglot_l.ColumnLineageInfo], + ) -> None: + for target_column_lineage in target_dataset_cll: + upstreams: List[sqlglot_l.ColumnRef] = [] + # Look for temp_table_urn in upstream of column_lineage, if found then we need to replace it with + # column of permanent table + for target_column_ref in target_column_lineage.upstreams: + if target_column_ref.table == temp_table_urn: + # Look for column_ref.table and column_ref.column in downstream of source_dataset_cll. + # The source_dataset_cll contains CLL generated from create statement of temp table (temp_table_urn) + for source_column_lineage in source_dataset_cll: + if ( + source_column_lineage.downstream.table + == target_column_ref.table + and source_column_lineage.downstream.column + == target_column_ref.column + ): + resolved_columns = self.resolve_column_refs( + source_column_lineage.upstreams + ) + # Add all upstream of above temporary column into upstream of target column + upstreams.extend(resolved_columns) + continue + + upstreams.append(target_column_ref) + + if upstreams: + # update the upstreams + target_column_lineage.upstreams = upstreams + + def _add_permanent_datasets_recursively( + self, + db_name: str, + temp_table_rows: List[TempTableRow], + visited_tables: Set[str], + connection: redshift_connector.Connection, + permanent_lineage_datasets: List[LineageDataset], + target_dataset_cll: Optional[List[sqlglot_l.ColumnLineageInfo]], + ) -> None: + transitive_temp_tables: List[TempTableRow] = [] + + for temp_table in temp_table_rows: + logger.debug( + f"Processing temp table with transaction id: {temp_table.transaction_id} and query text {temp_table.query_text}" + ) + + intermediate_l_datasets, cll = self._get_sources_from_query( + db_name=db_name, + query=temp_table.query_text, + parsed_result=temp_table.parsed_result, + ) + + if ( + temp_table.urn is not None + and target_dataset_cll is not None + and cll is not None + ): # condition to silent the lint + self._update_target_dataset_cll( + temp_table_urn=temp_table.urn, + target_dataset_cll=target_dataset_cll, + source_dataset_cll=cll, + ) + + # make sure lineage dataset should not contain a temp table + # if such dataset is present then add it to transitive_temp_tables to resolve it to original permanent table + for lineage_dataset in intermediate_l_datasets: + db, schema, table = split_qualified_table_name(lineage_dataset.urn) + + if table in visited_tables: + # The table is already processed + continue + + # Check if table found is again a temp table + repeated_temp_table: List[TempTableRow] = self.find_temp_tables( + temp_table_rows=list(self.temp_tables.values()), + temp_table_names=[table], + ) + + if not repeated_temp_table: + logger.debug(f"Unable to find table {table} in temp tables.") + + if repeated_temp_table: + transitive_temp_tables.extend(repeated_temp_table) + visited_tables.add(table) + continue + + permanent_lineage_datasets.append(lineage_dataset) + + if transitive_temp_tables: + # recursive call + self._add_permanent_datasets_recursively( + db_name=db_name, + temp_table_rows=transitive_temp_tables, + visited_tables=visited_tables, + connection=connection, + permanent_lineage_datasets=permanent_lineage_datasets, + target_dataset_cll=target_dataset_cll, + ) + + def update_table_and_column_lineage( + self, + db_name: str, + temp_table_names: List[str], + connection: redshift_connector.Connection, + target_source_dataset: List[LineageDataset], + target_dataset_cll: Optional[List[sqlglot_l.ColumnLineageInfo]], + ) -> int: + permanent_lineage_datasets: List[LineageDataset] = [] + + temp_table_rows: List[TempTableRow] = self.find_temp_tables( + temp_table_rows=list(self.temp_tables.values()), + temp_table_names=temp_table_names, + ) + + visited_tables: Set[str] = set(temp_table_names) + + self._add_permanent_datasets_recursively( + db_name=db_name, + temp_table_rows=temp_table_rows, + visited_tables=visited_tables, + connection=connection, + permanent_lineage_datasets=permanent_lineage_datasets, + target_dataset_cll=target_dataset_cll, + ) + + target_source_dataset.extend(permanent_lineage_datasets) + + return len(permanent_lineage_datasets) diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/query.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/query.py index 92e36fffd6bb41..93beb5980ea624 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/query.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/query.py @@ -1,9 +1,14 @@ from datetime import datetime +from typing import List redshift_datetime_format = "%Y-%m-%d %H:%M:%S" class RedshiftQuery: + CREATE_TEMP_TABLE_CLAUSE = "create temp table" + CREATE_TEMPORARY_TABLE_CLAUSE = "create temporary table" + CREATE_TABLE_CLAUSE = "create table" + list_databases: str = """SELECT datname FROM pg_database WHERE (datname <> ('padb_harvest')::name) AND (datname <> ('template0')::name) @@ -97,7 +102,7 @@ class RedshiftQuery: NULL as table_description FROM pg_catalog.svv_external_tables ORDER BY "schema", - "relname"; + "relname" """ list_columns: str = """ SELECT @@ -379,7 +384,8 @@ def list_insert_create_queries_sql( target_schema, target_table, username, - querytxt as ddl + query as query_id, + LISTAGG(CASE WHEN LEN(RTRIM(querytxt)) = 0 THEN querytxt ELSE RTRIM(querytxt) END) WITHIN GROUP (ORDER BY sequence) as ddl from ( select @@ -388,7 +394,9 @@ def list_insert_create_queries_sql( sti.table as target_table, sti.database as cluster, usename as username, - querytxt, + text as querytxt, + sq.query, + sequence, si.starttime as starttime from stl_insert as si @@ -396,19 +404,20 @@ def list_insert_create_queries_sql( sti.table_id = tbl left join svl_user_info sui on si.userid = sui.usesysid - left join stl_query sq on + left join STL_QUERYTEXT sq on si.query = sq.query left join stl_load_commits slc on slc.query = si.query where sui.usename <> 'rdsdb' - and sq.aborted = 0 and slc.query IS NULL and cluster = '{db_name}' and si.starttime >= '{start_time}' and si.starttime < '{end_time}' + and sequence < 320 ) as target_tables - order by cluster, target_schema, target_table, starttime asc + group by cluster, query_id, target_schema, target_table, username, starttime + order by cluster, query_id, target_schema, target_table, starttime asc """.format( # We need the original database name for filtering db_name=db_name, @@ -443,3 +452,118 @@ def list_copy_commands_sql( start_time=start_time.strftime(redshift_datetime_format), end_time=end_time.strftime(redshift_datetime_format), ) + + @staticmethod + def get_temp_table_clause(table_name: str) -> List[str]: + return [ + f"{RedshiftQuery.CREATE_TABLE_CLAUSE} {table_name}", + f"{RedshiftQuery.CREATE_TEMP_TABLE_CLAUSE} {table_name}", + f"{RedshiftQuery.CREATE_TEMPORARY_TABLE_CLAUSE} {table_name}", + ] + + @staticmethod + def temp_table_ddl_query(start_time: datetime, end_time: datetime) -> str: + start_time_str: str = start_time.strftime(redshift_datetime_format) + + end_time_str: str = end_time.strftime(redshift_datetime_format) + + return rf"""-- DataHub Redshift Source temp table DDL query + select + * + from + ( + select + session_id, + transaction_id, + start_time, + userid, + REGEXP_REPLACE(REGEXP_SUBSTR(REGEXP_REPLACE(query_text,'\\\\n','\\n'), '(CREATE(?:[\\n\\s\\t]+(?:temp|temporary))?(?:[\\n\\s\\t]+)table(?:[\\n\\s\\t]+)[^\\n\\s\\t()-]+)', 0, 1, 'ipe'),'[\\n\\s\\t]+',' ',1,'p') as create_command, + query_text, + row_number() over ( + partition by TRIM(query_text) + order by start_time desc + ) rn + from + ( + select + pid as session_id, + xid as transaction_id, + starttime as start_time, + type, + query_text, + userid + from + ( + select + starttime, + pid, + xid, + type, + userid, + LISTAGG(case + when LEN(RTRIM(text)) = 0 then text + else RTRIM(text) + end, + '') within group ( + order by sequence + ) as query_text + from + SVL_STATEMENTTEXT + where + type in ('DDL', 'QUERY') + AND starttime >= '{start_time_str}' + AND starttime < '{end_time_str}' + -- See https://stackoverflow.com/questions/72770890/redshift-result-size-exceeds-listagg-limit-on-svl-statementtext + AND sequence < 320 + group by + starttime, + pid, + xid, + type, + userid + order by + starttime, + pid, + xid, + type, + userid + asc) + where + type in ('DDL', 'QUERY') + ) + where + (create_command ilike 'create temp table %' + or create_command ilike 'create temporary table %' + -- we want to get all the create table statements and not just temp tables if non temp table is created and dropped in the same transaction + or create_command ilike 'create table %') + -- Redshift creates temp tables with the following names: volt_tt_%. We need to filter them out. + and query_text not ilike 'CREATE TEMP TABLE volt_tt_%' + and create_command not like 'CREATE TEMP TABLE volt_tt_' + -- We need to filter out our query and it was not possible earlier when we did not have any comment in the query + and query_text not ilike '%https://stackoverflow.com/questions/72770890/redshift-result-size-exceeds-listagg-limit-on-svl-statementtext%' + + ) + where + rn = 1; + """ + + @staticmethod + def alter_table_rename_query( + db_name: str, start_time: datetime, end_time: datetime + ) -> str: + start_time_str: str = start_time.strftime(redshift_datetime_format) + end_time_str: str = end_time.strftime(redshift_datetime_format) + + return f""" + SELECT transaction_id, + session_id, + start_time, + query_text + FROM sys_query_history SYS + WHERE SYS.status = 'success' + AND SYS.query_type = 'DDL' + AND SYS.database_name = '{db_name}' + AND SYS.start_time >= '{start_time_str}' + AND SYS.end_time < '{end_time_str}' + AND SYS.query_text ILIKE 'alter table % rename to %' + """ diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/redshift_schema.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/redshift_schema.py index ca81682ae00e49..0ea073c050502c 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/redshift_schema.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/redshift_schema.py @@ -9,6 +9,7 @@ from datahub.ingestion.source.sql.sql_generic import BaseColumn, BaseTable from datahub.metadata.com.linkedin.pegasus2avro.schema import SchemaField from datahub.utilities.hive_schema_to_avro import get_schema_fields_for_hive_column +from datahub.utilities.sqlglot_lineage import SqlParsingResult logger: logging.Logger = logging.getLogger(__name__) @@ -80,6 +81,26 @@ class LineageRow: filename: Optional[str] +@dataclass +class TempTableRow: + transaction_id: int + session_id: str + query_text: str + create_command: str + start_time: datetime + urn: Optional[str] + parsed_result: Optional[SqlParsingResult] = None + + +@dataclass +class AlterTableRow: + # TODO unify this type with TempTableRow + transaction_id: int + session_id: str + query_text: str + start_time: datetime + + # this is a class to be a proxy to query Redshift class RedshiftDataDictionary: @staticmethod @@ -359,9 +380,62 @@ def get_lineage_rows( target_table=row[field_names.index("target_table")] if "target_table" in field_names else None, - ddl=row[field_names.index("ddl")] if "ddl" in field_names else None, + # See https://docs.aws.amazon.com/redshift/latest/dg/r_STL_QUERYTEXT.html + # for why we need to remove the \\n. + ddl=row[field_names.index("ddl")].replace("\\n", "\n") + if "ddl" in field_names + else None, filename=row[field_names.index("filename")] if "filename" in field_names else None, ) rows = cursor.fetchmany() + + @staticmethod + def get_temporary_rows( + conn: redshift_connector.Connection, + query: str, + ) -> Iterable[TempTableRow]: + cursor = conn.cursor() + + cursor.execute(query) + + field_names = [i[0] for i in cursor.description] + + rows = cursor.fetchmany() + while rows: + for row in rows: + yield TempTableRow( + transaction_id=row[field_names.index("transaction_id")], + session_id=row[field_names.index("session_id")], + # See https://docs.aws.amazon.com/redshift/latest/dg/r_STL_QUERYTEXT.html + # for why we need to replace the \n with a newline. + query_text=row[field_names.index("query_text")].replace( + r"\n", "\n" + ), + create_command=row[field_names.index("create_command")], + start_time=row[field_names.index("start_time")], + urn=None, + ) + rows = cursor.fetchmany() + + @staticmethod + def get_alter_table_commands( + conn: redshift_connector.Connection, + query: str, + ) -> Iterable[AlterTableRow]: + # TODO: unify this with get_temporary_rows + cursor = RedshiftDataDictionary.get_query_result(conn, query) + + field_names = [i[0] for i in cursor.description] + + rows = cursor.fetchmany() + while rows: + for row in rows: + yield AlterTableRow( + transaction_id=row[field_names.index("transaction_id")], + session_id=row[field_names.index("session_id")], + query_text=row[field_names.index("query_text")], + start_time=row[field_names.index("start_time")], + ) + rows = cursor.fetchmany() diff --git a/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py b/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py index 333c851650fb3a..36ac7955f15d51 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redshift/report.py @@ -35,6 +35,7 @@ class RedshiftReport(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowRep num_lineage_tables_dropped: int = 0 num_lineage_dropped_query_parser: int = 0 num_lineage_dropped_not_support_copy_path: int = 0 + num_lineage_processed_temp_tables = 0 lineage_start_time: Optional[datetime] = None lineage_end_time: Optional[datetime] = None @@ -43,6 +44,7 @@ class RedshiftReport(ProfilingSqlReport, IngestionStageReport, BaseTimeWindowRep usage_start_time: Optional[datetime] = None usage_end_time: Optional[datetime] = None stateful_usage_ingestion_enabled: bool = False + num_unresolved_temp_columns: int = 0 def report_dropped(self, key: str) -> None: self.filtered.append(key) diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py index b896df1fa340e3..aad4a6ed27cb89 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py +++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_config.py @@ -140,7 +140,9 @@ class SnowflakeV2Config( # This is required since access_history table does not capture whether the table was temporary table. temporary_tables_pattern: List[str] = Field( default=DEFAULT_TABLES_DENY_LIST, - description="[Advanced] Regex patterns for temporary tables to filter in lineage ingestion. Specify regex to match the entire table name in database.schema.table format. Defaults are to set in such a way to ignore the temporary staging tables created by known ETL tools.", + description="[Advanced] Regex patterns for temporary tables to filter in lineage ingestion. Specify regex to " + "match the entire table name in database.schema.table format. Defaults are to set in such a way " + "to ignore the temporary staging tables created by known ETL tools.", ) rename_upstreams_deny_pattern_to_temporary_table_pattern = pydantic_renamed_field( @@ -150,13 +152,16 @@ class SnowflakeV2Config( shares: Optional[Dict[str, SnowflakeShareConfig]] = Field( default=None, description="Required if current account owns or consumes snowflake share." - " If specified, connector creates lineage and siblings relationship between current account's database tables and consumer/producer account's database tables." + "If specified, connector creates lineage and siblings relationship between current account's database tables " + "and consumer/producer account's database tables." " Map of share name -> details of share.", ) email_as_user_identifier: bool = Field( default=True, - description="Format user urns as an email, if the snowflake user's email is set. If `email_domain` is provided, generates email addresses for snowflake users with unset emails, based on their username.", + description="Format user urns as an email, if the snowflake user's email is set. If `email_domain` is " + "provided, generates email addresses for snowflake users with unset emails, based on their " + "username.", ) @validator("convert_urns_to_lowercase") diff --git a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py index abe4f826737778..5b063451df9cf4 100644 --- a/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py +++ b/metadata-ingestion/src/datahub/utilities/sqlglot_lineage.py @@ -1037,6 +1037,14 @@ def _sqlglot_lineage_inner( default_db = default_db.upper() if default_schema: default_schema = default_schema.upper() + if _is_dialect_instance(dialect, "redshift") and not default_schema: + # On Redshift, there's no "USE SCHEMA " command. The default schema + # is public, and "current schema" is the one at the front of the search path. + # See https://docs.aws.amazon.com/redshift/latest/dg/r_search_path.html + # and https://stackoverflow.com/questions/9067335/how-does-the-search-path-influence-identifier-resolution-and-the-current-schema?noredirect=1&lq=1 + # default_schema = "public" + # TODO: Re-enable this. + pass logger.debug("Parsing lineage from sql statement: %s", sql) statement = _parse_statement(sql, dialect=dialect) diff --git a/metadata-ingestion/tests/unit/redshift_query_mocker.py b/metadata-ingestion/tests/unit/redshift_query_mocker.py new file mode 100644 index 00000000000000..631e6e7ceaf1f6 --- /dev/null +++ b/metadata-ingestion/tests/unit/redshift_query_mocker.py @@ -0,0 +1,104 @@ +from datetime import datetime +from unittest.mock import MagicMock + + +def mock_temp_table_cursor(cursor: MagicMock) -> None: + cursor.description = [ + ["transaction_id"], + ["session_id"], + ["query_text"], + ["create_command"], + ["start_time"], + ] + + cursor.fetchmany.side_effect = [ + [ + ( + 126, + "abc", + "CREATE TABLE #player_price distkey(player_id) AS SELECT player_id, SUM(price) AS " + "price_usd from player_activity group by player_id", + "CREATE TABLE #player_price", + datetime.now(), + ) + ], + [ + # Empty result to stop the while loop + ], + ] + + +def mock_stl_insert_table_cursor(cursor: MagicMock) -> None: + cursor.description = [ + ["source_schema"], + ["source_table"], + ["target_schema"], + ["target_table"], + ["ddl"], + ] + + cursor.fetchmany.side_effect = [ + [ + ( + "public", + "#player_price", + "public", + "player_price_with_hike_v6", + "INSERT INTO player_price_with_hike_v6 SELECT (price_usd + 0.2 * price_usd) as price, '20%' FROM " + "#player_price", + ) + ], + [ + # Empty result to stop the while loop + ], + ] + + +query_vs_cursor_mocker = { + ( + "-- DataHub Redshift Source temp table DDL query\n select\n *\n " + "from\n (\n select\n session_id,\n " + " transaction_id,\n start_time,\n userid,\n " + " REGEXP_REPLACE(REGEXP_SUBSTR(REGEXP_REPLACE(query_text,'\\\\\\\\n','\\\\n'), '(CREATE(?:[" + "\\\\n\\\\s\\\\t]+(?:temp|temporary))?(?:[\\\\n\\\\s\\\\t]+)table(?:[\\\\n\\\\s\\\\t]+)[" + "^\\\\n\\\\s\\\\t()-]+)', 0, 1, 'ipe'),'[\\\\n\\\\s\\\\t]+',' ',1,'p') as create_command,\n " + " query_text,\n row_number() over (\n partition " + "by TRIM(query_text)\n order by start_time desc\n ) rn\n " + " from\n (\n select\n pid " + "as session_id,\n xid as transaction_id,\n starttime " + "as start_time,\n type,\n query_text,\n " + " userid\n from\n (\n " + "select\n starttime,\n pid,\n " + " xid,\n type,\n userid,\n " + " LISTAGG(case\n when LEN(RTRIM(text)) = 0 then text\n " + " else RTRIM(text)\n end,\n " + " '') within group (\n order by sequence\n " + " ) as query_text\n from\n " + "SVL_STATEMENTTEXT\n where\n type in ('DDL', " + "'QUERY')\n AND starttime >= '2024-01-01 12:00:00'\n " + " AND starttime < '2024-01-10 12:00:00'\n -- See " + "https://stackoverflow.com/questions/72770890/redshift-result-size-exceeds-listagg-limit-on-svl" + "-statementtext\n AND sequence < 320\n group by\n " + " starttime,\n pid,\n " + "xid,\n type,\n userid\n " + " order by\n starttime,\n pid,\n " + " xid,\n type,\n userid\n " + " asc)\n where\n type in ('DDL', " + "'QUERY')\n )\n where\n (create_command ilike " + "'create temp table %'\n or create_command ilike 'create temporary table %'\n " + " -- we want to get all the create table statements and not just temp tables " + "if non temp table is created and dropped in the same transaction\n or " + "create_command ilike 'create table %')\n -- Redshift creates temp tables with " + "the following names: volt_tt_%. We need to filter them out.\n and query_text not " + "ilike 'CREATE TEMP TABLE volt_tt_%'\n and create_command not like 'CREATE TEMP " + "TABLE volt_tt_'\n -- We need to filter out our query and it was not possible " + "earlier when we did not have any comment in the query\n and query_text not ilike " + "'%https://stackoverflow.com/questions/72770890/redshift-result-size-exceeds-listagg-limit-on-svl" + "-statementtext%'\n\n )\n where\n rn = 1;\n " + ): mock_temp_table_cursor, + "select * from test_collapse_temp_lineage": mock_stl_insert_table_cursor, +} + + +def mock_cursor(cursor: MagicMock, query: str) -> None: + query_vs_cursor_mocker[query](cursor=cursor) diff --git a/metadata-ingestion/tests/unit/test_redshift_lineage.py b/metadata-ingestion/tests/unit/test_redshift_lineage.py index db5af3a71efb99..6a3e6e47bd96aa 100644 --- a/metadata-ingestion/tests/unit/test_redshift_lineage.py +++ b/metadata-ingestion/tests/unit/test_redshift_lineage.py @@ -1,8 +1,31 @@ +from datetime import datetime +from functools import partial +from typing import List +from unittest.mock import MagicMock + +import datahub.utilities.sqlglot_lineage as sqlglot_l from datahub.ingestion.api.common import PipelineContext +from datahub.ingestion.graph.client import DataHubGraph from datahub.ingestion.source.redshift.config import RedshiftConfig -from datahub.ingestion.source.redshift.lineage import RedshiftLineageExtractor +from datahub.ingestion.source.redshift.lineage import ( + LineageCollectorType, + LineageDataset, + LineageDatasetPlatform, + LineageItem, + RedshiftLineageExtractor, + parse_alter_table_rename, +) +from datahub.ingestion.source.redshift.redshift_schema import TempTableRow from datahub.ingestion.source.redshift.report import RedshiftReport -from datahub.utilities.sqlglot_lineage import ColumnLineageInfo, DownstreamColumnRef +from datahub.metadata._schema_classes import NumberTypeClass, SchemaFieldDataTypeClass +from datahub.utilities.sqlglot_lineage import ( + ColumnLineageInfo, + DownstreamColumnRef, + QueryType, + SqlParsingDebugInfo, + SqlParsingResult, +) +from tests.unit.redshift_query_mocker import mock_cursor def test_get_sources_from_query(): @@ -120,16 +143,45 @@ def test_get_sources_from_query_with_only_table(): ) -def test_cll(): - config = RedshiftConfig(host_port="localhost:5439", database="test") +def test_parse_alter_table_rename(): + assert parse_alter_table_rename("public", "alter table foo rename to bar") == ( + "public", + "foo", + "bar", + ) + assert parse_alter_table_rename( + "public", "alter table second_schema.storage_v2_stg rename to storage_v2; " + ) == ( + "second_schema", + "storage_v2_stg", + "storage_v2", + ) + + +def get_lineage_extractor() -> RedshiftLineageExtractor: + config = RedshiftConfig( + host_port="localhost:5439", + database="test", + resolve_temp_table_in_lineage=True, + start_time=datetime(2024, 1, 1, 12, 0, 0).isoformat() + "Z", + end_time=datetime(2024, 1, 10, 12, 0, 0).isoformat() + "Z", + ) report = RedshiftReport() + lineage_extractor = RedshiftLineageExtractor( + config, report, PipelineContext(run_id="foo", graph=mock_graph()) + ) + + return lineage_extractor + + +def test_cll(): test_query = """ select a,b,c from db.public.customer inner join db.public.order on db.public.customer.id = db.public.order.customer_id """ - lineage_extractor = RedshiftLineageExtractor( - config, report, PipelineContext(run_id="foo") - ) + + lineage_extractor = get_lineage_extractor() + _, cll = lineage_extractor._get_sources_from_query(db_name="db", query=test_query) assert cll == [ @@ -149,3 +201,600 @@ def test_cll(): logic=None, ), ] + + +def cursor_execute_side_effect(cursor: MagicMock, query: str) -> None: + mock_cursor(cursor=cursor, query=query) + + +def mock_redshift_connection() -> MagicMock: + connection = MagicMock() + + cursor = MagicMock() + + connection.cursor.return_value = cursor + + cursor.execute.side_effect = partial(cursor_execute_side_effect, cursor) + + return connection + + +def mock_graph() -> DataHubGraph: + + graph = MagicMock() + + graph._make_schema_resolver.return_value = sqlglot_l.SchemaResolver( + platform="redshift", + env="PROD", + platform_instance=None, + graph=None, + ) + + return graph + + +def test_collapse_temp_lineage(): + lineage_extractor = get_lineage_extractor() + + connection: MagicMock = mock_redshift_connection() + + lineage_extractor._init_temp_table_schema( + database=lineage_extractor.config.database, + temp_tables=lineage_extractor.get_temp_tables(connection=connection), + ) + + lineage_extractor._populate_lineage_map( + query="select * from test_collapse_temp_lineage", + database=lineage_extractor.config.database, + all_tables_set={ + lineage_extractor.config.database: {"public": {"player_price_with_hike_v6"}} + }, + connection=connection, + lineage_type=LineageCollectorType.QUERY_SQL_PARSER, + ) + + print(lineage_extractor._lineage_map) + + target_urn: str = "urn:li:dataset:(urn:li:dataPlatform:redshift,test.public.player_price_with_hike_v6,PROD)" + + assert lineage_extractor._lineage_map.get(target_urn) is not None + + lineage_item: LineageItem = lineage_extractor._lineage_map[target_urn] + + assert list(lineage_item.upstreams)[0].urn == ( + "urn:li:dataset:(urn:li:dataPlatform:redshift," + "test.public.player_activity,PROD)" + ) + + assert lineage_item.cll is not None + + assert lineage_item.cll[0].downstream.table == ( + "urn:li:dataset:(urn:li:dataPlatform:redshift," + "test.public.player_price_with_hike_v6,PROD)" + ) + + assert lineage_item.cll[0].downstream.column == "price" + + assert lineage_item.cll[0].upstreams[0].table == ( + "urn:li:dataset:(urn:li:dataPlatform:redshift," + "test.public.player_activity,PROD)" + ) + + assert lineage_item.cll[0].upstreams[0].column == "price" + + +def test_collapse_temp_recursive_cll_lineage(): + lineage_extractor = get_lineage_extractor() + + temp_table: TempTableRow = TempTableRow( + transaction_id=126, + query_text="CREATE TABLE #player_price distkey(player_id) AS SELECT player_id, SUM(price_usd) AS price_usd " + "from #player_activity_temp group by player_id", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_price", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price_usd", + ) + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + + temp_table_activity: TempTableRow = TempTableRow( + transaction_id=127, + query_text="CREATE TABLE #player_activity_temp SELECT player_id, SUM(price) AS price_usd " + "from player_activity", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_activity_temp", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price_usd", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)", + column="price", + ) + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + ) + + assert temp_table.urn + assert temp_table_activity.urn + + lineage_extractor.temp_tables[temp_table.urn] = temp_table + lineage_extractor.temp_tables[temp_table_activity.urn] = temp_table_activity + + target_dataset_cll: List[sqlglot_l.ColumnLineageInfo] = [ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v6,PROD)", + column="price", + column_type=SchemaFieldDataTypeClass(type=NumberTypeClass()), + native_column_type="DOUBLE PRECISION", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + ) + ], + logic=None, + ) + ] + + datasets = lineage_extractor._get_upstream_lineages( + sources=[ + LineageDataset( + platform=LineageDatasetPlatform.REDSHIFT, + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + ], + target_table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v4,PROD)", + raw_db_name="dev", + alias_db_name="dev", + all_tables_set={ + "dev": { + "public": set(), + } + }, + connection=MagicMock(), + target_dataset_cll=target_dataset_cll, + ) + + assert len(datasets) == 1 + + assert ( + datasets[0].urn + == "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)" + ) + + assert target_dataset_cll[0].upstreams[0].table == ( + "urn:li:dataset:(urn:li:dataPlatform:redshift," + "dev.public.player_activity,PROD)" + ) + assert target_dataset_cll[0].upstreams[0].column == "price" + + +def test_collapse_temp_recursive_with_compex_column_cll_lineage(): + lineage_extractor = get_lineage_extractor() + + temp_table: TempTableRow = TempTableRow( + transaction_id=126, + query_text="CREATE TABLE #player_price distkey(player_id) AS SELECT player_id, SUM(price+tax) AS price_usd " + "from #player_activity_temp group by player_id", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_price", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price", + ), + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="tax", + ), + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + + temp_table_activity: TempTableRow = TempTableRow( + transaction_id=127, + query_text="CREATE TABLE #player_activity_temp SELECT player_id, price, tax " + "from player_activity", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_activity_temp", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)", + column="price", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="tax", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)", + column="tax", + ) + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + ) + assert temp_table.urn + assert temp_table_activity.urn + + lineage_extractor.temp_tables[temp_table.urn] = temp_table + lineage_extractor.temp_tables[temp_table_activity.urn] = temp_table_activity + + target_dataset_cll: List[sqlglot_l.ColumnLineageInfo] = [ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v6,PROD)", + column="price", + column_type=SchemaFieldDataTypeClass(type=NumberTypeClass()), + native_column_type="DOUBLE PRECISION", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v6,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(type=NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ] + + datasets = lineage_extractor._get_upstream_lineages( + sources=[ + LineageDataset( + platform=LineageDatasetPlatform.REDSHIFT, + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + ], + target_table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v4,PROD)", + raw_db_name="dev", + alias_db_name="dev", + all_tables_set={ + "dev": { + "public": set(), + } + }, + connection=MagicMock(), + target_dataset_cll=target_dataset_cll, + ) + + assert len(datasets) == 1 + + assert ( + datasets[0].urn + == "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)" + ) + + assert target_dataset_cll[0].upstreams[0].table == ( + "urn:li:dataset:(urn:li:dataPlatform:redshift," + "dev.public.player_activity,PROD)" + ) + assert target_dataset_cll[0].upstreams[0].column == "price" + assert target_dataset_cll[0].upstreams[1].column == "tax" + assert target_dataset_cll[1].upstreams[0].column == "player_id" + + +def test_collapse_temp_recursive_cll_lineage_with_circular_reference(): + lineage_extractor = get_lineage_extractor() + + temp_table: TempTableRow = TempTableRow( + transaction_id=126, + query_text="CREATE TABLE #player_price distkey(player_id) AS SELECT player_id, SUM(price_usd) AS price_usd " + "from #player_activity_temp group by player_id", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_price", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price_usd", + ) + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + + temp_table_activity: TempTableRow = TempTableRow( + transaction_id=127, + query_text="CREATE TABLE #player_activity_temp SELECT player_id, SUM(price) AS price_usd " + "from #player_price", + start_time=datetime.now(), + session_id="abc", + create_command="CREATE TABLE #player_activity_temp", + parsed_result=SqlParsingResult( + query_type=QueryType.CREATE, + in_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_activity,PROD)" + ], + out_tables=[ + "urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)" + ], + debug_info=SqlParsingDebugInfo(), + column_lineage=[ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="INTEGER", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="player_id", + ) + ], + logic=None, + ), + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price_usd", + column_type=SchemaFieldDataTypeClass(NumberTypeClass()), + native_column_type="BIGINT", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + column="price_usd", + ) + ], + logic=None, + ), + ], + ), + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_activity_temp,PROD)", + ) + + assert temp_table.urn + assert temp_table_activity.urn + + lineage_extractor.temp_tables[temp_table.urn] = temp_table + lineage_extractor.temp_tables[temp_table_activity.urn] = temp_table_activity + + target_dataset_cll: List[sqlglot_l.ColumnLineageInfo] = [ + ColumnLineageInfo( + downstream=DownstreamColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v6,PROD)", + column="price", + column_type=SchemaFieldDataTypeClass(type=NumberTypeClass()), + native_column_type="DOUBLE PRECISION", + ), + upstreams=[ + sqlglot_l.ColumnRef( + table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + column="price_usd", + ) + ], + logic=None, + ) + ] + + datasets = lineage_extractor._get_upstream_lineages( + sources=[ + LineageDataset( + platform=LineageDatasetPlatform.REDSHIFT, + urn="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.#player_price,PROD)", + ) + ], + target_table="urn:li:dataset:(urn:li:dataPlatform:redshift,dev.public.player_price_with_hike_v4,PROD)", + raw_db_name="dev", + alias_db_name="dev", + all_tables_set={ + "dev": { + "public": set(), + } + }, + connection=MagicMock(), + target_dataset_cll=target_dataset_cll, + ) + + assert len(datasets) == 1 + # Here we only interested if it fails or not From 0e418b527e64b9314c2a4da1df7794b129ac21cb Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Thu, 1 Feb 2024 16:33:15 -0800 Subject: [PATCH 247/540] fix(ingest): upgrade pytest-docker (#9765) --- metadata-ingestion/setup.py | 2 +- .../tests/test_helpers/docker_helpers.py | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index f8d51997330a9d..d4e2ada1fc68f4 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -468,7 +468,7 @@ pytest_dep, "pytest-asyncio>=0.16.0", "pytest-cov>=2.8.1", - "pytest-docker>=1.0.1", + "pytest-docker>=1.1.0", deepdiff_dep, "requests-mock", "freezegun", diff --git a/metadata-ingestion/tests/test_helpers/docker_helpers.py b/metadata-ingestion/tests/test_helpers/docker_helpers.py index 2eb61068196a23..bacb8d80b9e721 100644 --- a/metadata-ingestion/tests/test_helpers/docker_helpers.py +++ b/metadata-ingestion/tests/test_helpers/docker_helpers.py @@ -2,7 +2,7 @@ import logging import os import subprocess -from typing import Callable, Optional, Union +from typing import Callable, Iterator, List, Optional, Union import pytest import pytest_docker.plugin @@ -37,9 +37,11 @@ def wait_for_port( docker_services.wait_until_responsive( timeout=timeout, pause=pause, - check=checker - if checker - else lambda: is_responsive(container_name, container_port, hostname), + check=( + checker + if checker + else lambda: is_responsive(container_name, container_port, hostname) + ), ) logger.info(f"Container {container_name} is ready!") finally: @@ -62,14 +64,16 @@ def docker_compose_runner( ): @contextlib.contextmanager def run( - compose_file_path: Union[str, list], key: str, cleanup: bool = True - ) -> pytest_docker.plugin.Services: + compose_file_path: Union[str, List[str]], key: str, cleanup: bool = True + ) -> Iterator[pytest_docker.plugin.Services]: with pytest_docker.plugin.get_docker_services( docker_compose_command=docker_compose_command, - docker_compose_file=compose_file_path, + # We can remove the type ignore once this is merged: + # https://github.com/avast/pytest-docker/pull/108 + docker_compose_file=compose_file_path, # type: ignore docker_compose_project_name=f"{docker_compose_project_name}-{key}", docker_setup=docker_setup, - docker_cleanup=docker_cleanup if cleanup else False, + docker_cleanup=docker_cleanup if cleanup else [], ) as docker_services: yield docker_services From 63a23d784fc9993655267d18b19e7fc047b17888 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Fri, 2 Feb 2024 09:44:51 -0600 Subject: [PATCH 248/540] fix(patch): patch builder import TagUrn (#9764) --- .../metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java index ff34b187f6151e..bfd10da37bb3f0 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/builder/GlobalTagsPatchBuilder.java @@ -4,7 +4,7 @@ import static com.linkedin.metadata.Constants.GLOBAL_TAGS_ASPECT_NAME; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.linkedin.common.TagUrn; +import com.linkedin.common.urn.TagUrn; import com.linkedin.metadata.aspect.patch.PatchOperationType; import javax.annotation.Nonnull; import javax.annotation.Nullable; From 815176913c45e4f7ecb25b1732764996d03a9765 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Fri, 2 Feb 2024 15:55:36 -0500 Subject: [PATCH 249/540] feat(forms) Add support for metadata forms on entities in the UI (#9718) --- .../datahub/graphql/GmsGraphQLEngine.java | 3 +- .../src/main/resources/forms.graphql | 29 -- datahub-web-react/package.json | 1 + datahub-web-react/src/Mocks.tsx | 3 + datahub-web-react/src/app/entity/Entity.tsx | 7 +- .../src/app/entity/EntityRegistry.tsx | 7 +- .../src/app/entity/dataset/DatasetEntity.tsx | 84 ++--- .../containers/profile/EntityProfile.tsx | 6 +- .../profile/sidebar/EntityInfo/EntityInfo.tsx | 66 ++++ .../sidebar/FormInfo/CompletedView.tsx | 59 ++++ .../profile/sidebar/FormInfo/FormInfo.tsx | 48 +++ .../sidebar/FormInfo/IncompleteView.tsx | 65 ++++ .../FormInfo/OptionalPromptsRemaining.tsx | 23 ++ .../FormInfo/RequiredPromptsRemaining.tsx | 15 + .../FormInfo/SidebarFormInfoWrapper.tsx | 56 +++ .../FormInfo/VerificationAuditStamp.tsx | 26 ++ .../sidebar/FormInfo/__tests__/utils.test.ts | 187 ++++++++++ .../profile/sidebar/FormInfo/components.ts | 60 ++++ .../sidebar/FormInfo/useGetPromptInfo.ts | 38 ++ .../sidebar/FormInfo/useIsUserAssigned.ts | 24 ++ .../profile/sidebar/FormInfo/utils.ts | 324 +++++++++++++++++ .../entity/shared/entityForm/EntityForm.tsx | 15 + .../shared/entityForm/EntityFormContext.tsx | 54 +++ .../entityForm/EntityFormContextProvider.tsx | 78 +++++ .../shared/entityForm/EntityFormModal.tsx | 59 ++++ .../src/app/entity/shared/entityForm/Form.tsx | 100 ++++++ .../entity/shared/entityForm/FormByEntity.tsx | 71 ++++ .../entityForm/FormHeader/FormPageHeader.tsx | 40 +++ .../entityForm/FormHeader/components.ts | 36 ++ .../FormSelectionModal/FormItem.tsx | 101 ++++++ .../FormSelectionModal/FormRequestedBy.tsx | 24 ++ .../FormSelectionModal/FormSelectionModal.tsx | 21 ++ .../FormSelectionModal/FormSelector.tsx | 48 +++ .../entity/shared/entityForm/ProgressBar.tsx | 38 ++ .../shared/entityForm/__tests__/Form.test.tsx | 62 ++++ .../useShouldShowVerificationPrompt.test.ts | 48 +++ .../app/entity/shared/entityForm/constants.ts | 3 + .../src/app/entity/shared/entityForm/mocks.ts | 221 ++++++++++++ .../shared/entityForm/prompts/Prompt.tsx | 65 ++++ .../CompletedPromptAuditStamp.tsx | 69 ++++ .../StructuredPropertyPrompt/DateInput.tsx | 18 + .../DropdownLabel.tsx | 34 ++ .../MultiSelectInput.tsx | 82 +++++ .../MultipleStringInput.tsx | 83 +++++ .../StructuredPropertyPrompt/NumberInput.tsx | 23 ++ .../RichTextInput.tsx | 38 ++ .../SingleSelectInput.tsx | 56 +++ .../StructuredPropertyPrompt/StringInput.tsx | 31 ++ .../StructuredPropertyPrompt.tsx | 207 +++++++++++ .../UrnInput/SelectedEntity.tsx | 40 +++ .../UrnInput/UrnInput.tsx | 78 +++++ .../UrnInput/useUrnInput.tsx | 108 ++++++ .../ValueDescription.tsx | 24 ++ .../useStructuredPropertyPrompt.ts | 99 ++++++ .../prompts/StructuredPropertyPrompt/utils.ts | 36 ++ .../entityForm/prompts/VerificationPrompt.tsx | 72 ++++ .../schemaFieldPrompts/DropdownHeader.tsx | 62 ++++ .../SchemaFieldDropdown.tsx | 45 +++ .../schemaFieldPrompts/SchemaFieldPrompts.tsx | 36 ++ .../useShouldShowVerificationPrompt.ts | 38 ++ .../src/app/entity/shared/siblingUtils.ts | 3 + .../src/app/entity/shared/types.ts | 2 + .../src/app/home/HomePageRecommendations.tsx | 8 +- .../src/app/identity/user/UserList.tsx | 4 +- .../src/app/onboarding/OnboardingConfig.tsx | 2 + .../config/FormOnboardingConfig.tsx | 178 ++++++++++ .../useToggleEducationStepIdsAllowList.tsx | 18 + .../useUpdateEducationStepIdsAllowlist.tsx | 20 -- .../useUpdateEducationStepsAllowList.tsx | 22 ++ .../src/app/search/SearchHeader.tsx | 26 +- .../src/app/search/SearchPage.tsx | 6 +- .../src/app/shared/AppLogoLink.tsx | 31 ++ .../app/shared/DeferredRenderComponent.tsx | 23 ++ datahub-web-react/src/app/shared/Loading.tsx | 27 ++ .../src/app/shared/VirtualScrollChild.tsx | 24 ++ .../src/app/shared/admin/HeaderLinks.tsx | 4 +- .../src/app/shared/components.tsx | 6 + .../src/app/shared/useHasComponentRendered.ts | 18 + datahub-web-react/src/graphql/dataset.graphql | 21 ++ datahub-web-react/src/graphql/form.graphql | 7 + .../src/graphql/fragments.graphql | 64 ++++ .../src/images/background_dots.svg | 330 ++++++++++++++++++ .../src/images/bulk-form-type-comparison.svg | 68 ++++ .../src/images/greenCircleTwoTone.svg | 5 + .../src/images/verificationBlue.svg | 4 + .../src/images/verificationGreen.svg | 4 + .../src/images/verificationPurple.svg | 4 + .../src/images/verificationPurpleWhite.svg | 4 + .../src/images/verificationWarningGray.svg | 4 + .../src/images/verticalogo copy.png | Bin 0 -> 25763 bytes datahub-web-react/yarn.lock | 5 + 91 files changed, 4308 insertions(+), 128 deletions(-) create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntityInfo/EntityInfo.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/CompletedView.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/FormInfo.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/IncompleteView.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/OptionalPromptsRemaining.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/RequiredPromptsRemaining.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/SidebarFormInfoWrapper.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/VerificationAuditStamp.tsx create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/__tests__/utils.test.ts create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/components.ts create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useGetPromptInfo.ts create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useIsUserAssigned.ts create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/EntityForm.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/EntityFormContext.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/EntityFormContextProvider.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/EntityFormModal.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/Form.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormByEntity.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormHeader/FormPageHeader.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormHeader/components.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormItem.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormRequestedBy.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelectionModal.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelector.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/ProgressBar.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/__tests__/Form.test.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/__tests__/useShouldShowVerificationPrompt.test.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/constants.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/mocks.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/Prompt.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/CompletedPromptAuditStamp.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultipleStringInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/NumberInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/RichTextInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/SingleSelectInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/SelectedEntity.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/UrnInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/useUrnInput.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/ValueDescription.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/utils.ts create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/VerificationPrompt.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/DropdownHeader.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldDropdown.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldPrompts.tsx create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/useShouldShowVerificationPrompt.ts create mode 100644 datahub-web-react/src/app/onboarding/config/FormOnboardingConfig.tsx create mode 100644 datahub-web-react/src/app/onboarding/useToggleEducationStepIdsAllowList.tsx delete mode 100644 datahub-web-react/src/app/onboarding/useUpdateEducationStepIdsAllowlist.tsx create mode 100644 datahub-web-react/src/app/onboarding/useUpdateEducationStepsAllowList.tsx create mode 100644 datahub-web-react/src/app/shared/AppLogoLink.tsx create mode 100644 datahub-web-react/src/app/shared/DeferredRenderComponent.tsx create mode 100644 datahub-web-react/src/app/shared/Loading.tsx create mode 100644 datahub-web-react/src/app/shared/VirtualScrollChild.tsx create mode 100644 datahub-web-react/src/app/shared/useHasComponentRendered.ts create mode 100644 datahub-web-react/src/graphql/form.graphql create mode 100644 datahub-web-react/src/images/background_dots.svg create mode 100644 datahub-web-react/src/images/bulk-form-type-comparison.svg create mode 100644 datahub-web-react/src/images/greenCircleTwoTone.svg create mode 100644 datahub-web-react/src/images/verificationBlue.svg create mode 100644 datahub-web-react/src/images/verificationGreen.svg create mode 100644 datahub-web-react/src/images/verificationPurple.svg create mode 100644 datahub-web-react/src/images/verificationPurpleWhite.svg create mode 100644 datahub-web-react/src/images/verificationWarningGray.svg create mode 100644 datahub-web-react/src/images/verticalogo copy.png diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 41f48e0a7dc3e6..8d9b9a5ad82c84 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -92,6 +92,7 @@ import com.linkedin.datahub.graphql.generated.QuerySubject; import com.linkedin.datahub.graphql.generated.QuickFilter; import com.linkedin.datahub.graphql.generated.RecommendationContent; +import com.linkedin.datahub.graphql.generated.ResolvedAuditStamp; import com.linkedin.datahub.graphql.generated.SchemaField; import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; import com.linkedin.datahub.graphql.generated.SearchAcrossLineageResult; @@ -1642,7 +1643,7 @@ private void configureResolvedAuditStampResolvers(final RuntimeWiring.Builder bu typeWiring.dataFetcher( "actor", new LoadableTypeResolver<>( - corpUserType, (env) -> ((CorpUser) env.getSource()).getUrn()))); + corpUserType, (env) -> ((ResolvedAuditStamp) env.getSource()).getActor().getUrn()))); } /** diff --git a/datahub-graphql-core/src/main/resources/forms.graphql b/datahub-graphql-core/src/main/resources/forms.graphql index 0ff55cfa9f1733..a0f84f8e3bb1a6 100644 --- a/datahub-graphql-core/src/main/resources/forms.graphql +++ b/datahub-graphql-core/src/main/resources/forms.graphql @@ -275,20 +275,6 @@ input SubmitFormPromptInput { structuredPropertyParams: StructuredPropertyInputParams } -""" -Input for responding to a singular prompt in a form for a batch of entities -""" -input BatchSubmitFormPromptInput { - """ - The urns of the entities this prompt submission is for - """ - assetUrns: [String!]! - - """ - Input for responding to a specific prompt on a form - """ - input: SubmitFormPromptInput -} """ Input for collecting structured property values to apply to entities @@ -390,18 +376,3 @@ input VerifyFormInput { """ entityUrn: String! } - -""" -Input for verifying a batch of entities for a give form -""" -input BatchVerifyFormInput { - """ - The urns of the entities getting verified for this form - """ - assetUrns: [String!]! - - """ - The urn of the form being verified on the given entities - """ - formUrn: String! -} diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index 97830cec4e164e..337b0dc87ec1c1 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -65,6 +65,7 @@ "react-helmet-async": "^1.3.0", "react-highlighter": "^0.4.3", "react-icons": "4.3.1", + "react-intersection-observer": "^9.5.3", "react-js-cron": "^2.1.0", "react-router": "^5.3", "react-router-dom": "^5.3", diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 9f339bb7db548a..f533e8d50385b1 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -298,6 +298,7 @@ export const dataset1 = { browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, autoRenderAspects: [], structuredProperties: null, + forms: null, }; export const dataset2 = { @@ -395,6 +396,7 @@ export const dataset2 = { browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' }, autoRenderAspects: [], structuredProperties: null, + forms: null, }; export const dataset3 = { @@ -629,6 +631,7 @@ export const dataset3 = { lastProfile: null, lastOperation: null, structuredProperties: null, + forms: null, } as Dataset; export const dataset3WithSchema = { diff --git a/datahub-web-react/src/app/entity/Entity.tsx b/datahub-web-react/src/app/entity/Entity.tsx index 5920919a9cdab2..3277051661bf1b 100644 --- a/datahub-web-react/src/app/entity/Entity.tsx +++ b/datahub-web-react/src/app/entity/Entity.tsx @@ -1,6 +1,6 @@ import { EntityType, SearchResult } from '../../types.generated'; import { FetchedEntity } from '../lineage/types'; -import { GenericEntityProperties } from './shared/types'; +import { EntitySidebarSection, GenericEntityProperties } from './shared/types'; export enum PreviewType { /** @@ -176,4 +176,9 @@ export interface Entity { * Returns the profile component to be displayed in our Chrome extension */ renderEmbeddedProfile?: (urn: string) => JSX.Element; + + /** + * Returns the entity profile sidebar sections for an entity type. Only implemented on Datasets for now. + */ + getSidebarSections?: () => EntitySidebarSection[]; } diff --git a/datahub-web-react/src/app/entity/EntityRegistry.tsx b/datahub-web-react/src/app/entity/EntityRegistry.tsx index 6642c2c7b0467c..4a2e0e386b7686 100644 --- a/datahub-web-react/src/app/entity/EntityRegistry.tsx +++ b/datahub-web-react/src/app/entity/EntityRegistry.tsx @@ -4,7 +4,7 @@ import { FetchedEntity } from '../lineage/types'; import { SearchResultProvider } from '../search/context/SearchResultContext'; import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from './Entity'; import { GLOSSARY_ENTITY_TYPES } from './shared/constants'; -import { GenericEntityProperties } from './shared/types'; +import { EntitySidebarSection, GenericEntityProperties } from './shared/types'; import { dictToQueryStringParams, getFineGrainedLineageWithSiblings, urlEncodeUrn } from './shared/utils'; function validatedGet(key: K, map: Map): V { @@ -194,6 +194,11 @@ export default class EntityRegistry { return entity.displayName(data); } + getSidebarSections(type: EntityType): EntitySidebarSection[] { + const entity = validatedGet(type, this.entityTypeToEntity); + return entity.getSidebarSections ? entity.getSidebarSections() : []; + } + getGenericEntityProperties(type: EntityType, data: T): GenericEntityProperties | null { const entity = validatedGet(type, this.entityTypeToEntity); return entity.getGenericEntityProperties(data); diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index f60eb959374527..90fac38ebd6b3c 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -190,51 +190,51 @@ export class DatasetEntity implements Entity { }, }, ]} - sidebarSections={[ - { - component: SidebarAboutSection, - }, - { - component: SidebarOwnerSection, - properties: { - defaultOwnerType: OwnershipType.TechnicalOwner, - }, - }, - { - component: SidebarSiblingsSection, - display: { - visible: (_, dataset: GetDatasetQuery) => - (dataset?.dataset?.siblings?.siblings?.length || 0) > 0, - }, - }, - { - component: SidebarViewDefinitionSection, - display: { - visible: (_, dataset: GetDatasetQuery) => - (dataset?.dataset?.viewProperties?.logic && true) || false, - }, - }, - { - component: SidebarTagsSection, - properties: { - hasTags: true, - hasTerms: true, - }, - }, - { - component: SidebarDomainSection, - }, - { - component: DataProductSection, - }, - // TODO: Add back once entity-level recommendations are complete. - // { - // component: SidebarRecommendationsSection, - // }, - ]} + sidebarSections={this.getSidebarSections()} /> ); + getSidebarSections = () => [ + { + component: SidebarAboutSection, + }, + { + component: SidebarOwnerSection, + properties: { + defaultOwnerType: OwnershipType.TechnicalOwner, + }, + }, + { + component: SidebarSiblingsSection, + display: { + visible: (_, dataset: GetDatasetQuery) => (dataset?.dataset?.siblings?.siblings?.length || 0) > 0, + }, + }, + { + component: SidebarViewDefinitionSection, + display: { + visible: (_, dataset: GetDatasetQuery) => (dataset?.dataset?.viewProperties?.logic && true) || false, + }, + }, + { + component: SidebarTagsSection, + properties: { + hasTags: true, + hasTerms: true, + }, + }, + { + component: SidebarDomainSection, + }, + { + component: DataProductSection, + }, + // TODO: Add back once entity-level recommendations are complete. + // { + // component: SidebarRecommendationsSection, + // }, + ]; + getOverridePropertiesFromEntity = (dataset?: Dataset | null): GenericEntityProperties => { // if dataset has subTypes filled out, pick the most specific subtype and return it const subTypes = dataset?.subTypes; diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx index a781c732c9de63..60d67355d5d7dd 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx @@ -45,6 +45,7 @@ import { import { useAppConfig } from '../../../../useAppConfig'; import { useUpdateDomainEntityDataOnChange } from '../../../../domain/utils'; import ProfileSidebar from './sidebar/ProfileSidebar'; +import SidebarFormInfoWrapper from './sidebar/FormInfo/SidebarFormInfoWrapper'; type Props = { urn: string; @@ -333,7 +334,10 @@ export const EntityProfile = ({ - + )} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntityInfo/EntityInfo.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntityInfo/EntityInfo.tsx new file mode 100644 index 00000000000000..1d1400a8cc7539 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/EntityInfo/EntityInfo.tsx @@ -0,0 +1,66 @@ +import Link from 'antd/lib/typography/Link'; +import React from 'react'; +import styled from 'styled-components'; +import PlatformContent from '../../header/PlatformContent'; +import { useEntityData } from '../../../../EntityContext'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import { StyledDivider } from '../FormInfo/components'; +import { DatasetStatsSummarySubHeader } from '../../../../../dataset/profile/stats/stats/DatasetStatsSummarySubHeader'; +import LinkOut from '../../../../../../../images/link-out.svg?react'; +import FormInfo from '../FormInfo/FormInfo'; + +const EntityName = styled.div` + font-size: 16px; + font-weight: 700; + line-height: 24px; + margin-bottom: 8px; +`; + +const EntityInfoWrapper = styled.div` + padding-top: 20px; +`; + +const StyledLink = styled(Link)` + font-size: 14px; + line-height: 18px; + display: inline-flex; + align-items: center; + + svg { + height: 14px; + width: 14px; + } +`; + +const FormInfoWrapper = styled.div` + margin-top: 12px; +`; + +interface Props { + formUrn: string; +} + +export default function EntityInfo({ formUrn }: Props) { + const entityRegistry = useEntityRegistry(); + const { entityType, entityData } = useEntityData(); + const entityName = entityData ? entityRegistry.getDisplayName(entityType, entityData) : ''; + + return ( + + + {entityName} + + View Profile + + + + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/CompletedView.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/CompletedView.tsx new file mode 100644 index 00000000000000..f8c0b74cc2cd4f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/CompletedView.tsx @@ -0,0 +1,59 @@ +import Link from 'antd/lib/typography/Link'; +import React from 'react'; +import styled from 'styled-components'; +import GreenVerificationLogo from '../../../../../../../images/verificationGreen.svg?react'; +import PurpleVerificationLogo from '../../../../../../../images/verificationPurple.svg?react'; +import { CTAWrapper, FlexWrapper, StyledIcon, StyledReadOutlined, Title } from './components'; +import OptionalPromptsRemaining from './OptionalPromptsRemaining'; +import VerificationAuditStamp from './VerificationAuditStamp'; + +const StyledLink = styled(Link)` + margin-top: 8px; + font-size: 12px; + display: block; +`; + +interface Props { + showVerificationStyles: boolean; + numOptionalPromptsRemaining: number; + isUserAssigned: boolean; + formUrn?: string; + shouldDisplayBackground?: boolean; + openFormModal?: () => void; +} + +export default function CompletedView({ + showVerificationStyles, + numOptionalPromptsRemaining, + isUserAssigned, + formUrn, + shouldDisplayBackground, + openFormModal, +}: Props) { + return ( + + + {showVerificationStyles ? ( + + ) : ( + + )} +

    + {showVerificationStyles ? 'Verified' : 'Documented'} + + {isUserAssigned && ( + <> + + {!!openFormModal && ( + View and edit responses + )} + + )} +
    + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/FormInfo.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/FormInfo.tsx new file mode 100644 index 00000000000000..681555a919b636 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/FormInfo.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useEntityData } from '../../../../EntityContext'; +import useGetPromptInfo from './useGetPromptInfo'; +import { isVerificationComplete, shouldShowVerificationInfo } from './utils'; +import CompletedView from './CompletedView'; +import IncompleteView from './IncompleteView'; +import useIsUserAssigned from './useIsUserAssigned'; + +interface Props { + formUrn?: string; + shouldDisplayBackground?: boolean; + openFormModal?: () => void; +} + +export default function FormInfo({ formUrn, shouldDisplayBackground, openFormModal }: Props) { + const { entityData } = useEntityData(); + const { numRequiredPromptsRemaining, numOptionalPromptsRemaining } = useGetPromptInfo(formUrn); + const showVerificationInfo = shouldShowVerificationInfo(entityData, formUrn); + const isUserAssigned = useIsUserAssigned(); + const allRequiredPromptsAreComplete = numRequiredPromptsRemaining === 0; + + const shouldShowCompletedView = showVerificationInfo + ? allRequiredPromptsAreComplete && isVerificationComplete(entityData, formUrn) + : allRequiredPromptsAreComplete; + + if (shouldShowCompletedView) { + return ( + + ); + } + + return ( + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/IncompleteView.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/IncompleteView.tsx new file mode 100644 index 00000000000000..b08e1baec5f54a --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/IncompleteView.tsx @@ -0,0 +1,65 @@ +import { Button } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import PurpleVerificationLogo from '../../../../../../../images/verificationPurple.svg?react'; +import GrayVerificationIcon from '../../../../../../../images/verificationWarningGray.svg?react'; +import { CTAWrapper, FlexWrapper, StyledIcon, StyledReadFilled, Title } from './components'; +import OptionalPromptsRemaining from './OptionalPromptsRemaining'; +import RequiredPromptsRemaining from './RequiredPromptsRemaining'; + +const StyledButton = styled(Button)` + width: 100%; + margin-top: 12px; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +`; + +interface Props { + showVerificationStyles: boolean; + numOptionalPromptsRemaining: number; + numRequiredPromptsRemaining: number; + isUserAssigned: boolean; + openFormModal?: () => void; +} + +export default function IncompleteView({ + showVerificationStyles, + numOptionalPromptsRemaining, + numRequiredPromptsRemaining, + isUserAssigned, + openFormModal, +}: Props) { + return ( + + + {isUserAssigned && ( + <> + {showVerificationStyles ? ( + + ) : ( + + )} + + )} + {!isUserAssigned && } +
    + Awaiting {showVerificationStyles ? 'Verification' : 'Documentation'} + {isUserAssigned && ( + <> + You are being asked to complete a set of requirements for this entity. + + + + )} +
    +
    + {!!openFormModal && isUserAssigned && ( + + {showVerificationStyles ? 'Complete Verification' : 'Complete Documentation'} + + )} +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/OptionalPromptsRemaining.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/OptionalPromptsRemaining.tsx new file mode 100644 index 00000000000000..3198453ce467a1 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/OptionalPromptsRemaining.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import styled from 'styled-components'; +import { pluralize } from '../../../../../../shared/textUtil'; +import { ANTD_GRAY_V2 } from '../../../../constants'; + +const OptionalPromptsWrapper = styled.div` + color: ${ANTD_GRAY_V2[8]}; + margin-top: 4px; +`; + +interface Props { + numRemaining: number; +} + +export default function OptionalPromptsRemaining({ numRemaining }: Props) { + if (numRemaining <= 0) return null; + + return ( + + {numRemaining} additional {pluralize(numRemaining, 'question', 's')} remaining + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/RequiredPromptsRemaining.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/RequiredPromptsRemaining.tsx new file mode 100644 index 00000000000000..e275b2d75146ac --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/RequiredPromptsRemaining.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { pluralize } from '../../../../../../shared/textUtil'; +import { SubTitle } from './components'; + +interface Props { + numRemaining: number; +} + +export default function RequiredPromptsRemaining({ numRemaining }: Props) { + return ( + + {numRemaining} required {pluralize(numRemaining, 'question', 's')} remaining + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/SidebarFormInfoWrapper.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/SidebarFormInfoWrapper.tsx new file mode 100644 index 00000000000000..9340a7f51d537d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/SidebarFormInfoWrapper.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import EntityFormModal from '../../../../entityForm/EntityFormModal'; +import FormInfo from './FormInfo'; +import { StyledDivider } from './components'; +import { useEntityData } from '../../../../EntityContext'; +import { getFormAssociations } from './utils'; +import FormSelectionModal from '../../../../entityForm/FormSelectionModal/FormSelectionModal'; + +const FormInfoWrapper = styled.div` + margin-top: 16px; +`; + +export default function SidebarFormInfoWrapper() { + const { entityData } = useEntityData(); + const [isFormSelectionModalVisible, setIsFormSelectionModalVisible] = useState(false); + const [isFormVisible, setIsFormVisible] = useState(false); + const [selectedFormUrn, setSelectedFormUrn] = useState(null); + const formAssociations = getFormAssociations(entityData); + + if (!formAssociations.length) return null; + + function openFormModal() { + if (formAssociations.length === 1) { + setSelectedFormUrn(formAssociations[0].form.urn); + setIsFormVisible(true); + } else { + setIsFormSelectionModalVisible(true); + } + } + + function selectFormUrn(urn: string) { + setSelectedFormUrn(urn); + setIsFormVisible(true); + setIsFormSelectionModalVisible(false); + } + + return ( + <> + + + + + setIsFormVisible(false)} + /> + setIsFormSelectionModalVisible(false)} + selectFormUrn={selectFormUrn} + /> + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/VerificationAuditStamp.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/VerificationAuditStamp.tsx new file mode 100644 index 00000000000000..f4373632418f42 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/VerificationAuditStamp.tsx @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; +import React from 'react'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import { getVerificationAuditStamp } from './utils'; +import { useEntityData } from '../../../../EntityContext'; + +interface Props { + formUrn?: string; +} + +export default function VerificationAuditStamp({ formUrn }: Props) { + const entityRegistry = useEntityRegistry(); + const { entityData } = useEntityData(); + const verifiedAuditStamp = getVerificationAuditStamp(entityData, formUrn); + const verifiedTimestamp = verifiedAuditStamp?.time; + const verifiedActor = verifiedAuditStamp?.actor; + + if (!verifiedTimestamp) return null; + + return ( +
    + On {dayjs(verifiedTimestamp).format('ll')}{' '} + {verifiedActor && <>by {entityRegistry.getDisplayName(verifiedActor.type, verifiedActor)}} +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/__tests__/utils.test.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/__tests__/utils.test.ts new file mode 100644 index 00000000000000..7898befaf7ee3c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/__tests__/utils.test.ts @@ -0,0 +1,187 @@ +import { FormAssociation, FormPrompt } from '../../../../../../../../types.generated'; +import { mockEntityData, mockEntityDataAllVerified } from '../../../../../entityForm/mocks'; +import { isAssignedToForm } from '../useIsUserAssigned'; +import { + getEntityPromptsInfo, + getFieldPromptsInfo, + getFormAssociations, + getNumEntityPromptsRemaining, + getNumPromptsCompletedForField, + getNumSchemaFieldPromptsRemaining, + getPromptsForForm, + getVerificationAuditStamp, + isVerificationComplete, + shouldShowVerificationInfo, +} from '../utils'; + +// only looking at IDs +const prompts = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }] as FormPrompt[]; + +describe('form prompt utils', () => { + it('should get the correct number of top-level prompts remaining', () => { + const numPromptsRemaining = getNumEntityPromptsRemaining(prompts, mockEntityData); + expect(numPromptsRemaining).toBe(2); + }); + + // if there are 2 top level prompts for schema fields, 8 fields in the schema, then there are 16 total schema-field prompts + // there are 5 completed prompts in the mock data, should have 11 remaining + it('should get the correct number of field-level prompts remaining', () => { + const fieldFormPrompts = [{ id: '1' }, { id: '2' }] as FormPrompt[]; + const numPromptsRemaining = getNumSchemaFieldPromptsRemaining(mockEntityData, fieldFormPrompts, 8); + expect(numPromptsRemaining).toBe(11); + }); + + it('should get the correct number of field-level prompts remaining given a form urn', () => { + const fieldFormPrompts = [{ id: '1' }, { id: '2' }] as FormPrompt[]; + const numPromptsRemaining = getNumSchemaFieldPromptsRemaining( + mockEntityData, + fieldFormPrompts, + 8, + 'urn:li:form:1', + ); + expect(numPromptsRemaining).toBe(11); + }); + + it('should get the correct number of field-level prompts remaining given a form urn with no field level prompts completed', () => { + const fieldFormPrompts = [{ id: '3' }] as FormPrompt[]; + const numPromptsRemaining = getNumSchemaFieldPromptsRemaining( + mockEntityData, + fieldFormPrompts, + 8, + 'urn:li:form:2', + ); + // none are completed in this form, with only 1 schema field prompt with 8 schema fields, so all 8 should be remaining + expect(numPromptsRemaining).toBe(8); + }); + + it('should get the numer of completed prompts for a given schema field in incompletePrompts', () => { + const numCompleted = getNumPromptsCompletedForField('test2', mockEntityData, 'urn:li:form:1'); + expect(numCompleted).toBe(1); + }); + + it('should get the numer of completed prompts for a given schema field in completedPrompts and incompletePrompts', () => { + const numCompleted = getNumPromptsCompletedForField('test3', mockEntityData, 'urn:li:form:1'); + expect(numCompleted).toBe(2); + }); + + it('should get the prompts for a given form urn correctly', () => { + const promptsForForm = getPromptsForForm('urn:li:form:1', mockEntityData); + expect(promptsForForm.length).toBe(2); + expect(promptsForForm.map((p) => p.id)).toMatchObject(['1', '2']); + }); + + it('should get information for entity specific prompts', () => { + const promptsForForm = getPromptsForForm('urn:li:form:2', mockEntityData); + const { entityPrompts, numOptionalEntityPromptsRemaining, numRequiredEntityPromptsRemaining } = + getEntityPromptsInfo(promptsForForm, mockEntityData); + + expect(entityPrompts.length).toBe(2); + expect(entityPrompts.map((p) => p.id)).toMatchObject(['3', '5']); + expect(numOptionalEntityPromptsRemaining).toBe(1); + expect(numRequiredEntityPromptsRemaining).toBe(0); + }); + + it('should get information for field specific prompts', () => { + const promptsForForm = getPromptsForForm('urn:li:form:1', mockEntityData); + const { fieldPrompts, numOptionalFieldPromptsRemaining, numRequiredFieldPromptsRemaining } = + getFieldPromptsInfo(promptsForForm, mockEntityData, 8, 'urn:li:form:1'); + + expect(fieldPrompts.length).toBe(2); + expect(fieldPrompts.map((p) => p.id)).toMatchObject(['1', '2']); + expect(numOptionalFieldPromptsRemaining).toBe(11); + expect(numRequiredFieldPromptsRemaining).toBe(0); + }); + + it('should get all form associations for an entity', () => { + const formAssociations = getFormAssociations(mockEntityData); + expect(formAssociations.length).toBe(3); + expect(formAssociations.map((f) => f.form.urn)).toMatchObject([ + 'urn:li:form:1', + 'urn:li:form:2', + 'urn:li:form:3', + ]); + }); +}); + +describe('useIsUserAssigned utils tests', () => { + it('should return true if user is an owner and the form is assigned to owners', () => { + const isAssigned = isAssignedToForm(mockEntityData.forms?.incompleteForms[0] as FormAssociation, true); + expect(isAssigned).toBe(true); + }); + + it('should return false if user is not an owner and the form is assigned to owners', () => { + const isAssigned = isAssignedToForm(mockEntityData.forms?.incompleteForms[0] as FormAssociation, false); + expect(isAssigned).toBe(false); + }); + + it('should return true if the user is explicitly assigned', () => { + const isAssigned = isAssignedToForm(mockEntityData.forms?.completedForms[0] as FormAssociation, false); + expect(isAssigned).toBe(true); + }); +}); + +describe('shouldShowVerificationInfo', () => { + it('should return true if a form is supplied that is a verification form', () => { + const showVerificationInfo = shouldShowVerificationInfo(mockEntityData, 'urn:li:form:1'); + expect(showVerificationInfo).toBe(true); + }); + + it('should return false if a form is supplied that is not a verification form', () => { + const isAssigned = shouldShowVerificationInfo(mockEntityData, 'urn:li:form:3'); + expect(isAssigned).toBe(false); + }); + + it('should return true if no formUrn is supplied and there is a verification form', () => { + const isAssigned = shouldShowVerificationInfo(mockEntityData); + expect(isAssigned).toBe(true); + }); +}); + +describe('getVerificationAuditStamp', () => { + it('should return the audit stamp for a given form', () => { + const auditStamp = getVerificationAuditStamp(mockEntityData, 'urn:li:form:2'); + expect(auditStamp).toMatchObject({ + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 100, + }); + }); + + it('should return undefined for audit stamp for a given form with no verifications', () => { + const auditStamp = getVerificationAuditStamp(mockEntityData, 'urn:li:form:1'); + expect(auditStamp).toBe(null); + }); + + it('should return the most recent audit stamp when not given form', () => { + const auditStamp = getVerificationAuditStamp(mockEntityData); + expect(auditStamp).toMatchObject({ + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 101, + }); + }); +}); + +describe('isVerificationComplete', () => { + it('should return true if the given form is verified', () => { + const isComplete = isVerificationComplete(mockEntityData, 'urn:li:form:2'); + expect(isComplete).toBe(true); + }); + + it('should return false if the given form is not verified', () => { + const isComplete = isVerificationComplete(mockEntityData, 'urn:li:form:1'); + expect(isComplete).toBe(false); + }); + + it('should return false if no form is given and not all verification forms are complete', () => { + const isComplete = isVerificationComplete(mockEntityData); + expect(isComplete).toBe(false); + }); + + it('should return true if no form is given and all verification forms are complete', () => { + const isComplete = isVerificationComplete(mockEntityDataAllVerified); + expect(isComplete).toBe(true); + }); +}); diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/components.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/components.ts new file mode 100644 index 00000000000000..3c6aef5517d3db --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/components.ts @@ -0,0 +1,60 @@ +import { ReadFilled, ReadOutlined } from '@ant-design/icons'; +import Icon from '@ant-design/icons/lib/components/Icon'; +import { Divider } from 'antd'; +import styled from 'styled-components'; + +export const FlexWrapper = styled.div` + display: flex; + line-height: 18px; +`; + +export const StyledIcon = styled(Icon)<{ addLineHeight?: boolean }>` + font-size: 18px; + margin-right: 8px; + ${(props) => props.addLineHeight && `line-height: 24px;`} +`; + +export const SubTitle = styled.div<{ addMargin?: boolean }>` + font-weight: 600; + margin-bottom: 4px; + ${(props) => props.addMargin && `margin-top: 8px;`} +`; + +export const Title = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +`; + +export const StyledDivider = styled(Divider)` + margin: 12px 0 0 0; +`; + +export const StyledReadOutlined = styled(ReadOutlined)<{ addLineHeight?: boolean }>` + margin-right: 8px; + height: 13.72px; + width: 17.5px; + color: #373d44; + ${(props) => props.addLineHeight && `line-height: 24px;`} +`; + +export const StyledReadFilled = styled(ReadFilled)<{ addLineHeight?: boolean }>` + margin-right: 8px; + height: 13.72px; + width: 17.5px; + color: #7532a4; + ${(props) => props.addLineHeight && `line-height: 24px;`} +`; + +export const CTAWrapper = styled.div<{ shouldDisplayBackground?: boolean }>` + color: #373d44; + font-size: 14px; + ${(props) => + props.shouldDisplayBackground && + ` + border-radius: 8px; + padding: 16px; + background-color: #f9f0ff; + border: 1px solid #8338b8; + `} +`; diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useGetPromptInfo.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useGetPromptInfo.ts new file mode 100644 index 00000000000000..252cefb9f6f4f2 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useGetPromptInfo.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import { useEntityData } from '../../../../EntityContext'; +import { useGetEntityWithSchema } from '../../../../tabs/Dataset/Schema/useGetEntitySchema'; +import { getAllPrompts, getEntityPromptsInfo, getFieldPromptsInfo, getPromptsForForm } from './utils'; + +export default function useGetPromptInfo(formUrn?: string) { + const { entityData } = useEntityData(); + const { entityWithSchema } = useGetEntityWithSchema(); + const prompts = useMemo( + () => (formUrn ? getPromptsForForm(formUrn, entityData) : getAllPrompts(entityData)), + [formUrn, entityData], + ); + + const { + entityPrompts, + numRequiredEntityPromptsRemaining, + numOptionalEntityPromptsRemaining, + requiredEntityPrompts, + } = getEntityPromptsInfo(prompts, entityData); + const { fieldPrompts, numRequiredFieldPromptsRemaining, numOptionalFieldPromptsRemaining, requiredFieldPrompts } = + getFieldPromptsInfo(prompts, entityData, entityWithSchema?.schemaMetadata?.fields.length || 0, formUrn); + // Multiply number of field prompts by number of schema fields for total number of schema field prompts + const totalRequiredSchemaFieldPrompts = + (entityWithSchema?.schemaMetadata?.fields.length || 0) * requiredFieldPrompts.length; + + const numRequiredPromptsRemaining = numRequiredEntityPromptsRemaining + numRequiredFieldPromptsRemaining; + const numOptionalPromptsRemaining = numOptionalEntityPromptsRemaining + numOptionalFieldPromptsRemaining; + + return { + prompts, + fieldPrompts, + totalRequiredSchemaFieldPrompts, + entityPrompts, + numRequiredPromptsRemaining, + numOptionalPromptsRemaining, + requiredEntityPrompts, + }; +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useIsUserAssigned.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useIsUserAssigned.ts new file mode 100644 index 00000000000000..067513bfef03d0 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/useIsUserAssigned.ts @@ -0,0 +1,24 @@ +import { FormAssociation } from '../../../../../../../types.generated'; +import { useUserContext } from '../../../../../../context/useUserContext'; +import { useEntityData } from '../../../../EntityContext'; +import { getFormAssociations } from './utils'; + +export function isAssignedToForm(formAssociation: FormAssociation, isUserAnOwner: boolean) { + const { isAssignedToMe, owners: isAssignedToOwners } = formAssociation.form.info.actors; + return isAssignedToMe || (isAssignedToOwners && isUserAnOwner); +} + +// returns true if this user is assigned (explicitly or by ownership) to a given form or any forms on this entity +export default function useIsUserAssigned(formUrn?: string) { + const { entityData } = useEntityData(); + const owners = entityData?.ownership?.owners; + const { user: loggedInUser } = useUserContext(); + const isUserAnOwner = !!owners?.find((owner) => owner.owner.urn === loggedInUser?.urn); + + const formAssociations = getFormAssociations(entityData); + if (formUrn) { + const formAssociation = formAssociations.find((association) => association.form.urn === formUrn); + return formAssociation ? isAssignedToForm(formAssociation, isUserAnOwner) : false; + } + return formAssociations.some((formAssociation) => isAssignedToForm(formAssociation, isUserAnOwner)); +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts new file mode 100644 index 00000000000000..48206b411374c4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts @@ -0,0 +1,324 @@ +import { Maybe } from 'graphql/jsutils/Maybe'; +import { + FieldFormPromptAssociation, + FormAssociation, + FormPrompt, + FormPromptAssociation, + FormType, + ResolvedAuditStamp, + SchemaField, +} from '../../../../../../../types.generated'; +import { SCHEMA_FIELD_PROMPT_TYPES } from '../../../../entityForm/constants'; +import { GenericEntityProperties } from '../../../../types'; + +export function getFormAssociations(entityData: GenericEntityProperties | null) { + return [...(entityData?.forms?.incompleteForms || []), ...(entityData?.forms?.completedForms || [])]; +} + +export function getFormAssociation(formUrn: string, entityData: GenericEntityProperties | null) { + return ( + entityData?.forms?.incompleteForms?.find((formAssoc) => formAssoc.form.urn === formUrn) || + entityData?.forms?.completedForms?.find((formAssoc) => formAssoc.form.urn === formUrn) + ); +} + +/* + * For a given prompt, get all of the completed field prompts. + * Takes in an optional set of prompt IDs, if this exists, only return the + * completed field prompts for this prompt if this prompt ID is in the set. + */ +function getCompletedFieldPromptsFromPrompt(prompt: FormPromptAssociation, relevantFieldFormPromptIds?: Set) { + if (relevantFieldFormPromptIds && relevantFieldFormPromptIds.has(prompt.id)) { + return prompt.fieldAssociations?.completedFieldPrompts || []; + } + if (!relevantFieldFormPromptIds) { + return prompt.fieldAssociations?.completedFieldPrompts || []; + } + return []; +} + +/* + * For a given form, loop over all prompts and retrieve completedFieldPrompts from each. + * Note: we pass in an optional set of prompt IDs to choose from in order to get completed + * field prompts for a certain set of entity prompts id we choose. + */ +export function getCompletedFieldPromptsFromForm( + formAssociation: FormAssociation, + relevantFieldFormPromptIds?: Set, +) { + let completedFieldPromptAssociations: FieldFormPromptAssociation[] = []; + formAssociation.completedPrompts?.forEach((completedPrompt) => { + completedFieldPromptAssociations = completedFieldPromptAssociations.concat( + getCompletedFieldPromptsFromPrompt(completedPrompt, relevantFieldFormPromptIds), + ); + }); + formAssociation.incompletePrompts?.forEach((incompletPrompt) => { + completedFieldPromptAssociations = completedFieldPromptAssociations.concat( + getCompletedFieldPromptsFromPrompt(incompletPrompt, relevantFieldFormPromptIds), + ); + }); + return completedFieldPromptAssociations; +} + +/* + * Gets a list of the completed field prompt associations which live as children to top + * level prompt associations for each schema field. + * We need to loop over every prompt both completed and incomplete, form complete and incomplete forms. + * For each prompt, get their list of completedFieldPrompts + * Takes in an optional list of relevant prompt IDs to filter results down. + */ +export function getCompletedFieldPromptAssociations( + entityData: GenericEntityProperties | null, + relevantFieldFormPromptIds?: Set, +) { + let completedFieldPromptAssociations: FieldFormPromptAssociation[] = []; + + const forms = entityData?.forms; + forms?.completedForms?.forEach((formAssociation) => { + completedFieldPromptAssociations = completedFieldPromptAssociations.concat( + getCompletedFieldPromptsFromForm(formAssociation, relevantFieldFormPromptIds), + ); + }); + forms?.incompleteForms?.forEach((formAssociation) => { + completedFieldPromptAssociations = completedFieldPromptAssociations.concat( + getCompletedFieldPromptsFromForm(formAssociation, relevantFieldFormPromptIds), + ); + }); + + return completedFieldPromptAssociations; +} + +/* + * For a given form, gets a list of the completed field prompt associations which live + * as children to top level prompt associations for each schema field. + */ +export function getCompletedFieldPromptAssociationsForForm( + formUrn: string, + entityData: GenericEntityProperties | null, + relevantFieldFormPromptIds?: Set, +) { + const formAssociation = getFormAssociation(formUrn, entityData); + return formAssociation ? getCompletedFieldPromptsFromForm(formAssociation, relevantFieldFormPromptIds) : []; +} + +export function getNumPromptsCompletedForField( + fieldPath: string, + entityData: GenericEntityProperties | null, + formUrn: string, +) { + const completedFieldPromptAssociations = getCompletedFieldPromptAssociationsForForm(formUrn, entityData); + return completedFieldPromptAssociations.filter((association) => association.fieldPath === fieldPath).length; +} + +/* + * Returns the number of schema field prompts not completed yet. + * The total number of schema field prompts equals the top level number of field prompts + * on the form multiplied by the number of schema fields. + * Optionally takes in a formUrn to look at one specific form or all forms + */ +export function getNumSchemaFieldPromptsRemaining( + entityData: GenericEntityProperties | null, + fieldFormPrompts: FormPrompt[], + numSchemaFields: number, + formUrn?: string, +) { + const numFieldPromptsAvailable = numSchemaFields * fieldFormPrompts.length; + // we pass in either required or optional fieldFormPrompts that we care about in this method, need to check against these + const relevantFieldFormPromptIds = new Set(fieldFormPrompts.map((prompt) => prompt.id)); + const completedFieldPromptAssociations = formUrn + ? getCompletedFieldPromptAssociationsForForm(formUrn, entityData, relevantFieldFormPromptIds) + : getCompletedFieldPromptAssociations(entityData, relevantFieldFormPromptIds); + + return numFieldPromptsAvailable - completedFieldPromptAssociations.length; +} + +// Get completed prompts from both complete and incomplete forms for this entity +export function getCompletedPrompts(entityData: GenericEntityProperties | null) { + const forms = entityData?.forms; + let completedPrompts = + forms?.incompleteForms?.flatMap((form) => (form.completedPrompts ? form.completedPrompts : [])) || []; + completedPrompts = completedPrompts.concat( + forms?.completedForms?.flatMap((form) => (form.completedPrompts ? form.completedPrompts : [])) || [], + ); + console.log('entityData', entityData); + console.log('getCompletedPrompts', completedPrompts); + return completedPrompts; +} + +// Get incomplete prompts from both complete and incomplete forms for this entity +export function getIncompletePrompts(entityData: GenericEntityProperties | null) { + const forms = entityData?.forms; + let incompletePrompts = + forms?.incompleteForms?.flatMap((form) => (form.incompletePrompts ? form.incompletePrompts : [])) || []; + incompletePrompts = incompletePrompts.concat( + forms?.completedForms?.flatMap((form) => (form.incompletePrompts ? form.incompletePrompts : [])) || [], + ); + return incompletePrompts; +} + +export function isPromptComplete(prompt: FormPrompt, completedPrompts: FormPromptAssociation[]) { + return !!completedPrompts.find((completedPrompt) => completedPrompt.id === prompt.id); +} + +export function findCompletedFieldPrompt(fieldPrompt?: SchemaField, promptAssociation?: FormPromptAssociation) { + return promptAssociation?.fieldAssociations?.completedFieldPrompts?.find( + (fieldPath) => fieldPath.fieldPath === fieldPrompt?.fieldPath, + ); +} + +export function isFieldPromptComplete(fieldPrompt, promptAssociation) { + return !!findCompletedFieldPrompt(fieldPrompt, promptAssociation); +} + +// For every prompt provided, check if it's in our list of completed prompts and return number prompts not completed +export function getNumEntityPromptsRemaining(entityPrompts: FormPrompt[], entityData: GenericEntityProperties | null) { + const completedPrompts = getCompletedPrompts(entityData); + let numPromptsRemaining = 0; + + entityPrompts.forEach((prompt) => { + if (prompt && !isPromptComplete(prompt, completedPrompts)) { + numPromptsRemaining += 1; + } + }); + + return numPromptsRemaining; +} + +// Get prompts from both complete and incomplete forms +export function getAllPrompts(entityData: GenericEntityProperties | null) { + let prompts = entityData?.forms?.incompleteForms?.flatMap((form) => form.form.info.prompts) || []; + prompts = prompts.concat(entityData?.forms?.completedForms?.flatMap((form) => form.form.info.prompts) || []); + return prompts; +} + +// Find a specific prompt association from both complete and incomplete prompts +export function findPromptAssociation(prompt: FormPrompt, allPrompts: Array) { + return allPrompts?.find((myprompt) => myprompt.id === prompt.id); +} + +// Get the prompts for a given form +export function getPromptsForForm(formUrn: string, entityData: GenericEntityProperties | null) { + const formAssociation = getFormAssociation(formUrn, entityData); + return formAssociation?.form.info.prompts || []; +} + +/* + * Gets information for entity level prompts + */ +export function getEntityPromptsInfo(prompts: FormPrompt[], entityData: GenericEntityProperties | null) { + const entityPrompts = prompts.filter((prompt) => !SCHEMA_FIELD_PROMPT_TYPES.includes(prompt.type)); + const requiredEntityPrompts = entityPrompts.filter((prompt) => prompt.required); + const optionalEntityPrompts = entityPrompts.filter((prompt) => !prompt.required); + + const numRequiredEntityPromptsRemaining = getNumEntityPromptsRemaining(requiredEntityPrompts, entityData); + const numOptionalEntityPromptsRemaining = getNumEntityPromptsRemaining(optionalEntityPrompts, entityData); + + return { + entityPrompts, + numRequiredEntityPromptsRemaining, + numOptionalEntityPromptsRemaining, + requiredEntityPrompts, + }; +} + +/* + * Gets information for schema field level prompts + */ +export function getFieldPromptsInfo( + prompts: FormPrompt[], + entityData: GenericEntityProperties | null, + numSchemaFields: number, + formUrn?: string, +) { + const fieldPrompts = prompts.filter((prompt) => SCHEMA_FIELD_PROMPT_TYPES.includes(prompt.type)); + const requiredFieldPrompts = fieldPrompts.filter((prompt) => prompt.required); + const optionalFieldPrompts = fieldPrompts.filter((prompt) => !prompt.required); + + const numRequiredFieldPromptsRemaining = getNumSchemaFieldPromptsRemaining( + entityData, + requiredFieldPrompts, + numSchemaFields, + formUrn, + ); + const numOptionalFieldPromptsRemaining = getNumSchemaFieldPromptsRemaining( + entityData, + optionalFieldPrompts, + numSchemaFields, + formUrn, + ); + + return { fieldPrompts, numRequiredFieldPromptsRemaining, numOptionalFieldPromptsRemaining, requiredFieldPrompts }; +} + +export function getFormVerification(formUrn: string, entityData: GenericEntityProperties | null) { + return entityData?.forms?.verifications?.find((verification) => verification.form.urn === formUrn); +} + +export function getVerificationForms(entityData: GenericEntityProperties | null) { + const formAssociations = getFormAssociations(entityData); + return formAssociations.filter((formAssociation) => formAssociation.form.info.type === FormType.Verification); +} + +export function areAllFormsVerified(formAssociations: FormAssociation[], entityData: GenericEntityProperties | null) { + return formAssociations.every((formAssociation) => !!getFormVerification(formAssociation.form.urn, entityData)); +} + +/* + * If a form urn is supplied, return true if that form is verified. + * If no form is supplied, return true if all verification type forms are verified. + * If there are no verification type forms or any are missing verification, return false. + */ +export function isVerificationComplete(entityData: GenericEntityProperties | null, formUrn?: string) { + if (formUrn) { + return !!getFormVerification(formUrn, entityData); + } + const verificationForms = getVerificationForms(entityData); + if (verificationForms.length) { + return areAllFormsVerified(verificationForms, entityData); + } + return false; +} + +export function isFormVerificationType(entityData: GenericEntityProperties | null, formUrn: string) { + const formAssociation = getFormAssociation(formUrn, entityData); + return formAssociation?.form.info.type === FormType.Verification; +} + +/* + * If given a single form we should show verification copy and styles if it is of type verification. + * If no formUrn is supplied, return true if any of our multiple forms are of type verification. + */ +export function shouldShowVerificationInfo(entityData: GenericEntityProperties | null, formUrn?: string) { + if (formUrn) { + return isFormVerificationType(entityData, formUrn); + } + return getVerificationForms(entityData).length > 0; +} + +function getMostRecentVerificationAuditStamp(entityData: GenericEntityProperties | null) { + let mostRecentTimestamp: Maybe = null; + entityData?.forms?.verifications?.forEach((verification) => { + if (mostRecentTimestamp === null || (verification.lastModified?.time || 0) > (mostRecentTimestamp?.time || 0)) { + mostRecentTimestamp = verification.lastModified; + } + }); + return mostRecentTimestamp; +} + +/* + * If given one form, return the verification lastModified for it. Otherwise, find the most + * recently completed verification time stamp from any of the forms on this entity + */ +export function getVerificationAuditStamp(entityData: GenericEntityProperties | null, formUrn?: string) { + if (formUrn) { + return getFormVerification(formUrn, entityData)?.lastModified || null; + } + return getMostRecentVerificationAuditStamp(entityData); +} + +export function getBulkByQuestionPrompts(formUrn: string, entityData: GenericEntityProperties | null) { + const formAssociation = getFormAssociation(formUrn, entityData); + return ( + formAssociation?.form.info.prompts.filter((prompt) => !SCHEMA_FIELD_PROMPT_TYPES.includes(prompt.type)) || [] + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/EntityForm.tsx b/datahub-web-react/src/app/entity/shared/entityForm/EntityForm.tsx new file mode 100644 index 00000000000000..136bbabb61e0b4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/EntityForm.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import FormByEntity from './FormByEntity'; +import { FormView, useEntityFormContext } from './EntityFormContext'; + +interface Props { + formUrn: string; +} + +export default function EntityForm({ formUrn }: Props) { + const { formView } = useEntityFormContext(); + + if (formView === FormView.BY_ENTITY) return ; + + return null; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContext.tsx b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContext.tsx new file mode 100644 index 00000000000000..aa5e1e3c4a8fed --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContext.tsx @@ -0,0 +1,54 @@ +import React, { useContext } from 'react'; +import { Entity } from '../../../../types.generated'; +import { EntityAndType, GenericEntityProperties } from '../types'; + +export enum FormView { + BY_ENTITY, +} + +export type EntityFormContextType = { + formUrn: string; + isInFormContext: boolean; + entityData: GenericEntityProperties | undefined; + loading: boolean; + selectedEntity: Entity | undefined; + selectedPromptId: string | null; + formView: FormView; + selectedEntities: EntityAndType[]; + setSelectedEntities: (entities: EntityAndType[]) => void; + setFormView: (formView: FormView) => void; + refetch: () => Promise; + setSelectedEntity: (sortOption: Entity) => void; + setSelectedPromptId: (promptId: string) => void; + shouldRefetchSearchResults: boolean; + setShouldRefetchSearchResults: (shouldRefetch: boolean) => void; + isVerificationType: boolean; +}; + +export const DEFAULT_CONTEXT = { + formUrn: '', + isInFormContext: false, + entityData: undefined, + loading: false, + refetch: () => Promise.resolve({}), + selectedEntity: undefined, + setSelectedEntity: (_: Entity) => null, + selectedEntities: [], + setSelectedEntities: (_: EntityAndType[]) => null, + formView: FormView.BY_ENTITY, + setFormView: (_: FormView) => null, + selectedPromptId: null, + setSelectedPromptId: (_: string) => null, + shouldRefetchSearchResults: false, + setShouldRefetchSearchResults: () => null, + isVerificationType: true, +}; + +export const EntityFormContext = React.createContext(DEFAULT_CONTEXT); + +export function useEntityFormContext() { + const context = useContext(EntityFormContext); + if (context === null) + throw new Error(`${useEntityFormContext.name} must be used under a EntityFormContextProvider`); + return context; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContextProvider.tsx b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContextProvider.tsx new file mode 100644 index 00000000000000..41d7fcf4bd8998 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormContextProvider.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import { EntityFormContext, FormView } from './EntityFormContext'; +import { useEntityContext } from '../EntityContext'; +import { Entity } from '../../../../types.generated'; +import { useGetDatasetQuery } from '../../../../graphql/dataset.generated'; +import { EntityAndType, GenericEntityProperties } from '../types'; +import { getFormAssociation, isFormVerificationType } from '../containers/profile/sidebar/FormInfo/utils'; +import usePrevious from '../../../shared/usePrevious'; +import { SCHEMA_FIELD_PROMPT_TYPES } from './constants'; + +interface Props { + children: React.ReactNode; + formUrn: string; +} + +export default function EntityFormContextProvider({ children, formUrn }: Props) { + const { entityData, refetch: refetchEntityProfile, loading: profileLoading } = useEntityContext(); + const formAssociation = getFormAssociation(formUrn, entityData); + const initialPromptId = + formAssociation?.form.info.prompts.filter((prompt) => !SCHEMA_FIELD_PROMPT_TYPES.includes(prompt.type))[0] + ?.id || null; + const isVerificationType = isFormVerificationType(entityData, formUrn); + const [formView, setFormView] = useState(FormView.BY_ENTITY); + const [selectedEntity, setSelectedEntity] = useState(entityData as Entity); + const [selectedPromptId, setSelectedPromptId] = useState(initialPromptId); + const [selectedEntities, setSelectedEntities] = useState([]); + const [shouldRefetchSearchResults, setShouldRefetchSearchResults] = useState(false); + + useEffect(() => { + if (!selectedPromptId && formAssociation) { + setSelectedPromptId(initialPromptId); + } + }, [selectedPromptId, formAssociation, initialPromptId]); + + const previousFormUrn = usePrevious(formUrn); + useEffect(() => { + if (formUrn && previousFormUrn !== formUrn) { + setFormView(FormView.BY_ENTITY); + setSelectedPromptId(initialPromptId); + } + }, [formUrn, previousFormUrn, initialPromptId]); + + const { + data: fetchedData, + refetch, + loading, + } = useGetDatasetQuery({ + variables: { urn: selectedEntity.urn }, + }); + + const isOnEntityProfilePage = selectedEntity.urn === entityData?.urn; + const selectedEntityData = isOnEntityProfilePage ? entityData : (fetchedData?.dataset as GenericEntityProperties); + + return ( + + {children} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/EntityFormModal.tsx b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormModal.tsx new file mode 100644 index 00000000000000..47026472c43f9c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/EntityFormModal.tsx @@ -0,0 +1,59 @@ +import { CloseOutlined } from '@ant-design/icons'; +import React from 'react'; +import { Modal } from 'antd'; +import styled from 'styled-components'; +import EntityForm from './EntityForm'; +import FormPageHeader from './FormHeader/FormPageHeader'; +import EntityFormContextProvider from './EntityFormContextProvider'; + +const StyledModal = styled(Modal)` + &&& .ant-modal-content { + display: flex; + flex-direction: column; + height: calc(100vh); + } + + .ant-modal-header { + padding: 0; + } + + .ant-modal-body { + flex: 1; + max-height: 100%; + overflow: hidden; + padding: 0; + display: flex; + } +`; + +const StyledClose = styled(CloseOutlined)` + && { + color: white; + font-size: 24px; + margin: 18px 12px 0 0; + } +`; + +interface Props { + selectedFormUrn: string | null; + isFormVisible: boolean; + hideFormModal: () => void; +} + +export default function EntityFormModal({ selectedFormUrn, isFormVisible, hideFormModal }: Props) { + return ( + + } + closeIcon={} + style={{ top: 0, height: '100vh', minWidth: '100vw' }} + destroyOnClose + > + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/Form.tsx b/datahub-web-react/src/app/entity/shared/entityForm/Form.tsx new file mode 100644 index 00000000000000..bf1b093d984345 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/Form.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useEntityData } from '../EntityContext'; +import { FormPrompt } from '../../../../types.generated'; +import Prompt, { PromptWrapper } from './prompts/Prompt'; +import { ANTD_GRAY_V2 } from '../constants'; +import { useEntityRegistry } from '../../../useEntityRegistry'; +import { PromptSubTitle } from './prompts/StructuredPropertyPrompt/StructuredPropertyPrompt'; +import SchemaFieldPrompts from './schemaFieldPrompts/SchemaFieldPrompts'; +import useGetPromptInfo from '../containers/profile/sidebar/FormInfo/useGetPromptInfo'; +import VerificationPrompt from './prompts/VerificationPrompt'; +import useShouldShowVerificationPrompt from './useShouldShowVerificationPrompt'; +import { getFormAssociation } from '../containers/profile/sidebar/FormInfo/utils'; +import FormRequestedBy from './FormSelectionModal/FormRequestedBy'; +import useHasComponentRendered from '../../../shared/useHasComponentRendered'; +import Loading from '../../../shared/Loading'; +import { DeferredRenderComponent } from '../../../shared/DeferredRenderComponent'; +import { OnboardingTour } from '../../../onboarding/OnboardingTour'; +import { FORM_ASSET_COMPLETION } from '../../../onboarding/config/FormOnboardingConfig'; + +const TabWrapper = styled.div` + background-color: ${ANTD_GRAY_V2[1]}; + overflow: auto; + padding: 24px; + flex: 1; + max-height: 100%; +`; + +const IntroTitle = styled.div` + font-size: 20px; + font-weight: 600; +`; + +const HeaderWrapper = styled(PromptWrapper)``; + +const SubTitle = styled(PromptSubTitle)` + margin-top: 16px; +`; + +const RequestedByWrapper = styled(PromptSubTitle)` + color: ${ANTD_GRAY_V2[8]}; +`; + +interface Props { + formUrn: string; +} + +function Form({ formUrn }: Props) { + const entityRegistry = useEntityRegistry(); + const { entityType, entityData } = useEntityData(); + const { entityPrompts, fieldPrompts } = useGetPromptInfo(formUrn); + const shouldShowVerificationPrompt = useShouldShowVerificationPrompt(formUrn); + const { hasRendered } = useHasComponentRendered(); + + if (!hasRendered) return ; + + const formAssociation = getFormAssociation(formUrn, entityData); + const title = formAssociation?.form.info.name; + const associatedUrn = formAssociation?.associatedUrn; + const description = formAssociation?.form.info.description; + const owners = formAssociation?.form.ownership?.owners; + + return ( + + + + + {title ? <>{title} : <>{entityRegistry.getEntityName(entityType)} Requirements} + + {owners && owners.length > 0 && ( + + + + )} + {description ? ( + {description} + ) : ( + + Please fill out the following information for this {entityRegistry.getEntityName(entityType)} so + that we can keep track of the status of the asset + + )} + + {entityPrompts?.map((prompt, index) => ( + + ))} + {fieldPrompts.length > 0 && } + {shouldShowVerificationPrompt && } + + ); +} + +export default function FormContainer({ formUrn }: Props) { + return } />; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormByEntity.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormByEntity.tsx new file mode 100644 index 00000000000000..23550e8fcca5f9 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormByEntity.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import styled from 'styled-components'; +import Form from './Form'; +import { ANTD_GRAY_V2 } from '../constants'; +import ProfileSidebar from '../containers/profile/sidebar/ProfileSidebar'; +import { useEntityRegistry } from '../../../useEntityRegistry'; +import EntityContext, { useEntityContext } from '../EntityContext'; +import EntityInfo from '../containers/profile/sidebar/EntityInfo/EntityInfo'; +import { useEntityFormContext } from './EntityFormContext'; +import ProgressBar from './ProgressBar'; +import { OnboardingTour } from '../../../onboarding/OnboardingTour'; +import { + FORM_QUESTION_VIEW_BUTTON, + WELCOME_TO_BULK_BY_ENTITY_ID, +} from '../../../onboarding/config/FormOnboardingConfig'; + +const ContentWrapper = styled.div` + background-color: ${ANTD_GRAY_V2[1]}; + max-height: 100%; + display: flex; + flex-direction: column; + width: 100%; + flex: 1; +`; + +const FlexWrapper = styled.div` + display: flex; + max-height: 100%; + overflow: auto; + width: 100%; +`; + +interface Props { + formUrn: string; +} + +export default function FormByEntity({ formUrn }: Props) { + const { selectedEntity, entityData: selectedEntityData, refetch, loading } = useEntityFormContext(); + const { entityType } = useEntityContext(); + const entityRegistry = useEntityRegistry(); + const sidebarSections = entityRegistry.getSidebarSections(selectedEntity?.type || entityType); + + return ( + {}, + refetch, + }} + > + + + + + }} + backgroundColor="white" + alignLeft + /> + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/FormPageHeader.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/FormPageHeader.tsx new file mode 100644 index 00000000000000..4baa762fcf88d4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/FormPageHeader.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import styled from 'styled-components'; +import AppLogoLink from '../../../../shared/AppLogoLink'; + +const Header = styled.div` + padding: 12px 24px; + background-color: black; + font-size: 24px; + display: flex; + align-items: center; + color: white; + justify-content: space-between; +`; + +const HeaderText = styled.div` + margin-left: 24px; +`; + +const StyledDivider = styled.div` + display: flex; + flex-direction: column; +`; + +const TitleWrapper = styled.div` + display: flex; + align-items: center; +`; + +export default function FormPageHeader() { + return ( + +
    + + + Complete Documentation Requests + +
    +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/components.ts b/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/components.ts new file mode 100644 index 00000000000000..c94dc0ef633003 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormHeader/components.ts @@ -0,0 +1,36 @@ +import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; +import { ANTD_GRAY_V2 } from '../../constants'; +import BackgroundDots from '../../../../../images/background_dots.svg'; + +export const BulkNavigationWrapper = styled.div<{ $hideBackground?: boolean }>` + padding: 16px 68px 16px 24px; + background-color: ${ANTD_GRAY_V2[10]}; + display: flex; + justify-content: flex-end; + ${(props) => + !props.$hideBackground && + ` + background-image: url(${BackgroundDots}); + background-position: right; + background-repeat: no-repeat; + `} +`; + +export const NavigationWrapper = styled.div<{ isHidden: boolean }>` + font-size: 20px; + color: white; + display: flex; + flex-wrap: nowrap; + ${(props) => props.isHidden && 'opacity: 0;'} +`; + +export const ArrowLeft = styled(ArrowLeftOutlined)` + margin-right: 24px; + cursor: pointer; +`; + +export const ArrowRight = styled(ArrowRightOutlined)` + margin-left: 24px; + cursor: pointer; +`; diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormItem.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormItem.tsx new file mode 100644 index 00000000000000..c23fd39d8a10fe --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormItem.tsx @@ -0,0 +1,101 @@ +import { Tooltip } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { FormAssociation } from '../../../../../types.generated'; +import useGetPromptInfo from '../../containers/profile/sidebar/FormInfo/useGetPromptInfo'; +import { useEntityData } from '../../EntityContext'; +import { isVerificationComplete, shouldShowVerificationInfo } from '../../containers/profile/sidebar/FormInfo/utils'; +import { pluralize } from '../../../../shared/textUtil'; +import { WhiteButton } from '../../../../shared/components'; +import { ANTD_GRAY_V2 } from '../../constants'; +import useIsUserAssigned from '../../containers/profile/sidebar/FormInfo/useIsUserAssigned'; +import FormRequestedBy from './FormRequestedBy'; + +const FormItemWrapper = styled.div` + display: flex; + padding: 16px; + justify-content: space-between; +`; + +const FormName = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +`; + +const FormAssigner = styled.div` + font-size: 14px; + color: #373d44; + margin-top: -4px; + margin-bottom: 4px; +`; + +const OptionalText = styled.div` + color: ${ANTD_GRAY_V2[8]}; + font-weight: normal; +`; + +const CompleteWrapper = styled.div` + display: flex; + align-items: center; +`; + +const FormInfoWrapper = styled.div` + font-size: 12px; + color: #373d44; + font-weight: 600; +`; + +interface Props { + formAssociation: FormAssociation; + selectFormUrn: (urn: string) => void; +} + +export default function FormItem({ formAssociation, selectFormUrn }: Props) { + const { entityData } = useEntityData(); + const { form } = formAssociation; + const { numRequiredPromptsRemaining, numOptionalPromptsRemaining } = useGetPromptInfo(form.urn); + const allRequiredPromptsAreComplete = numRequiredPromptsRemaining === 0; + const showVerificationInfo = shouldShowVerificationInfo(entityData, form.urn); + const isComplete = showVerificationInfo + ? isVerificationComplete(entityData, form.urn) + : allRequiredPromptsAreComplete; + const isUserAssigned = useIsUserAssigned(form.urn); + const owners = form.ownership?.owners; + + return ( + +
    + {form.info.name} + {owners && owners.length > 0 && ( + + + + )} + + {isComplete && ( + {showVerificationInfo ? <>Verified : <>Complete} + )} + {!isComplete && ( +
    + {numRequiredPromptsRemaining} required {pluralize(numRequiredPromptsRemaining, 'response')}{' '} + remaining +
    + )} + {numOptionalPromptsRemaining > 0 && ( + + {numOptionalPromptsRemaining} optional {pluralize(numOptionalPromptsRemaining, 'response')}{' '} + remaining + + )} +
    +
    + + selectFormUrn(form.urn)} disabled={!isUserAssigned}> + {isComplete && 'View'} + {!isComplete && <>{showVerificationInfo ? 'Verify' : 'Complete'}} + + +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormRequestedBy.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormRequestedBy.tsx new file mode 100644 index 00000000000000..fa4834b5a4f851 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormRequestedBy.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Owner } from '../../../../../types.generated'; +import { useEntityRegistry } from '../../../../useEntityRegistry'; + +interface Props { + owners: Owner[]; +} + +export default function FormRequestedBy({ owners }: Props) { + const entityRegistry = useEntityRegistry(); + + return ( + <> + Requested by:{' '} + {owners.map((ownerAssoc, index) => ( + <> + {owners.length > 1 && index === owners.length - 1 && 'and '} + {entityRegistry.getDisplayName(ownerAssoc.owner.type, ownerAssoc.owner)} + {owners.length > 1 && index !== owners.length - 1 && ', '} + + ))} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelectionModal.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelectionModal.tsx new file mode 100644 index 00000000000000..17452b30f6c117 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelectionModal.tsx @@ -0,0 +1,21 @@ +import { Modal } from 'antd'; +import React from 'react'; +import FormSelector from './FormSelector'; + +interface Props { + isFormSelectionModalVisible: boolean; + hideFormSelectionModal: () => void; + selectFormUrn: (urn: string) => void; +} + +export default function FormSelectionModal({ + isFormSelectionModalVisible, + hideFormSelectionModal, + selectFormUrn, +}: Props) { + return ( + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelector.tsx b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelector.tsx new file mode 100644 index 00000000000000..6c9d593b38f0ea --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/FormSelectionModal/FormSelector.tsx @@ -0,0 +1,48 @@ +import { Divider } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { useEntityData } from '../../EntityContext'; +import { getFormAssociations } from '../../containers/profile/sidebar/FormInfo/utils'; +import FormItem from './FormItem'; + +const FormSelectorWrapper = styled.div` + font-size: 14px; +`; + +const HeaderText = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; +`; + +const Subheader = styled.div` + margin-bottom: 8px; +`; + +const StyledDivider = styled(Divider)` + margin: 8px 0; +`; + +interface Props { + selectFormUrn: (urn: string) => void; +} + +export default function FormSelector({ selectFormUrn }: Props) { + const { entityData } = useEntityData(); + const formAssociations = getFormAssociations(entityData); + + return ( + + Choose Which Form to View + + There are multiple open requests for this entity. Choose which one you’d like to view or complete. + + {formAssociations.map((formAssociation, index) => ( +
    + + {index !== formAssociations.length - 1 && } +
    + ))} +
    + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/ProgressBar.tsx b/datahub-web-react/src/app/entity/shared/entityForm/ProgressBar.tsx new file mode 100644 index 00000000000000..a4473fc825e905 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/ProgressBar.tsx @@ -0,0 +1,38 @@ +import { Progress } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import useGetPromptInfo from '../containers/profile/sidebar/FormInfo/useGetPromptInfo'; +import { ANTD_GRAY } from '../constants'; + +const StyledProgress = styled(Progress)` + &&& .ant-progress-outer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + } + + .ant-progress-bg { + height: 4px !important; + } +`; + +interface Props { + formUrn: string; +} +export default function ProgressBar({ formUrn }: Props) { + const { totalRequiredSchemaFieldPrompts, numRequiredPromptsRemaining, requiredEntityPrompts } = + useGetPromptInfo(formUrn); + const totalRequiredPrompts = requiredEntityPrompts.length + totalRequiredSchemaFieldPrompts; + const percent = ((totalRequiredPrompts - numRequiredPromptsRemaining) / totalRequiredPrompts) * 100; + + return ( + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/__tests__/Form.test.tsx b/datahub-web-react/src/app/entity/shared/entityForm/__tests__/Form.test.tsx new file mode 100644 index 00000000000000..d1e458d37bd5ed --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/__tests__/Form.test.tsx @@ -0,0 +1,62 @@ +import { MockedProvider } from '@apollo/client/testing'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import EntityContext from '../../EntityContext'; +import { mockEntityDataWithFieldPrompts, mockEntityData } from '../mocks'; +import { EntityType } from '../../../../../types.generated'; +import Form from '../Form'; +import TestPageContainer from '../../../../../utils/test-utils/TestPageContainer'; +import { mocks } from '../../../../../Mocks'; + +beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = vi.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; +}); + +describe('Form', () => { + it('should show field-level header if there are schema field prompts', async () => { + const { getByTestId, findByTestId } = render( + + + + + + + , + ); + // DeferredRenderComponent defers rendering for a short period, wait for that + await waitFor(() => findByTestId('field-level-requirements')); + expect(getByTestId('field-level-requirements')).toBeInTheDocument(); + }); + + it('should not show field-level header if there are no schema field prompts', () => { + const { queryByTestId } = render( + + + + + + + , + ); + expect(queryByTestId('field-level-requirements')).not.toBeInTheDocument(); + }); +}); diff --git a/datahub-web-react/src/app/entity/shared/entityForm/__tests__/useShouldShowVerificationPrompt.test.ts b/datahub-web-react/src/app/entity/shared/entityForm/__tests__/useShouldShowVerificationPrompt.test.ts new file mode 100644 index 00000000000000..93413fcf634946 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/__tests__/useShouldShowVerificationPrompt.test.ts @@ -0,0 +1,48 @@ +import { FormType, FormVerificationAssociation } from '../../../../../types.generated'; +import { shouldShowVerificationPrompt } from '../useShouldShowVerificationPrompt'; + +describe('shouldShowVerificationPrompt', () => { + const formVerification = { + form: { urn: 'urn:li:form:1' }, + lastModified: { time: 100 }, + } as FormVerificationAssociation; + + it('should return true if the form is verification, there are no prompts remaining, and no verification', () => { + const shouldShow = shouldShowVerificationPrompt({ + formType: FormType.Verification, + numRequiredPromptsRemaining: 0, + }); + + expect(shouldShow).toBe(true); + }); + + it('should return false if the form was verified', () => { + const shouldShow = shouldShowVerificationPrompt({ + formType: FormType.Verification, + numRequiredPromptsRemaining: 0, + formVerification, + }); + + expect(shouldShow).toBe(false); + }); + + it('should return false if the form is not of type verification', () => { + const shouldShow = shouldShowVerificationPrompt({ + formType: FormType.Completion, + numRequiredPromptsRemaining: 0, + formVerification, + }); + + expect(shouldShow).toBe(false); + }); + + it('should return false if the form has prompts remaining', () => { + const shouldShow = shouldShowVerificationPrompt({ + formType: FormType.Verification, + numRequiredPromptsRemaining: 1, + formVerification, + }); + + expect(shouldShow).toBe(false); + }); +}); diff --git a/datahub-web-react/src/app/entity/shared/entityForm/constants.ts b/datahub-web-react/src/app/entity/shared/entityForm/constants.ts new file mode 100644 index 00000000000000..fb62ab5de7323e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/constants.ts @@ -0,0 +1,3 @@ +import { FormPromptType } from '../../../../types.generated'; + +export const SCHEMA_FIELD_PROMPT_TYPES = [FormPromptType.FieldsStructuredProperty]; diff --git a/datahub-web-react/src/app/entity/shared/entityForm/mocks.ts b/datahub-web-react/src/app/entity/shared/entityForm/mocks.ts new file mode 100644 index 00000000000000..b29848e4b119b8 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/mocks.ts @@ -0,0 +1,221 @@ +import { EntityType, FormPromptType, FormType } from '../../../../types.generated'; +import { GenericEntityProperties } from '../types'; + +const form1 = { + urn: 'urn:li:form:1', + type: EntityType.Form, + info: { + name: '', + type: FormType.Verification, + prompts: [ + { + id: '1', + type: FormPromptType.FieldsStructuredProperty, + title: '', + formUrn: 'urn:li:form:1', + required: false, + }, + { + id: '2', + type: FormPromptType.FieldsStructuredProperty, + title: '', + formUrn: 'urn:li:form:1', + required: false, + }, + ], + actors: { + owners: true, + users: null, + groups: null, + isAssignedToMe: false, + }, + }, +}; + +const form2 = { + urn: 'urn:li:form:2', + type: EntityType.Form, + info: { + name: '', + prompts: [ + { + id: '3', + type: FormPromptType.StructuredProperty, + title: '', + formUrn: 'urn:li:form:2', + required: true, + }, + { + id: '4', + type: FormPromptType.FieldsStructuredProperty, + title: '', + formUrn: 'urn:li:form:2', + required: false, + }, + { + id: '5', + type: FormPromptType.StructuredProperty, + title: '', + formUrn: 'urn:li:form:2', + required: false, + }, + ], + type: FormType.Verification, + actors: { + owners: false, + users: null, + groups: null, + isAssignedToMe: true, + }, + }, +}; + +export const mockEntityData = { + schemaMetadata: { fields: [{ fieldPath: 'test' }] }, + forms: { + verifications: [ + { + form: form2, + lastModified: { + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 100, + }, + }, + { + form: form2, + lastModified: { + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 101, + }, + }, + ], + incompleteForms: [ + { + completedPrompts: [ + { + id: '1', + lastModified: { time: 123 }, + fieldAssociations: { + completedFieldPrompts: [ + { fieldPath: 'test3', lastModified: { time: 123 } }, + { fieldPath: 'test4', lastModified: { time: 123 } }, + ], + }, + }, + ], + incompletePrompts: [ + { + id: '2', + lastModified: { time: 1234 }, + fieldAssociations: { + completedFieldPrompts: [ + { fieldPath: 'test1', lastModified: { time: 123 } }, + { fieldPath: 'test2', lastModified: { time: 123 } }, + { fieldPath: 'test3', lastModified: { time: 123 } }, + ], + }, + }, + ], + associatedUrn: '', + form: form1, + }, + ], + completedForms: [ + { + completedPrompts: [{ id: '3', lastModified: { time: 1234 } }], + incompletePrompts: [ + { id: '4', lastModified: { time: 123 } }, + { id: '5', lastModified: { time: 123 } }, + ], + associatedUrn: '', + form: form2, + }, + { + completedPrompts: [{ id: '6', lastModified: { time: 1234 } }], + associatedUrn: '', + form: { + urn: 'urn:li:form:3', + type: EntityType.Form, + info: { + name: '', + prompts: [ + { + id: '6', + type: FormPromptType.StructuredProperty, + title: '', + formUrn: 'urn:li:form:3', + required: true, + }, + ], + type: FormType.Completion, + actors: { + owners: true, + users: null, + groups: null, + isAssignedToMe: false, + }, + }, + }, + }, + ], + }, +} as GenericEntityProperties; + +export const mockEntityDataAllVerified = { + ...mockEntityData, + forms: { + ...mockEntityData.forms, + verifications: [ + { + form: form2, + lastModified: { + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 100, + }, + }, + { + form: form1, + lastModified: { + actor: { + urn: 'urn:li:corpuser:test', + }, + time: 101, + }, + }, + ], + }, +} as GenericEntityProperties; + +export const mockEntityDataWithFieldPrompts = { + ...mockEntityData, + forms: { + ...mockEntityData.forms, + incompleteForms: [ + { + ...(mockEntityData as any).forms.incompleteForms[0], + form: { + urn: 'urn:li:form:1', + type: EntityType.Form, + info: { + name: '', + prompts: [ + { + id: '1', + type: FormPromptType.FieldsStructuredProperty, + title: '', + formUrn: 'urn:li:form:1', + required: false, + }, + ], + }, + }, + }, + ], + }, +} as GenericEntityProperties; diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/Prompt.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/Prompt.tsx new file mode 100644 index 00000000000000..23512dff599393 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/Prompt.tsx @@ -0,0 +1,65 @@ +import { message } from 'antd'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { + FormPrompt as PromptEntity, + FormPromptType, + SubmitFormPromptInput, + SchemaField, +} from '../../../../../types.generated'; +import StructuredPropertyPrompt from './StructuredPropertyPrompt/StructuredPropertyPrompt'; +import { useSubmitFormPromptMutation } from '../../../../../graphql/form.generated'; +import { useMutationUrn } from '../../EntityContext'; + +export const PromptWrapper = styled.div` + background-color: white; + border-radius: 8px; + padding: 24px; + margin-bottom: 8px; +`; + +interface Props { + promptNumber?: number; + prompt: PromptEntity; + field?: SchemaField; + associatedUrn?: string; +} + +export default function Prompt({ promptNumber, prompt, field, associatedUrn }: Props) { + const [optimisticCompletedTimestamp, setOptimisticCompletedTimestamp] = useState(null); + const urn = useMutationUrn(); + const [submitFormPrompt] = useSubmitFormPromptMutation(); + + function submitResponse(input: SubmitFormPromptInput, onSuccess: () => void) { + submitFormPrompt({ variables: { urn: associatedUrn || urn, input } }) + .then(() => { + onSuccess(); + setOptimisticCompletedTimestamp(Date.now()); + }) + .catch(() => { + message.error('Unknown error while submitting form response'); + }); + } + + return ( + + {prompt.type === FormPromptType.StructuredProperty && ( + + )} + {prompt.type === FormPromptType.FieldsStructuredProperty && ( + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/CompletedPromptAuditStamp.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/CompletedPromptAuditStamp.tsx new file mode 100644 index 00000000000000..ff11f0db5f8bc4 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/CompletedPromptAuditStamp.tsx @@ -0,0 +1,69 @@ +import { Typography } from 'antd'; +import React from 'react'; +import Icon from '@ant-design/icons'; +import styled from 'styled-components'; +import GreenCircleIcon from '../../../../../../images/greenCircleTwoTone.svg?react'; +import { ANTD_GRAY_V2 } from '../../../constants'; + +const PadIcon = styled.div` + align-items: flex-start; + padding-top: 1px; + padding-right: 2px; +`; + +const CompletedPromptContainer = styled.div` + display: flex; + align-self: end; + max-width: 350px; +`; + +const AuditStamp = styled.div` + color: #373d44; + font-size: 14px; + font-family: Manrope; + font-weight: 600; + line-height: 18px; + overflow: hidden; + white-space: nowrap; + display: flex; +`; + +const AuditStampSubTitle = styled.div` + color: ${ANTD_GRAY_V2[8]}; + font-size: 12px; + font-family: Manrope; + font-weight: 500; + line-height: 16px; + word-wrap: break-word; +`; + +const StyledIcon = styled(Icon)` + font-size: 16px; + margin-right: 4px; +`; + +const AuditWrapper = styled.div` + max-width: 95%; +`; + +interface Props { + completedByName: string; + completedByTime: string; +} + +export default function CompletedPromptAuditStamp({ completedByName, completedByTime }: Props) { + return ( + + + + + + + Completed by  + {completedByName} + + {completedByTime} + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx new file mode 100644 index 00000000000000..23c322ea4c8f2b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DateInput.tsx @@ -0,0 +1,18 @@ +import { DatePicker } from 'antd'; +import React from 'react'; +import moment, { Moment } from 'moment'; + +interface Props { + selectedValues: any[]; + updateSelectedValues: (values: string[] | number[]) => void; +} + +export default function DateInput({ selectedValues, updateSelectedValues }: Props) { + function updateInput(_: Moment | null, value: string) { + updateSelectedValues([value]); + } + + const currentValue = selectedValues[0] ? moment(selectedValues[0]) : undefined; + + return ; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx new file mode 100644 index 00000000000000..0e002f8cc6150f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/DropdownLabel.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from 'styled-components'; + +const StyledValue = styled.div` + font-family: Manrope; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + color: #373d44; +`; + +const StyledDescription = styled.div` + font-family: Manrope; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + color: #5e666e; +`; + +interface Props { + value: string | number | null; + description?: string | null; +} + +export default function DropdownLabel({ value, description }: Props) { + return ( + <> + {value} + {description} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx new file mode 100644 index 00000000000000..606430e68400fe --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx @@ -0,0 +1,82 @@ +import { Checkbox, Select, Tag } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY_V2 } from '../../../constants'; +import { getStructuredPropertyValue } from '../../../utils'; +import ValueDescription from './ValueDescription'; +import { AllowedValue } from '../../../../../../types.generated'; +import DropdownLabel from './DropdownLabel'; + +const StyledCheckbox = styled(Checkbox)` + display: flex; + margin: 0 0 4px 0; + .ant-checkbox-inner { + border-color: ${ANTD_GRAY_V2[8]}; + } + &&& { + margin-left: 0; + } +`; + +const StyleTag = styled(Tag)` + font-family: Manrope; + font-size: 14px; + font-style: normal; + font-weight: 400; +`; + +const DROPDOWN_STYLE = { minWidth: 320, maxWidth: 320, textAlign: 'left' }; + +interface Props { + selectedValues: any[]; + allowedValues: AllowedValue[]; + toggleSelectedValue: (value: string | number) => void; + updateSelectedValues: (values: string[] | number[]) => void; +} + +export default function MultiSelectInput({ + toggleSelectedValue, + updateSelectedValues, + allowedValues, + selectedValues, +}: Props) { + return allowedValues.length > 5 ? ( + selectSingleValue(value)} + optionLabelProp="value" + > + {allowedValues.map((allowedValue) => ( + + + + ))} + + ) : ( + selectSingleValue(e.target.value)}> + {allowedValues.map((allowedValue) => ( + + {getStructuredPropertyValue(allowedValue.value)} + {allowedValue.description && } + + ))} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx new file mode 100644 index 00000000000000..8c69174a35bf3d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StringInput.tsx @@ -0,0 +1,31 @@ +import { Input } from 'antd'; +import React, { ChangeEvent } from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY_V2 } from '../../../constants'; +import { PropertyCardinality } from '../../../../../../types.generated'; +import MultipleStringInput from './MultipleStringInput'; + +const StyledInput = styled(Input)` + width: 75%; + min-width: 350px; + max-width: 500px; + border: 1px solid ${ANTD_GRAY_V2[6]}; +`; + +interface Props { + selectedValues: any[]; + cardinality?: PropertyCardinality | null; + updateSelectedValues: (values: string[] | number[]) => void; +} + +export default function StringInput({ selectedValues, cardinality, updateSelectedValues }: Props) { + function updateInput(event: ChangeEvent) { + updateSelectedValues([event.target.value]); + } + + if (cardinality === PropertyCardinality.Multiple) { + return ; + } + + return ; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx new file mode 100644 index 00000000000000..d7f29779156816 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/StructuredPropertyPrompt.tsx @@ -0,0 +1,207 @@ +import { Button } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { + EntityType, + FormPrompt, + PropertyCardinality, + SchemaField, + StdDataType, + SubmitFormPromptInput, +} from '../../../../../../types.generated'; +import SingleSelectInput from './SingleSelectInput'; +import MultiSelectInput from './MultiSelectInput'; +import useStructuredPropertyPrompt from './useStructuredPropertyPrompt'; +import StringInput from './StringInput'; +import RichTextInput from './RichTextInput'; +import DateInput from './DateInput'; +import NumberInput from './NumberInput'; +import UrnInput from './UrnInput/UrnInput'; +import { useEntityData } from '../../../EntityContext'; +import { + findCompletedFieldPrompt, + findPromptAssociation, + getCompletedPrompts, + getIncompletePrompts, + isFieldPromptComplete, + isPromptComplete, +} from '../../../containers/profile/sidebar/FormInfo/utils'; +import { useEntityRegistry } from '../../../../../useEntityRegistry'; +import { getTimeFromNow } from '../../../../../shared/time/timeUtils'; +import CompletedPromptAuditStamp from './CompletedPromptAuditStamp'; +import { applyOpacity } from '../../../../../shared/styleUtils'; +import { useUserContext } from '../../../../../context/useUserContext'; + +const PromptWrapper = styled.div<{ displayBulkStyles?: boolean }>` + display: flex; + justify-content: space-between; + height: min-content; + ${(props) => props.displayBulkStyles && `color: white;`} +`; + +const PromptTitle = styled.div<{ displayBulkStyles?: boolean }>` + font-size: 16px; + font-weight: 600; + line-height: 20px; + ${(props) => props.displayBulkStyles && `font-size: 20px;`} +`; + +const RequiredText = styled.span<{ displayBulkStyles?: boolean }>` + font-size: 12px; + margin-left: 4px; + color: #a8071a; + ${(props) => + props.displayBulkStyles && + ` + color: #FFCCC7; + margin-left: 8px; + `} +`; + +export const PromptSubTitle = styled.div` + font-size: 14px; + font-weight: 500; + line-height: 18px; + margin-top: 4px; +`; + +const InputSection = styled.div` + margin-top: 8px; +`; + +const StyledButton = styled(Button)` + align-self: end; + margin-left: 8px; + + &:focus { + box-shadow: 0 0 3px 2px ${(props) => applyOpacity(props.theme.styles['primary-color'] || '', 50)}; + } +`; + +const PromptInputWrapper = styled.div` + flex: 1; +`; + +interface Props { + promptNumber?: number; + prompt: FormPrompt; + submitResponse: (input: SubmitFormPromptInput, onSuccess: () => void) => void; + field?: SchemaField; + optimisticCompletedTimestamp?: number | null; +} + +export default function StructuredPropertyPrompt({ + promptNumber, + prompt, + submitResponse, + field, + optimisticCompletedTimestamp, +}: Props) { + const { + isSaveVisible, + selectedValues, + selectSingleValue, + toggleSelectedValue, + submitStructuredPropertyResponse, + updateSelectedValues, + } = useStructuredPropertyPrompt({ prompt, submitResponse, field }); + const { entityData } = useEntityData(); + const { user } = useUserContext(); + const entityRegistry = useEntityRegistry(); + const completedPrompts = getCompletedPrompts(entityData); + const incompletePrompts = getIncompletePrompts(entityData); + const promptAssociation = findPromptAssociation(prompt, completedPrompts.concat(incompletePrompts)); + const completedFieldPrompt = findCompletedFieldPrompt(field, promptAssociation); + + const structuredProperty = prompt.structuredPropertyParams?.structuredProperty; + if (!structuredProperty) return null; + + const { displayName, description, allowedValues, cardinality, valueType } = structuredProperty.definition; + + function getCompletedByName() { + let actor = completedFieldPrompt?.lastModified?.actor || promptAssociation?.lastModified?.actor; + if (optimisticCompletedTimestamp) { + actor = user; + } + return actor ? entityRegistry.getDisplayName(EntityType.CorpUser, actor) : ''; + } + + function getCompletedByRelativeTime() { + let completedTimestamp = completedFieldPrompt?.lastModified?.time || promptAssociation?.lastModified?.time; + if (optimisticCompletedTimestamp) { + completedTimestamp = optimisticCompletedTimestamp; + } + return completedTimestamp ? getTimeFromNow(completedTimestamp) : ''; + } + + return ( + + + + {promptNumber !== undefined && <>{promptNumber}. } + {displayName} + {prompt.required && required} + + {description && {description}} + + {allowedValues && allowedValues.length > 0 && ( + <> + {cardinality === PropertyCardinality.Single && ( + + )} + {cardinality === PropertyCardinality.Multiple && ( + + )} + + )} + {!allowedValues && valueType.info.type === StdDataType.String && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.RichText && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Date && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Number && ( + + )} + {!allowedValues && valueType.info.type === StdDataType.Urn && ( + + )} + + + {isSaveVisible && selectedValues.length > 0 && ( + + Save + + )} + {(isPromptComplete(prompt, completedPrompts) || + isFieldPromptComplete(field, promptAssociation) || + optimisticCompletedTimestamp) && + !isSaveVisible && ( + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/SelectedEntity.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/SelectedEntity.tsx new file mode 100644 index 00000000000000..d5ed2e9693fc9c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/SelectedEntity.tsx @@ -0,0 +1,40 @@ +import { Typography } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { Entity } from '../../../../../../../types.generated'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import EntityIcon from '../../../../components/styled/EntityIcon'; + +const SelectedEntityWrapper = styled.div` + display: flex; + align-items: center; + font-size: 14px; + overflow: hidden; +`; + +const IconWrapper = styled.span` + margin-right: 4px; + display: flex; +`; + +const NameWrapper = styled(Typography.Text)` + margin-right: 4px; +`; + +interface Props { + entity: Entity; +} + +export default function SelectedEntity({ entity }: Props) { + const entityRegistry = useEntityRegistry(); + const displayName = entityRegistry.getDisplayName(entity.type, entity); + + return ( + + + + + {displayName} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/UrnInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/UrnInput.tsx new file mode 100644 index 00000000000000..54d53c75607e5f --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/UrnInput.tsx @@ -0,0 +1,78 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Select } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; +import { StructuredPropertyEntity } from '../../../../../../../types.generated'; +import useUrnInput from './useUrnInput'; +import SelectedEntity from './SelectedEntity'; + +const EntitySelect = styled(Select)` + width: 75%; + min-width: 400px; + max-width: 600px; + + .ant-select-selector { + padding: 4px; + } +`; + +const LoadingWrapper = styled.div` + padding: 8px; + display: flex; + justify-content: center; + + svg { + height: 24px; + width: 24px; + } +`; + +interface Props { + structuredProperty: StructuredPropertyEntity; + selectedValues: any[]; + updateSelectedValues: (values: string[] | number[]) => void; +} + +export default function UrnInput({ structuredProperty, selectedValues, updateSelectedValues }: Props) { + const { + onSelectValue, + onDeselectValue, + handleSearch, + tagRender, + selectedEntities, + searchResults, + loading, + entityTypeNames, + } = useUrnInput({ structuredProperty, selectedValues, updateSelectedValues }); + + const placeholder = `Search for ${entityTypeNames ? entityTypeNames.map((name) => ` ${name}`) : 'entities'}...`; + + return ( + onSelectValue(urn)} + onDeselect={(urn: any) => onDeselectValue(urn)} + onSearch={(value: string) => handleSearch(value.trim())} + tagRender={tagRender} + value={selectedEntities.map((e) => e.urn)} + loading={loading} + notFoundContent={ + loading ? ( + + + + ) : undefined + } + > + {searchResults?.map((searchResult) => ( + + + + ))} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/useUrnInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/useUrnInput.tsx new file mode 100644 index 00000000000000..4f621f7018f12e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/UrnInput/useUrnInput.tsx @@ -0,0 +1,108 @@ +import { Tag } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { Entity, PropertyCardinality, StructuredPropertyEntity } from '../../../../../../../types.generated'; +import { useGetSearchResultsForMultipleLazyQuery } from '../../../../../../../graphql/search.generated'; +import { useEntityData } from '../../../../EntityContext'; +import { getInitialEntitiesForUrnPrompt } from '../utils'; +import SelectedEntity from './SelectedEntity'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import usePrevious from '../../../../../../shared/usePrevious'; + +const StyleTag = styled(Tag)` + margin: 2px; + padding: 4px 6px; + display: flex; + justify-content: start; + align-items: center; + white-space: nowrap; + opacity: 1; + color: #434343; + line-height: 16px; + font-size: 12px; + max-width: 100%; +`; + +interface Props { + structuredProperty: StructuredPropertyEntity; + selectedValues: any[]; + updateSelectedValues: (values: any[]) => void; +} + +export default function useUrnInput({ structuredProperty, selectedValues, updateSelectedValues }: Props) { + const entityRegistry = useEntityRegistry(); + const { entityData } = useEntityData(); + const initialEntities = useMemo( + () => getInitialEntitiesForUrnPrompt(structuredProperty.urn, entityData, selectedValues), + [structuredProperty.urn, entityData, selectedValues], + ); + + // we store the selected entity objects here to render display name, platform, etc. + // selectedValues contains a list of urns that we store for the structured property values + const [selectedEntities, setSelectedEntities] = useState(initialEntities); + const [searchAcrossEntities, { data: searchData, loading }] = useGetSearchResultsForMultipleLazyQuery(); + const searchResults = + searchData?.searchAcrossEntities?.searchResults?.map((searchResult) => searchResult.entity) || []; + const allowedEntityTypes = structuredProperty.definition.typeQualifier?.allowedTypes?.map( + (allowedType) => allowedType.info.type, + ); + const entityTypeNames: string[] | undefined = allowedEntityTypes?.map( + (entityType) => entityRegistry.getEntityName(entityType) || '', + ); + const isMultiple = structuredProperty.definition.cardinality === PropertyCardinality.Multiple; + + const previousEntityUrn = usePrevious(entityData?.urn); + useEffect(() => { + if (entityData?.urn !== previousEntityUrn) { + setSelectedEntities(initialEntities || []); + } + }, [entityData?.urn, previousEntityUrn, initialEntities]); + + function handleSearch(query: string) { + if (query.length > 0) { + searchAcrossEntities({ variables: { input: { query, types: allowedEntityTypes } } }); + } + } + + const onSelectValue = (urn: string) => { + const newValues = isMultiple ? [...selectedValues, urn] : [urn]; + updateSelectedValues(newValues); + + const selectedEntity = searchResults?.find((result) => result.urn === urn) as Entity; + const newEntities = isMultiple ? [...selectedEntities, selectedEntity] : [selectedEntity]; + setSelectedEntities(newEntities); + }; + + const onDeselectValue = (urn: string) => { + const newValues = selectedValues.filter((value) => value !== urn); + updateSelectedValues(newValues); + + const newSelectedEntities = selectedEntities.filter((entity) => entity.urn !== urn); + setSelectedEntities(newSelectedEntities); + }; + + const tagRender = (props: any) => { + // eslint-disable-next-line react/prop-types + const { closable, onClose, value } = props; + const selectedEntity = selectedEntities.find((term) => term.urn === value); + + if (!selectedEntity) return <>; + + return ( + + + + ); + }; + + return { + tagRender, + handleSearch, + onSelectValue, + onDeselectValue, + selectedEntities, + searchResults, + loading, + entityTypeNames, + }; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/ValueDescription.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/ValueDescription.tsx new file mode 100644 index 00000000000000..716bd74fe6630d --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/ValueDescription.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY_V2 } from '../../../constants'; + +const DescriptionText = styled.span` + color: ${ANTD_GRAY_V2[8]}; +`; + +const DescriptionSeparator = styled.span` + margin: 0 8px; +`; + +interface Props { + description: string; +} + +export default function ValueDescription({ description }: Props) { + return ( + <> + - + {description} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts new file mode 100644 index 00000000000000..d238a17b097992 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/useStructuredPropertyPrompt.ts @@ -0,0 +1,99 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useEntityContext } from '../../../EntityContext'; +import { FormPrompt, FormPromptType, SchemaField, SubmitFormPromptInput } from '../../../../../../types.generated'; +import { getInitialValues } from './utils'; +import usePrevious from '../../../../../shared/usePrevious'; +import { useGetEntityWithSchema } from '../../../tabs/Dataset/Schema/useGetEntitySchema'; +import { FormView, useEntityFormContext } from '../../EntityFormContext'; + +interface Props { + prompt: FormPrompt; + submitResponse: (input: SubmitFormPromptInput, onSuccess: () => void) => void; + field?: SchemaField; +} + +export default function useStructuredPropertyPrompt({ prompt, submitResponse, field }: Props) { + const { refetch: refetchSchema } = useGetEntityWithSchema(); + const { refetch, entityData } = useEntityContext(); + const { selectedPromptId, formView } = useEntityFormContext(); + const [isSaveVisible, setIsSaveVisible] = useState(false); + const initialValues = useMemo( + () => (formView === FormView.BY_ENTITY ? getInitialValues(prompt, entityData, field) : []), + [formView, entityData, prompt, field], + ); + const [selectedValues, setSelectedValues] = useState(initialValues || []); + + const structuredProperty = prompt.structuredPropertyParams?.structuredProperty; + + const previousEntityUrn = usePrevious(entityData?.urn); + useEffect(() => { + if (entityData?.urn !== previousEntityUrn) { + setSelectedValues(initialValues || []); + } + }, [entityData?.urn, previousEntityUrn, initialValues]); + + const previousSelectedPromptId = usePrevious(selectedPromptId); + useEffect(() => { + if (selectedPromptId !== previousSelectedPromptId) { + setIsSaveVisible(false); + setSelectedValues(initialValues || []); + } + }, [previousSelectedPromptId, selectedPromptId, initialValues]); + + // respond to prompts + function selectSingleValue(value: string | number) { + setIsSaveVisible(true); + setSelectedValues([value as string]); + } + + function toggleSelectedValue(value: string | number) { + setIsSaveVisible(true); + if (selectedValues.includes(value)) { + setSelectedValues((prev) => prev.filter((v) => v !== value)); + } else { + setSelectedValues((prev) => [...prev, value]); + } + } + + function updateSelectedValues(values: any[]) { + setSelectedValues(values); + setIsSaveVisible(true); + } + + // submit structured property prompt + function submitStructuredPropertyResponse() { + submitResponse( + { + promptId: prompt.id, + formUrn: prompt.formUrn, + type: field ? FormPromptType.FieldsStructuredProperty : FormPromptType.StructuredProperty, + fieldPath: field?.fieldPath, + structuredPropertyParams: { + structuredPropertyUrn: structuredProperty?.urn as string, + values: selectedValues.map((value) => { + if (typeof value === 'string') { + return { stringValue: value as string }; + } + return { numberValue: value as number }; + }), + }, + }, + () => { + refetch(); + setIsSaveVisible(false); + if (field) { + refetchSchema(); + } + }, + ); + } + + return { + isSaveVisible, + selectedValues, + selectSingleValue, + toggleSelectedValue, + submitStructuredPropertyResponse, + updateSelectedValues, + }; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/utils.ts b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/utils.ts new file mode 100644 index 00000000000000..1050c5fcde7283 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/utils.ts @@ -0,0 +1,36 @@ +import { getStructuredPropertyValue } from '../../../utils'; +import { GenericEntityProperties } from '../../../types'; +import { + Entity, + FormPrompt, + PropertyValue, + SchemaField, + StructuredPropertiesEntry, +} from '../../../../../../types.generated'; + +export function getInitialValues(prompt: FormPrompt, entityData: GenericEntityProperties | null, field?: SchemaField) { + const structuredProperty = prompt.structuredPropertyParams?.structuredProperty; + let structuredPropertyAssignment: StructuredPropertiesEntry | undefined; + if (field) { + structuredPropertyAssignment = field?.schemaFieldEntity?.structuredProperties?.properties?.find( + (propAssignment) => propAssignment.structuredProperty.urn === structuredProperty?.urn, + ); + } else { + structuredPropertyAssignment = entityData?.structuredProperties?.properties?.find( + (propAssignment) => propAssignment.structuredProperty.urn === structuredProperty?.urn, + ); + } + return structuredPropertyAssignment?.values.map((value) => getStructuredPropertyValue(value as PropertyValue)); +} + +export function getInitialEntitiesForUrnPrompt( + structuredPropertyUrn: string, + entityData: GenericEntityProperties | null, + selectedValues: any[], +) { + const structuredPropertyEntry = entityData?.structuredProperties?.properties?.find( + (p) => p.structuredProperty.urn === structuredPropertyUrn, + ); + const entities = structuredPropertyEntry?.valueEntities?.filter((e) => selectedValues.includes(e?.urn)); + return entities ? (entities as Entity[]) : []; +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/VerificationPrompt.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/VerificationPrompt.tsx new file mode 100644 index 00000000000000..7578436cc993ac --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/VerificationPrompt.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { Button, Divider, message } from 'antd'; +import { useVerifyFormMutation } from '../../../../../graphql/form.generated'; +import { useEntityContext, useMutationUrn } from '../../EntityContext'; +import { PromptWrapper } from './Prompt'; +import { useUpdateEducationStepsAllowList } from '../../../../onboarding/useUpdateEducationStepsAllowList'; +import { FORM_ASSET_COMPLETION } from '../../../../onboarding/config/FormOnboardingConfig'; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 16px; + font-weight: 600; +`; + +const VerifyButton = styled(Button)` + margin-top: 16px; + width: 60%; + max-width: 600px; + font-size: 16px; + font-weight: 600; + height: auto; +`; + +interface Props { + formUrn: string; + associatedUrn?: string; +} + +export default function VerificationPrompt({ formUrn, associatedUrn }: Props) { + const urn = useMutationUrn(); + const { refetch } = useEntityContext(); + const [verifyFormMutation] = useVerifyFormMutation(); + const { addIdToAllowList } = useUpdateEducationStepsAllowList(); + + function verifyForm() { + verifyFormMutation({ variables: { input: { entityUrn: associatedUrn || urn || '', formUrn } } }) + .then(() => { + refetch(); + addIdToAllowList(FORM_ASSET_COMPLETION); + }) + .catch(() => { + message.error('Error when verifying responses on form'); + }); + } + + const verificationPrompt = useRef(null); + useEffect(() => { + (verificationPrompt?.current as any)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest', + }); + }, []); + + return ( + <> + + + + All questions for verification have been completed. Please verify your responses. + + Verify Responses + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/DropdownHeader.tsx b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/DropdownHeader.tsx new file mode 100644 index 00000000000000..0d09cce4a97aa8 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/DropdownHeader.tsx @@ -0,0 +1,62 @@ +import Icon from '@ant-design/icons/lib/components/Icon'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import GreenCircleIcon from '../../../../../images/greenCircleTwoTone.svg?react'; +import { SchemaField } from '../../../../../types.generated'; +import translateFieldPath from '../../../dataset/profile/schema/utils/translateFieldPath'; +import { getNumPromptsCompletedForField } from '../../containers/profile/sidebar/FormInfo/utils'; +import { useEntityData } from '../../EntityContext'; +import { ANTD_GRAY_V2 } from '../../constants'; +import { pluralize } from '../../../../shared/textUtil'; +import { useEntityFormContext } from '../EntityFormContext'; + +const HeaderWrapper = styled.div` + display: flex; + justify-content: space-between; + font-size: 16px; + align-items: center; +`; + +const PromptsRemainingText = styled.span` + font-size: 14px; + color: ${ANTD_GRAY_V2[8]}; + font-weight: 400; +`; + +const PromptsCompletedText = styled.span` + font-size: 14px; + color: #373d44; + font-weight: 600; +`; + +interface Props { + field: SchemaField; + numPrompts: number; + isExpanded: boolean; +} + +export default function DropdownHeader({ field, numPrompts, isExpanded }: Props) { + const { entityData } = useEntityData(); + const { formUrn } = useEntityFormContext(); + const numPromptsCompletedForField = useMemo( + () => getNumPromptsCompletedForField(field.fieldPath, entityData, formUrn), + [entityData, field.fieldPath, formUrn], + ); + const numPromptsRemaining = numPrompts - numPromptsCompletedForField; + + return ( + + Field: {translateFieldPath(field.fieldPath)} + {numPromptsRemaining > 0 && ( + + {numPromptsRemaining} {pluralize(numPrompts, 'question')} remaining + + )} + {numPromptsRemaining === 0 && !isExpanded && ( + + {numPrompts} {pluralize(numPrompts, 'Question')} Completed + + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldDropdown.tsx b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldDropdown.tsx new file mode 100644 index 00000000000000..bdb6b99dc1dbf6 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldDropdown.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Collapse } from 'antd'; +import { FormPrompt, SchemaField } from '../../../../../types.generated'; +import Prompt from '../prompts/Prompt'; +import DropdownHeader from './DropdownHeader'; + +const StyledCollapse = styled(Collapse)` + margin-bottom: 16px; + + .ant-collapse-header { + font-size: 14px; + font-weight: bold; + padding: 12px 0; + } + &&& .ant-collapse-item { + background-color: white; + border-radius: 5px; + } + .ant-collapse-content-box { + padding: 0; + } +`; + +interface Props { + field: SchemaField; + prompts: FormPrompt[]; + associatedUrn?: string; +} + +export default function SchemaFieldDropdown({ field, prompts, associatedUrn }: Props) { + const [isExpanded, setIsExpanded] = useState(false); + return ( + setIsExpanded(!isExpanded)}> + } + key="0" + > + {prompts.map((prompt) => ( + + ))} + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldPrompts.tsx b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldPrompts.tsx new file mode 100644 index 00000000000000..087a42e3f8000c --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/schemaFieldPrompts/SchemaFieldPrompts.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Divider } from 'antd'; +import { FormPrompt, SchemaField } from '../../../../../types.generated'; +import { useGetEntityWithSchema } from '../../tabs/Dataset/Schema/useGetEntitySchema'; +import SchemaFieldDropdown from './SchemaFieldDropdown'; +import VirtualScrollChild from '../../../../shared/VirtualScrollChild'; + +const FieldPromptsTitle = styled.div` + margin-bottom: 16px; + font-size: 16px; + font-weight: 600; +`; + +interface Props { + prompts: FormPrompt[]; + associatedUrn?: string; +} + +export default function SchemaFieldPrompts({ prompts, associatedUrn }: Props) { + const { entityWithSchema } = useGetEntityWithSchema(); + + if (!entityWithSchema?.schemaMetadata || !entityWithSchema.schemaMetadata.fields.length) return null; + + return ( + <> + + Field-Level Requirements + {entityWithSchema?.schemaMetadata?.fields.map((field) => ( + + + + ))} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/entityForm/useShouldShowVerificationPrompt.ts b/datahub-web-react/src/app/entity/shared/entityForm/useShouldShowVerificationPrompt.ts new file mode 100644 index 00000000000000..d7a8a417a0c866 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/entityForm/useShouldShowVerificationPrompt.ts @@ -0,0 +1,38 @@ +import { FormType, FormVerificationAssociation } from '../../../../types.generated'; +import { useEntityData } from '../EntityContext'; +import useGetPromptInfo from '../containers/profile/sidebar/FormInfo/useGetPromptInfo'; +import { getFormAssociation, getFormVerification } from '../containers/profile/sidebar/FormInfo/utils'; + +interface ShowVerificationPromptProps { + formType?: FormType; + numRequiredPromptsRemaining: number; + formVerification?: FormVerificationAssociation; +} + +export function shouldShowVerificationPrompt({ + formType, + numRequiredPromptsRemaining, + formVerification, +}: ShowVerificationPromptProps) { + return formType === FormType.Verification && numRequiredPromptsRemaining === 0 && !formVerification; +} + +/* + * Returns whether or not we should show ther verification prompt for a given form. + * We want to show this prompt if (1) the form is a VERIFICATION form (2) there are no more + * require prompts remaining and either (3a) the form is not verified or (3b) it has been + * edited more recently than the verification timestamp. + */ +export default function useShouldShowVerificationPrompt(formUrn: string) { + const { numRequiredPromptsRemaining } = useGetPromptInfo(formUrn); + const { entityData } = useEntityData(); + const formVerification = getFormVerification(formUrn, entityData); + const formAssociation = getFormAssociation(formUrn, entityData); + const formType = formAssociation?.form.info.type; + + return shouldShowVerificationPrompt({ + formType, + numRequiredPromptsRemaining, + formVerification, + }); +} diff --git a/datahub-web-react/src/app/entity/shared/siblingUtils.ts b/datahub-web-react/src/app/entity/shared/siblingUtils.ts index 66481051055ec1..5e21c2a7c5ac44 100644 --- a/datahub-web-react/src/app/entity/shared/siblingUtils.ts +++ b/datahub-web-react/src/app/entity/shared/siblingUtils.ts @@ -117,6 +117,9 @@ const customMerge = (isPrimary, key) => { if (key === 'platform' || key === 'siblings') { return (secondary, primary) => (isPrimary ? primary : secondary); } + if (key === 'forms') { + return (_secondary, primary) => primary; + } if ( key === 'tags' || key === 'terms' || diff --git a/datahub-web-react/src/app/entity/shared/types.ts b/datahub-web-react/src/app/entity/shared/types.ts index 47cad4a69096de..919dfb78a52f6b 100644 --- a/datahub-web-react/src/app/entity/shared/types.ts +++ b/datahub-web-react/src/app/entity/shared/types.ts @@ -39,6 +39,7 @@ import { DataJobInputOutput, ParentDomainsResult, StructuredProperties, + Forms, } from '../../../types.generated'; import { FetchedEntity } from '../../lineage/types'; @@ -119,6 +120,7 @@ export type GenericEntityProperties = { origin?: Maybe; browsePathV2?: Maybe; inputOutput?: Maybe; + forms?: Maybe; }; export type GenericEntityUpdate = { diff --git a/datahub-web-react/src/app/home/HomePageRecommendations.tsx b/datahub-web-react/src/app/home/HomePageRecommendations.tsx index 6ce7735c4a7c85..cc9f4b265455b2 100644 --- a/datahub-web-react/src/app/home/HomePageRecommendations.tsx +++ b/datahub-web-react/src/app/home/HomePageRecommendations.tsx @@ -20,7 +20,7 @@ import { HOME_PAGE_MOST_POPULAR_ID, HOME_PAGE_PLATFORMS_ID, } from '../onboarding/config/HomePageOnboardingConfig'; -import { useUpdateEducationStepIdsAllowlist } from '../onboarding/useUpdateEducationStepIdsAllowlist'; +import { useToggleEducationStepIdsAllowList } from '../onboarding/useToggleEducationStepIdsAllowList'; const PLATFORMS_MODULE_ID = 'Platforms'; const MOST_POPULAR_MODULE_ID = 'HighUsageEntities'; @@ -147,15 +147,15 @@ export const HomePageRecommendations = ({ user }: Props) => { // Render domain onboarding step if the domains module exists const hasDomains = !!domainRecommendationModule; - useUpdateEducationStepIdsAllowlist(hasDomains, HOME_PAGE_DOMAINS_ID); + useToggleEducationStepIdsAllowList(hasDomains, HOME_PAGE_DOMAINS_ID); // Render platforms onboarding step if the platforms module exists const hasPlatforms = !!recommendationModules?.some((module) => module?.moduleId === PLATFORMS_MODULE_ID); - useUpdateEducationStepIdsAllowlist(hasPlatforms, HOME_PAGE_PLATFORMS_ID); + useToggleEducationStepIdsAllowList(hasPlatforms, HOME_PAGE_PLATFORMS_ID); // Render most popular onboarding step if the most popular module exists const hasMostPopular = !!recommendationModules?.some((module) => module?.moduleId === MOST_POPULAR_MODULE_ID); - useUpdateEducationStepIdsAllowlist(hasMostPopular, HOME_PAGE_MOST_POPULAR_ID); + useToggleEducationStepIdsAllowList(hasMostPopular, HOME_PAGE_MOST_POPULAR_ID); return ( diff --git a/datahub-web-react/src/app/identity/user/UserList.tsx b/datahub-web-react/src/app/identity/user/UserList.tsx index 178f54325ecde0..4a9e84d6e2248a 100644 --- a/datahub-web-react/src/app/identity/user/UserList.tsx +++ b/datahub-web-react/src/app/identity/user/UserList.tsx @@ -21,7 +21,7 @@ import { USERS_INVITE_LINK_ID, USERS_SSO_ID, } from '../../onboarding/config/UsersOnboardingConfig'; -import { useUpdateEducationStepIdsAllowlist } from '../../onboarding/useUpdateEducationStepIdsAllowlist'; +import { useToggleEducationStepIdsAllowList } from '../../onboarding/useToggleEducationStepIdsAllowList'; import { DEFAULT_USER_LIST_PAGE_SIZE, removeUserFromListUsersCache } from './cacheUtils'; import { useUserContext } from '../../context/useUserContext'; @@ -113,7 +113,7 @@ export const UserList = () => { const error = usersError || rolesError; const selectRoleOptions = rolesData?.listRoles?.roles?.map((role) => role as DataHubRole) || []; - useUpdateEducationStepIdsAllowlist(canManagePolicies, USERS_INVITE_LINK_ID); + useToggleEducationStepIdsAllowList(canManagePolicies, USERS_INVITE_LINK_ID); return ( <> diff --git a/datahub-web-react/src/app/onboarding/OnboardingConfig.tsx b/datahub-web-react/src/app/onboarding/OnboardingConfig.tsx index 7cc382fe8f2798..83fa6acd0cc252 100644 --- a/datahub-web-react/src/app/onboarding/OnboardingConfig.tsx +++ b/datahub-web-react/src/app/onboarding/OnboardingConfig.tsx @@ -1,6 +1,7 @@ import { BusinessGlossaryOnboardingConfig } from './config/BusinessGlossaryOnboardingConfig'; import { DomainsOnboardingConfig } from './config/DomainsOnboardingConfig'; import { EntityProfileOnboardingConfig } from './config/EntityProfileOnboardingConfig'; +import { FormOnboardingConfig } from './config/FormOnboardingConfig'; import { GroupsOnboardingConfig } from './config/GroupsOnboardingConfig'; import { HomePageOnboardingConfig } from './config/HomePageOnboardingConfig'; import { IngestionOnboardingConfig } from './config/IngestionOnboardingConfig'; @@ -23,6 +24,7 @@ const ALL_ONBOARDING_CONFIGS: OnboardingStep[][] = [ RolesOnboardingConfig, PoliciesOnboardingConfig, LineageGraphOnboardingConfig, + FormOnboardingConfig, ]; export const OnboardingConfig: OnboardingStep[] = ALL_ONBOARDING_CONFIGS.reduce( (acc, config) => [...acc, ...config], diff --git a/datahub-web-react/src/app/onboarding/config/FormOnboardingConfig.tsx b/datahub-web-react/src/app/onboarding/config/FormOnboardingConfig.tsx new file mode 100644 index 00000000000000..d50a25badfabb0 --- /dev/null +++ b/datahub-web-react/src/app/onboarding/config/FormOnboardingConfig.tsx @@ -0,0 +1,178 @@ +import { SmileOutlined } from '@ant-design/icons'; +import React from 'react'; +import { Typography } from 'antd'; +import styled from 'styled-components'; +import { OnboardingStep } from '../OnboardingStep'; +import BulkTypeComparions from '../../../images/bulk-form-type-comparison.svg'; + +const DiagramHeader = styled.div` + display: flex; + justify-content: center; + margin: 16px 0 4px 0; +`; + +const AssetCompletionHeader = styled.div` + font-size: 20px; + font-weight: normal; +`; + +const ByAssetWrapper = styled.span` + margin-left: 10px; + font-size: 14px; +`; + +const ByQuestionWrapper = styled.span` + margin-left: 80px; + font-size: 14px; +`; + +const StyledSmile = styled(SmileOutlined)` + color: ${(props) => props.theme.styles['primary-color']}; + margin-right: 4px; +`; + +export const WELCOME_TO_BULK_BY_ENTITY_ID = 'welcome-to-bulk-by-entity'; +export const FORM_QUESTION_VIEW_BUTTON = 'form-question-view-button'; +export const FORM_ASSET_COMPLETION = 'form-asset-completion'; +export const WELCOME_TO_BULK_BY_QUESTION_ID = 'welcome-to-bulk-by-question'; +export const FORM_ASSETS_ASSIGNED_ID = 'form-assets-assigned'; +export const FORM_FILTER_AND_BROWSE_ID = 'form-filter-and-browse'; +export const FORM_ANSWER_IN_BULK_ID = 'form-answer-in-bulk'; +export const FORM_BULK_VERIFY_INTRO_ID = 'form-bulk-verify-intro'; +export const FORM_CHECK_RESPONSES_ID = 'form-check-responses'; +export const FORM_BULK_VERIFY_ID = 'form-bulk-verify'; + +export const FormOnboardingConfig: OnboardingStep[] = [ + { + id: WELCOME_TO_BULK_BY_ENTITY_ID, + selector: `#${WELCOME_TO_BULK_BY_ENTITY_ID}`, + title: 'Let’s complete your documentation requests!', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Here you can easily respond to all documentation requests efficiently. We’ll track your progress and + move you seamlessly through all your requests. +
    + Let’s get started completing the needs for this form. +
    + ), + }, + { + id: FORM_QUESTION_VIEW_BUTTON, + selector: `#${FORM_QUESTION_VIEW_BUTTON}`, + title: "Switch to the 'Complete by Question' view.", + style: { width: '520px', maxWidth: '520px' }, + content: ( + + If an answer fits multiple assets, this view lets you tackle questions across different assets at once, + making documentation even faster and more efficient. + + ), + }, + { + id: FORM_ASSET_COMPLETION, + selector: `#${FORM_ASSET_COMPLETION}`, + isActionStep: true, + title: ( + + Congratulations, You’ve Completed 1 Asset! + + ), + style: { width: '640px', maxWidth: '640px' }, + content: ( + + Now that you’ve completed one asset, try switching to the ‘Complete by Question’ view. If an answer fits + multiple assets, this view lets you tackle questions across different assets at once, making + documentation even faster and more efficient. + + By Asset + By Question + + bulk form type comparions + + ), + }, + { + id: WELCOME_TO_BULK_BY_QUESTION_ID, + selector: `#${WELCOME_TO_BULK_BY_QUESTION_ID}`, + title: "Welcome to the 'Complete by Question' view!", + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Here, you can easily provide the same response for multiple assets at once for a faster documenting + experience. + + ), + }, + { + id: FORM_ASSETS_ASSIGNED_ID, + selector: `#${FORM_ASSETS_ASSIGNED_ID}`, + title: 'Focus on only the assets that require your attention', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + In this view, we’ve simplified your workflow by only showing assets that require documentation from you. + + ), + }, + { + id: FORM_FILTER_AND_BROWSE_ID, + selector: `#${FORM_FILTER_AND_BROWSE_ID}`, + title: 'Filter and Browse to Select the Specific Assets', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Filter by type, terms, or browse by platform, database and schemas to select only the assets that you’d + like to set the response for. + + ), + }, + { + id: FORM_ANSWER_IN_BULK_ID, + selector: `#${FORM_ANSWER_IN_BULK_ID}`, + title: 'Answer in Bulk', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + After selecting your assets, set a collective response and start answering for groups of 1,000 assets at + a time. + + ), + }, + { + id: FORM_BULK_VERIFY_INTRO_ID, + selector: `#${FORM_BULK_VERIFY_INTRO_ID}`, + title: 'Streamline Verification in Bulk!', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Here you can quickly review responses for a few datasets, ensuring accuracy. When you're ready, + proceed to verify all assets at once, simplifying the entire verification process. + + ), + }, + { + id: FORM_CHECK_RESPONSES_ID, + selector: `#${FORM_CHECK_RESPONSES_ID}`, + title: 'Check Responses', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Click on "View Responses" to easily spot-check your responses before the final Verification + step. + + ), + }, + { + id: FORM_BULK_VERIFY_ID, + selector: `#${FORM_BULK_VERIFY_ID}`, + title: 'Bulk Verify Assets', + style: { width: '520px', maxWidth: '520px' }, + content: ( + + Once you're confident in your responses, verify up to 1,000 assets at a time for this form with a + click of a button. + + ), + }, +]; diff --git a/datahub-web-react/src/app/onboarding/useToggleEducationStepIdsAllowList.tsx b/datahub-web-react/src/app/onboarding/useToggleEducationStepIdsAllowList.tsx new file mode 100644 index 00000000000000..acf85d0a87b109 --- /dev/null +++ b/datahub-web-react/src/app/onboarding/useToggleEducationStepIdsAllowList.tsx @@ -0,0 +1,18 @@ +import { useContext, useEffect } from 'react'; +import { EducationStepsContext } from '../../providers/EducationStepsContext'; +import { useUpdateEducationStepsAllowList } from './useUpdateEducationStepsAllowList'; + +export function useToggleEducationStepIdsAllowList(condition: boolean, id: string) { + const { educationStepIdsAllowlist } = useContext(EducationStepsContext); + const { addIdToAllowList, removeIdFromAllowList } = useUpdateEducationStepsAllowList(); + + useEffect(() => { + const allowlistIncludesStepId = educationStepIdsAllowlist.has(id); + + if (condition && !allowlistIncludesStepId) { + addIdToAllowList(id); + } else if (!condition && allowlistIncludesStepId) { + removeIdFromAllowList(id); + } + }, [condition, id, addIdToAllowList, removeIdFromAllowList, educationStepIdsAllowlist]); +} diff --git a/datahub-web-react/src/app/onboarding/useUpdateEducationStepIdsAllowlist.tsx b/datahub-web-react/src/app/onboarding/useUpdateEducationStepIdsAllowlist.tsx deleted file mode 100644 index 4eb1f6c02b6b81..00000000000000 --- a/datahub-web-react/src/app/onboarding/useUpdateEducationStepIdsAllowlist.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useContext, useEffect } from 'react'; -import { EducationStepsContext } from '../../providers/EducationStepsContext'; - -export function useUpdateEducationStepIdsAllowlist(condition: boolean, id: string) { - const { educationStepIdsAllowlist, setEducationStepIdsAllowlist } = useContext(EducationStepsContext); - - useEffect(() => { - const allowlistIncludesStepId = educationStepIdsAllowlist.has(id); - - if (condition && !allowlistIncludesStepId) { - const newStepdIdsAllowlist: Set = new Set(educationStepIdsAllowlist); - newStepdIdsAllowlist.add(id); - setEducationStepIdsAllowlist(newStepdIdsAllowlist); - } else if (!condition && allowlistIncludesStepId) { - const newStepdIdsAllowlist: Set = new Set(educationStepIdsAllowlist); - newStepdIdsAllowlist.delete(id); - setEducationStepIdsAllowlist(newStepdIdsAllowlist); - } - }, [condition, id, educationStepIdsAllowlist, setEducationStepIdsAllowlist]); -} diff --git a/datahub-web-react/src/app/onboarding/useUpdateEducationStepsAllowList.tsx b/datahub-web-react/src/app/onboarding/useUpdateEducationStepsAllowList.tsx new file mode 100644 index 00000000000000..86b9000205b8b5 --- /dev/null +++ b/datahub-web-react/src/app/onboarding/useUpdateEducationStepsAllowList.tsx @@ -0,0 +1,22 @@ +import { useContext } from 'react'; +import { EducationStepsContext } from '../../providers/EducationStepsContext'; + +// function use + +export function useUpdateEducationStepsAllowList() { + const { educationStepIdsAllowlist, setEducationStepIdsAllowlist } = useContext(EducationStepsContext); + + function removeIdFromAllowList(id: string) { + const newStepdIdsAllowlist: Set = new Set(educationStepIdsAllowlist); + newStepdIdsAllowlist.delete(id); + setEducationStepIdsAllowlist(newStepdIdsAllowlist); + } + + function addIdToAllowList(id: string) { + const newStepdIdsAllowlist: Set = new Set(educationStepIdsAllowlist); + newStepdIdsAllowlist.add(id); + setEducationStepIdsAllowlist(newStepdIdsAllowlist); + } + + return { removeIdFromAllowList, addIdToAllowList }; +} diff --git a/datahub-web-react/src/app/search/SearchHeader.tsx b/datahub-web-react/src/app/search/SearchHeader.tsx index 76e78a11d3e9d9..0b6bf5488a3013 100644 --- a/datahub-web-react/src/app/search/SearchHeader.tsx +++ b/datahub-web-react/src/app/search/SearchHeader.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; -import { Image, Layout } from 'antd'; -import { Link } from 'react-router-dom'; -import styled, { useTheme } from 'styled-components'; +import { Layout } from 'antd'; +import styled from 'styled-components'; import { SearchBar } from './SearchBar'; import { ManageAccount } from '../shared/ManageAccount'; @@ -10,8 +9,8 @@ import EntityRegistry from '../entity/EntityRegistry'; import { ANTD_GRAY } from '../entity/shared/constants'; import { HeaderLinks } from '../shared/admin/HeaderLinks'; import { useAppConfig, useIsShowAcrylInfoEnabled } from '../useAppConfig'; -import { DEFAULT_APP_CONFIG } from '../../appConfigContext'; import DemoButton from '../entity/shared/components/styled/DemoButton'; +import AppLogoLink from '../shared/AppLogoLink'; const { Header } = Layout; @@ -29,13 +28,6 @@ const styles = { }, }; -const LogoImage = styled(Image)` - display: inline-block; - height: 32px; - width: auto; - margin-top: 2px; -`; - const LogoSearchContainer = styled.div` display: flex; flex: 1; @@ -77,7 +69,6 @@ export const SearchHeader = ({ entityRegistry, }: Props) => { const [isSearchBarFocused, setIsSearchBarFocused] = useState(false); - const themeConfig = useTheme(); const showAcrylInfo = useIsShowAcrylInfoEnabled(); const appConfig = useAppConfig(); const viewsEnabled = appConfig.config?.viewsConfig?.enabled || false; @@ -85,16 +76,7 @@ export const SearchHeader = ({ return (
    - - - + { }, [isSelectMode]); // Render new search filters v2 onboarding step if the feature flag is on - useUpdateEducationStepIdsAllowlist(showSearchFiltersV2, SEARCH_RESULTS_FILTERS_V2_INTRO); + useToggleEducationStepIdsAllowList(showSearchFiltersV2, SEARCH_RESULTS_FILTERS_V2_INTRO); // Render new browse v2 onboarding step if the feature flag is on - useUpdateEducationStepIdsAllowlist(showBrowseV2, SEARCH_RESULTS_BROWSE_SIDEBAR_ID); + useToggleEducationStepIdsAllowList(showBrowseV2, SEARCH_RESULTS_BROWSE_SIDEBAR_ID); return ( <> diff --git a/datahub-web-react/src/app/shared/AppLogoLink.tsx b/datahub-web-react/src/app/shared/AppLogoLink.tsx new file mode 100644 index 00000000000000..7d647194b07c4d --- /dev/null +++ b/datahub-web-react/src/app/shared/AppLogoLink.tsx @@ -0,0 +1,31 @@ +import { Image } from 'antd'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import styled, { useTheme } from 'styled-components'; +import { useAppConfig } from '../useAppConfig'; +import { DEFAULT_APP_CONFIG } from '../../appConfigContext'; + +const LogoImage = styled(Image)` + display: inline-block; + height: 32px; + width: auto; + margin-top: 2px; +`; + +export default function AppLogoLink() { + const appConfig = useAppConfig(); + const themeConfig = useTheme(); + + return ( + + + + ); +} diff --git a/datahub-web-react/src/app/shared/DeferredRenderComponent.tsx b/datahub-web-react/src/app/shared/DeferredRenderComponent.tsx new file mode 100644 index 00000000000000..7b5bdb949fc3db --- /dev/null +++ b/datahub-web-react/src/app/shared/DeferredRenderComponent.tsx @@ -0,0 +1,23 @@ +import React, { useEffect, useState } from 'react'; + +interface Props { + wrappedComponent: React.ReactNode; + loadingComponent?: React.ReactNode; + delay?: number; +} + +export function DeferredRenderComponent({ wrappedComponent, loadingComponent, delay = 250 }: Props) { + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + setTimeout(() => { + setShouldRender(true); + }, delay); + }, [delay]); + + if (shouldRender) { + return <>{wrappedComponent}; + } + + return loadingComponent ? <>{loadingComponent} : null; +} diff --git a/datahub-web-react/src/app/shared/Loading.tsx b/datahub-web-react/src/app/shared/Loading.tsx new file mode 100644 index 00000000000000..d03fb0a585dc1d --- /dev/null +++ b/datahub-web-react/src/app/shared/Loading.tsx @@ -0,0 +1,27 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import React from 'react'; +import styled from 'styled-components'; + +const LoadingWrapper = styled.div` + display: flex; + justify-content: center; + margin-top: 25%; + width: 100%; +`; + +const StyledLoading = styled(LoadingOutlined)<{ $height: number }>` + font-size: ${(props) => props.$height}px; + height: ${(props) => props.$height}px; +`; + +interface Props { + height?: number; +} + +export default function Loading({ height = 32 }: Props) { + return ( + + + + ); +} diff --git a/datahub-web-react/src/app/shared/VirtualScrollChild.tsx b/datahub-web-react/src/app/shared/VirtualScrollChild.tsx new file mode 100644 index 00000000000000..4cd539e0943062 --- /dev/null +++ b/datahub-web-react/src/app/shared/VirtualScrollChild.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useInView } from 'react-intersection-observer'; + +const VirtualChildWrapper = styled.div<{ $inView: boolean; $height: number }>` + height: ${(props) => (props.$inView ? 'auto' : `${props.$height}px`)}; + ${(props) => !props.$inView && 'visiblity: hidden;'} +`; + +interface VirtualProps { + height: number; + children: React.ReactNode; + triggerOnce?: boolean; +} + +export default function VirtualScrollChild({ height, children, triggerOnce }: VirtualProps) { + const [ref, inView] = useInView({ triggerOnce }); + + return ( + + {inView ? children : null} + + ); +} diff --git a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx index 4a7a4938ea9709..7d53afda2aa3a6 100644 --- a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx +++ b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx @@ -13,7 +13,7 @@ import { Button, Dropdown, Menu, Tooltip } from 'antd'; import { useAppConfig } from '../../useAppConfig'; import { ANTD_GRAY } from '../../entity/shared/constants'; import { HOME_PAGE_INGESTION_ID } from '../../onboarding/config/HomePageOnboardingConfig'; -import { useUpdateEducationStepIdsAllowlist } from '../../onboarding/useUpdateEducationStepIdsAllowlist'; +import { useToggleEducationStepIdsAllowList } from '../../onboarding/useToggleEducationStepIdsAllowList'; import { useUserContext } from '../../context/useUserContext'; import DomainIcon from '../../domain/DomainIcon'; @@ -74,7 +74,7 @@ export function HeaderLinks(props: Props) { const showIngestion = isIngestionEnabled && me && me.platformPrivileges?.manageIngestion && me.platformPrivileges?.manageSecrets; - useUpdateEducationStepIdsAllowlist(!!showIngestion, HOME_PAGE_INGESTION_ID); + useToggleEducationStepIdsAllowList(!!showIngestion, HOME_PAGE_INGESTION_ID); return ( diff --git a/datahub-web-react/src/app/shared/components.tsx b/datahub-web-react/src/app/shared/components.tsx index 68d2fb52cfdba0..3977a9c36b4026 100644 --- a/datahub-web-react/src/app/shared/components.tsx +++ b/datahub-web-react/src/app/shared/components.tsx @@ -47,3 +47,9 @@ export const BodyGridExpander = styled.div<{ isOpen: boolean }>` export const BodyContainer = styled.div` min-height: 0; `; + +export const WhiteButton = styled(Button)` + background-color: white; + color: ${(props) => props.theme.styles['primary-color']}; + text-shadow: none; +`; diff --git a/datahub-web-react/src/app/shared/useHasComponentRendered.ts b/datahub-web-react/src/app/shared/useHasComponentRendered.ts new file mode 100644 index 00000000000000..ff9b6987ecf11d --- /dev/null +++ b/datahub-web-react/src/app/shared/useHasComponentRendered.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +/* + * Returns whether a desired component is rendered or not. + * By setting a time out we place the state update at the + * end of the queue after this component has rendered. + */ +export default function useHasComponentRendered() { + const [hasRendered, setHasRendered] = useState(false); + + useEffect(() => { + setTimeout(() => { + setHasRendered(true); + }, 0); + }, []); + + return { hasRendered }; +} diff --git a/datahub-web-react/src/graphql/dataset.graphql b/datahub-web-react/src/graphql/dataset.graphql index 57c74e0c65d69a..e25d4fe6c86355 100644 --- a/datahub-web-react/src/graphql/dataset.graphql +++ b/datahub-web-react/src/graphql/dataset.graphql @@ -168,6 +168,27 @@ fragment nonSiblingDatasetFields on Dataset { canEditEmbed canEditQueries } + forms { + completedForms { + ...formAssociationFields + } + incompleteForms { + ...formAssociationFields + } + verifications { + form { + urn + } + lastModified { + time + actor { + urn + type + ...entityDisplayNameFields + } + } + } + } } query getRecentQueries($urn: String!) { diff --git a/datahub-web-react/src/graphql/form.graphql b/datahub-web-react/src/graphql/form.graphql new file mode 100644 index 00000000000000..3cd09697f79bd9 --- /dev/null +++ b/datahub-web-react/src/graphql/form.graphql @@ -0,0 +1,7 @@ +mutation submitFormPrompt($urn: String!, $input: SubmitFormPromptInput!) { + submitFormPrompt(urn: $urn, input: $input) +} + +mutation verifyForm($input: VerifyFormInput!) { + verifyForm(input: $input) +} diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql index e901c9af554ad1..dc534b315aadfa 100644 --- a/datahub-web-react/src/graphql/fragments.graphql +++ b/datahub-web-react/src/graphql/fragments.graphql @@ -1249,3 +1249,67 @@ fragment autoRenderAspectFields on RawAspect { key } } + +fragment formAssociationFields on FormAssociation { + associatedUrn + incompletePrompts { + ...formPromptAssociationFields + } + completedPrompts { + ...formPromptAssociationFields + } + form { + urn + type + info { + name + description + type + prompts { + id + formUrn + title + description + type + required + structuredPropertyParams { + structuredProperty { + ...structuredPropertyFields + } + } + } + actors { + owners + isAssignedToMe + } + } + ownership { + ...ownershipFields + } + } +} + +fragment formPromptAssociationFields on FormPromptAssociation { + id + lastModified { + time + actor { + urn + type + ...entityDisplayNameFields + } + } + fieldAssociations { + completedFieldPrompts { + fieldPath + lastModified { + time + actor { + urn + type + ...entityDisplayNameFields + } + } + } + } +} diff --git a/datahub-web-react/src/images/background_dots.svg b/datahub-web-react/src/images/background_dots.svg new file mode 100644 index 00000000000000..f4b3b917808b4d --- /dev/null +++ b/datahub-web-react/src/images/background_dots.svg @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/datahub-web-react/src/images/bulk-form-type-comparison.svg b/datahub-web-react/src/images/bulk-form-type-comparison.svg new file mode 100644 index 00000000000000..ac94f42394e361 --- /dev/null +++ b/datahub-web-react/src/images/bulk-form-type-comparison.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/datahub-web-react/src/images/greenCircleTwoTone.svg b/datahub-web-react/src/images/greenCircleTwoTone.svg new file mode 100644 index 00000000000000..ca9fd1ae742412 --- /dev/null +++ b/datahub-web-react/src/images/greenCircleTwoTone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/datahub-web-react/src/images/verificationBlue.svg b/datahub-web-react/src/images/verificationBlue.svg new file mode 100644 index 00000000000000..dafdd60fb156d8 --- /dev/null +++ b/datahub-web-react/src/images/verificationBlue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/datahub-web-react/src/images/verificationGreen.svg b/datahub-web-react/src/images/verificationGreen.svg new file mode 100644 index 00000000000000..d082db5dfc456b --- /dev/null +++ b/datahub-web-react/src/images/verificationGreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/datahub-web-react/src/images/verificationPurple.svg b/datahub-web-react/src/images/verificationPurple.svg new file mode 100644 index 00000000000000..a9549195779f06 --- /dev/null +++ b/datahub-web-react/src/images/verificationPurple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/datahub-web-react/src/images/verificationPurpleWhite.svg b/datahub-web-react/src/images/verificationPurpleWhite.svg new file mode 100644 index 00000000000000..c57d8b3105ebed --- /dev/null +++ b/datahub-web-react/src/images/verificationPurpleWhite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/datahub-web-react/src/images/verificationWarningGray.svg b/datahub-web-react/src/images/verificationWarningGray.svg new file mode 100644 index 00000000000000..725f448894532d --- /dev/null +++ b/datahub-web-react/src/images/verificationWarningGray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/datahub-web-react/src/images/verticalogo copy.png b/datahub-web-react/src/images/verticalogo copy.png new file mode 100644 index 0000000000000000000000000000000000000000..5da38f4e67c7d4ea72c02983bc8cf01c9b10752f GIT binary patch literal 25763 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 F{|CI7K7#-N literal 0 HcmV?d00001 diff --git a/datahub-web-react/yarn.lock b/datahub-web-react/yarn.lock index 37801e42b3eab9..c57454107ee0e7 100644 --- a/datahub-web-react/yarn.lock +++ b/datahub-web-react/yarn.lock @@ -9165,6 +9165,11 @@ react-icons@4.3.1: resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca" integrity sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ== +react-intersection-observer@^9.5.3: + version "9.5.3" + resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.3.tgz#f47a31ed3a0359cbbfdb91a53d7470ac2ab7b3c7" + integrity sha512-NJzagSdUPS5rPhaLsHXYeJbsvdpbJwL6yCHtMk91hc0ufQ2BnXis+0QQ9NBh6n9n+Q3OyjR6OQLShYbaNBkThQ== + react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" From b4d70dd13f7db37b201b0fe84dfce8e22fc6d664 Mon Sep 17 00:00:00 2001 From: cburroughs Date: Fri, 2 Feb 2024 17:46:54 -0500 Subject: [PATCH 250/540] chore(ingest/feast): drop redundant typeguard constraint (#9770) --- metadata-ingestion/setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index d4e2ada1fc68f4..ed3a886cc0bf3d 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -311,8 +311,6 @@ "feast": { "feast~=0.35.0", "flask-openid>=1.3.0", - # typeguard 3.x, released on 2023-03-14, seems to cause issues with Feast. - "typeguard<3", }, "glue": aws_common, # hdbcli is supported officially by SAP, sqlalchemy-hana is built on top but not officially supported From eb8017d6eb5d16d646150af3f9696fb1955466f7 Mon Sep 17 00:00:00 2001 From: cburroughs Date: Fri, 2 Feb 2024 17:47:26 -0500 Subject: [PATCH 251/540] feat(ingestion/feast): loosen feast dependency (#9769) --- metadata-ingestion/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index ed3a886cc0bf3d..67ea861230fed5 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -309,7 +309,7 @@ # https://github.com/elastic/elasticsearch-py/issues/1639#issuecomment-883587433 "elasticsearch": {"elasticsearch==7.13.4"}, "feast": { - "feast~=0.35.0", + "feast>=0.34.0,<1", "flask-openid>=1.3.0", }, "glue": aws_common, From fd34e41614a2920b457dffaa0dccefdad0afe4fc Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Sat, 3 Feb 2024 13:57:13 +0530 Subject: [PATCH 252/540] feat(ci): check for packages, change location of lint (#9759) --- .github/scripts/check_python_package.py | 18 ++++++++++ .github/workflows/docker-unified.yml | 33 ++++++++++++------- .../src/datahub/cli/config_utils.py | 7 ++-- .../tests/dataproduct/queries/__init__.py | 0 4 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/check_python_package.py create mode 100644 smoke-test/tests/dataproduct/queries/__init__.py diff --git a/.github/scripts/check_python_package.py b/.github/scripts/check_python_package.py new file mode 100644 index 00000000000000..f1f30056917006 --- /dev/null +++ b/.github/scripts/check_python_package.py @@ -0,0 +1,18 @@ +import setuptools + +folders = ["./smoke-test/tests"] + +for folder in folders: + print(f"Checking folder {folder}") + a = [i for i in setuptools.find_packages(folder) if "cypress" not in i] + b = [i for i in setuptools.find_namespace_packages(folder) if "cypress" not in i] + + in_a_not_b = set(a) - set(b) + in_b_not_a = set(b) - set(a) + + assert ( + len(in_a_not_b) == 0 + ), f"Found packages in {folder} that are not in namespace packages: {in_a_not_b}" + assert ( + len(in_b_not_a) == 0 + ), f"Found namespace packages in {folder} that are not in packages: {in_b_not_a}" diff --git a/.github/workflows/docker-unified.yml b/.github/workflows/docker-unified.yml index 24490ef83ae5de..ae323181a52156 100644 --- a/.github/workflows/docker-unified.yml +++ b/.github/workflows/docker-unified.yml @@ -37,8 +37,29 @@ env: DATAHUB_INGESTION_IMAGE: "acryldata/datahub-ingestion" jobs: + check_lint: + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: hsheth2/sane-checkout-action@v1 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: "pip" + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 17 + - name: Ensure packages are correct + run: | + python ./.github/scripts/check_python_package.py + - name: Run lint on smoke test + run: | + ./gradlew :smoke-test:lint setup: runs-on: ubuntu-latest + needs: check_lint outputs: tag: ${{ steps.tag.outputs.tag }} slim_tag: ${{ steps.tag.outputs.slim_tag }} @@ -64,18 +85,6 @@ jobs: steps: - name: Check out the repo uses: hsheth2/sane-checkout-action@v1 - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: "pip" - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: "zulu" - java-version: 17 - - name: Run lint on smoke test - run: | - ./gradlew :smoke-test:lint - name: Compute Tag id: tag run: | diff --git a/metadata-ingestion/src/datahub/cli/config_utils.py b/metadata-ingestion/src/datahub/cli/config_utils.py index 4b69aec8081ab3..7877a6bf6df593 100644 --- a/metadata-ingestion/src/datahub/cli/config_utils.py +++ b/metadata-ingestion/src/datahub/cli/config_utils.py @@ -1,3 +1,7 @@ +""" +For helper methods to contain manipulation of the config file in local system. +""" + import logging import os import sys @@ -9,9 +13,6 @@ from datahub.cli.env_utils import get_boolean_env_variable -__help__ = ( - "For helper methods to contain manipulation of the config file in local system." -) log = logging.getLogger(__name__) DEFAULT_GMS_HOST = "http://localhost:8080" diff --git a/smoke-test/tests/dataproduct/queries/__init__.py b/smoke-test/tests/dataproduct/queries/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 From f4cc60bcba2c839f57b72f29fa6325f5ed0925de Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Mon, 5 Feb 2024 13:19:25 +0530 Subject: [PATCH 253/540] feat(ui/policies): Add tag filer in policy creation page (#9756) --- .../app/permissions/policy/ManagePolicies.tsx | 1 + .../permissions/policy/PolicyBuilderModal.tsx | 10 +- .../policy/PolicyPrivilegeForm.tsx | 200 +++++++++++++++++- .../src/app/permissions/policy/policyUtils.ts | 4 + .../src/app/permissions/policy/usePolicy.ts | 14 +- 5 files changed, 219 insertions(+), 10 deletions(-) diff --git a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx index 72c22f3bddc2cd..5765babcb575ef 100644 --- a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx +++ b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx @@ -370,6 +370,7 @@ export const ManagePolicies = () => { {showPolicyBuilderModal && ( ; setPolicy: (policy: Omit) => void; visible: boolean; + focusPolicyUrn: string | undefined; onClose: () => void; onSave: (savePolicy: Omit) => void; }; @@ -39,9 +40,11 @@ const NextButtonContainer = styled.div` * Component used for constructing new policies. The purpose of this flow is to populate or edit a Policy * object through a sequence of steps. */ -export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose, onSave }: Props) { +export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose, onSave, focusPolicyUrn }: Props) { // Step control-flow. const [activeStepIndex, setActiveStepIndex] = useState(0); + const [selectedTags, setSelectedTags] = useState([]); + const [isEditState,setEditState] = useState(true) // Go to next step const next = () => { @@ -90,12 +93,17 @@ export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose title: 'Configure Privileges', content: ( { setPolicy({ ...policy, resources }); }} + setSelectedTags={setSelectedTags} + selectedTags={selectedTags} + setEditState={setEditState} + isEditState={isEditState} privileges={policy.privileges} setPrivileges={(privileges: string[]) => setPolicy({ ...policy, privileges })} /> diff --git a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx index ac73a1f5ece7ce..7a0de67f414192 100644 --- a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx +++ b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Form, Select, Tag, Tooltip, Typography } from 'antd'; +import { Form, Select, Tag, Tooltip, Typography, Tag as CustomTag } from 'antd'; import styled from 'styled-components/macro'; import { useEntityRegistry } from '../../useEntityRegistry'; @@ -9,13 +9,14 @@ import { useGetSearchResultsForMultipleLazyQuery, useGetSearchResultsLazyQuery, } from '../../../graphql/search.generated'; -import { ResourceFilter, PolicyType, EntityType, Domain } from '../../../types.generated'; +import { ResourceFilter, PolicyType, EntityType, Domain, Entity } from '../../../types.generated'; import { convertLegacyResourceFilter, createCriterionValue, createCriterionValueWithEntity, EMPTY_POLICY, getFieldValues, + getFieldValuesOfTags, mapResourceTypeToDisplayName, mapResourceTypeToEntityType, mapResourceTypeToPrivileges, @@ -24,20 +25,28 @@ import { import DomainNavigator from '../../domain/nestedDomains/domainNavigator/DomainNavigator'; import { BrowserWrapper } from '../../shared/tags/AddTagsTermsModal'; import ClickOutside from '../../shared/ClickOutside'; +import { TagTermLabel } from '../../shared/tags/TagTermLabel'; +import { ENTER_KEY_CODE } from '../../shared/constants'; +import { useGetRecommendations } from '../../shared/recommendation'; type Props = { policyType: PolicyType; resources?: ResourceFilter; setResources: (resources: ResourceFilter) => void; + selectedTags?: any[]; + setSelectedTags: (data: any) => void; + setEditState: (data: boolean) => void; + isEditState: boolean; privileges: Array; setPrivileges: (newPrivs: Array) => void; + focusPolicyUrn: string | undefined; }; const SearchResultContainer = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: 12px; + padding: 4px; `; const PrivilegesForm = styled(Form)` @@ -46,6 +55,21 @@ const PrivilegesForm = styled(Form)` margin-bottom: 40px; `; +const TagSelect = styled(Select)` + width: 480px; +`; + +const StyleTag = styled(CustomTag)` + margin: 2px; + display: flex; + justify-content: start; + align-items: center; + white-space: nowrap; + opacity: 1; + color: #434343; + line-height: 16px; +`; + /** * Component used to construct the "privileges" and "resources" portion of a DataHub * access Policy. @@ -56,10 +80,21 @@ export default function PolicyPrivilegeForm({ setResources, privileges, setPrivileges, + setSelectedTags, + selectedTags, + setEditState, + isEditState, + focusPolicyUrn, }: Props) { const entityRegistry = useEntityRegistry(); const [domainInputValue, setDomainInputValue] = useState(''); const [isFocusedOnInput, setIsFocusedOnInput] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [tagTermSearch, { data: tagTermSearchData }] = useGetSearchResultsLazyQuery(); + const [recommendedData] = useGetRecommendations([EntityType.Tag]); + const tagSearchResults = tagTermSearchData?.search?.searchResults?.map((searchResult) => searchResult.entity) || []; + + const inputEl = useRef(null); // Configuration used for displaying options const { @@ -295,6 +330,131 @@ export default function PolicyPrivilegeForm({ setDomainInputValue(''); } + function handleBlurTag() { + setInputValue(''); + } + + const renderSearchResultTags = (entity: Entity) => { + const displayName = + entity.type === EntityType.Tag ? (entity as any).name : entityRegistry.getDisplayName(entity.type, entity); + const tagOrTermComponent = ; + return ( + + {tagOrTermComponent} + + ); + }; + const tags = getFieldValues(resources.filter, 'TAG') || []; + const newTag = getFieldValues(resources.filter, 'TAG').map((criterionValue) => { + if (criterionValue?.value) { + return criterionValue?.value; + } + return criterionValue; + }); + + const editTags = getFieldValuesOfTags(resources.filter, 'TAG').map((criterionValue) => { + if (criterionValue?.value) { + return criterionValue?.entity; + } + return criterionValue; + }); + const tagResult = !inputValue || inputValue.length === 0 ? recommendedData : tagSearchResults; + useEffect(() => { + if (focusPolicyUrn && isEditState && setEditState && editTags && newTag) { + setEditState(false); + const filter = resources.filter || { + criteria: [], + }; + setSelectedTags(editTags); + setResources({ + ...resources, + filter: setFieldValues(filter, 'TAG', [...(newTag as any)]), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusPolicyUrn, isEditState, setEditState, editTags, newTag]); + + const onSelectValue = (urn: string) => { + const filter = resources.filter || { + criteria: [], + }; + const selectedTagOption = tagResult?.find((tag) => tag.urn === urn); + + setResources({ + ...resources, + filter: setFieldValues(filter, 'TAG', [...tags, urn as any]), + }); + setSelectedTags([...(selectedTags as any), selectedTagOption]); + if (inputEl && inputEl.current) { + (inputEl.current as any).blur(); + } + }; + + // When a Tag search result is deselected, remove the Tags + const onDeselectValue = (urn: string) => { + const filter = resources.filter || { + criteria: [], + }; + setInputValue(''); + setSelectedTags(selectedTags?.filter((term) => term.urn !== urn)); + + setResources({ + ...resources, + filter: setFieldValues( + filter, + 'TAG', + tags?.filter((criterionValue) => (criterionValue as any) !== urn), + ), + }); + }; + + const type = EntityType.Tag; + const handleSearch = (text: string) => { + if (text.length > 0) { + tagTermSearch({ + variables: { + input: { + type, + query: text, + start: 0, + count: 10, + }, + }, + }); + } + }; + + const tagSearchOptions = tagResult?.map((result) => { + return renderSearchResultTags(result); + }); + + function clearInput() { + setInputValue(''); + setTimeout(() => setIsFocusedOnInput(true), 0); // call after click outside + } + + const tagRender = (props) => { + // eslint-disable-next-line react/prop-types + const { closable, onClose, value } = props; + const onPreventMouseDown = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const selectedItem = selectedTags?.find((term) => term?.urn === value); + return ( + + {selectedItem?.name} + + ); + }; + + function handleKeyDown(event) { + if (event.keyCode === ENTER_KEY_CODE) { + (inputEl.current as any).blur(); + } + } + return ( {showResourceFilterInput && ( @@ -362,6 +522,38 @@ export default function PolicyPrivilegeForm({ )} + {showResourceFilterInput && ( + Select Tags}> + + The policy will apply to all entities containing all of the chosen tags. If no tags are + selected, the policy will not account for tags. + + onSelectValue(asset)} + onDeselect={(asset: any) => onDeselectValue(asset)} + onSearch={(value: string) => { + // eslint-disable-next-line react/prop-types + handleSearch(value.trim()); + // eslint-disable-next-line react/prop-types + setInputValue(value.trim()); + }} + tagRender={tagRender} + value={tags} + onClear={clearInput} + onBlur={handleBlurTag} + onInputKeyDown={handleKeyDown} + > + {tagSearchOptions} + + + )} {showResourceFilterInput && ( Select Domains}> diff --git a/datahub-web-react/src/app/permissions/policy/policyUtils.ts b/datahub-web-react/src/app/permissions/policy/policyUtils.ts index 27aa8fcd351e9b..c7ec171bc2c290 100644 --- a/datahub-web-react/src/app/permissions/policy/policyUtils.ts +++ b/datahub-web-react/src/app/permissions/policy/policyUtils.ts @@ -118,6 +118,10 @@ export const getFieldValues = (filter: Maybe | undefined, res return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || []; }; +export const getFieldValuesOfTags = (filter: Maybe | undefined, resourceFieldType: string) => { + return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || []; +}; + export const setFieldValues = ( filter: PolicyMatchFilter, resourceFieldType: string, diff --git a/datahub-web-react/src/app/permissions/policy/usePolicy.ts b/datahub-web-react/src/app/permissions/policy/usePolicy.ts index 6f359805e42db1..d04ea25d20b239 100644 --- a/datahub-web-react/src/app/permissions/policy/usePolicy.ts +++ b/datahub-web-react/src/app/permissions/policy/usePolicy.ts @@ -44,19 +44,22 @@ export function usePolicy( const [deletePolicy, { error: deletePolicyError }] = useDeletePolicyMutation(); - const toFilterInput = (filter: PolicyMatchFilter): PolicyMatchFilterInput => { + const toFilterInput = (filter: PolicyMatchFilter,state?:string | undefined): PolicyMatchFilterInput => { + console.log({state}) return { criteria: filter.criteria?.map((criterion): PolicyMatchCriterionInput => { return { field: criterion.field, - values: criterion.values.map((criterionValue) => criterionValue.value), + values: criterion.values.map((criterionValue) => + criterion.field === 'TAG' && state !=='TOGGLE' ? (criterionValue as any) : criterionValue.value, + ), condition: criterion.condition, }; }), }; }; - const toPolicyInput = (policy: Omit): PolicyUpdateInput => { + const toPolicyInput = (policy: Omit,state?:string | undefined): PolicyUpdateInput => { let policyInput: PolicyUpdateInput = { type: policy.type, name: policy.name, @@ -79,7 +82,7 @@ export function usePolicy( allResources: policy.resources.allResources, }; if (policy.resources.filter) { - resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter) }; + resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter,state) }; } // Add the resource filters. policyInput = { @@ -151,7 +154,7 @@ export function usePolicy( updatePolicy({ variables: { urn: policy?.urn as string, // There must be a focus policy urn. - input: toPolicyInput(newPolicy), + input: toPolicyInput(newPolicy,'TOGGLE'), }, }).then(()=>{ const updatePolicies= { @@ -178,6 +181,7 @@ export function usePolicy( __typename: 'ListPoliciesResult', urn: focusPolicyUrn, ...savePolicy, + resources: null, }; analytics.event({ type: EventType.UpdatePolicyEvent, From f743d3c044f6c1025d5f9891b3bab0a873d83b61 Mon Sep 17 00:00:00 2001 From: Andrea Scarpino Date: Mon, 5 Feb 2024 12:06:50 +0000 Subject: [PATCH 254/540] feat(ingest/metabase): Allow to configure a different url for links (#9727) --- .../src/datahub/ingestion/source/metabase.py | 21 ++++++++++++++----- .../tests/unit/test_metabase_source.py | 9 ++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/metabase.py b/metadata-ingestion/src/datahub/ingestion/source/metabase.py index d22bfb2b8b52ff..a65bb76345f4b0 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/metabase.py +++ b/metadata-ingestion/src/datahub/ingestion/source/metabase.py @@ -6,7 +6,7 @@ import dateutil.parser as dp import pydantic import requests -from pydantic import Field, validator +from pydantic import Field, root_validator, validator from requests.models import HTTPError import datahub.emitter.mce_builder as builder @@ -53,6 +53,10 @@ class MetabaseConfig(DatasetLineageProviderConfigBase): # See the Metabase /api/session endpoint for details # https://www.metabase.com/docs/latest/api-documentation.html#post-apisession connect_uri: str = Field(default="localhost:3000", description="Metabase host URL.") + display_uri: Optional[str] = Field( + default=None, + description="optional URL to use in links (if `connect_uri` is only for ingestion)", + ) username: Optional[str] = Field(default=None, description="Metabase username.") password: Optional[pydantic.SecretStr] = Field( default=None, description="Metabase password." @@ -76,10 +80,17 @@ class MetabaseConfig(DatasetLineageProviderConfigBase): description="Default schema name to use when schema is not provided in an SQL query", ) - @validator("connect_uri") + @validator("connect_uri", "display_uri") def remove_trailing_slash(cls, v): return config_clean.remove_trailing_slashes(v) + @root_validator(skip_on_failure=True) + def default_display_uri_to_connect_uri(cls, values): + base = values.get("display_uri") + if base is None: + values["display_uri"] = values.get("connect_uri") + return values + @platform_name("Metabase") @config_class(MetabaseConfig) @@ -239,7 +250,7 @@ def construct_dashboard_from_api_data( self, dashboard_info: dict ) -> Optional[DashboardSnapshot]: dashboard_id = dashboard_info.get("id", "") - dashboard_url = f"{self.config.connect_uri}/api/dashboard/{dashboard_id}" + dashboard_url = f"{self.config.display_uri}/api/dashboard/{dashboard_id}" try: dashboard_response = self.session.get(dashboard_url) dashboard_response.raise_for_status() @@ -297,7 +308,7 @@ def construct_dashboard_from_api_data( @lru_cache(maxsize=None) def _get_ownership(self, creator_id: int) -> Optional[OwnershipClass]: - user_info_url = f"{self.config.connect_uri}/api/user/{creator_id}" + user_info_url = f"{self.config.display_uri}/api/user/{creator_id}" try: user_info_response = self.session.get(user_info_url) user_info_response.raise_for_status() @@ -362,7 +373,7 @@ def get_card_details_by_id(self, card_id: Union[int, str]) -> dict: :param int datasource_id: Numeric datasource ID received from Metabase API :return: dict with info or empty dict """ - card_url = f"{self.config.connect_uri}/api/card/{card_id}" + card_url = f"{self.config.display_uri}/api/card/{card_id}" try: card_response = self.session.get(card_url) card_response.raise_for_status() diff --git a/metadata-ingestion/tests/unit/test_metabase_source.py b/metadata-ingestion/tests/unit/test_metabase_source.py index 642752b8ca0e67..08c0ddb5036648 100644 --- a/metadata-ingestion/tests/unit/test_metabase_source.py +++ b/metadata-ingestion/tests/unit/test_metabase_source.py @@ -40,3 +40,12 @@ def test_get_platform_instance(): # database_id_to_instance_map is missing, platform_instance_map is defined and key missing assert metabase.get_platform_instance("missing-platform", 999) is None + + +def test_set_display_uri(): + display_uri = "some_host:1234" + + config = MetabaseConfig.parse_obj({"display_uri": display_uri}) + + assert config.connect_uri == "localhost:3000" + assert config.display_uri == display_uri From 1d514cb0d4337fef306c8ad8374d0841917afb83 Mon Sep 17 00:00:00 2001 From: Dimitri <36767102+dim-ops@users.noreply.github.com> Date: Mon, 5 Feb 2024 13:07:56 +0100 Subject: [PATCH 255/540] docs(ingesti/elastcsearch): add description fields (#9693) Co-authored-by: Dimitri GRISARD --- .../src/datahub/ingestion/source/elastic_search.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/source/elastic_search.py b/metadata-ingestion/src/datahub/ingestion/source/elastic_search.py index 7ec71a22f6fe69..653b80c116adfd 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/elastic_search.py +++ b/metadata-ingestion/src/datahub/ingestion/source/elastic_search.py @@ -299,9 +299,13 @@ class ElasticsearchSourceConfig(PlatformInstanceConfigMixin, EnvConfigMixin): profiling: ElasticProfiling = Field( default_factory=ElasticProfiling, + description="Configs to ingest data profiles from ElasticSearch.", ) collapse_urns: CollapseUrns = Field( default_factory=CollapseUrns, + description="""List of regex patterns to remove from the name of the URN. All of the indices before removal of URNs are considered as the same dataset. These are applied in order for each URN. + The main case where you would want to have multiple of these if the name where you are trying to remove suffix from have different formats. + e.g. ending with -YYYY-MM-DD as well as ending -epochtime would require you to have 2 regex patterns to remove the suffixes across all URNs.""", ) def is_profiling_enabled(self) -> bool: From 3b898dbae94df3e668ec7f128da28a259320dc2e Mon Sep 17 00:00:00 2001 From: cburroughs Date: Mon, 5 Feb 2024 11:28:24 -0500 Subject: [PATCH 256/540] fix(ingest/snowflake): stop pinning transitive spacy dependency (#9768) --- metadata-ingestion/setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 67ea861230fed5..bbbab73fd1cf54 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -186,8 +186,6 @@ "cryptography", "msal", "acryl-datahub-classify==0.0.9", - # spacy version restricted to reduce backtracking, used by acryl-datahub-classify, - "spacy==3.4.3", } trino = { From bf1ce96ae63ed3e1c9bc26ec216b0eb481fb3c40 Mon Sep 17 00:00:00 2001 From: skrydal Date: Mon, 5 Feb 2024 17:29:14 +0100 Subject: [PATCH 257/540] feat(cli): Add support for custom ownership types to dataproduct CLI (#9762) Co-authored-by: Harshal Sheth --- .../api/entities/dataproduct/dataproduct.py | 32 ++--- .../datahub/cli/specific/dataproduct_cli.py | 11 +- .../dataproduct_ownership_type_urn.yaml | 25 ++++ ...ct_ownership_type_urn_different_owner.yaml | 28 ++++ ...en_dataproduct_out_ownership_type_urn.json | 125 ++++++++++++++++++ .../entities/dataproducts/test_dataproduct.py | 50 +++++++ 6 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn.yaml create mode 100644 metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn_different_owner.yaml create mode 100644 metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_ownership_type_urn.json diff --git a/metadata-ingestion/src/datahub/api/entities/dataproduct/dataproduct.py b/metadata-ingestion/src/datahub/api/entities/dataproduct/dataproduct.py index 28e4a03b8f75f7..61bda90447c624 100644 --- a/metadata-ingestion/src/datahub/api/entities/dataproduct/dataproduct.py +++ b/metadata-ingestion/src/datahub/api/entities/dataproduct/dataproduct.py @@ -69,23 +69,9 @@ class Ownership(ConfigModel): type: str @pydantic.validator("type") - def ownership_type_must_be_mappable(cls, v: str) -> str: - _ownership_types = [ - OwnershipTypeClass.BUSINESS_OWNER, - OwnershipTypeClass.CONSUMER, - OwnershipTypeClass.DATA_STEWARD, - OwnershipTypeClass.DATAOWNER, - OwnershipTypeClass.DELEGATE, - OwnershipTypeClass.DEVELOPER, - OwnershipTypeClass.NONE, - OwnershipTypeClass.PRODUCER, - OwnershipTypeClass.STAKEHOLDER, - OwnershipTypeClass.TECHNICAL_OWNER, - ] - if v.upper() not in _ownership_types: - raise ValueError(f"Ownership type {v} not in {_ownership_types}") - - return v.upper() + def ownership_type_must_be_mappable_or_custom(cls, v: str) -> str: + _, _ = builder.validate_ownership_type(v) + return v class DataProduct(ConfigModel): @@ -155,9 +141,13 @@ def _mint_owner(self, owner: Union[str, Ownership]) -> OwnerClass: ) else: assert isinstance(owner, Ownership) + ownership_type, ownership_type_urn = builder.validate_ownership_type( + owner.type + ) return OwnerClass( owner=builder.make_user_urn(owner.id), - type=owner.type, + type=ownership_type, + typeUrn=ownership_type_urn, ) def _generate_properties_mcp( @@ -314,6 +304,8 @@ def from_datahub(cls, graph: DataHubGraph, id: str) -> "DataProduct": for o in owners.owners: if o.type == OwnershipTypeClass.TECHNICAL_OWNER: yaml_owners.append(o.owner) + elif o.type == OwnershipTypeClass.CUSTOM: + yaml_owners.append(Ownership(id=o.owner, type=str(o.typeUrn))) else: yaml_owners.append(Ownership(id=o.owner, type=str(o.type))) glossary_terms: Optional[GlossaryTermsClass] = graph.get_aspect( @@ -355,7 +347,7 @@ def _patch_ownership( if isinstance(new_owner, Ownership): new_owner_type_map[new_owner.id] = new_owner.type else: - new_owner_type_map[new_owner] = "TECHNICAL_OWNER" + new_owner_type_map[new_owner] = OwnershipTypeClass.TECHNICAL_OWNER owners_matched = set() patches_add: list = [] patches_drop: dict = {} @@ -385,7 +377,7 @@ def _patch_ownership( owners_matched.add(owner_urn) if new_owner_type_map[owner_urn] != o.type: patches_replace[i] = { - "id": o, + "id": o.id, "type": new_owner_type_map[owner_urn], } else: diff --git a/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py b/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py index a52a9dddff1276..afac38e29722e9 100644 --- a/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py +++ b/metadata-ingestion/src/datahub/cli/specific/dataproduct_cli.py @@ -14,7 +14,11 @@ from datahub.api.entities.dataproduct.dataproduct import DataProduct from datahub.cli.specific.file_loader import load_file -from datahub.emitter.mce_builder import make_group_urn, make_user_urn +from datahub.emitter.mce_builder import ( + make_group_urn, + make_user_urn, + validate_ownership_type, +) from datahub.ingestion.graph.client import DataHubGraph, get_default_graph from datahub.metadata.schema_classes import OwnerClass, OwnershipTypeClass from datahub.specific.dataproduct import DataProductPatchBuilder @@ -332,8 +336,11 @@ def add_owner(urn: str, owner: str, owner_type: str) -> None: if not urn.startswith("urn:li:dataProduct:"): urn = f"urn:li:dataProduct:{urn}" dataproduct_patcher: DataProductPatchBuilder = DataProduct.get_patch_builder(urn) + owner_type, owner_type_urn = validate_ownership_type(owner_type) dataproduct_patcher.add_owner( - owner=OwnerClass(owner=_get_owner_urn(owner), type=owner_type) + owner=OwnerClass( + owner=_get_owner_urn(owner), type=owner_type, typeUrn=owner_type_urn + ) ) with get_default_graph() as graph: _abort_if_non_existent_urn(graph, urn, "add owners") diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn.yaml b/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn.yaml new file mode 100644 index 00000000000000..543f9db6abd225 --- /dev/null +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn.yaml @@ -0,0 +1,25 @@ +id: pet_of_the_week +domain: Marketing +display_name: Pet of the Week Campaign +description: |- + This campaign includes Pet of the Week data. + +assets: + - urn:li:container:DATABASE + - urn:li:container:SCHEMA + - urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes) + +owners: + - id: urn:li:corpuser:jdoe + type: urn:li:ownershipType:abc +properties: + version: 2.0 + classification: pii + +tags: + - urn:li:tag:awesome + +terms: + - urn:li:glossaryTerm:ClientsAndAccounts.AccountBalance + +external_url: https://github.com/datahub-project/datahub diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn_different_owner.yaml b/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn_different_owner.yaml new file mode 100644 index 00000000000000..fcc79bcbe5edb7 --- /dev/null +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/dataproduct_ownership_type_urn_different_owner.yaml @@ -0,0 +1,28 @@ +id: pet_of_the_week +domain: Marketing +display_name: Pet of the Week Campaign +description: |- + This campaign includes Pet of the Week data. + +assets: + - urn:li:container:DATABASE + - urn:li:container:SCHEMA + - urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes) + +owners: + - id: urn:li:corpuser:jsmith + type: urn:li:ownershipType:abc + - id: urn:li:corpuser:jdoe + type: TECHNICAL_OWNER + +properties: + version: 2.0 + classification: pii + +tags: + - urn:li:tag:awesome + +terms: + - urn:li:glossaryTerm:ClientsAndAccounts.AccountBalance + +external_url: https://github.com/datahub-project/datahub diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_ownership_type_urn.json b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_ownership_type_urn.json new file mode 100644 index 00000000000000..35c46caa682db7 --- /dev/null +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/golden_dataproduct_out_ownership_type_urn.json @@ -0,0 +1,125 @@ +[ + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "dataProductProperties", + "aspect": { + "json": { + "customProperties": { + "version": "2.0", + "classification": "pii" + }, + "externalUrl": "https://github.com/datahub-project/datahub", + "name": "Pet of the Week Campaign", + "description": "This campaign includes Pet of the Week data.", + "assets": [ + { + "destinationUrn": "urn:li:container:DATABASE", + "created": { + "time": 1681455600000, + "actor": "urn:li:corpuser:datahub", + "message": "yaml" + } + }, + { + "destinationUrn": "urn:li:container:SCHEMA", + "created": { + "time": 1681455600000, + "actor": "urn:li:corpuser:datahub", + "message": "yaml" + } + }, + { + "destinationUrn": "urn:li:mlFeatureTable:(urn:li:dataPlatform:feast,test_feature_table_all_feature_dtypes)", + "created": { + "time": 1681455600000, + "actor": "urn:li:corpuser:datahub", + "message": "yaml" + } + } + ] + } + } + }, + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "domains", + "aspect": { + "json": { + "domains": [ + "urn:li:domain:12345" + ] + } + } + }, + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "globalTags", + "aspect": { + "json": { + "tags": [ + { + "tag": "urn:li:tag:awesome" + } + ] + } + } + }, + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "glossaryTerms", + "aspect": { + "json": { + "terms": [ + { + "urn": "urn:li:glossaryTerm:ClientsAndAccounts.AccountBalance" + } + ], + "auditStamp": { + "time": 1681455600000, + "actor": "urn:li:corpuser:datahub", + "message": "yaml" + } + } + } + }, + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [ + { + "owner": "urn:li:corpuser:jdoe", + "type": "CUSTOM", + "typeUrn": "urn:li:ownershipType:abc" + } + ], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:unknown" + } + } + } + }, + { + "entityType": "dataProduct", + "entityUrn": "urn:li:dataProduct:pet_of_the_week", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + } + } +] \ No newline at end of file diff --git a/metadata-ingestion/tests/unit/api/entities/dataproducts/test_dataproduct.py b/metadata-ingestion/tests/unit/api/entities/dataproducts/test_dataproduct.py index 9900eb1e7f2838..e796f0b3f37219 100644 --- a/metadata-ingestion/tests/unit/api/entities/dataproducts/test_dataproduct.py +++ b/metadata-ingestion/tests/unit/api/entities/dataproducts/test_dataproduct.py @@ -153,3 +153,53 @@ def test_dataproduct_patch_yaml( ) is False ) + + +@freeze_time(FROZEN_TIME) +def test_dataproduct_ownership_type_urn_from_yaml( + pytestconfig: pytest.Config, + tmp_path: Path, + test_resources_dir: Path, + base_mock_graph: MockDataHubGraph, +) -> None: + data_product_file = test_resources_dir / "dataproduct_ownership_type_urn.yaml" + mock_graph = base_mock_graph + data_product = DataProduct.from_yaml(data_product_file, mock_graph) + + for mcp in data_product.generate_mcp(upsert=False): + mock_graph.emit(mcp) + + output_file = tmp_path / "test_dataproduct_out.json" + mock_graph.sink_to_file(output_file) + golden_file = test_resources_dir / "golden_dataproduct_out_ownership_type_urn.json" + check_golden_file(pytestconfig, output_file, golden_file) + + +@freeze_time(FROZEN_TIME) +def test_dataproduct_ownership_type_urn_patch_yaml( + tmp_path: Path, test_resources_dir: Path, base_mock_graph: MockDataHubGraph +) -> None: + mock_graph = base_mock_graph + source_file = test_resources_dir / "golden_dataproduct_out_ownership_type_urn.json" + mock_graph.import_file(source_file) + + data_product_file = ( + test_resources_dir / "dataproduct_ownership_type_urn_different_owner.yaml" + ) + original_data_product: DataProduct = DataProduct.from_yaml( + data_product_file, mock_graph + ) + + data_product: DataProduct = DataProduct.from_datahub( + mock_graph, id="urn:li:dataProduct:pet_of_the_week" + ) + + dataproduct_output_file = ( + tmp_path / "patch_dataproduct_ownership_type_urn_different_owner.yaml" + ) + data_product.patch_yaml(original_data_product, dataproduct_output_file) + + assert not check_yaml_golden_file( + str(dataproduct_output_file), + str(test_resources_dir / "dataproduct_ownership_type_urn.yaml"), + ) From bec01e02cfe1d1e4e540d8569bdec04225bb2566 Mon Sep 17 00:00:00 2001 From: Hyejin Yoon <0327jane@gmail.com> Date: Tue, 6 Feb 2024 01:32:35 +0900 Subject: [PATCH 258/540] docs: update champion bio (#9773) --- docs-website/src/pages/champions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-website/src/pages/champions/index.js b/docs-website/src/pages/champions/index.js index ca161354ecbdcd..7109edde6a8805 100644 --- a/docs-website/src/pages/champions/index.js +++ b/docs-website/src/pages/champions/index.js @@ -166,7 +166,7 @@ const championSections = [ bio: ( <>

    - Reliably provided dirxection to Community Members across all support channels in Slack. + Reliably provides direction to community members and submitted 5 pull request, including improvements to Athena ingestion (support for nested schemas) and the REST emitter.

    ), From 3bfc1e46a708c4779feae17c6e2ab727bfee2817 Mon Sep 17 00:00:00 2001 From: Tamas Nemeth Date: Mon, 5 Feb 2024 20:51:57 +0100 Subject: [PATCH 259/540] fix(ingest/fivetran): Fixing issue when connector user is empty (#9774) --- .../source/fivetran/fivetran_log_api.py | 8 +- ...nowflake_empty_connection_user_golden.json | 618 ++++++++++++++++++ .../integration/fivetran/test_fivetran.py | 115 +++- 3 files changed, 726 insertions(+), 15 deletions(-) create mode 100644 metadata-ingestion/tests/integration/fivetran/fivetran_snowflake_empty_connection_user_golden.json diff --git a/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran_log_api.py b/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran_log_api.py index bdef28e30db2b4..93e2e51acba19f 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran_log_api.py +++ b/metadata-ingestion/src/datahub/ingestion/source/fivetran/fivetran_log_api.py @@ -158,11 +158,13 @@ def _get_user_name(self, user_id: Optional[str]) -> Optional[str]: return None user_details = self._query( self.fivetran_log_query.get_user_query(user_id=user_id) - )[0] - return ( - f"{user_details[Constant.GIVEN_NAME]} {user_details[Constant.FAMILY_NAME]}" ) + if not user_details: + return None + + return f"{user_details[0][Constant.GIVEN_NAME]} {user_details[0][Constant.FAMILY_NAME]}" + def get_allowed_connectors_list( self, connector_patterns: AllowDenyPattern, report: FivetranSourceReport ) -> List[Connector]: diff --git a/metadata-ingestion/tests/integration/fivetran/fivetran_snowflake_empty_connection_user_golden.json b/metadata-ingestion/tests/integration/fivetran/fivetran_snowflake_empty_connection_user_golden.json new file mode 100644 index 00000000000000..65f8620d0b4c42 --- /dev/null +++ b/metadata-ingestion/tests/integration/fivetran/fivetran_snowflake_empty_connection_user_golden.json @@ -0,0 +1,618 @@ +[ +{ + "entityType": "dataFlow", + "entityUrn": "urn:li:dataFlow:(fivetran,calendar_elected,PROD)", + "changeType": "UPSERT", + "aspectName": "dataFlowInfo", + "aspect": { + "json": { + "customProperties": {}, + "name": "postgres" + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataFlow", + "entityUrn": "urn:li:dataFlow:(fivetran,calendar_elected,PROD)", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:fivetran" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataFlow", + "entityUrn": "urn:li:dataFlow:(fivetran,calendar_elected,PROD)", + "changeType": "UPSERT", + "aspectName": "globalTags", + "aspect": { + "json": { + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataJob", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "changeType": "UPSERT", + "aspectName": "dataJobInfo", + "aspect": { + "json": { + "customProperties": { + "paused": "False", + "sync_frequency": "1440", + "destination_id": "'interval_unconstitutional'" + }, + "name": "postgres", + "type": { + "string": "COMMAND" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataJob", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "changeType": "UPSERT", + "aspectName": "dataJobInputOutput", + "aspect": { + "json": { + "inputDatasets": [ + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV)", + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV)" + ], + "outputDatasets": [ + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD)" + ], + "inputDatajobs": [], + "fineGrainedLineages": [ + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV),id)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD),id)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV),name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD),name)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV),id)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD),id)" + ], + "confidenceScore": 1.0 + }, + { + "upstreamType": "FIELD_SET", + "upstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV),name)" + ], + "downstreamType": "FIELD", + "downstreams": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD),name)" + ], + "confidenceScore": 1.0 + } + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV)", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataset", + "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV)", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataJob", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "changeType": "UPSERT", + "aspectName": "ownership", + "aspect": { + "json": { + "owners": [], + "lastModified": { + "time": 0, + "actor": "urn:li:corpuser:fivetran" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataJob", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "changeType": "UPSERT", + "aspectName": "globalTags", + "aspect": { + "json": { + "tags": [] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceProperties", + "aspect": { + "json": { + "customProperties": {}, + "name": "4c9a03d6-eded-4422-a46a-163266e58243", + "type": "BATCH_SCHEDULED", + "created": { + "time": 1695191853000, + "actor": "urn:li:corpuser:datahub" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRelationships", + "aspect": { + "json": { + "parentTemplate": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "upstreamInstances": [] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceInput", + "aspect": { + "json": { + "inputs": [ + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV)", + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceOutput", + "aspect": { + "json": { + "outputs": [ + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1695191853000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "STARTED" + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:ee88d32dbe3133a23a9023c097050190", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1695191885000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "COMPLETE", + "result": { + "type": "SUCCESS", + "nativeResultType": "fivetran" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceProperties", + "aspect": { + "json": { + "customProperties": {}, + "name": "f773d1e9-c791-48f4-894f-8cf9b3dfc834", + "type": "BATCH_SCHEDULED", + "created": { + "time": 1696343730000, + "actor": "urn:li:corpuser:datahub" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRelationships", + "aspect": { + "json": { + "parentTemplate": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "upstreamInstances": [] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceInput", + "aspect": { + "json": { + "inputs": [ + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV)", + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceOutput", + "aspect": { + "json": { + "outputs": [ + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1696343730000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "STARTED" + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:be36f55c13ec4e313c7510770e50784a", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1696343732000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "COMPLETE", + "result": { + "type": "SKIPPED", + "nativeResultType": "fivetran" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceProperties", + "aspect": { + "json": { + "customProperties": {}, + "name": "63c2fc85-600b-455f-9ba0-f576522465be", + "type": "BATCH_SCHEDULED", + "created": { + "time": 1696343755000, + "actor": "urn:li:corpuser:datahub" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRelationships", + "aspect": { + "json": { + "parentTemplate": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "upstreamInstances": [] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceInput", + "aspect": { + "json": { + "inputs": [ + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.employee,DEV)", + "urn:li:dataset:(urn:li:dataPlatform:postgres,postgres_db.public.company,DEV)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceOutput", + "aspect": { + "json": { + "outputs": [ + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.employee,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:snowflake,test_database.postgres_public.company,PROD)" + ] + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1696343755000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "STARTED" + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataProcessInstance", + "entityUrn": "urn:li:dataProcessInstance:d8f100271d2dc3fa905717f82d083c8d", + "changeType": "UPSERT", + "aspectName": "dataProcessInstanceRunEvent", + "aspect": { + "json": { + "timestampMillis": 1696343790000, + "partitionSpec": { + "type": "FULL_TABLE", + "partition": "FULL_TABLE_SNAPSHOT" + }, + "status": "COMPLETE", + "result": { + "type": "FAILURE", + "nativeResultType": "fivetran" + } + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataFlow", + "entityUrn": "urn:li:dataFlow:(fivetran,calendar_elected,PROD)", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +}, +{ + "entityType": "dataJob", + "entityUrn": "urn:li:dataJob:(urn:li:dataFlow:(fivetran,calendar_elected,PROD),calendar_elected)", + "changeType": "UPSERT", + "aspectName": "status", + "aspect": { + "json": { + "removed": false + } + }, + "systemMetadata": { + "lastObserved": 1654621200000, + "runId": "powerbi-test", + "lastRunId": "no-run-id-provided" + } +} +] \ No newline at end of file diff --git a/metadata-ingestion/tests/integration/fivetran/test_fivetran.py b/metadata-ingestion/tests/integration/fivetran/test_fivetran.py index 22dbd58acf1e51..a757975b2e1379 100644 --- a/metadata-ingestion/tests/integration/fivetran/test_fivetran.py +++ b/metadata-ingestion/tests/integration/fivetran/test_fivetran.py @@ -1,4 +1,5 @@ import datetime +from functools import partial from unittest import mock from unittest.mock import MagicMock @@ -18,24 +19,28 @@ FROZEN_TIME = "2022-06-07 17:00:00" +default_connector_query_results = [ + { + "connector_id": "calendar_elected", + "connecting_user_id": "reapply_phone", + "connector_type_id": "postgres", + "connector_name": "postgres", + "paused": False, + "sync_frequency": 1440, + "destination_id": "interval_unconstitutional", + }, +] -def default_query_results(query): + +def default_query_results( + query, connector_query_results=default_connector_query_results +): fivetran_log_query = FivetranLogQuery() fivetran_log_query.set_db("test") if query == fivetran_log_query.use_database("test_database"): return [] elif query == fivetran_log_query.get_connectors_query(): - return [ - { - "connector_id": "calendar_elected", - "connecting_user_id": "reapply_phone", - "connector_type_id": "postgres", - "connector_name": "postgres", - "paused": False, - "sync_frequency": 1440, - "destination_id": "interval_unconstitutional", - }, - ] + return connector_query_results elif query == fivetran_log_query.get_table_lineage_query("calendar_elected"): return [ { @@ -182,6 +187,92 @@ def test_fivetran_with_snowflake_dest(pytestconfig, tmp_path): ) +@freeze_time(FROZEN_TIME) +@pytest.mark.integration +def test_fivetran_with_snowflake_dest_and_null_connector_user(pytestconfig, tmp_path): + test_resources_dir = pytestconfig.rootpath / "tests/integration/fivetran" + + # Run the metadata ingestion pipeline. + output_file = tmp_path / "fivetran_test_events.json" + golden_file = ( + test_resources_dir / "fivetran_snowflake_empty_connection_user_golden.json" + ) + + with mock.patch( + "datahub.ingestion.source.fivetran.fivetran_log_api.create_engine" + ) as mock_create_engine: + connection_magic_mock = MagicMock() + + connector_query_results = [ + { + "connector_id": "calendar_elected", + "connecting_user_id": None, + "connector_type_id": "postgres", + "connector_name": "postgres", + "paused": False, + "sync_frequency": 1440, + "destination_id": "interval_unconstitutional", + }, + ] + + connection_magic_mock.execute.side_effect = partial( + default_query_results, connector_query_results=connector_query_results + ) + + mock_create_engine.return_value = connection_magic_mock + + pipeline = Pipeline.create( + { + "run_id": "powerbi-test", + "source": { + "type": "fivetran", + "config": { + "fivetran_log_config": { + "destination_platform": "snowflake", + "snowflake_destination_config": { + "account_id": "testid", + "warehouse": "test_wh", + "username": "test", + "password": "test@123", + "database": "test_database", + "role": "testrole", + "log_schema": "test", + }, + }, + "connector_patterns": { + "allow": [ + "postgres", + ] + }, + "sources_to_database": { + "calendar_elected": "postgres_db", + }, + "sources_to_platform_instance": { + "calendar_elected": { + "env": "DEV", + } + }, + }, + }, + "sink": { + "type": "file", + "config": { + "filename": f"{output_file}", + }, + }, + } + ) + + pipeline.run() + pipeline.raise_from_status() + + mce_helpers.check_golden_file( + pytestconfig, + output_path=f"{output_file}", + golden_path=f"{golden_file}", + ) + + @freeze_time(FROZEN_TIME) @pytest.mark.integration def test_fivetran_with_bigquery_dest(pytestconfig, tmp_path): From a3ef587f54067598141afe3e584aa5742f817fc7 Mon Sep 17 00:00:00 2001 From: Harshal Sheth Date: Mon, 5 Feb 2024 13:12:49 -0800 Subject: [PATCH 260/540] feat(ingest/glossary): support pydantic v2 in business-glossary source (#9763) --- .../source/metadata/business_glossary.py | 64 ++++++++++--------- .../powerbi/rest_api_wrapper/powerbi_api.py | 1 + 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py b/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py index 675c87b13313d2..e3f0fd118625a3 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py +++ b/metadata-ingestion/src/datahub/ingestion/source/metadata/business_glossary.py @@ -34,44 +34,44 @@ class Owners(ConfigModel): - users: Optional[List[str]] - groups: Optional[List[str]] + users: Optional[List[str]] = None + groups: Optional[List[str]] = None class KnowledgeCard(ConfigModel): - url: Optional[str] - label: Optional[str] + url: Optional[str] = None + label: Optional[str] = None class GlossaryTermConfig(ConfigModel): - id: Optional[str] + id: Optional[str] = None name: str description: str - term_source: Optional[str] - source_ref: Optional[str] - source_url: Optional[str] - owners: Optional[Owners] - inherits: Optional[List[str]] - contains: Optional[List[str]] - values: Optional[List[str]] - related_terms: Optional[List[str]] - custom_properties: Optional[Dict[str, str]] - knowledge_links: Optional[List[KnowledgeCard]] - domain: Optional[str] + term_source: Optional[str] = None + source_ref: Optional[str] = None + source_url: Optional[str] = None + owners: Optional[Owners] = None + inherits: Optional[List[str]] = None + contains: Optional[List[str]] = None + values: Optional[List[str]] = None + related_terms: Optional[List[str]] = None + custom_properties: Optional[Dict[str, str]] = None + knowledge_links: Optional[List[KnowledgeCard]] = None + domain: Optional[str] = None # Private fields. _urn: str class GlossaryNodeConfig(ConfigModel): - id: Optional[str] + id: Optional[str] = None name: str description: str - owners: Optional[Owners] - terms: Optional[List["GlossaryTermConfig"]] - nodes: Optional[List["GlossaryNodeConfig"]] - knowledge_links: Optional[List[KnowledgeCard]] - custom_properties: Optional[Dict[str, str]] + owners: Optional[Owners] = None + terms: Optional[List["GlossaryTermConfig"]] = None + nodes: Optional[List["GlossaryNodeConfig"]] = None + knowledge_links: Optional[List[KnowledgeCard]] = None + custom_properties: Optional[Dict[str, str]] = None # Private fields. _urn: str @@ -80,7 +80,7 @@ class GlossaryNodeConfig(ConfigModel): class DefaultConfig(ConfigModel): """Holds defaults for populating fields in glossary terms""" - source: Optional[str] + source: Optional[str] = None owners: Owners url: Optional[str] = None source_type: str = "INTERNAL" @@ -98,8 +98,8 @@ class BusinessGlossarySourceConfig(ConfigModel): class BusinessGlossaryConfig(DefaultConfig): version: str - terms: Optional[List["GlossaryTermConfig"]] - nodes: Optional[List["GlossaryNodeConfig"]] + terms: Optional[List["GlossaryTermConfig"]] = None + nodes: Optional[List["GlossaryNodeConfig"]] = None @validator("version") def version_must_be_1(cls, v): @@ -337,12 +337,14 @@ def get_mces_from_term( ] = [] term_info = models.GlossaryTermInfoClass( definition=glossaryTerm.description, - termSource=glossaryTerm.term_source - if glossaryTerm.term_source is not None - else defaults.source_type, - sourceRef=glossaryTerm.source_ref - if glossaryTerm.source_ref - else defaults.source, + termSource=( + glossaryTerm.term_source + if glossaryTerm.term_source is not None + else defaults.source_type + ), + sourceRef=( + glossaryTerm.source_ref if glossaryTerm.source_ref else defaults.source + ), sourceUrl=glossaryTerm.source_url if glossaryTerm.source_url else defaults.url, parentNode=parentNode, customProperties=glossaryTerm.custom_properties, diff --git a/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/powerbi_api.py b/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/powerbi_api.py index 304e999f81a843..d72624bd70512a 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/powerbi_api.py +++ b/metadata-ingestion/src/datahub/ingestion/source/powerbi/rest_api_wrapper/powerbi_api.py @@ -195,6 +195,7 @@ def get_workspaces(self) -> List[Workspace]: groups = self._get_resolver().get_groups() except: self.log_http_error(message="Unable to fetch list of workspaces") + raise # we want this exception to bubble up workspaces = [ Workspace( From 92850ac55625f3fbee6cdd8699970b43c18a6f58 Mon Sep 17 00:00:00 2001 From: gaurav2733 <77378510+gaurav2733@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:41:15 +0530 Subject: [PATCH 261/540] fix(datahub-flow-diagram): add missing image for onboarding tour (#9783) --- .../config/HomePageOnboardingConfig.tsx | 3 ++- .../src/images/datahub-flow-diagram-light.png | Bin 0 -> 270748 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 datahub-web-react/src/images/datahub-flow-diagram-light.png diff --git a/datahub-web-react/src/app/onboarding/config/HomePageOnboardingConfig.tsx b/datahub-web-react/src/app/onboarding/config/HomePageOnboardingConfig.tsx index 8b361db5ab344c..65c703db38fe9b 100644 --- a/datahub-web-react/src/app/onboarding/config/HomePageOnboardingConfig.tsx +++ b/datahub-web-react/src/app/onboarding/config/HomePageOnboardingConfig.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Image, Typography } from 'antd'; import { OnboardingStep } from '../OnboardingStep'; import { ANTD_GRAY } from '../../entity/shared/constants'; +import dataHubFlowDiagram from '../../../images/datahub-flow-diagram-light.png'; export const GLOBAL_WELCOME_TO_DATAHUB_ID = 'global-welcome-to-datahub'; export const HOME_PAGE_INGESTION_ID = 'home-page-ingestion'; @@ -20,7 +21,7 @@ export const HomePageOnboardingConfig: OnboardingStep[] = [ height={184} width={500} style={{ marginLeft: '50px' }} - src="https://datahubproject.io/assets/ideal-img/datahub-flow-diagram-light.5ce651b.1600.png" + src={dataHubFlowDiagram} /> Welcome to DataHub! 👋 diff --git a/datahub-web-react/src/images/datahub-flow-diagram-light.png b/datahub-web-react/src/images/datahub-flow-diagram-light.png new file mode 100644 index 0000000000000000000000000000000000000000..cf110f7fc48761487f53429eb6e0349fe8c656aa GIT binary patch literal 270748 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 z z=S@KUzW2@*9I4hlC;DaLnxh9YO7U1g`Ah{tQ9*}5kc?|vg@uebjqN7#UQq$7qE`wV zA~hF1aC$cdR0y3qhOT`cX#*v1V3tM2X-k$|ppP`W<{MwYpfq>}7M|$kQPMD9v80)4 zb6Lp`YMK#KnactbmCvz_Yi>o?nie z{^t@D0!-%bv=bHClI)tQyS>7F#`2|I+H^@%MZJy4bF|^4S+WVajihrY`oDJS=Y2=1 z$42bDnmXo(_eS>)F7N%SHgk8qt*y6C#PY8Loch`?57>O8FvWujuq1+NHr3xUtkLqA zJ5gob5M5oln|2Ae(5-M|_?k!UEB`_;Iz;QTHIR~fG3*?Kr`%QhEB6_>PM*W4W`5?; z+P25HGhuY!t2_3qHHzQ*lyXN%!ype4jLm_I?rZu5Df3F6bKV0FNX5Y)tbt4W`*9UfHYeFPxR$2v6D<+mKBk`Bt;)h<{ z^$4V!cf2Ms+jrby?BrApRBUZC-s_wpP&EXkmSozM4}0hC75;Cm&sb{U08T- zq)3or<1T1vQrvg4T9k3|t+g^|pTq~yxiM#>H@pfLYs2*M;1MSw+0DvDW)Ig~9p0)t za@Nt~WMD;>UraA~aImlTCHkSWs8OH06mTs@oPQW1a2r}Y%!sdct&UFn@#3et%`I+d z#8wKHH-X%X{^fT4l}mB-{d8mHUG9|}`WusA=d#RF=&?S;MP%e?)%E#XoSE8JZ+TUE z=&@GZX(gw$Vw_0CFRRJ!4h`&;j6d2i|NYV>ZF2cUn%!dhgrFssaG~l{c+^U?MpL}& zi8)oLrWfh)TEseZE#f#J-3Wc~1!JDZ%|E5DDuWv*jm&0m=Gw*ZWd+{78xVOpD5Hx4 z!r#D&$jNF=b5`aAIAt){&VI3j$ApKAHJ=Wt1)U6mtdC$m*DJDP{b)pT=03CH^1f&4 zP_QOEv3>Qp2A60Eo~XP1sFZFD`>-PAD+sm-i+ly%u^MkQS(H35*GwhW(z<_17gM+! zx>i_jC#umZsF>{WD*r77Yq(e>j5#p@IxHu4?NyKrSCC8)8)Z28ZbE;uvpj1g-)}?F zN{ad|7ESw+lg?AJT)~!Z~$_5 zjEl7jg4&wLj0x7hngq_3oqIX$ll~wA-*e~MJN;w}O-8<(Jn`&(GLpKq zGV(;?2mwJP*>vrGt?6#9nefPGf_e8#jC}eR>`K=uhun1e7YpX?iyIwOqA03u?}jh+ z%gw369M+qa#wQr9_DDQo>FGrptb6``@xZ$%6EzBQ1(L}aMW9cxFnlY+!o|Y1z+X8@$_6Ll8={x8@zR<$b|S{sknDRl73H!7Y1qV;;6-$~P++nZB16vo@=a z3eWI4dYGD;qDA|}#Ig_X*Rv-gT_I>A^F$$d0FR$8dRejiPRi{=O0-%$HvR*&@dF^$ zD=#-HFISZ^JR*xX_DAq|2QR&Pnaszis6h?Ou5%0YB7`YwX+?Q%<$cUF@~L;wwJy<- zw`>U5k!?nuPZ+hl{&6^GB(2>_I>Wnpqyx8>4$lg=in4t-6dS!!G)z6}cNb)nCVQBD_p3&yP}hx7U5{!uOb;cc0JT z!E(iNC;WK(t_**(JrEqU+`CTt)%&*XR7c!8wpeM&2;9zD3n3N?_V4i=bw6Lyn>MiJ>R&VNGtA4a7SIkpupIUxXrnl7$o*}b?+;S6 zL4tPGH7${xtu~PrwA&s|qOpfEen3PU@w79{4)d5U?;axZ=~S~IR(YPbIrb|qWPnKS zcvqT50p!1xfMqh0-WLywK;sJwj47m;ia=~GoM|O2>|eiGVS?bX#PDo}lVOC?poiAt zxoxoWU{s5-T9Ba^(!8Tj+`qe->ADE`!<+wfJ!d&nsJf&CRZC0DD>ZFDrCu(2Wi6+u zs%qc=(H_dWrtMGG;JiCjn`YRJ2WxE|f*SgDrInJuO1jsEF~0cQ_+Tpsf3cKz&{O$< z^-T~To54!1M3-DpZb#i4WCgF3hLj*ULi($;N9{drrE&! zE_3;VG{-;(M%uh{6b&YdFaRCGCs1vv1hETAX)5y9TArkAxru_isxOe$eO?Z(qvKMG z2W%E4ir>(|$ck2@-9h9dQvO)WpF7q;dBYSZeKuKWxtc z>vLv5;fF$xuotwZp56H0za#+T)+DnIC8dai?KK4gb)wwc*hH$7CSocGIdmBo`y*S> z;`GmtIf@xK@qzh3M<;J)BJ}_zT6q?cQ*7ekNzKOdC&{s=l0H(h&Iqt!6uzH`O@8yl zr+gv&4k)L5U#DxIj4WXPs(n(OrDEEYt?^t4gQ^2%V)>41>pDgKyjo$k?MySB zlb3v()Ig*+Tx?Lam2lzP2w%Q!j@MRc!BXpwWHQ1_<}WS;NjeW?6Aw2*@~0g;H4$4U zW7hk<7Vn1tokb|(<%u=T=VU7of8BZxnMm7W)>Wa@bwuiZHN{+cl8!_6Wx6iMlX4Tj z)aBff=sfqX`q!)Gq9^KRZ6hXwhe8=v%(vKHa=m2W%31e>!u0C=38cfhjL*LPD)<@B zqRJQ#wWEQeQ#6#8Z+whZ4TGySECuSckupmL&X*iwm%F5>%6_blN?Jz=FD1cPX=N!c2JZy~xT{Jv@+Cypq(r>_zONVv}ft|Rjy^}?}tQA9Hru%<{NFtOz!$j0y@F2w1+;uGO1pA2MvYANIWNql@y_q z#)91V6Ou=o<8Y9Yxyr{gz0p#67E_4MVbEr~uQ*ws{&FIjoRm$3soscMy?tX+&tm#t zS}VnT22ar!;-hIugPCcNdZuysAJ|bLsZKofYJ?Z^?u~4Z9Vc3aV^qbeK9^d9OY^Y+ zv*+p1mT2^-m_-|W!2PddbBs?8%!snG7`@}qe)sxQ)e(eI$lE)99KrltVTg`o@0lLs zAMPGIfe$-7hcJ_Km7_TlXs$16fEi|h)JI<~HD29&?N&R%uhVYyx+-34Vr;YSKY0Fa zc2#iMZ+kYfG`gU}*(+adRE1W6C9tYMlba1&l8et+HfO{ozcuLQuA4HuF3nA3{)E*T zxmC@GnUpNd{+;1y3n;N%ZsV?;&3`WUQyX2{gG=|PIgccX(iQ&0I8H%J*t^$sqKF~g zv~ueGv^QdLXf;lVTxBfZgV3cC>6^=p74oTW-@;q7XhfK9iR9{xdW&bab%?A;3;QW= zK6TV-6s-s8AOnrNlrA0!vQEDA3nlD9dG_8u&m9(;_j$cRpy5Nx01q5H$*PU=I35w^G)In&TDtei z_Q`$c{!9GFkCT=BnNlp(hxb>=YhjSPWCr2F)p8AWt?B$9XW4?zzb*JeTN)cpfl1Au zi9hRx>Z7IFMh&7>Q=zqay`$u!hnJP}%io8R23}b{ZG{pC?b$XgUeF@^*-7b(;$L)&MU|RR7s(GMn3=XDs ztkf3C$on|8`(UHAn&XQZp>;co#Jorv@IK`EHn%=1r=YG8Z)S^#3VsC|j^UHO7dlDN zc1%P!mnPpGKWoRRofsu7ElfP~rV_md1{a6ATkkaBI?t@Yr4}{&rGmmaXB$}^v(B!a zcZh4v-lKBriIgtF|63B(wLF<&)Z5N75HF3NsOqmlQDiyjIQ+89%*2$3_}W>V_%`5+ ztWKUZrkh6FA?fajK>aYyuAlJM!}&h|u<^ix$3gIzmoc5FrJm~BDe$p(^ExT3N1t7m z#Qo=fqGT+V(VF)92$fSz=Ce{zk#%C{;IL(t7vA~&{x83nC#jBrYU@mc9W{1h$zNSg zSKU_A!Fp2uMO{Uk~2M-!I8O30%K*=4st-tkoH?C;GV&kfD9US}Xo* zk_~Q~YuHZt+RqicR^lVRurVq$6I{J!!^5;-jKe=&^$xz;DL)Pwme<)Si%{mA6!xRk zq!une`lJ-QFjpz78F8dPB>Hy?Iu5Q+S;{lr>V3FO?CX;o865@TTPsOAhF7C(P6gl5 zGHUQmt8-+a(`t4$cYLh&Nldt63aKOWxf5Lba2h8b5!=&YUlaeXUgPNyH}}>7HC@M!lo2tKLP*v?9%H)mp5iN5*LOcLZj%L-YGOwOg6eaD9oQh(e61n z{+JU)jjLaOuey(uhv--+PiZ@V7LeDMiesC>Ow~#!fx<*2gt%f=Ds;wJP}~>1Bq0qX zYwInT5dyb}jqk15pB&d_4O4QgA=EFZDZi*Le&ZUr5#|zX1v#3oz5_<#JWJXtJidE4 z`Porh6?l+U-(foy6bpxGHdmhy;KCCM{wFq?D;o8Z0TFVw&4gzdp8+}*cCdhTvSE8U zPsrFUoY)D!+BX|${;)fE(*1c^Xj9ZkUw=(mSy}Gk16#~wrMFOLq!0dB7-U|tSCisw ze-U@0*#h9(86vr-KO?1SL zU=!V0jZ4C4Jig65DW1CXN8f}K8=IShHVu|UD4Wlr;wjOD+^FZE*vyOB zt@^GU)>jW;Vs=W{WJQ8V5)jyG5o8QcTXYfxX!7VFzN)qOh5xb2vxGb=p%?l8PCpWU zn&mKe@vx z^|jZH={@7QK9TtOGaV$|1q_NFDN-9~a`wXMV4)xI%%{aVCV`&Mw-!+##x*3<)6;F7 zJPR)?9nzDWTBYVa!L0xU{>u;dq7oN%iIl@d;&eFm?Bq#mf8q{P9@~@x-jl&wf6f2Y zrp5HE$%0t>dw7eUIHIW}$)*U^Qs9<2WLId5_ont1vL}GY=3Vcx^5K+G{Y_<@tc!GS zWLmELBXROjgf_p2mCiBc%HU9aEoUm=5VJ6u>mR`1_ngDPkVCNW&y9M=@L;rbbIR;e zlK#%oj4J)jCR(=#Nv`fIZPa7^p%TG!zuuuhA-P`bvVR_}9&(rdNAkD|W(umF*I*1(es~$Jz7hPp& zVbfj+g)c=UgW`HJLlaUf8eJjMcoqIRI$kKv_je^FOyWX9SJV3oEzA9+9fPDY3@KXiC-fK%|E>Z_?(8=Q| znghCO@&K&nP@89y(dDH;{z)c(tk%Fk-hNYgi~BAO2~Uz!OctNpJ4bla)}Pty54Rd` z`LZY`04iP?8bSmV!FCm}W~PEK@}Fn;R=!cVyhYVD)PK>LQrMeAT&6XGUe#;$Wcaw0 zz*NTpcFV>#DrT>rc@pl*y69{8z=!B{(wJ5}FU>&knEU`--^}Uq=I8Z+M!zrG2V!cF z9XV`w&hai0uCQeiwErx|9=mOdfqB_cYM}J{*M#k=UUxGFg4Lv#z4g?!g)I9qh1m4J zqlJ(sZ0A%dyGNzHKmU~sco<-UsIkSky;M*2T8L3Dc(WLOMz%n^Scu~V5h`JcSyse< z`yX6siHqS&5kvK30{6oKs)xO-mR925>KDjTskA`&{BHnQ;5C5c5o7*dv8?oXS6OT} z1HMz^^xv<~?y8MC#LxDfgKw3|1srroy4dOP_0j>vp^AbB@Z>CAAlcxdh6Q!(#1wV4 zVBL4F|EK-KAL!F4D+ijxX;ub!A;jZl@&>j#Q4+qMyp6gVz*YpJs^6RF>!Fmg+}vuz z@#)VOlqxI7TvppYKK*nNW0r+FG7>XbWz$}it)gyTold^$HS3+af5@)0ii6>eiPvF{ zWN6aHeEm|hQdUM-P0aQ0C z7Xg53>K>4wYbi51OyUzom0r@adXzJ0f(Sm6pzbrv>isP&5{KkM4<=xrT=BvSO2-*%@Raa-Tmc&*H83$U z!pW#$nOCM4U^{jeIPDR`+Y0#eD2-j~?(V(>Y2_p;+|)^EesRBOv=zj!LDg^NkYk)& zUPvISdjs0EoFbLBJpYtFn>7E%Uu1}Rvu*Fzkm1tiI^iLN2j`gA9WkFzX5gPs1W^ov zI9ti8<)-cvrMqO||E|I}vO33!3qGS8nB2p|Hy8PvOFnjiij zuH%sqm(TMtk4O5?laDViXO&Y=W{6+VNR4=t{Ej&S7Bw0uE2lBy|Ha%Ja(tbl8*m&f zneAKhX;&rOH*00??7DLO%=ex>6M0ouNgA7TR6`lBwGk74}M12 zHb)3e(``YR_NGl4MHfh2^(}BjGX)BaB&z*|Wh4nVk2DtMlD|XRi<&yI#c2PSh?h1k ziAhL6)~PwZKIFBDNg_xktfWG`KEi(<K(t0o}gtR2+GZ|ZqbIa2=4uJWW@CIn2=qQ5v+?c%%#h! zMe5hZg1MJSjUinLZQW^QD}B$_AjnS*&3<`zLY2y@Yc)}_(kU@0 zmJ8j!rjvO&s5uGh$)g*vvr6i~^fF+beEA3f$z)h5WUziQ zVHyWH3IQ8A>?>eN^;LqMzw3gP`&V-snq;6}Nn@(NX%qgP*6~h(_%( z%yDP5e<^&%Hagi0+ z7XTRz@SUB-s@8-&&sD-PKMl2a#qYMdZ|P=#WpU2^)Xxbrru&}n_9fBf+t!I+XJx7^ zrB^p;2a+U>*->Zl-Uw1^o=*kQ<6U@9qrmoU^?$mfh(eZi=vLx-h-5*}AGc?y1ShFN zep^M<6{5;$->XiOY9Zi%P=Lo?;o%T-KElAE$`h!kf~qf&`UXmB!SPpC zbdzR7540Qd^t?t31mXOtGU3pCiRkSSe4DMj5CMNNYcst6Fr5+pOYtuJ4iWZC@y*fR zzy2>;i|{($GM$J$vVDlwcP7pBm-4=3HN<`oIJKXjA0#lF1Ly?5x24QU-kuFT|%kOfIj-288b^L!d`-BuzAEMFV#GlgpzJ8w9R?;lsL`XS*y-LQNXb zJl2>=w26KjVoJ)8*^;w&LS~|Xv&1C;tn&w!@7xdr1PTmfL?eL=P40K^N?*O!YQj4v zF`-3gM(9#u#8(CLG^;*f8`$Cmqy%`ztuCu6z5<^%c1C$YoNGI?_|;dufBOUC7j+ru zpGr#+z>%5*o`vk12pctOzxs~+Kpp#nYAd*J-5F8eWSMG(2 zfr8^YP-BSlB5iy~5xOb)rArIE)$@0XM-i+?*D0)}?to_&`{BZGysJB*DuO+OTQs%2 z@Aa6>CZ7LXKm`<)6)a6%zvG7Vstofc^aUYzV*Ljbgh82Z3$iE`a_IF=2Bz_aK07Dp z+P4TSU1Q?}9AG*w*66$3OdouvKZkzR6&khw_5%Wg{l zH%W|gBiyqsSP)nSCz`X&W+6&uz(LxE!4VO9`(~)Lv`Rf@r~t%{dmIOEeYyVO98})Y z(t_6AWi{`TY}szf%k$Bu*(xp<1Pb1N2TFo}qfyjBSG?4>vjfJXB~EnimU;)tR@bC3 zv|2aKdm5P60Nm%szhit^6eTIq?q@5BWR#4YSRkaNmTnCLp;aOIV}cvO@wOq_ic$y2 z5KDR)u0WSGjm9@RlomzvJu^VGUw5CWb!G{DtT;c^`_!a3plxQ@MReBJngd`4mm$WC zf@W=5B)KAWP}?5cMpsvSNbgpKZHq@|wWpwdyI3Jt>ZY$-Ae+ni0>VdM=@90)sb$ja z9K8j{0VDU#vgIZj|i;J8hF@=jDG>!0cgH-!EG0vC5~dw|>Qk z1b#Jj`JRxaJ@G$4@qPV=!p7~wUsC9|3SnpnSFADhXI#i)+5%)5?qQ-*(6s2`O=%E1 z2v&zcJS#L>7W{w*DE#x+uRPwFLT`RXpOxKeBu(Js^LB)A%zf`5&*23E(LPetT%u<&OMrby-kA zwa^%L`fkuGsH~mP7Ff(!+&!N}Fhyz5-J;nFh~&Ny>kWG`gFvb*Kxn}Z!SC{{Md#O# zJRn$qB+&pe4&FEuF_BOR~H7YP<0>Mz?)=xkf1?XpQ3I9cR+Fz# zCAaI^^o|_Q?D<|xOX#g#$WefPRf6JHgZ`og*eQJCYA!}EUoq}*R`o(f z;B(!|H!J>M;Ng!_P$n8$^fygVw0#ny=fH}r4#E~90qIs?Gb28^gdPz5-K zaXuUpY2M6Sl&k@dr9hdfCliTU2y^Dor)1ZIr-uDV~$5o+EvB_=K z zfOC}NHoXZRI;%*l~_0L6ij%E5Hgs_4tYh$j9%%#DG(Bu~(w&SEDaM zLui^++b}mS53WM`CMSHoLAcOP0v2M*wH-&k{_i*EM>v>czQnNDXxC;vKKwCDIIRV?PsA9|PyfB_a|t zG^7ZTG%z3sV*%*_m-WI?(-2T|fIB`|-H`qOC4L)>%YivEf@q8fq!m6(dhFwkdhc82 zPW+-b-VGbt%{n`yP)oFn7_;@A+2;EH`SZ18EVQMG_~S1dz8LV3!Se1vq;ID&h!**} zdroYbTV}&PxkHfH7k$f3SrP39s}{wKavd>^Gcz*_0f70gUl+xVe0@Ek>G zZsUTMLK%=Xbg&QiK=S%y;9KahQB$tjEp&XfU}w=Ae>|z5HgKN=NGqZYJ(4QnF(?+r zIrsh1Q}AKXI>^1pS(vXn zs>cjSVIF${IN`B}xAn;iqZG7QniiSdsfB#^SCveIGclD>E1@3hXe12FOmf&GEnsug zuFbZpY&hLK*6zf}L^u|vdmB0Xpc$l;$CWVf!0$|Tk=>9!{r0Vw7coc_=f+Q5*p~;w zZm{MR!@0)YTKTK}eyy$Hy^;0P8Ou#KM|mg&V-gcQuM3{vIyYXb1QqA-W9oY;>UFa( zyYKS0R8E^E#n{gXU;OU)2%LJiJQcFpAQS@e?d095g)B&l){W7CW2Zt#Zig)59z)1z z=IetBSiZLcip*>J?SE-fx>X^9NJ7Yo(*9?*WES->WB+0o%|}Ufu%~UC9h(YRL== z7zr9*Nf-J^{ZZBwTe%@K?xTnNi(Y4?+n7lP5=bk*HC)cis;Y3Zm$S(|X8agOk#w22 z!IB|2DJ5m!^1uCMa}Z0Oo11H6Z;u4JH|+c|ouB&X-~OihDX7sd>6CX$8W4`ZL2Etx zL1YXm8H??beVckCuB~0^Y{UVSj-JVwKFM7ukXjR6@|vdFS{SeO=*n7OET~|d8Cbw> z1pHnZ6_xnn-S6q~e}<#P%$s<~)S$9+8r$xL*IC<%|F#o<8;z$4XZPw>_1^mSL|Ai3 zjbd*ZBDe&86O#ZXDJl4UAG1_|`B^aOT8f1#VgB|3>OSNm3zghSI;@>Mv}(Js2p4im zW9)I(s41%e7|HOaZBAGD<#Bws^*S_ylQ%4YIZ;mjnQ#?b^8!+G_7bSR;D%hXETWE8 z-#!f1e!@;dC z4qV*VK15MTbCJs0+p~d$OHNS!@xeO{>_KF$txFkqY0*n15iz7bw8%K}kN?8{ITLe9 zMlvd9J&F{Y6k44%7!q(9<@R7yr_#=r`1fb_9{b2toU-Fq3VG5?Wbk`H2rz)7D3C`S z1&$m1b7}kJn?M6UF|E3#XA|~2`(*hSQ5mZNXV){XPQ5?VclK1VL>Xi2&Ue%oFPyM; zf*9X4ZeNKvyJ(`1c^`iGFpCMs9~%tEie;@Be_i$WAyj-g0mP|xRxzz1({dO@sDe;q*?KPWd!`yqepzgl=Bgx_x`(FX#pt~AS@ z)1(Q&^X=3yljM)A_Eu>=Lynf^ntzZ`%4o zvogPf&5Dl>`M|Uz8>e~S4TTfQ_&iIFkF@(5#}e!RXaS^08b}d>+>Ox#1Nhmv4Z~j$ z@i^rjRmQHA|{0C?{-Mv9n7x$Jf&67BHb!{)h9Mo z!izU-^-x(>cJ29#Hx2DA{+JY8Aan^@{n>ka{V>X9vkkYDCuluA zyIOk(LI+>u5~)=k;}JWecDft3=N#c=%)i*qX2Ye-;CESc4q>oC9n0NJ1lEdyqcG}&fowBjm3ScSNpo$$weB5#X&Rl8o z;lK1tZvqAgg;NflB}^uer5Mcj6#icPFw9k(nO6W20zi5xVbyqa(!;(1l$#RYNJY_U z*V^#D(YD(M)y?EM-Br^(G{`^Z+0mBq=NDyfL!&`OCe&gCm|!o|1DWKkPSX0O77JV ze$&ef0dj?SVp>QGxNg*0>8Sd3A!~ozE!DmnWpFuS^%A__?Xh+(ICoLDEC-2#tT~Hw@XY~?1sPQ8 z<$duY9^l;nMqH1s8+G1?#*YQiel$C}Hkt;$;WFxaWJMx=7fzy<_mX2xaU)(Y^11l_ zSDfX`wPIU;5u-qUNrQ$fEOaR~8{AA>j8dw?s}27=)=LXP33u%~72yzZPOZ<{-&R`n zQe-X39!!jl`@ygg2p~uZkKgF%SUj>FHa09c_;{-Tv%CkK2-&-FOwv z51G^l!szHEluIGr4csHW&su)JTFFU7@X-p+Cfz(ows7Ff*c5pb_OL+F8!d_-kj4oy z!567bRJ>F@Pr#bS!loHM7c^z6AfNeI8QEB3CW`^v*Bk3|0NB1jfi&670&QeE)?|j< z8A>0KTDRjSjBdO)T9h+ID&UIJ2KP%TjZ-+nij!VC+nNvUe_-%BBrj6P7_r+7jQSs? z+d0uU{-S_p!d*erEpwF!hYcitz`wQU1K(Z-cm@6gv}|Xq+hZ^+_Cz{e9Y|nQOKT!Y zyRW8NAU=pdG#~-nJ@0R+2Ypo+24up9kLgdH^0>u2rCE!heATOzIMA+}gAeYV)zkdj z#jWwVxwdj9dW{MYduh<@pxB<{B1A#BFLpLA1B`2)UmPsOBB9}$0#H_}nhSzz`a#m3 z>uy!c+c!WTr3UStrwEvf0LM6_+nq8<-hfRlvDf!k&p*hW6P51<_KT^z;`WA9A>_Rt z?M}2EB>#nzvSVKfMS}n4(bLJV!S0-9-QSlzjJ_yFh<=&p@59Y10eh>&)}uLJ0qHEX z<Qk99D)V=1(CDUI%R zpyEKklGF8F_(2FpRf!&?f0kKkX*fjM!SiD$&WeE$X_Eo!MgWFwiPLK-YDhvQqJ%<` z0A@dVgxPlp;Y9+q1Xldj10QB%I7N1l#-pbTi!4;}DIbp00n=i-1MQGI{ zu_nNU6*j>efs`JlKuv4f4Ba|D`)uZ8dHF3ZoeIVDu`)EAo)mc;jh0xLVc0E-KAasz zGJP{ONewVni~q$`VISuP%=4Y0LnI>Q`yI_BJ}fO^2I^Q*7|I7iT&n%4l3k^>CEU{F+}pzBTUuVcW8?b|9>v%_2{ zh|x3!JO%3vh_2<|%N>09I=(l%;H4xS5V~=w#zw-)Qbs+`Q z)4E;4jNvD-6EPe-Lzh+=na`H|WHxH$3hp9;WLFhGQ-Ak<8cli`3{==p0>yt?R3E7E z24Q!f$JOc3%wx9WA8uH6?Wj9*_4ZGhEJ=2kJw{%ND>V0 z;{2P8hiQ#$_#o;Pt4xj5bW{%CzhEjq{}xAsUYI;iIf)8A>_&p%NmJwx*HhQLp{2!3 z!uRt~1m9gCl*9zwpWnd!d9Iu+yCW7#V-6-tr;kibU+!B{$vL^xzxfkFOo-BTdq5;G zD$I0onZ;Xi^B;q5J;onEE(2ulo-`adaL z-4_o$l7c7+$M`@dpm*QdYD|H49594OJ~95R_8yFU=B9%0h9K)a9IwdM(!a=et-e_K5i**~s-8 zr^>}qPf-2_FN~Sz#QM2<_OYR^U(@ymQy49vc<9B1YrRwq=5b5I!h#C!p4lbfFEvxl zpym4-bG3o?NU=fBC>pA|0YZYAsHaNxO0-_uLPX<>CZ+I6ABbC^`AoDG6x=xd^JIrS$jM4hU^lwcyx;BP+q3hxzNUN~w? zeUHtj`@@HS&YwX9aNo%Zk0GayI=Ks>=1Hxv`~y_p!|~&z;wh(h=lrl5vP>K# zEUZ$nVgp9qP~0fMDI?d+*em8DC$R!$dra3jKcnKJZ}Mn{CC~3>+@tA!73;lqa5i;j zV95kTgN?FE4`ZzTlCJC;lB$Y-{)5L5zMVgDre5PTKE7Qbk{e2Rp;#C&WWCH2%K$D= z3}2paHkvOPH+-qSd0Lb7%18s9V?WJ2gZ|`*PBSxwA^4S&XZy} z(9HpPahjV)5i6NJyQV9S#U3*h$6Bck%gt>QL`nrfyYs=6`s=3KYL#c z8*W3-UopK|nVH?Qv!noSPRz|kKe~mCGWNiivw-2#-JKW!Hxs{p5y!676Yj{;eUF}M zz+UKb&MnwHrW~I2DtkLrpT2xWl^%4Vr<~qIicpvVtSK3fYUPhgsn9=J%n3I)>S z>-Fc|LV>-i^pO*<)|UB8R;gzrw{u$%pt=43#jDnlaUJX=3 zDE+f6;R~arTc4x2f8o$TCYX}qxJP{x#Sfjf@rYbUhi?i#uqcUYw;)B*!oYTi=%)q*C0meW0#;+{Nog%xP))p{I$f|bGk$bpt{Q}FXH(;WM*{|t%R2= z7}gXMk2kEqFzh|Abu9>wE39mp%_SQWag8$rF<`8vW^t>%(S(nLQ#2v-W4(NA#a-8O zCMMJYFVer(3i!O&25b~)U=9L=*-;z@&8EPg1&j)t?i#G5i#_Q!r)+g>7s`q1zP}h) zX%i%EHnRIsZ-`?slgAo&hfweVrb~iW;X7t5Rch|#|KD(#Q zqVix5pol66O!LsaMJ@7C!`Z^qZX1`@wE9U^BY59)omvsg^t$`yt*(%B-VHy^fbMV5 zN{fi}z)rERYSPT8+kyQU=fqK0)!0Z|sp05A#Bb{T-^xSOC?}I{C9x*Q zG#XQ=6!Ium56)slHsvD4zHxkJ%YWY)_}^N&7nA@R(F*aH$mWF$nWX;Uj^=L0>L!GU zJE6OuF)zlU)?1$5$x;Ug*6pzK8-r+LG_YFw_#GoaKu}G3disi6+lp7ms#gaG$pukE z5$*EsiHlGiuot5MAO{>#9{{KmQ&WWG&R*LH5WRHpeLp1`;Qz1wBrZOBsj;I&v>Cgf zlt10zC2cdKXPj_`8pXxhcJ{9T3F>y~o=1FRsR!Yb_&x^tiM_BJ#>v?|lG*b8r zm=DzoXa0@+>J`MeW#oFY);Grwb}Z0sf`&uCmLv3y`AuRc{L@&==R_g9*0btgGZP}` zng%}$b@cRNzZJdZQ1_(lyn0+r7%y<&W?r2;PG0@PNZ9)lh4$LSm<K2+$Ug$;v%Ah+9O_7qeORVvg=EtUyahmAi*M#2zyfXuZc){^AjGQVj#L9=UjTk<= zQs<)53`((D@5V><;d@h=UC;``~+gWaZKYIUi8C4 zWNL29UeTb&mvW%X=34WR_|eRG%Lf9;3wTXO>_p;g=E^^8)O`mF4_JF5Fe6pm%Zu;v ze&UG?WGdC1AYqVofqwP*qWtp*Id6h+d)RF9_wPsm7lDxgNMKR}IBY(E>vttSvDp2+ zBp55s^?UW)K~2u!uchJk(M0JtkykCh|62d4E$qaIwP-Z}jb#^@d}h|214tLQ?eXVv z)1IRY(DKP#L6n2|z(*C=9R(krja&Zjr$*~!FSR?#w+Xh)XmY=PWdSN!FoUFpt!T{% zMfdLGv(6_}Zp5tbW(Vx-MIbaW_Mb`#@rDkk4e9f;%9iu^5t6e=cr-)Wt`+Eg+{XR*xA#VAa>@RnQ?J2D zeHtW(1Qw!(%EyB425su8W|rp~9KFo3++R@D$t0+-KA^zE3Q^C$OL&CzC|S!B%FoGD zVewa?q6$*Y8=(J(%ufT_PAYC7{=@)oFPM7>>w_*M%;Ga6@upWjE14Ek73+NPy=kvi z4n?H1!H3!cy6*f=FDyxM7o69$2d}hWYf$+rrc=HX0Ai4Iqj9v=Pum!d--7pZ*wEDxE z=hg~LBhM-U;onpw3H`a#@Zm%ym6aTqy_UcY-aq0sJTuT@ln3Y^K#Wzz_GE$iAm0P- zfGYDC3_`}pG!7I1w?1gapJ~tWab-||J@+{-XG)5SE8wk}tF=G{cz9*JE!Ax9uelD9 z>{?@OK_p|()i@RXOnZ@&0qhZ)w*Pbg11Ve%(rREF8J|6nYygpY*Xe0&S~9khMq(kf zaBy_YY_~25Iec4P=omuWa%NzVXu{DNzeAn}zwFBkfU^JA*eF`xaT8}g1?p(Nkp1NK z%9Y0_*x{Qbn60SgvhQoGz*98wIE@hbbn_B3(8VRe26bmJZSeW@|K`PRh1Zgf>S-Xv zZz|@Fg9l@Y85ulqjV@KB>O8)B;{9$}j)x>-cCQbVH9(Yg6nND(Qma%j`Ppq_$YjZ$ z+$Y|HejU4q{>#rvawm|{@EbqWIhFWXZu+ds5HDb4-N($ zXO{-tCWH&yonbzKoa*~TulDsG@D7ZOjJQZrH0k$B5TPG}BSHO)Ba_tvDLajH=Uc1t za+4D!Ah%t5(D_z=>pLM-pSCapuKT};V}5BV((_5-a4~0=WD1(3g(jz@F(e2$NxCxF z_1_)!tU9yB0zXjmrv8n9JU*j;Amh9Um28CUxD!I zXY3QDe@{e5=dp<=J8l(CtT!5oaFEA|C|RFx?cZ`CCc`F3feY3 z^#sW?#3e|ylm{**obDBw``bxFAt|S>r z9FJ_9HZIzk{Z-|^mqF3}%Xfc2ylGD6UnP3zLgqsZYUsCOuYcRWyFPO~R-Re2H{BiQ zgHt+$z_{;J3=908vaJsu&#o6vqu#~E#jn;nJqfZi^&gT4t~;@t5b`Ym1wL+F#|*R! zvo7O#6Rx=iZ&l>7#8 zIYt+%4vnKF76J817u;|Za|iV81Z_v+&B_bYlK$P?Ubcu9_eVL`w( zQSM-I^}6|(|FA?;^Vj>rFRq^N%;FowH@+Vs9t#I^Jv;&al5*6UxCjefpLxBBCFl9X zk`)vbbOe;jGbjk6IeC=gV!Tt4ed3>teRqoiMtBY6ePLi;>}caeGV6^&9XR}jfv=C> z;~yTtyUZ#D5c^+oXM4!V%ECZM`5b#GA6F?WV;2Qo*5E-DKsblA6sBFtw=l^14X5u`ijU%y%q#)%2$iUh+C z;exSl7;sOBIM=^(k|cDmpO}Es5U3g`Zw6(*yZ`%HRzf3TX&D2xvMuLobnM-@d z!r+l>YN~r=W4?D|tSZ7f5mv`ifs*~0e3SX@0xKNsUc>0l5HyKsTec3anTXfGG!RJ< zg@uuz-%^>4-8o~vHUNW`z&|dd1>C?D-=l@mT=Ndg&ybog zq-ZE#F?j}M$RG5&(fs$iufjG?L&)g2lO8u)9n@SIp;pKk5LWuqvYTnLw8dL$XkZ}4 zgeS?wr%?%|!TSFipsg9JlUIrGl|9WInD_k9~#fLF{8QBbi};pS9G_ZU~>5 z^|80=_DS#&F{IKcCaZ3Z=x`%o^Vu(w)P9L-fg>qJnl4IE5dgSg0@DR1!bf$8isFGwZOg1H8G9P>(j%Yyy$JF!NMjaa9{i*%P0}fd zrU8*mxu?Mf!z_eABTijBjr|2gi=_7U0&5cxKn>F@rEdcj0@{KOxA<&&Mh#v+w4bnj=+)6mW~@#+PaWMDJP{`ywO;K>Qp)U3tiUfLl)O~XA9IPx`8WP z7)bqqU;uzrHKkk$GDdaDNYR9sM5_AW6OcXy_O?!wqu~&$?`VaGL(p0;vhdx0oi~{I z_Z*7X>{82C1iCqkD6UdGM!+jyb9cty0@pI# zTn%l%t6>?*u<~gCJw5S=1S&bdk&Mh3)})VMoPtom(A0C@pT5zEd)}Mvi08z(}~7_yde#0e&IyjAskr#=Ut}^73%IaB7Ez zItlxm#o|?q0S8F4debOL5s(WB7J@8(nma6PD&3#X)Yii2i!G#U)q?Yt44DZ>_GPT0 zJUQ@#R1;#j;;MnXly{%9q-ABP;6(svCZz+gyyvu`VodTY7E-XB*zq-cRD$SLKaoT* zSFp!#XfHL>DLUZ1A>~39NI<-0u5X$S`ZWIDG#F}#p|bl86$_zm#zM{f>LxV!hslc% zzfL5)R(8Yqe}8bwz4|xe);q>@`|S7>;E`c1W0Ft(Y}#f!jNd<}LS>(O#EZTcX>Hxg z?P7p9LQAaz<9$9QoAkUZTc*IV<>NyfnLnT?2qKu9{~pdgfqcVtc#%!{uX_(*U*FvL zK%!gr=$_+b0pSm>-{;yKWcJsvINE_;5S05nhleuy`U_dS`0LCLrS4t)tAQiP3n5tk zmq9+q0NdW?KC2Vcl{E9%nkn78-Z*3A5)0Ivc40*wd}pZJd`bjdx~PO4y@^VYl?&fD zx0(|FTMIBXIT`%#ADgIkh|BOvUku**2SVa|KdC&yteRlibuz+?%<3=FX6s)KIC!)* zdwRlgqHk0**o=wf>5BMLj4$bpUYQ#4kvCu1x^^sr3ErnyR0ID{;8E+`3x z-;!W`hbG?>Vt6~^e}=qsSqJ?3M3twhN93guRJ$QnIFb&< zN0Io@QcFZ2!_wz3q>{C%bZCET0mQooEjst0&$j(_1Z@dXC`CWfEIFADH+Is^6DZl0 z7uQ`|-WnPLTNw+0Rf-A%z;h@L3SbpX{pi?%?65B9dBmo9zO-z)u}$eU~x ziL)flqOpEUq=tUoXu7^X^t34^ESd`b%^#VI7}McMfU(uMb|3(*u%LjNp!^Mg)H{mp z48$kVtzNhf;X=1!TdygQlKQNPXIhD0t83!h8XWTTQC@DN92>daS>K$2hHF_!ZL3C4 zD}EwWf2m|u|7duEc}xx#r;VmOe!}UB7mxbQl}H6I2Tn|{lrU)hqLrMWo}-evQiK#| zwPPv6fPLK>o%!=;H=a@#ck&zQ=xJHv#95jNe=%F5u-}%3`X2am@FUu&0Ze7I)34iW`*?KWj@jb#AsQ#hm{)*3A|R)zewokZ~>wZ4-%~xMfe?_piuxN-~4xqLM9qA z471JQbS&w+)CB%?b?un#7D{oh^cn6agfG=;YPBCUx$ENzaJ{tk|oG^(Kc-@^L!cYap=)|G1*4i<@Kk|29oDeB-OPF@tq&Hnp=n2$o=`D z1$G6`?|zV%z8+$dKU>R&kC7w%Pa_S*lqu}G-t-v%ZOHa}+JstSr41O7NKxecfe+>b z=6{$a2mfO5rt}a|QZmYLMbV#pkful=D?l3T_%!pcAVw6-a{~SqNhZ9n51(AAjqm1X zli$!y&djLS0!Ir#{N-!FRev2+*so3R8UncS9wQdNVO?@Z`IQgZxE)V83Ww? ziHhWS9JeE-F^qPr12?Y#SyUNajI5^So7C`f?J7w;X*M>tjc5T&U0Gb8_@0E37f+Eh zCHT_VKzOmr*oC25GWZg9UJM4J*K%qlUe&Rv1pWB6mYx0Acn>p}|LHin7rNE^DFCDw z95%zG{1D8=8s)dD z&ii85^~*+sUU^TnU+{B=TkD@!iTuj7IdN`cfQ+#)hHu}XF?kSFYKrfgUEFL=*(QL$mGT;@Lxxoe}bMBSccA8u`zh+^{&!jMyX>L?=Ota zSkoxeq*Sw|NJFO-Y>ADlB%+zXIWuJ7&pn4Gw^-?m&z!;zBp1TgYYF9*xW3d%(cfkyHcIl~_UFqNelrh{ zaGcJ85C|Vg7rfuCkCE$mK=s59l|j4lCO;~nkAL_oyp|Z(tArqIjjftK?<>j?Fbe zL|;nCF)bSkqPDn2Zc$=GOwS1_T*scOiE-p>Moyur&+n?$qQVA6V(uw|0|MDR2BmTm z^11gbVb@y**J*e@MoHA8c12*J;S$QPbHxfnfDy{QU>ksD7YUQ|K(d!Axs zp1Zngnk^PY0VWne@O?nfJh`-#2+&sr)on29D!T98&dyF(u87~h-)qtSYyE^cp~tm4 z#!&vVjDWl{u`e%usHHzQXX_ADd%qb+9cX_iW)!(ea>_ogfcmg*V5gH8^ zlnTg2etp#(seG@%M(|o&JM+4dzHb0kUr@QwO9Bs-saz8t@x}I^e!9T z2iq~j#Qa=)sw#%R_mt_X@FAc`^MlKg5p z$e7YeacnA_yeQo4QGfW9UT%83hn~WT8rp3%d7caItZ$L4dOnq^l5a5m9_hYByLrd> z{44H2d4V#|x7K3Er)b0vT?y=2VTE|MeS=$KwUJab=a2T^wVrg?dtV17^x3Dzi6MjE z+a@I;SsPHv1dc;QiL1PR?%-v(znq4ig-T>s-O?0!rqzq3;zG0oO6v`2R>38w-Ykwd zkiw}iEx&hptdY)COXoKftOb(tQM{e$NVq%cV1YF?(Y36-vb9Nyp&qe@x>u@7^1%({}FcH>VcU^4wxZHnh%m$%*0FlfVRZOL;FzT4+W43i1 zIJA>T1wLJ!>w&c3QTZ>rP+{G|cQU%-GMX2rrp&=5;#jHGL>+02$3&^#8Rfm|aXh)t z17Ys64sIyOg4BC6196$`Zg@$JVV0XY<8r*_n<%%u!60AZEXA}Tb1*`y8mulUAHm(f zhH2e?DGIk;wr@P#X!@pQG!p>tZ?BOQ5Zt%?y0>pLo}eB|WdPr?$`9+CBSYwy@u5_8 zLjY}sd2)|eNTTWn(#5Zz%UHEs-AlTDAr)Ng+x9JO7A$f5`l7yb$^Zqpr!C&b94t;w zPQr~hM)vecD$|3{tv;xEp$rMnN~k1?5c4AMkdKEADkTUY&9C|o$Gp192r)YQ5z*g9 zE-0|0^NF9V>E+}d*6C5c4~52>OCLG23|RI}&!yQ}z8#$+`*3nnS#i$&_)rQ;uWn~q zB3ut~=pQ{33WE(7F3=Qz{rVLca20arvZN_qvB@q@$=iL5vP;5e)!IpVH-y>HwTl6x zoAEr)3GC?s3O}@ig9C(8B|02_fQPP~@I&+hpVgouA|ki#;LFq@IyL5b(>BX7k_!DM z4#uHUiucr^znbZ@H+xUcXuhAj-KqIxNp|f%H3wUgKBPy?=80dPqF#<+iLX?4id&yG zIpmZUGChx25X|`;7YMG9v zW~)U>fFoUj0$(HfJ{DCN5{_<1{qSqsfcfO-o>0qE6HcN53w1(i@5zD_{dMWVWpM~h zm(>$*^vhWU{dw(Q*W5R6aet^h_AYt+Sp6BMcC{W`NR^|!mjpr|x7xMRyyNd)qNWTq zzbN>*d4@;fWY&D*z34p_kv>BL(;?$V#FJ8rmtw^jO!!B=j;tQ{oM9_NZEMp~XKjqi z^-c_;RnWyA!$)HD26wI4z8#Lx=j%W{2BlY;PSfH_@gx7QE@=wNZh8BJ;N0r1As`lP zht7ux7=7uPW|bWY>OW3MM6bRvt_?tB(zGR+@= z>}(7Ft)ulW{L0RolC|L>9IVZGDjh_wl>fxy*HKf9F z_Y__n2c8EZ6si#7A0qo?kpLk|5 zY&T|(-+bKtxxSY&mgrR;5I<@qCY%@@L(C4?tXP+yDKajuD>Nz>;OASUvtKl~l5yZ9 zu*yrFwyq5BZgoX&OcTK}i~c+1^_aR$=JyORSxi5?B0X2RU{lpgYiC7 z0wGsDk>{%nH1q{5gx&zUMw67p#Bh#>>%#-ejCllWGZM^CQMrq>v4uwc^mr)040p<8 zellIQss+-<$H!AqB+-K6xuHQb=m^Z&9Wuv(;JX45D5%PE(hQFgu%#^%zpBc+c*3XMTg<{(;d-f?!!xS<>)Sizr{IY^)0F`xc`k8{4G3L;Z-}jtm(TR)r|~u0VJ05Y}b4_ zgcJE~UQV}lqvk<%5NUre;&*!r&iB{j4VFG&I@M*!AyR_cI`U+|r3K@B?+F9grXU5p z{(}&KQn3^S_4QvTu<0c>ygp-IUR;oSd{z41Yi1F~pPj`GI_mS!qqv*f_uun)C@|4o zM!YyZrb?tztcFKwMn@9rtR^gBHu^Z<)0`5Ourr-NL-SF^3f@9f=_FF=7F|c!b!y&4 z1UP-hc)x&b^7sGs7ma6T~b!xaCY7D#9{i%5L2E&psR{ObiWnu>SN1ZwBTc}uL2D~Z85GbDBrTWyn z>aFL~H`G7IsY&N1wCK4p|c}N1FH;gHFt3?~!8Q)(jLw$yi+xs-a z0%!@6V%D=()I$gBK=09>aj^Xpv2Id5aj>U^ljYR9M@m%+I5+_b1s;Af;4V~mATGA0 zj|Fx4^GNCE!DnuCpTL-%s-TCxWD5aK(^8_Io*p7%VhMmSgUj7q7m%Vz6anW`wd%a` z#af*S_bit`TgOJE_gh}E=I>F4DX8cm-Zip#`+o|cZ-khv{^F$ZJ3wk2$2H-en4hOR zgVcw)*xI6e94%b@2|)8-<=T72cnJni|E{R>gw;<@NyJ)-SC=r)-c!I03#Gkz+CZBY zd!T%~++n1%0wWF|DnK9hKK0iCf;6^L2o;HxjSaXVXakS6;GdG7@Vg@$EE;~5J-Dzd zP>-7jlRx2fS4`=p=4RL%t$4s%ILqkJkrm~;%H09W-O3~)jaFUj!z5&89XJozC>0^(1 zu2uDiF0&H4l&CQf)&;|6PHx76Nxh*p!>?kzZh9Ki^7Oju?aHg0UR3f0MM0AAK0$I& zEW78+jey==1B2D+%JA}i>B&#zMoa8 zJ#k#*)<0CsBf`N;HX_hCWzSA`y|&Q4R7n-mNF$hP(kSIz*Uw61%IpQpJ|ndnj`!oT zMN=1pop+n?JKv9M;gr52N0RI!q0vkb&HdH3uXSXT7Y<&1H?Bs5IdEsa**J{vGiP_+ z+5beU;7lCF%fn;R7mE*sY~oCKdfYcZ95m)rbauS@1_zU51SiWP7KWS%@tx@UD5fjp@#l+?o6=Ypd9nzY4g>W zmsRtED*2b(m=G}Z7#I-thF~Dow!c^^3|iC(UzVqP$48fw)U2r-$ha`Iwd1`?9{Gy&I+_Tg{OF+6jaozZ7#FS4;6<{p}=OrN`x1Sn}!VVC_<<%g3v;q zCXot;)${^a>q^7;U)sz^X;HU=RiB|W7O(4gq=xLRk17((1&IbfU1rpqFW3_s!!GDU zb2~WW8WQfaSgdTazh63vMzQ?N1q~l~A|epXxrxDlcsqTZJ2%f3C&?w3p5cTjOJ<>; zK6ewL?T0NiE&yL)~6BrxYP;x$_gihMvg|sX4ko-lDtK|c=V;BbSP#)TO zrR;Z?TkbEDZhvAJT=*MbP6|QeYBl)XPrs-uvA`=Zl1e#>{Bz5PonU61*HA!-7N#BS zc)^1}vLKu&U1kNKi|jbzktkrvj8zF7lT2bbC5nOxrjrxc6`p_UU^s_d_FiPNo(|Sl zWKP)vWo;t4ki7Cq4Q^=!1z*W|^EZ*;Ao#qGJjZ40672$Z%Xcm1F)NHhFRdG0W?ysj zB|mDaD4FN3hy=xfU!Z0f1Mg)0DOD<+DS#QEN~Oo+omFF}#S2c->v>B_k?fy9G(l1O z%b3w#P%qw~5Cq=XO8h|_O!pkUTS5jU5?5ZbIkxKGn5j;82<;&TG~w|iB|(Vw7?=v%Dhlht*=~FD3zUe8oJ6L5TVsxkPV>5*~Cfex% z49GIii@$+Os^Pztt9zq?{$XXps4;r1Ow#?&_sEsJMk^GR zxAetiR^y5>{Er#UA12L85NReZ2|`8o7CDcd7b8f=KgH${7SGrOWC0`}pk*dNTLp2I zqF_%L@D?o_sxH}Nqd&ot1a2f)phjyAPZ`inthVDPl!(ZsSmz}>bKeTY)T*_M78y(N zL7Q=h+o205JN`5XxA9q$=zsdj(D+Eg>F`)Ohi2EDTV(KpAhGV9(?aGAX_vtPGJl{G z=8tg~NYT4?i>Q;1^Jus^hyDx+@0x2L4YF}3fF=Rjtvq?EucgCq?~SeXuAiH?UxnV% z;D>ls@tc+Y4IGS8Dju7EOl-qyEnGIS>3BSOQlgk9lM3_*y7wnClc+4}C9CRMp=IfV zb-mn1@YSlgUUSmfs-0v<{Ke0DiwY`@B{SC8!a#|(hv==!`n0u-;h=k7U21@>U`3W$+qi z`iMH4?6Xe7O^7q7Xdu4jH7lQLAG`=pfe+72A1A_4z1V?Pji{BJD)7{@N$ zAom&5Nv3Bh2g^}kedg+r*OI%d)Cgm3?0w%=Gs@KFz!L}PLD|!I?b(rZQC{k<(@mylJst_%UYC2Ni~d6*q`s@;hYO4mVI! zWWzjmFuQ3^EWzbI8Nz^H{3dHMAB=+eBglKjtQ6LGl24m(Dqyg|Ox{=-7~U7i&vEh- zT6JJ7@E8ngk{-GZsrZzXNSk`RP=)a1wm&b}%Sy2Xcr7~;jRo5bC&Zn8OpAg$m^FiZ+y+2aAI%kW--55|&f>s3_Gvj$;m=GXrhXe!d7La7;0~zLu zKe%ur!NjpWW|99ix6@NN29}G*cVD&g%1M{>dA%jJKFYzhDZ`)Ke(rF1=pU$^^Qr2P z!)bW$6W;G(z2lGk&Mjd0?byVGnWZJ7(*x%d!muA7m87^%p=1)SwX2Hrj2`Gk!d6Ne zu(TugGM3UFFG$~7sSg@7NA54A4Ys?W|5>i7&*4^cbuF=a8ZFlyWg*7G5{F1Ztx@2y zGhidO9k>+d{V^q<+&DjvwZ~^*JBVHN1*IQP9O3adl)ZKTGTo0nHiJW*Oe`JsCPO1w0gz&=96rklIoaut7dR2o0SyWJ-ui zn4NY%m3QX)xJ^9y>BE>sTvBD-u)+ppp&I8X|49TY%L4_{kJ0QTyO`u@{z>T_3pE&5jZftW2|{v zNn3c)N+m2P9Mu+L2Go@JN60XX<%ow7NmS;lf>DG^$!s(bULadRc~TR3gzKD&_SUaFpqtxJWEgI1TK=gA_PxMnTJz%ttfIh&{G zYKa@YnNR|(%BxR(7kjf8E_(kGxzc4x5vdpxO1rpSm+G~|6f`wqA>dqan~-?Fj*2!U zq@Eu@x`m^cli#h3y3>&Nt>i={^wP&#tQRxbNa1vI%rNDRIo~s8>3OycPzwN2Xh5qz z8+zO1_~LV%;+2Z0{tD!CU<@pHV>|b~prAlI+XmlC0Px6ppg)$8*vxi$HU8oI%xfqy zZ*QJ5wxlEP*a(q=&7Y8e7WdQGhscP&V@Mq~m-kc{IUB!O26O=%DH-yagD+6*V>CZ> z=JMw=YW+5iCA7HLW}v*sgir;oOkh1c`CEFl@cx=2V=#0FA**AbYkX^cB#tNhAJAB< ztGZ4_7{s3@o>bAv`Sc)2h=a7W|3gf;ZVuaiGUigIZ>CF=13GHq6H9MzqV0*i*%@CT z0sQ}BC2>7U=9jG+-fP|;FHEs=Pk0%nAE$rlcra3;USVwG)>+tK|0vsfFavdX^fZ)r zfa$uwVf8-Sd&1{4@_7vaX(=8#YWQJHr!#cmux0+!f2~aq&-d#Ytzkj=bF^tQad!1YDbOw1))sX2A2 z*pVJJ6n1*Ip5w@xW&&sfgBQ=qi|XO1WH)r*5#LIMcLZb6K;AYvENGcq^{#4Qv^~0E zp9>~gAagLB#n+T-@d?_MiIS-2&!+k7I(CS=B6U^zwX!R91JJ@qztjjo6~6&ii6nl< zIl6$T5>fk+X3y8z+Ir^;ZNeIx0>CWI0A`u2|D@T&cB<1aN22D>5t1Rm^*i%IS^!5Y zYm13Z&8%G`s~;Eb9D{~0@(|?L4ccT?Y4@Q2FC!PeKzp(B*v;KH1h z%H7(s#F7L}<5w1aBXUpoIgPu!H9O-w5&&n-FDy(=O@%n~qv>X(yrBc5qn}0i;6qS& zf?MOkqechqSrh`o4MO1JW{n#@Q=sc+a0$w%&EMVKmD}#Q_F#6Bcl>0&>74+I8^CI*w=CzW`wEI~ zO=V)QaeX*yq8KhV()Uhr*-rZX(cMYv&E`GuQQDLJdw%b#ZWxt%90*>Z7;Fqh#F!zO zrf4B#>1=)!g=y-U zMACz+aPk}?5ck9n$VLEZo}ABYkuETS#Ri|X<`>bQKhu*y30zt#OXv=u3@&c&!ZJn*i;&_82jrsmK^$9(UD~1&(kaoJply{r9UJluCn^bpk^|;+4Hcs{eea>2mw9C}^-G ze6a*=S2%pmtc zeaP_Rzt!{&(njSZnkJQ9Fxz~R87XVS23+=_-~s@r(g~!a%xjWe@{{q(Ue>w{_yaT( z#&0U2C1*qt6c6yf{ZI8wM17x8Fvqx6AB&cDMSw&g&D&HS)R#&KjV1~HVld{Ow8K}u z5`o+;>WoLP<0ATc(OxAc+PsY!P4|-j5By!Ka{%55H|cD9`f5~;7qcvU@v|Jw!qQNI zx1obY#gn@gWz<`FPkm=v_1aM!L+c$I&3B9+yxs%AS z*kA6XtYurT8K4*e!-kr+!H@0#<}X`HYfrq%fTwG6#J!=h5l|nL!2iwbM>zt+`IvZ~ zH3$cpwM6j$*GFYZxS!+n&71ucQU0YICHBshJjHJ?C-B% zy@5puAlKpKfUI6G`Z=+{bW)8(=*^$lmHJUu{7$GI=ik<6D+k8pv~l}igx!^|J?v() z=R8Wl)jE~cNgM_IC|i%1!J16Y`lSup)8Xy)%74)aeVWO%RIXdMnu$T*{}Ral-qCOp ztJtHLA*@RaY9?67H``2m2}fT9|%dRy}jD-KD4MB8g;BmXt~ z+I33EQqRuEA@{Qt#SZYwg5a6dN&fYJi8ln%wr^y8i~ zjH>&}^LM2ic5*O5o{Ntt_@D=I+~D|GB}l1y*(UWoN``Ie%6$qKHlZkq)vjJUoD%&TO?v!}IChr4vcPu@+en zc-MCpRE|LdHekYA52LET){GzZ-|gV2AmXQwvG6>;9)ss}oXGzZ*5`6hm9H@qSVfk! zC(ZslYC+znSdID1R}^X0{I4v}g?vYVR*X>AoEe2S%6`Wl&O{idsI?RNTZ~dgMeYf_ zV7UD0@%IJrLME*~Exn(z8f;v@;$EX?c)x_MR4El*_C#2iw8&{lo znkwtO^Vup*mY>5U-%AALeQ|d|S|zJO1KFhjeYCJy{`4-#rGb$0rlGj0ms|s6&W1O) zr{)vjroL8$6|)o!d~MHg(V@ZuW4WH$2oSXM8UDRGIes|SGaB%}8A|+wg!(YPbb0EJ zim<^(+#@V4R^HzHD(5i4KwAcQX|R^Lqv~dh*ZMQ%Ic)Z?Omg-qC2^&r=z5h>$_#10UPU{!^IR77 zZ+R;hquzEBhP4@amaJDbpurMh|0>S@6{@tV^TVx2K+C-Gy6DH0Q@poB2?;boQ;HY5kAZi|yG|NK&9dnR^?DnsQ7x4w*K z!tD1Kq-Ew<;|vseD`My?Gn@2DQ7~-n!#g)IWROTvJY1V)d9ns8m)Gi(DS!JSv%|%B zW=)gilMT>a%CWJr#`$1@U@~_QTiBV?nou_9rxb`=YC!b z40Sch52c`}uE@^Xpe5g%4bC=!DA=$K`x4p7!{XTb#!(FPmW`AOz8& zx)v}fxZV(xbUY~?6mh(8vJgh+3B7QNW+e2d!$5e-xeb$%hu68iaCl;*t2jxeOrE1( zXKQ}$(IhQT)R^o3-XvGr_?VM3mgWh;!}A#qhK532SENf*||zpBKzgs@(g}YDG5d7Nv6E(AAQJV zzz;X{%5Ru@sfxh)hJ2P~T^;OifHaTiQXqriRuBYDFD}|aS8wZuGA~Q3f1LJKq&kNs}jak zpo=1b0}qA8@?D{T=ldr8#SB-H{FC>;#IgHxa%>evc==54VWOw~2NVk3a*f4TRU?#+ z-OUu4cQ=wPa}D1YFNuj3+}8Wi0oG?;r~8cfsg0UnEd@8z%S-9e4C1xDAUV+P>8-unzGd{AMPEvYEUa@%p2_ z94l(C;<$wZfhk3mqnXeuf> zNV`AqT6lMa>GXvhz-H1~74ipSgt=s~!U-j2ym#oCRF1ERPYDf)*F>5V8Md`qIY1LZANjc zElj4*X_;jJTjuyWV<177g3I0Av@QGDGm=W-2T$kGz;MQ=##u1XjLsSxT2{tv&K>6| z4T^7|rAC+2dtb#CYys)Mj#4SWAc`Om%8+ zRL%htM#lmI@BXuT#nhll z9*_`v(@@Kx_YU)I%OKqKzEq}1HztiS$=vp~@^x#S4$ZDj&m?R9W}~veG(%!r{xbG| zXxGFS#K%moSLBT1SLB)G)@p`RQWTSB30j;NkR3TgBN{S4CDc!ixaa&E3ruE-UZBz$ z0mH9dBXW!BC=RNaVsBq#!Gm;Vt{_W_?#g8vXwOKaWu7PMh158``Ge{SoXhz?-U-{lBCp>6bx4N}c9v)D6 zKXwu;?W8#FMPaoUbBttz%%RayG`xtCtmwjM;H67v)!urq%99%4hcmnYSomAmpSr{! z`6{POj|`f!zb~=*=OdrlX-O_R3-xLy=+xXeF4oqdk9jq*KfFtH5GKrYeqltFQZApe zO^T_(c>*~U&Gkkv{a-wNbySt#^Yx`eBrYXLNH<7Hr*yZ3gdid%9hU}?4r%FbDd`Xp zP!Oc0ySuyJbNzgO?^^c{7b5C&o-?y&&)zeGkNL0uZl5roRku20aGtdK$$Dsb0OP|cxv_bu=R*XfdD?Z7NnEzD)mjtfhokuPRyj4w>$CyRWR)A*( zi0PQWsi4ipQ?G*kDXWA-tMTwnjX3;s6wM~oNsZBuOtcvrddw-o5fdkdTu>h?>RD+M z0Z|SWz&fjMpsqYKWV)0lziH#yFYLJ?jAru2u0j!Q3&0b zLL=C@%2~ z!hUG)6`3t&7KZC+?Qh8wvjL@A-G{E{J3KihP1<0AetEMNg&LFUegX)Bl@f5e@~!_m zqPB6cbnGTN>7x0{L6zp;C!XMlo5^jWY~3wyjs$HCpR#yCoDC0L!~blc=%x(dzCR0W zd0K%YF{y|y@aJ9((L-s@{K>*^qXp0*(*pGjf8uk%arErjHmP?6%9mO3jqL^MWsu?rQK`+iT-viArzdnfh%W}(&`j?%B}0{*10`@t%;3| zjfA$gI)OBT_%Ma)iS)T7hR83dK}rukIr>qJO=6Bxfy#24|mIS^u>|N|U!fJ9cW4c+7AYrX3<+NJ(=XJ2TQ7j}188BAlate6?_-Nz{9!$QQV(|Cj>*0sb#rxfRE(=;eI9=e zQQATXvQ%JdGv7UVZYE${VJ6pDsn9aDK_ns52}I9Ea(E@czWtWcZ)0-Q_n=^~7^?fb zUZUYvXcO(Ssg76zHv^$8%D%MfAtQe`!XMR#>^a$^Nbd*LZxc~w!w5h?iDDlUtcm;4 zcEXl4!Rgp4}|Ttek*-NUXU1)Q;Xb?prd$o*Iq`fZ^`6*}} zLA+cG>bgcYZs_a6Uu23UEOXB`e8Lj)T7r5TCE;W{OI}{)d(+ z>A`)kFxs;^>ZwSR54&7$Lw%lVXxf1(O~De1R&?LcX-*N9s=M8+`}^Yi--$0th6CT` zJr6_}!w|K#oz8y;ekKc*eOe&$b>7Z@4QlqZvAt6->WL=I#34>E6c`6{6wS!Ow?_T4 zOZ*oARj?yb@q2@A9t#ZW7aYpVai@Cx(|V^y2XMxHS(&4!Lqnf0*sVA(H}u57!J^S` z3KwJWNlRr=LDKcxu$_=!a1|G#oK803w<8S23mWN{VuG}*2c@5J{?^?vMaxIB3%o4f zgJ3||Dvpjfd7|hOnz5RG0iy5)(Gb#^@zbCTqy#|~fni#e$iqpC{`}Ss@Mct#0V?QO z7&TSk>Sog2*Lr+$tFYg^ ziV1#F|9lFirKOKR@RN-q1B2<{bH?Q_iV5?5&@RcZ$yltIKuT>b!=zZyY1S|hzw5Il ziTQBDlQ(liH8+k!rQ2-%Gle#n(b+I8rzol9TaFP|U`Yvlq#x+G%}cas&G6T*HJ(7R zf9YMTC*)Uu{Y4z>4q^Z7*b+{D^GBogK~Y(=i2SFK8^Er>1qjX-xB%aB)qGvLz4aQ{ z(bDcuRpkurHfuVFmL&MyjStO);*KJih)wF||^|HM?iiLMc=xCXjBzYw#A)c;eio zXatGPxf4_sYu|pEEFo5|WUeDfp;N7(`hpfPVF#!@fxpQfuSO~?-`psy=i_TLZ-P@= zK90HW@IyyIa7+`-%qN}@)VVJs9ZYfl^K+GxQ6Q%7)X}_57Fm&BC4}67BW*NH4oOmg{N$YfO-{tz`D1jvV$MWthEC`kNT?hm=NgF z+iv^Xq&z)62i(nqWvOG@!E{DI^1~TGaQ8O1s#@L|I41-7B4BiT3^HI-uD3*`Z$*Di z*mL%XrNI=th<(Z0cO`At+kgY#LM-FwbiuTbF`NbBvhyOO6*3%M)1^Yz*^!KP_B=z( z9IMOEp9k6|MehF;Nr~q7j?)&^levtTz|vy*XmOw>Ypnys)AkMd{r}Sf;Nv5t$$uN1 zeivHEgx=CX6E0@O-a>w2)^XhRn>Jq|&f02fK7ZPTw>9=OGdY>WunW|rpIH0AnzLA1 zs?`4V_`Z?z(X|h`HrHvyp*eD3(DFAgD>_GhX_McGo>Q8J(@a3HTvrC~!z-@{O5BL( zn{477u_(1aH>yXO?2q`s@^~%b6q_RteF_7=%m_ho1)*}@np%bydWE&=YI6Svb&kFc zM&4S!*ZEn^4WiXBoJY<95bt!Vq zt1=*UN)BN7Z4~IT3)BSid4M8{$G1IZL;Ak{@sh{W2RB&7V|z6k$pkJplB;uE+4$Y{ zUE)it@Baj83Gf>``$jUlYXp|3AeABMTkB}>s~hYH+x5j~mo>-)YJ7Nq=AR`24XD%| z72aEp0+<9Wpn%YIc5%sWYxDiB;=)ZsL-T~396UF;n=m(8)Jh;)zmHqzWTRu?c(FFm zkK>-!kI%w-v~h|6G2_miD09{%ef147trODJf-nA*;=*eJkL}quR%D%*uTO@*#d`Ek z{UVTtucihawtvQ>*NsrH@r+K9XU+93D1N}G2~bd`X&6HY-BVIhp2@e^OOul761KyYbL#1)N`u+rcXy=523E}x6(E`Vmy~#P41nSo zEG#=I;1i8FVKn!~CGy6mKYCxWWM0Fc=A}#dlN(aNOaLnow^IH6$$ z7S-uZQR6hHJ>l>j@OlXZR6-ywqg741trznEF2dC1;}zl1!JWD+>6#`yyV5Vd4;5Iq z5(ZW7(wDk1vW_27$!zPb6rEM^|97XKyf`O>`W&IE(Db z{h_c&CcW>!jtmzN`qN(}`Oiz}(&j7<$!CNtqo8_BPrUT0O~wR=yW@DUeOv(P^kEHO4@zM}6i3MJX77D1 zvYZl4ZEqmf#Y_2Vvch@6{)WBB9y$l%$Ft_)Vx*A zlQ|O~KDr_KE>u>X;Lc%1W`7OQ={QPnm1*q`{~YlJ8&x2H6Y!#D016zT>dg|3N-?rD zg~G(C@33kxC5%X*Z5>d%DC!H+EuJEG$Gf8)ed)_hnS$ML4CRKJ)_C&i3Jf%M*P!+Ke%xnwo_J|#)58U>*R&cxiG18=ic+Pc)fsXXNNMEcC&q|E-n5p|17ae zv1p7+=;1{yvGoYmqilb^0g?GI4QPv{O)L%8<9dJ#h2ox^1piRGRN0t6xuc;x`E7#Ol<-ruJiRXPH*PozBC ze&W4sfzU!wF{gZSrm%rv#d@mcH`IU)X9dM)Hf(mtR0>Ds@-@IGai#Xo^%-uD{0^sY zl9skKJs3Mft2)g&0<`3CV-t`pw*)3CYkjjdC^&plC9w)uelJoo(a8&gfA4J6sAF_0 zr_aG`T@*SM3^5w`%+k}xx{-RAoQfXvg$bK02&c4n_PuxDCsfPn$(0w>cL|8xJ$M70 zv$5G9xewMY+B)HqkD+EhZ$9QMmsmLJgB5z*Y zd;?LD1soT)4zo24)!R~umZ{z@a>G@G#|!SL?Q=vOsz#*IzzvQNfe_+H@iaP><;q?G zbl}MhILG4C5o?F|0y2iI2%s?P^-K9quUhh;ARjIpvlDVE(nxG-vH#flB2C+4x*Flp zXRIR{v$`3UQ*g5gk$JKGzwQe&=7JAPaQb3o3e8i2w6#Bzle$5;)QHb^slBC%XL(_= zH*M-n+Rn;rESHDZPGtkWBcTMQ;65}FeBdH z>J7}(a8T6Ifk;F|1Xh&ARxp_hf$d>H@zvDc>5!PiBux53PFVFJCV<1{wSL`6uPB&2 z(ZX!jcy;Lo7Iz4Tf;D_;=T_!*HD`7N?mS`M>|M+KcEDMmnnI@XsZynf3c^Tz{N9^t z!DKp_^5FG*eN zOMDL$`(!hm3B%uVKKJC}@b#;fHe3AE^w~sakKN>)oS1Oyjgh$`j1HylLyZpA8Ge7t zoi$^#e-Xl9l>jyhu)Xt4?Oaj=^ZmY+lwjsCS}|;!KN3l4H&fakMy4#|e&zX9ulm=( z`b|bm3?AU~z|Df|BSDiASZxJH0UiR45kTDv>6`X_usWKl>JsWR9Lv_Vm3{MlVGuOB zjBn*3b_3%dl7V*RQ7WNutF7f$7yGx+1Dcx~JIQod?T5Y^$)>webeHjRg@7DJ&{Fo; zVspY^X9@U`)!LRsi}AxGRGl@g=`cMUnjysTrq133nvV&3A?RK=KUhUu3JzOKbS9AI z>IRKIH0Zt48+hI9`uHD?p^p0v%#=ty%FCQdgUs5?ONd}lf&sF`CQl+JXC(Kz!TWsK z7Xhwbq;{?!-aH&o1x9d%Zsfe-U(9`)RAv&&_YrbG&0{6@T9>H%5u*PqjX=yd%!MeB zL7c~r_ne9Z6qjpb9gKo1cCOo_q-nAw$X2qtvsQwRQhH7ZZkorop8y%Y^pJRTXI6J@ zIt1G(w3`B;&SDHAhOaBSN^4B7R$+L~og9I7uIS)Y5Y$N8S-G)*_sY;UPu-`>EI-vN zvRfhZ$Tq%n>2Dm6zq3+-{*Aw%>x8zJV;}1YWT>wiu*`;=V5>rP^;N5cfdwcpfNACj zxJ+>KJ8)Eiqgqw22o;6Aj{9roR#<}HMm0Cd^xD#*7(acM+j2296YX%Yt-`i#-cT7? z)s4x8sXjkj=^jyG`3CftCFT+>3zin{FilVHfWhYFY(pd~U*dG#Agwgky-df>s14~; zsN2^`AWojad3T~r(hr@ox4`0x+zP=8)AV3S9o!I5pm?_OOox_q9gR2IUo)|NP zQE%<;uEL>)py_7&pW+wo*CH9n%KSqixRTh!jd&iIr@zemN>F_K=qkTd00c>>8YMVV zMD0$c+{3~oNvb3+7&d#pPYI}1=R66;MVB|ditfsnCtN{)(}*gy1i}H;Ba5%W;eXyu z`CQrJ>t81fE%f6(hp+SSN}f-bfk3Ub$B8`L^%`zrP$KX^4G)Osh=uWB=6eH*{ITFT zqnIQe^hAFDznVU!=IfJ9aw#9Z5^ta}m5r(`hOqKl!)F+5Kz3J(^~kUb$3Zc{6g2Q7 z>@A>Ej~*a|9#ZOmDOq&Bwv)TfzA>2j9@2>aB3Wq}42l9XoyGadWK>mg0K7aF)aMyy z08CZoWxc*Z;7Yo$XiA#HoIR;W|8PCi#6BAGwEpn`^~76*!StlV5$#c`mdS3N?5=9N zxPZ3Cq%h}O56a*b)`RaC6hPJr3JH=A1a4X`TYFuKlTyGyg1FiD~z zJUMvYfT;gr2f%YKe4yWqnW0?Vjwfu3e|~}pCr9bGAWAO`C0D0VJjiBxWY#c5l&}Ig zhl9;<08#q)ap!N#M(XHux^(KmSu4`QbVg#Y@2}o0w~OkAU;`{)j!!Q9m~H2+=x=X| z_N=FUg0Kw#`2h3c*@o8K;i9X7aLb{+Ly(@9?fLtnkB*MMmXshif95dRVs9n;gBX4O zia;84-{fPRi)C*f(%t4QHw^4W)>WWDi^atipvwy6n=by{Y2_#PHwR|!?z+U@aK;5( ztN+F}0#xz(y9XgC-(|RxrGU`@?oRV)t!>iWS@`lHj;JNpJ*)O)`l}zjr;%K5)kZG~7J>$P#0-x+>Ui`@Z^Y%SS*}?1h z=c$~LH3E?vN+Pc*C;D_RS3?VQDz#}%Q?*31X>i>B@hGWf1Kph7kun3p=19rUW zJ5K$ZZacO5&_^HQVTo3w|GJ5#NBuX5Lj;Jem3lvj?{y`dUfL2{NWVDpW<0=t3EOxm z`+fVV-HOLc7@NvnQF+~1fO|tfa=bXy}7L(Asjd z->gP}5|(2Epg-g|*6k0qDrb#%vxA_i`#0N}7l4!I2m62nRfa^PK;0ujm&VlDptzow zL47jP`$5^C{`Pf=a_JnBVv7Li21Z*B)uc+ul4>J!^IsaQ< zAQ*><8p9#6yQ1MA2@IaB*?}(^Q~|9uxKicPyDHEZYYlD~Xy4-pcP@Pq06KZh)iefp z+9{b$e1;C-5?m&aj0k+aC}1L$qM~Be6dMa*nok4M&(B@qQ*FUO%(+-{uKx+*An=*x zDEXAlj2dCt>$)zYQ*%W1y^mDnMG{yqK&jkzr3V3Zm5E`>yRDibV9UyC;kIf1J6d_h z-!&s@J9FFnqim+#as5&oZ}2<&QvY9QX5?dPRZU#?bAMJ*n2_qyl6~6ZugItH#o&j4 zV3Yt$TB`NHkepWvirQl>E8I!${A3SrO30q0O#cu-nO=fn)Et=gKL4(xU_F~dHi7=W z5x*~xvQ0z$8k8O04ds&lz~z#rfM?oA9{4E@g*bs`)MPoXQ4qELdSQ0lh{H^GLN!%J z1gB~5(8Z9Nbrmu2vmwz+4W>ZuZdHtkm^d)UJ~z~(#wol98E1u&4vatuEzJjx*kM7?c@sHliGC~*bwnc|9}kwJVdt`EDWCl%{dKM^i;0nR85 zDy+5L2!@zf;{V+EVPN=oqPXH_F_?q}ez=og!3o&RX1EDBcYkq{se~D>I5}E-FpIQn z#Q|Rb0K^7(yWhz{xEg9j{DANWsQQ08MQfrlvtV(JEn#L@dU~7m94!xll_Hyu=M3Fr z?+tB?!SsaZ%_c8%c`&_bn}(|OB%FGFttI%=pB2Tm6*b%@f|BX+j8VX(SKHd9zV|$t z=g|3>YxW~yu2hREy_si6hXf#t2t?3TzkBx%KmyFR{1nK@9W z;)8inO^@D@DXnulN0D}uqwdXo;gXX8Iojay$pPvf!;uSOKvM5>7@cy6yoPK{v&tZR zX50(*!q0h2%s+mIB(>TB>}-8w(w#~y5jXQod8Ow5xUj}C>TBjLHINSr008DdMnrr? z`#vuT)Qx+rzsg+cgSMIwoUMhZ-qqsxj~AkNX;nl&LUZ}guT}wWMkD!a^yo4^_8td* zG7fH0(F`#5^(|NQ_}wd=gFJ0!9KW;?1CZ#4G(GkkR-(3_8X>y6)lC2BL3bJQ^kBVn zgp8-}!DqGu4=bo+C_HyLziYj!<&U|~VH+(szY$0;#62S@P*?-m;*JyJ;np+t8a?S! zE``)DeGjwsu$y`Og@C7_Wb!Rayqk)6`Yp=ehnRGLx|M2@5O3%m34s@Xd-Ez)BzZ#^T#q9}JpytUQS`w~d zYN6fQV(0>7*cME>g3&r36q_fFYvI-|DfK|mxPAb>vr5-JeW6riT)tl6{H$7-sVO3$ zcpAWLAQck!NYEfz2Et3QTMiilga`26HBeZhY?R&wIh6#)&Gpq;iyGZ#JgL$zRPp*{ zF%LcB6N@`sw@KKk#?o7gQD%nc25;-MHc^1LoRfq9q#+@eQUy-DL@#Im(yBS^$SV4( z!Z$eS5jV-AP+J_lP0YQ=1CqC~1Bf+^E#%`Tp+>SnFge7cSH$X_JHxj@c7uGPFE$fAPLRgs^P5O{(Zp|2O80P4&5rxTCk7Exm!^!DLm*`LwO*ukB}1RsZTA2T;2 zwF5E8Af0>wW7ua}`G+>c-&~4Iyx9Qan!J6B3=#r*a3|<*3~PD;K~y(goIGS`jRI)p zmFV%nCX}(UPhkHEI3>Yz(QW^^Hu>+TBAm;4U(A1kZ0)r%hS_JU&B$!i_Hi-B6-8(U zHjQ3*&+3u}v(;~R^I=%ZFI6Pdu_c1lRZzLt(S+B&x!9%TPg8I>_9~AxH*WJGFDa4K z*2a|t;zq#ecXR}Zswz(pXFUBxVe=SW0;m@u1g zXg3VRsUu_?ixaH(u&-r`d%{?wNR3qqb6G-VABON}h0TWhz>M0yf3nwHmD=A)AWx*P z`rv47Htx!$roGK_;{*Q5r6Ku=^vb8bBP6QS6@DKW%9~&Mfvmy&DPabx+wHI8SW2DU z%?Ok`5ddfVW(>WIF((0PbOE{sFps{S^-*d~?8V}Xm|u@yrxG5{>S1vF?3?x0V&a+|ld7yX{5haJ zkTkb!A7zYpG(w1B5i6>CjQf4cc6 z6)%y#O8U>NLV%;0Q{IZwaYm2*mH5JrdVPq3448mrAwFZFqCo1LioWgw;uQ|d@1XqE zUE07|Th2Qb$Wsg|v@eGzSd&$#?Qi1s^ut%M;fI11q?JLZDyK}XY7_?8vzJ+}H;hzy zg+zNUnDoq8h?mx#i%wZN9l**CZv*!;(2YW{mxq2!bWdxk{_`BsGLi{yHQ}HCv!o3d zfZqls|6{?wwD2n=0AQ*%M5M`Jhk&tEb{@D*dWHSP#UJ1WO5;~^z_k0*$VLIDYEEu0 z5&*pdq0$^Jhls#%22bUOojAm-nfdVg1?(#Z=0qegtg=|Ix$mmQitegGdVNXvL|>!* zUKe69&+L#s&B*O#`aMkcMe*gT2Rpa@z3=%vM_uI3z|B2H?-uoT8>9O9)NRaOPkYJU zvUDyXQr;M1Ka$)`IJ$z-V%3t-m>s6O-?MNJJL(0!#<-Dimr6?n(n;a{^fw~yspz}X zNyy`^YN>@WI=>&CMiWJw%R8s#l}foZn?ns&VH^Vjt4sDlzQJi?w>}U_Y72g05Z{9~ zyw-1a*v?HJ%5vJz{fm3JnX;8Y4{Y|&(s$QZ-Mm?(a;C8n`$$MN2=jDEC zt8OvKB4JUiNX&Lplw!(fql&?u8SbKl{FVmngh?j>X7kS3_sQE_RvbO8NC^?n0)g~& zM45qwhK&9jj*O9E0v~?M930q*?`^yy6}CejGy6o7KZ`3Fv9qDE8cIR{le4i;=+pO~ zMo4b@rl=P2q_KN~^`f<%P+Q zl5ikj@`4U2AR<#7 z>^wMVS9n+fp1W~#acknhw<}6B^V^jP1$VD6GftCgyBNXG21#zAj`~ZW4cSqc>4Qmy zEc$Tk1iFO^)X{iuyQ!2EP}Tki4F#&etoD~Zklazc@$6nxTXDmXw29~qHS*y1+N+%M zk7*#sYAbYp6vQza>=&18pMq3c8Y8APL=V6#IwAQ!mP6lzA8j$fdc<1oHyv5U7PQ-8 zKq7KU+B)aDJH9!ZW!<4OFdn5lXOO0&_4fr@Pe)C*ljUlQMfF7H67#Nx@A#4-VgQQ-zdMA~z6>+OfP1mQM>3@M z!S{&R*B=a57e4NxL8ujNZ8`2`nOs<~(_5XIo$UgeLp4?+_?j)yUIhgA_@B}KtXE<_ zXy-3itBh%O{a;mFkh-RNL>3N>THpm};A|n&9NgZzmgozzAcqDc;)FgucPJY*^Wqr5 zX)>G#o8Cek8zY+C4s^Q6#bAZ6KfK-^)|cIvk!Q+>v~%eP;zBZraXg1(dvXhJBHuu) z$jn}_kD&@usc1;djsz}?rv;Wbbh^w@zNS{iwz{3MeBOr9P(*StzOtsMW@<4{1d}_> z?s;h%D*D?{QhSJ#U!zx1=ern$ABFC4@?--ct)4Uvb%_dm`||%q;FR(h+kT3GIS{^nIaNW1=d8V3P#`6Go72LFej7!pFT|1|E9rm`k(&mLH*%?EOorLWu2>YS9lBS)&UZja?HQ)xx+LJ(~ zIsWos8`B*>qmZxRzFXDZWak{nL=>*8tL9w^#`hP=55wQv>1oosy1M6JQ&_HSw5g5F zSL(G}AT4^Ct=0}&W+1RX7cvkL;dNBaR)avmd%X;JOf-^Kp)vFlxXV#jqk43MzouKa66bU3lWrAb!d}8gGL+5 zC>D%vW!F7bKqq-}YEmjcswZ(qy`~~f(YC?ck~O72u3dB#rAvRwAGN}JU-gL2?KO73 z`eP99)wE2Kzuo_EA;(H=fDtL@k%_e7!IXLeSLP3z*B_9XYZ=N8GLb*~$GUmNdCAp) z%K;5f1VqYO-Pdl}EZ6u)Mf(uz^&{QHm;kFl%I{yXN*JbG7>3m8Q~NXgWgGLr(!-yh zQ{t73yw3Tz%MN$cBnMTVC|mV}53+aUvJ(0nSNFZ){f(2(Rg1nDJl+XHq3}e=(Q*mX?AkvCEIVbH<=51rg&w zP!$C7&Cgq$Z$1FZy z9^mlJ?65m%co9-r*LLGanZ^iCO5w8CTRBlevO9#4_S)I;*8Ab^0s*q-*r*}7GymPw z#k}Uv@tzdL(@fhundR*Em*`ulrIO-c=Z-w$NeU{j-|X`kiHk>M;*ZHHoUdFZ0iWXL zA(X{JAjNhh(+3SN+S@?&dxiUhC;Pbr*#?$XDHX({<T$};C{KTje?**l#->OZdfo`S ziTf;s`{K6sAbo%{)6-3Vi>8L*t6ZTUE(G!g9rvb*2heBQTqs+Z(}%GshiO#E0XD z{K{{9z`3o zEUl{3OMa4Q(|jZ6yR}+*u?9!e1=2aK_*fKFgUVO0Rdn)RvZ-CxNNtde|!`ZM9GBI=j7@aq>D zh24x0x0zq?@v%@xF~QD`x+%@9tsng~#RJp>81Gw- z?*xAD=+{#_I2z;leyWj=oTOKfbcs}ioC!Zlz#YNADvOjXe--)UlhmiIar1f%ymO~c zxzx**jrKt|jmyGFs+NaBir++KEp(YMc%-bSNVxp<5xU@!7hxy-CE`GQ1df%H+EkbL zd~w}x#0-~Jq-Z%Imp8J#mp?+eGAQ!fU*;(5ZoQ0*itL{rwIpF$UorlH`;gBU!hqK zD_j0?D2SL9p)(f577gVee`xocKUG+?v^KVqqIgyO6{TpK(+~PFanH;@1I%Wv@`T4u_{qze^2TCQ%T%RY?DG<^EyMU8)Azp!FF zFzA7rgkDna+Yv4d3s&S5rc1Fi^P_NF-`BL*VpO6UYtrWH}+Ln z>F|jlRfLAFMB6)pK#FIV7i0Ypn=dEHIqI%`cGsi6`B2Z+qpYq4;jX_K&)sh> zm~O9e5ZkUkwpSdW-=5pYrkg4X#Egi4|jn(g8Zkgt_aKswn$dKbFPHM^*t-qURe^2Y*1guAXXtN7p%KlAP3g-7d#G2{s; zsU*O1H#e6n(P-oM&(M$gG^=#K%JHFANR+;24;~xS240?JFiP=|%)W2U@$x5!kH2YE z2+)LjU2hcvK=*`#;@77~#M?&QEjoXp9{MD5yLpe*@!U!A)(?&P{&v z)l-HKNk_aJZM!9IUl3+IIyFu>x=f0n?Oe?;fnNnaw8WYdG#pEYyqbb{qxbhQT=(5o zT&*|gqi*O&xv6I93sogkTtM=F7gpnRu|G%ZUzpsrLL+cRe~3;6hkMsSyndb~U>del zll6R8m@q1+J;cwVoAK=i%(;@~v>Bgt#mDn|i#i1F2tIKp9e`>r9EIH*Ff7+p39H7) zs&a>l(Y()nO&m%|0P7K74C6e>(N4C&v=X;Nrr?UkT`uv7j8t(OP!B+qri2|$S!4xL z+`Qc)gb*LaWujOJUF?9~gCq$RtDmUJ`b(&2|H0D@X+6^ zi3J@NHj8pgYn@(Qe}uwwN07=M$qn@I)L4D{aCtlv<-?s`s9^p5>jbxZ=gHMjyqn#& zd+mhQlR4SsR(u8zs<^I5pMpbV7^}7HWi1Lteb;D_w9{63LDKOD?-p+nR3~PeBibNEje$Svg!ZqyxaoqK)?yPjcB%6cs#arZf(*4v zc;D{!6)gz}F-V_eSHEP_#mI7WCc?HeO406A=q=S$lX#l2qW?Zg$Sx0 z_p?0CQR_I`La!u){=qqf{irTv+IGKCZRT~Jk2s!WY`C#aEv_k9$w?o2N1sOT2^9|~ z!E5mR+07|BLDqiG!xQ+gZUMQ0I`0(mrd~}5$>5IpPK%YuVXc?Y=EDbgIRdtQ)x(#j z0`U^4)WHYb14K2t7O=*1;ffl78;~79dbh<8 zQICV6`up6ziyrZKxKhD6FZgvFBtj-u6!iiUlL7_oU2pA$6R zg1f$j(uCvl1I-2}oEY}-vEI|}AiCBYITD8Ggrmp``jZnyYKdL3JzAma3anh#1mn~k z8L>VyAFz-v0S39$A>lKaYqd|0LIpg=Q z=4w$ydR<^);*ufNYFolj_bnZy`UsPjV%8-asl`E--jT9;C3gPKV7WhhP!wko%gQ%! zR}x_2CL7De_m(zzwFh^C@$LA1lvIJAFGp1FU`4t+{$ZRh7$U9=S$4rD+{xV`!ZOQM z_#OdJkEXjjvwD%D1&w}Gr24wL%DTEBkSFf$?ouqlMd3Y;iewrDJ6D3hLhQzW2>~qJ ztZHjpuqP=S3SWp9*HcYb~R@jh6FV z+B)O&sJBvB)KU_q_Ji-u#kLa@f&azBtO=cz^BrI6{o0M+foREUdZBFcHv%+|sqU%w zlq7YN_;lAUUe^FyF`IwepKA{8?0g9lG_aqHg$Von7TUiVx6n=gq537K>m_<9^BpcM z5SN69L^YIK@MxXd&rTob0FfW8b}0WeOfLq9Rp@Y_<>|nk#lCI$=Pj4en;NEKd&3LV zOoz8&!gNl^pEvl!S)V_T(+~0&l-nGiE*fR8Kr6r6@K-9PB64c%2PIek+T`GB3@>n9eS!Q z|EzsJ9j^;2Mv@|}^bPf!m2B<#m%RN9j`Th+Qe85o=KU35`dDS^fw%$V0I@9y#!sd) zI@FT`-qzZ&|D(}~yWwy4Kyi?p8%dH2$(Qns+uTwNix;+>Qv1yk@3u=TmPb{^z&3QR z$VyE{fOAV|tRR&6(ep3@sW^RjUoaY9VH@uf z1f+w9q>#GzBYUTe^=wq5Am)eKiTwGd=TMNjmL?~1hg%%ct_ju}BC0mN{x#R^#+<>7a|&sK+$R&oSACJrMF#o`R}$@oA41MXK98tZ>%kuym_PmxU|ZTgc)AbrkN+_cwp?AxQE+nm2UXygfb9!%G?R0O0*$_~B>v*e ziLxHeSLNYY6a+AIOMji-ce))a{lf5w9h3M}K1%#VQQKcZXGtERIBPVG9r3dD?9c^t zF7z2AsRMCw^a)|7EsygTBvwFMshC9B;qiMH)>9DGv}q|cKd>Yyz+J*9xEbv$(i~_< zE&tv}Q#(7HL6HkGAn4pFu(P#Y%6z!W`?qE`guGHWQ9}(8^3L>ad31Y>M0a|7?26fTN4sPxk~V*QTH&jm z(0dJ@p{m%QSUvf?IkV8IZsbN|@_28ap&PS$y=Y3WLy5?*W>OS1M?9dk$u9H3e=C?y zvAs`@D=BxnS=D1!RI2-#U|9>5ISeYB)|>E?ynJVI2&#&$uvpv^rjvO@$CSTv@;dPs zwc*Q{;Lk)uRhE2N)T6?E?;6&N(-yb+`<{B#p~Zbrdk4s8$cCDT{NyJn4?xTGQ1S+E zT*+mrRO&&fR+m-I&pB87KH(3y+xB{+A_qVgto=b|Ge*Po{d@FivHQ#R9^w5p;&ADf z`|F*_nVC*7Fb_N#@X%g!x5an^l1IdxvJ=oi^By~Fpe};6Z*IOf{9SH@`^$ol5vlym z@Ieh7U;RDpVIL04{Qa{}#y-!qNZ9%C@RXqfB3%>r*RSYW&yviA?<$#7&*0sc@UDz! zy}GV9*YaWo&edWE&U3r_`;zGDG42ZaP;49=aNB7@yK}!QFF^3!(W&GM(5MILAS`Oc zylmO}AAY@B$CFH}DJAK`gQuyPw#S?CS4HwrbvgVn&EFLb_zY95*t*ccu%xdvfx7RV zIW1^nf-QyewBZ+;T4xHYedFz4(ykdetxsVb9Hwu5+On57x&0XWY5-J>-=C_K%6$QZ zzJ;1E^a}IKq!fDSbK5XsdVicJ5D*(lF+}Ei05AYm81F?ig^1t~mnwLj0M7}5qu|(# z>iYWNvNAR@5f6fYWqU+oDyUW9B|D(V;QIn7C}gU1yZ-!9dU?elcYSw7=e@`qiX1a} zk{Ye)e$&a0@0r6oj5T>$=rS@;Pmy@}3yHuDRf74b(;LgOj-sVNc1ze)tjjUhd*pR&Z z_!)}6{_K=@WCq!?4D$`$V$6h?IXZQWSU+AWN)aOnTZn*A>E1LMK0&4hYIKPMn!-P= z@&^^q@^_24|ER7Gd-o_F7T-&T0C*-jEsh?8V6U@K#*+TVU{6hG$#!DJh12?)_5$CV zPyN-AHOvgJ_Sc1Kc3_5F`k&bO&;9SBbLX#pA6Y$}#K@Kp#2w)R&zNmcsOWb4RogLJ z{g~Y5Q5+0t7em>aPjv_aGkkDzQYKgczYHjSu*O~pUim@%ZVuWg;j1jS-;#Nfmz9^B z+S)$;4+{ea1sVh}m0!!s-nhF@@+DiixDbMcWWI~vRK>(PCCE>)!z)KUuLlk;t1D^TJ}$!?+5 zP=5Mqw|Y>Gy>ztO^J1SWK|#CMa%vJ99iAv^Qvkp&Do7Vf)uQ1fJW^yXw+JXiz%>M{@x<8|iP5H?< zN~)!%O6f=Q>ri3(OMl{k*aA2RP=3v&N>S^ASAoc1T&RtDUc0L z{#Yw|L2}{uyK0Lp72L=pZ#3pmbIv^a7>ZoYWFmEg;0qCWw0&Yr9r)SRQggNH=d9pV zl9t0G5pnITO{q=!d<`%q?~d1TaB^}|oUX3Y~;2{dsybOh4Ob}@U+ksa&V5>m7oo42L96PVuJ zh7O)HvrBNGZ2a3;opTcP=?W~&{k`1uGhZMx59@+g*$Rl!F>Wo1W074i{ey8OZ?~jY zhe!9SqR{f=(VFbc6%-oNcw)K%$8@458J+I#M6U2TRO?fKQ2~ZPl#>icH_@l^mkGnu zTh5Jn6mX8PV*pW0V-oYPsGY$xMdc_{E8fxr%i7%C~PHk|49`(@%T z<1;?}{%esya<($VtvAdzH9*i0qVPmN^hCuloP0ygK+Kg`Vb`6zCMc$TcsbD<3PWpO z2EIizG7PG!oVoaPRB}U_q71&T?mrcANF(1;+_h~mzR7i_i0fK$^8`@#hU*J)0>Xd} zBLSxSPFX!*l0Jcg4{+@!9v)3=8~b~E+Up#EiK3v`H+S;h8rvZKG&n*LxbJe6RP=ZV zX_(pWBvlm|^MUR#HClS@VS$22&lnH)jbO(7#)QxHo6rg`iTqu!mkj&D_dhqi?z^7u z^LZ%7kK1u@B1Yr6+JqJ+x363qOYRxAyg=lR1Tv|+v*?*qy2c2Rf8A?$H=A=dhs;nw2SR)X__gdi^HD377{KJH2F$SuU<@)Ip@ctrs%ds(zgVYP zce;ZoSA~wpJEYHM;emR+9)(hTEKy`(vmF=BxtPxF`@jxlpz^Oq%W(c*fE)_W4z1Oo zs(C&|AiJ_4$Mmd(CBAr#SgDj_geF^0sL#rcH>$dqUH!dc{`u`SzCS3G z=kL`baoX?5cC)l44$rv z>I#LENKr3CiCjYYl9k?Z2Ga9(F(~)gleafRTCUGC5e{js*2tfA9Pkcw**1qUH)Xy+ z($-spl=!+%nPsS+o}OGE5d3Ym>#da=$PAMkcBS`;W7}uhgj|zRFrnk1J9Td%B5v9Nq#G3-x>o{dr)K{g;&?I)3X1eyPX|(ObwbMzC#eu54*m*-X!Bc z03q#EPe&Aj4MbE-OGYrIr|Sqn5)iZ(F}lO$>0C4jFhFf3Jvxh|JUN9H&RwUIUbr1J zshXjiBMMJ_@BIcd%Y3tP8Na_K#l7;Vb(2(3xY(T&AxK(NZ?7mw)zo`i?t}FwmBL1n z%|j;i1p)Kdq0W{EYrFFp8Qdm3y}^7b9W?#WEJ6av)8%X!<8d*o4-3JF1W1=Cq50dn?Oe014LywlPf)wtOj+FoF1z=UQmiftW4LOqk47NL4gs>2TtrZ?@yU-`%YdI65 zv{{cgZ+Ji6v-b28Ew|55T$wB(+<4CZS!Dl8_L;p%N{`fyglQZIt@>rX=ecL%*5R|@ zmW4a_zr;Iab;v3(R;X&z7h=H$=k@NkS|*HrDc{HpqEV;Y_li2ES@Ep6cQK6UUwmNCBh4+}*%?9$GyCjS zGvIYNMtLrHJS7Xlqx#{{~@K=Bqfj|f73CVa*@SVWd z0%8w_=zHydJ_UTwpOS09CGetx0CLB%K%HHCM@>~#H$xUL+VYa*6DgIucwx^~)YUC~ zoA)kiaUmt5t+iP#-VRt*!^is|(r7QyS8@ZL=KA9BeTO+T!>OJV&kJ^-KMYs4PImhO zqyi*v=uYE@FWnKf><8MsF1A27AWn}Msf=3^5Y#R1x-p(R4&fHi3HgO#O;K%&s?LTW z&?m{-X!lgeWms@5{|!i7Pp*?yh{3>+XnnSdq%2j<-hjDMU&L05JFWEOy>!DBgV%da z`E0`)@=4YW5tFx2wOPoFbn+BAJ@WRUoKcZvmeSCaoXspLOkpS^W<JguKj6r=EqY_6EEU&Z! zE*uGExU93{;JM_}0+~uK8khZGDGybNNpbJ0*^?n!Lsc8=3FgH%e@mzb;c?x*DQftq zfnff8?7RDs`CSelS5+>SXz~}i=}vlvOA~uU#!OIl%su#on{X8}%5`3whhpFEo^P$dBDT9Q@}|!X&ixQJF6Jy)iTntdQ5#C>ac@xpNH0f@ zK7`f$0}dNet!JLB>QXzT#=||1=W(bZ#Yuii0%2j{E)~B@7wFN4)blGQV#xr``eTqH zV}t(Q2@mPxt13ZmjM0>V2?-6d>6hN;1x2QY5F;S%_%|=_Ee0oXSQNiZV1bGy^+Yhq7da|F9elA4-YF$R1EAl3dF# zczpXkJiY0$<{LV+4?#4fKh9|E%$iEoS;F%y~4uO!*+fEO%ymtQ7I_0r~DAsCC> zIKpNm0Az5Pp@yd`&@YsW9WS8>0<0>eAkHz)K4b$u|Mpden))S{jWa|i`gyviQ21YowIiH;oiK8SJc z@e9FRGwnzRd4}`9Daz`XJX5BJXnGS>8;UkElXz3CZVnigiRHFHz^*>oTp*0Jz1RY6&QZ{RUBa#Bl|~@>sEmRLIEfz133?F7gVqZt zEp?mkOhB`!;obt{B(;nb! z6gV_E6Vw5RDiYrVfDz=O{{vnFw1b`nCGaQ%Dry~gpLAH>IGZZf)4g$K#rYBSHDE#a z&D!M;?4w6FY@1x=)f$Nss+>WR^&$-J$^P4lZUb8xJu_Y`d%J97+2jj@2(0esZ^6gP z3ImwipubM(Rr;Pl=clvxQ|dxgWJiH3oFg(tu|4LhZ%OVmGf}YBKMh-_7r(TosAh}Z zl}Q+Epn9AN_VJE>7~IWelVC)L(Mlt=NsGes$S3SkKE4dF%OS}VZihY2tHqPdW=raR z4pMnA>A+*Bew8MFbIC~zCleR{*VR)mNL%|+C)ua&~(j%x)a{*##p*pZ^>vmG(whjA}gqV4!yr0(*pdyF^^slic7}}P>;1`E`E6=SjdpI z(dA^o_I>Ki5)Yymr(MpU+RKYA8oHs_&x?9ob(79`h>^5iQg1tLu(fR&qoY>hUnIV=M*6r_j(~NZ|sIA!m>( z;E(|>|Igv|f9T_r^!jvuv9siQT(wXEkpwI(#>kSgcU&1tly~4x0 z$1iXhoCWhLpK|axAAUy-d}-w>``b&;oc9tF+QTTsVXQdMns8##ylOiN91jfc$|jF2 z$gv&XqeC0WZ3Fp3>yX2@ofr@zfpL{BHj+)m^Ea1EPbz*IJdoKYYf^fad^KjkVKGbT znQ~IJ;W@71y}yhFS9(J+^iPI*V*J=MGHa2)yy6j85q#{S^EV@_tVXrBpUz^YBHOb? z;{eYj+sQUp%g{m!%W9&1oVxH>IMo-A^UJXCg&Ec@(C7q8-lTbAJiPtOS}K-pAAQ8M z7BXNS=K*%ykdfsiQ^6tFSr`%txO2dU`thPdz#b^GVEbXr=|I|;IRsG7ss1eY2w-aX z(SR)Y`@&oP3RPjYnFzWTWew$pz%*-bW*Qbg+Mu16d zR_ol#rO!lj4ZC2Ifs#W&+Yy8@KW^Dq=;T{u^<#+^W4BZ0P^-&VkvYzcXdCs9({`cG z$H*In+U(D$^Q}L2=b7N{r5*3lvr})rV2@OS=$dVEXeo7@XTS=52ZGO9C_Ld0s*XHb zCw>6D(=rTsL@+L={ln~g%t^-ZO2&85_*GAac82}toM z!^Z}4_LsjhK?g^8^0BnCdXh$C-)xYf)qloNO66NvwFz<=Z_zHBXjoPs^+uN)Ddld{ zp(=IZI^>9^=zy--sT=-yx}y8l#AzN%lEN!Tcz*n*(UN0Qzrg=+bU-V@IG@0p0+$p& zfENJJft&>+W`m;)>U~6g&s<(;dIc%1yjytoS>=~6v4*M0B?|`!pT^vn;%2*j>e19r zgD08ebVl>+?#_LS{F!D_(8I`?~D!?isd;|BsKHk_=tikR=}rT!SQnWs~naJ}zel z%P3s1><6-tG5dZKQXkj6_}DEl7a-1OPt5!iYkJR6x6(sNQY}yQ(+cGX7WWvAKe!Wa zq6W-|EOw+}-P~_Sayw#sEt^$Y+_BP#*E0VtPiT@ox5rgzNJ(#!9E z=R6ikAZ^MIno;+X*{l~t`L^!AYk9{vE;EgL192u#*Gjv+qde*9FSA=7Y~1>ZcNGi@ zsdL|D=vo#4Oa(CJK^eRa9(8|$uW6|&Yt93qd}-L)SXRxQQSLzflGe?vQ#^p<;k|Y> z&ZxyS8O&kkx(&KXgN1`!M0lwQ#-eG$RZ^LPAG1U!1U!nu;+T1|fP>j}?{pa5YDT{b zCH3GG#*aH{<6llvdK$yv)9qJG@)|!FdBjcRs>^oh|DlZtM<_Ce;bZiwo`#XxgbEu= zv{9dshiS8=)G9D3)lG;SCm95tAZiDt7j%1Z zrp|en+n~R^=tWkhR8FRU3{nXf%i>225 zl32o&B|5{Muz1&1R*jZjEJlv;JsjB%@s;kBPDeJ_=q>TPT9eS0EsrH!zGH20K{Z~j zxw2XKMR1X063!HDSCiB^EZXCY{Y%U0aK#Y9h zX;4n_4~#yq;!P7#NS+2DS2!N@Uo0(tN@F`{Rq0OE`Gyq z7c=taPf44efm+kRrz?sQJ@5s&6ol7OAo&M*%EOpF=QSx1Agfz{C2HoI^cR5~q6W9R zTr6NxtzCRR0D2d*d<4Es;^D<%evmt4wf4QkPnZ7UmVK4ZwO2>?kY^9`Z_e*5RMkIrzn31Md}AcKM2Hd_?pBP8Iv%hx3zODvzyJ z;G)i0hX*s!0;C0iNKap14OEC4q?LPPMz6DhmkKNx(7<<|b17;QYTe{RKz=X@yaP0y z`!3t0y*Jx#^^C99ug$8Nli3DWVojyFA%mUN@lA!zD5H07V2mFIqjf;c6(s_8PCw(V zVa?0Tp!rFK9G-IIxsa&sFj-NKJ~vBdKP!N69v*dZ3$hAQ z)>Xdf-`=z}?9w^%w<(>x6e~~1wi*X|-CZvJQ*t4=@oej8ku%+h(UUl11G9xL_hArKR~3tRWRtFCf@ReYCI4NL#^o+hoj zFJl<}XLn%s1|e7(HV&jHkVm4l|G6wo7%Krncouu0y$cAAL-q4uB^&NSVm7sJqe-@5 z@PLLwC$aVQrGG9l=yUzN%9JoTzB;hha2@r^d{#XQlQ4W^fV(HV`TH~rH|Z5H%vgh! zqb@wPR{j4GN63d?8yf6>zz!b5^NkLZH4SNZR5_IKiaFB7I#{B!?;@g&=J`c9U{MT` zlO?s3uK+Xje@<>GDL~r|eomFgd#8IQ$KDmAn)UJy-KP^1w=XTJl5BmK`F{4TzUOYs z*MN3&syB{p10x6e&u~grh^lwLRGH zNTqaDq)qji_hrx}T={ki_8UbtBS$m3^xJf3{=0un->7(zC<#1QdV2RiR_Kr(Fqnm| z-eRc7Bur&XWhq1Y4biUjw%6($&3W<}u7$ool`b_2!|nt1U-90_=&5EGzGQx5>rnzt z)fjwJovpSY&O?0|hHI*08YKnK| z(y(9c3rorcH|s&^G+;q@-wtzvvmi6D^`UH)-=cn+PFntlgLD{N+J5^_jT{4zBZn*) zK@o`c?LNV5d}$LJ;HxR~=H^7Gec*tC-o6O}hJu2EAJXj`o#>e9hwT{^P~IGI?m$$BlAja#6~<5Bd1Z*#r8^g+ znSb0wtj<<}ukH#J$-+O)MIog6Z~pn4kKLYNC)`MIN6bd`ryCYG5cy3&=Jbht+zqG# zHtCPiE|N36$u_nUQ^yPT4HTYG?bV6DxsTOxgqCXzlV?PLsNrvI-J!Pzh7k}S@KnkD z3eJUKG|tXWMW7tH&rl&InF}A0zo$kXy=ABS7#D=1+-<#Xv9bXqEJ!EySa$ z=SOK38=5JmC@$kL2iL_4L1e4W@{7d(Ty(7*JoUZ7kX6&e{_l-ta?k<-d#Wt60g0Z5 z>xbIIWwO~O~94Zogs4iY+`m642Hob3PCX8L^q>6dYouT&SJ0Hj#a@Er_(8? z^h&LrcQiLQrz}?aGB|;bT+Xea{JT%h&gE}uqFydaQ*!}?UrMMKH!DeW3)$V1ke{YQ zWJfoiX;c0Ak3jE8Xf-c9L*{s6{xCxaA=t8eIEL*z=+= z;O@A$hrYv1_%qH~*f>5yrzNX=Qsi*;_*phiLKn`Tv_BgbDN7kJdMRpe(CBV9JvG%E zukD&drqMtoq75EANBcf{;qa(CX=nTp;ZN#hYs)DFe%(Gg2RtU?NWP;| z-It|XU9;a)Kd-tf*ZfR5pN6~4fU#4ta3mv*d?6lc+_rT;9(0E9e3=1~4m3aHde|}T zRYUHGbT%xXekA{n3I3kk{dj|yh8nN_94S*G8UpgF38=}DJTTd-wY$TrlSH0zH8{Fu(O@DcobiRKE zo--#D%|9b2Jx)JtBD8#|^op8ShJJBVyaw-6RpMQsRm`%Q%o3bJ%s&bkflNV@6xgqn z9Bw4%+`?brHh9QsGxkc~sYr6c&@?>LS@ccwX_Ts_hxp&wAD6#|Nb}PYO~jRf9qR2& zXTZrpuh$n?#^dq_$Y&Tvg*jG6SwtC9Z)qTTHPQ$^{tdoOqdXxPOk{T>tS0=3} zia%Cmf1Wj2*jK8$C`RlWBh1#hsHx;`E6qJ(HD}Ik{3h) z;oRBs1kCM^$34wL#q(cFyDBUnFN$e*zrpk|R_a*C02-#)KDq^Kqre$?Y1n-afR zV^_IR9mB^51YrUI!%#mn)>}`#HG)$Zydhlb(RCkKB;wMZY%A_MY2sZ}87~S~nc;lC zhcf<{$9$@|QY49(p3w{(JjU2z-$b*fd_@yZle<-^n~I;l|A zu_VR$iq~!vs+CV!cOG&$DgwJELZP%m(~}gEI}`RDcvsag86)K~s_LZ;POF(X?sALB zvW)IWux&;L(ck2r6aK%)`*GjW_|jzvZNN zcTnCiN^Jx1mTk_TzUNaS2&kEk$kuOYm%nvfWIO0FBz&Y?D4CHudANDzmsc4sm3Bu3 z$n&H55;DqxpW=E(bS~9N1f@IiejA=Q-E-$G=_}v5=k9 z5*7iqpZtZA(@S+T{2KaL^sk*2&u%YIt6p=>E|&+BjCg>Y{WWzLRB%H)ZndR?aH>M$ z+8kj8Neg@u0qQ{id4iBtk&nt~sjY5o;6EIY1C11NWzb*-MB=e{a#%LljqS^96bj7|jNm;oWm4*5D&Vild zHbwOEoK}NhOET{VgJYT-#84-w>&eiT2YS`5ub+gK&PhLC=yLo61>^Q`ehrGG0=e#q zeHbrOz)MCD$J!$?#;V`>uvoNLhT>_3*7-EBtNwGZ^`MSs{?`j2=|f!c^~suzr`#Zc z#7^tUwPnnB?x9>5lfThVSEib|9QlEAbpPkS;sl4j8cXXu8pf>}l-@Zt4%2cLlCHty z;=O@P^Lm_1+HH=NE$|7RPPkGQjfH{AhlI_Ed}Z#sM{?5ZBs94 znycj+AJEc*n3*@*v7WcBk@Xc5KaMGH_LCg>Wc1CNiRA|o?dC~8_%!=_Q$-r7yuA4N zHibS+mW+o@wmGH27#37eOAx==W?1R%jK{yw*qRpVV`!j|+2+zgglyn$Mcl0WA3W`| z(fRUWPcU`vS#w}dkPkhm;*L3~moCz>%wEc(DC9>`uxnW3Nvjg@Z^Ag%MwTdAkKzhJ zfruLfULJ4Hi!e#M(1BbpiS~&>tOSyKst0J+0vAQY)ol=F)eKi0D4(iZk5ehXQ3fPr zb$1amQ*l@D#vbiQ@Ce}q9v=?;fepOW`@Wqm>Zz9;mjWarZ%=vId1=Jg1 zgfsJsMBBA@dI-39_^sL|rx8IpZm<{pB3F(R;lj+V_u`tHQ&6L$~TVBi!h` z1PYFv0ThSfUS87Rzu|1N=#!45?OWS@!VrLS4dp@Lk%EgjCWAE(PL8mXo>&+Bj7iyU z7wi39&5oGW&*-748gUj|0z z>YgXKQl?u$^Slg?1;XpQYn6X~!;b2x+p#9uzoRmrDF7mUXwFRg-H_MLxF0j;t_U~4 zv?kcmMnjGT+n{!<>*2Ylss3iI>c8T8pw0pHW<5OSXZBCpKBM8M$@>mw<~VI{U8No% zMX_K6$&RG0ane>;rAz_u37*GwzT}mNI=nw3-zzmj=8xZ|f_5|wk*XPGkc!7JWJ^@^eMHArBi<$Bp^q!o(U)>&Hu0(jTK1$-iuyQqfUFfz z&=ZUoim@u|d63loYj*Z0I664io^SpnoH9X+aQURk)s?+uQ5NBa<&C=5@^t8P%xJK zbcGsnP0xvi5478>f<@6?@u8Mmw5%_sWTYa~+1Q-;mPJiGU3J~b=#wT#y+zX07XTK?5!wtQ!CnXg%;aeV0iBc| zJm$om82>eXD0{+A2bM7E{fm6-T1NBG#5O?|`!#`3l$ty?dl#kxR$d zg4U6hzzr2;M0fybS&}wKo^ftOCwvZf?{(B-(~Ws!3|?QTuA+0HZsP|G;?XN(;sv3& zD4j`L1EmQisdU9e+YcyKuwYd8I`}s^2J8Eh{nwJ?$^u}yNd`1EO zbj~I!J;N$#z^P!TULN?xxUm$N<%Y==%KbU=UjD-kfiltjX|MT|BV(BIfFrk0*fZ+V zSR&AhNC~@`!p()qiKcq_oq(a+-ggxJdl>lsECGuUKU>KQx&U{2w1ErVsz*V$8{H~3 z+OZ7lkBBxbLjrP@`SkuF6qZr*A&_#=7Rk@t9;7d15ySD z00o-V=L}MCK@Z!xn%Y`sWZNw@IK?k->pP@Nai;TZ)JLzp1DdcxNj)h z+_NaXcp-IJx4dR?_be+9^gwz~I8P0+%A)kMGsS76G{nlvOTSs%34A$s+QNjX1T8Kl z$r0T5lF_hZz^_Sv{vJk7iHmp_ua19C!C_%y6({VJ1Zk5rA*hJ5oq2jL=Rvq3Cs7{h zfms-2fa{M_&qEwz#`E$G^sp(tX@eG5!ERqlpFk@bKb;E6Foz{!w6=fCvuD2eufLBE zJjPALjt}3(Tv7cJSH}NpyC!_f%sGOa+>O@mn**5*{dwiW=_jG5h>Y~lfCDtz;kOV} z&WlTKwFpD|l!uVV5n`~nLLO@1HpFG>VeRokSeNL=YuU1KnM9;QC_UVQ?APa?9j{6f z@FdAZ_9j=Q!3ynNbd{28f3B-OEFNP`H#nr&DrHQYWDSs>{ z!iMAn1rr;%&mR-;kLFMSg$x-p&tE-bzqNQEnljSm&=y4HCjv1hszX|p{v$euJvaDp z{I&-+kqo38ai94YXD_FzJ~I8RlGV4b9@mnZifM;t@XcurjafY4n`%h^w#XU3{8v<6 zWg}2^)F#wLyq63>3W7m>bCUE&{<6`&Pi69Uxf}Bt37CDlNezsHIOi-hgKUS~t6rk+ zh2cX0ngXW!5kx_{eaFx~!2A9_$+U->^YyRF8f1=Si zC!saJW@<>mfC(crI6#=U<~wSeDk9Br&r$}1PNF~B&)wP+ErMab#&wKl9}%-{6k4;( zp1ECkgzSh~yL{RviF$K42)HWG1m8X6sMXOkEwRjDxNrucv%s6AVYbc@hfTFA{=+mK z=}sK`pjvjpEsnhS19`_>BTuxSU%@wR%2O3ZA2r4QjhDSOEC;A=3+JZti4K!Y*BP-u za%?Rvt!XCAg3rETu)TO8LlApfw^oVeO0A9gc6t^Qe*b7s_j%kYeLRj?`m_ zZMWr&j8OKfNg<{2?n!k;aPY(m-T0zq<3rzo$C?s^(JfM36{}6CFT*V|hR!I74aMe9saId))$jgR zlTn1@cxx~)HwU@K5i&oIzlFebaa?=XZBTeCm_pK?a~jmd0nCgA-m!kpWZ_j`tMh-2r=3i9YZN%YGi@gw+s z8!N&#wzI^*4KlcVSZK^Fup)wNM0fuE%rL(P+S*JXI(CBx?_EXa;3@z->U1;ZR<+335c(qHsA$l}SL zk?~DpW^sOQY@9lB&0+V7nO_H;Y|pZMrKodV`4x7BqUz!&&gvPX&-QbsPG$f|n3|eO zSS;b`nM9khP8vr4^|SGrgkm}vfEUTK$S}~is2~XA*hV=zt4p-hVkLZY4qC1vSf|JA zLSGrBdY(LZ$XVed^9|l#IQ|W3ZxvSvhpgE!uMn^nXXIA8L&9@Z`te3A+V?m*DoNN$ zJzqZCovuxXem_;8JG)?8!^vopBS9$vy)^~yY!jzwJ#n(-^sl+2{mqO{($h6k?|C|5 zs+sncyD!U^GDJUpR0Y)Z&y$lHtI4Q2l0v#riT}#Z!Q(RL^#Jz}=)E`U|2u0kdQecm zIiTu2`3qySqZ(dh2O)D4Wu;N#;L#8Ff~;HBc_&h=oH5zgR3T3+Nl!ALypK42_R(ba z`-60=*RLPAVc#HRIEDD%D`Hr2B_w>WT3b-~I&{WQB>0c%=?nT09{laCt!GwN1@{Bd zJiEWbBu;v(T8(+TA5%s%KK%_+R76fN%1K_SzVyMH4Qj-@8z$dgnGi5nXMNqLTnKCF zu%|DMwjyoltvGo@`NIkd(Y5B$71+$tYu z>``ESkl%KDZ=fpS)c1$UBzsI@#fI+~A$D4l9+ldnx zUR%L`(3gdDLrb+Gj=_c6(fvnIrMgW=m@4f4_Q%>VSfTCRf~=ZO9Eg@8ucDfQrWmCg z4gs^AQavcDz7Z2FfP78Bd;lnW@RJC_2tn{JRK-Ta^UG;AapM<6c#$8RlICsO6kXWJ ze-N=AmX?>-e#zMOjObzQd6td(BJjtEHz`)BX#X3q^#~R=fdW;~jIU-?wLDNs>0WS{ zxA&`6ProTJsuw5DF6fuPr+s6(UH6GoP>j2RB*-Rz+}C0NeR%X9;TSDA_Ltk(+?dK_ zU8V>70Qv}Ih4tORn-$SGh3&sNw9my2^J0A}pvZgU9Gx!%Dxm2%AV65*8mppUueTy< zdygJ&7L}&Wt0iD=4wS+pWR8_4>+8)C-xJ0GsV`V3O6z<4Jjf6GUemB0kG{q4(x`DK zkkExlWTS<$Hbp%2Z;?JLIQUS7C85$b4NP=DfYD3OKFW3oXPs=D2!C1^FFqIQ>uJ9$ z(;Ui_cnj@1$@^)?d3y6J-R6aoMq;-GXgW5QkmoX!R7h0SQoiiK22>RAB7T2R^a2O% z2DNL$*lzwEo*a47_B00u%t(of(3|9Ehh_t{7Dfw$Ra{GU5QWo*mvqdzViz(B1YtLU zS(?Lwrp5ZfFZUtkmK7`U$5nLFXmr5dCrH92Zi)L2+nnPqe8yR)+c?o@Q8T%y?h$*J^n zQ5#~oU-@s|K+7wue`)~KF)eiC7Bzm1jTSJ4Gprq$#ArHfwot{lSMqgSGT{a&O%>U( z@=huR4o%%1_8NAUbDAUB>0^qx6QWorjZGnzlUaKhJIO-X@&pytp-0B&Iq~u!m^;e zFl?dhEw9p}C=7JnaP`m1~w4HP{<9y1{emFM~6qAtfxlgh5A{DRkA#E&uFuINPYMHTrp zyWjy2M{X$OGrr^uSmu@05xIvBNTg8{2elp;rhTdhTe~=3uQka`hqyBRugS9~1YztK zu?paQ!M|VUYQX81Q!bazJ9uea=}Jl5Oft$psOib$dq+!^AeI7ArPacv`vL2lDhxEe zdZvYF=ZefS<|VTeS2JFFdHEB@46}1&Q{;GZ$`+x+yp`06Mbk3Dw zw0}|=`*14%see*4&+?#UCl3ILzoX^B9>(m6xk0_i^nl`ZKM3sbgU9-fk^G}7=h2vY z?4svZ391+T<&IS8VX_rDu>*_8AJRFWkv%(`+4|ZKhBYaGHex*Du{d}#_uswh(zrdz z_s)fN`d82-ld^@R0PeWL1pLk{-3Lwqu-$4H|E`>tEF;pJoWED zk-uo)^aPQ7v*{(?u`fGoVoznZR-7VokYgBh9a>JWS)y%D1Gv7&9GsajvWtBM(D;1E zqCQek`TX+mzVyUs^=3)d$3C~BhFryH+AM9MKbA|dj&ssn@A0J9?_|lV2am3phd*7WS^?UqU*Z!O!dP`Ah|YK;Gm99SrH* zjX&urHOt&Bb|tYdPpBO<+%U^cC^WOc;htvZ!m}%jKfa~aZTvUlhg*#rgI&1EFi}$TEmA8F&)rUX^ z`dM|1Z*`W3Zmm9eQUo;~b?VfU^vfzm^e<{IGZ^!)P8U1xl+25dnONw!)C76bbot@x z<;Ch{>=+b$IlUSb9Uq1CkhVx;gD0#B}&Rc!35Z(Q7B z<$cUBFrjnOQkCYs`S)=9S%!)QbB4^Q%lj<7D>2VfF#5gFP$+=<=Uqkc#1O&nJ#IZ) zrnBYeS4fz@Z_H{>`RIWy+_;pJ+9kfCm{W~!yGNz(bB;`FHr?9Si{y~DHtwzO+n328%651DR0-}5nIkC-}Pwc}ZM?e$h9 z`aA8%=cB=I=$oY?U(Fp-s%`nYM(4t(<`cU8&2SS z(DDJAF$D7YU0>BWQP(YRqi@tYB23*np2ZGnx;MPOLDX~_KZ!;i_lnGsuVLj|4+ha_ zUh{>M?Q*{eu%Zk9lM!9K(d-X~Y$jimhA?X~E)bd}8Og6)&v0e_%4SP}kb!+j;K}qo z)N@C{D@D)DGi#Pp^88NtE>g$fV3oFu^RSeY*#8;P0g<>F2k|;`Jaeb;G}L0&MDes_ z?BlSmA*~Ekff=!l)rzSdzzl)W@PF1JUg(vfm&@3tCI8P3QAV|^5=v1iqTea{vvOyrP2zXsn?aa z5UJH4sM4XWqRQ#Sc~lr(u)hVU@iXdpfHjxzE?NIGrvE6QvS^l*M}oDiByX5eWM+H( zitwXzpAdALlX%LGqr~SZSv6Q(XVs%zZ8WVM%JEu)5zncwDu%4;!1j^xCN`DaWwASW zW(Xv|x923?**lKLvvWbmV)rd_`z5i#scUZ-nJ42p$>FJcth40&I;hwYe#k9?$ zHZ9Ukh2oprf36=Td{Z{hwEHFjfy@gePt-U&JTY)ha&k?i7xdPy(3hGLzx+B(e`6bc zsQ$iuq>^==;*RNA_f)*3t}cbm$J_Z6UDvIZ#hgdUn<{2g}NrfBXG24k&tCx|;%bURy4fT8N5NGK}0C*LkbP zdsyidJJnF!e^KZEy4Z{*FIQ_%iSg72?Any1KKf6ZmFKPn={wt$z05d~*@ea%qWn5gQqi zq_%GC`dif!bsYU?|GipIqj&L+(3OJ0y;TRkvb=dcOPz!8=VVGBcR9I4eEp}x&BJqV zuTh`=EKrV<)4Z0B9`VNcoBFy%<94rUMR0drr)2o3XPNk3#m#J*w7G{;1(#R$DK(C# ze+8!YO!6IN;eLK|`Ht#S+}HXQJjHvMH+X3&K~s@126^P-^zmWKg_q9I_a@DL-{QkG zr#?w_w-yK0^`?iYkf4jRUG~P6W9y#x%8(U083xRkaOU|VG->PUv$rYm+FFR=7|%a< z>#n9BJz1KwNYB=6y_;9xanXQa1V=e=4JlHr#O8`d-I0&YVdwn$Hy}EB{YUs+r#>DF zi;QmJZXVZ_JS76&pM|^MA3kb)-kqnINb8<#;5v9s`qZiA8zefJ~I1 z8xDay!`fcAyR$gJY9aNu;o}9qYFAnG!}Fe2?I5a`vfsh(X^hi-^2bqM*&y{#XcxA& zJJkNcxhp8w*}ricJbW8s&3O=dQ9X3^Zq(%)vHodKDaX2?;%S13dVOl`Y%%KE(3a!r z8?E638O1p58bsKlzGB>M*_8YZw??Hnx&BP4@$kD3^8J}a%@e=h(EFY9{pNC)w&<&o zHneBoB6~v7V*|8&OH%l4PTE@Re#|}0xrM^No{QM4Cfi3DI;2nRWu2Bz*gZyKj(yO^ z-ADU(o}cvCv600-9HkYIp|Yd#_|z#@yOZW!T3Xy^+xi!~r7Mn1Wrh_*B(@ghzlikm zd5qOaqW;ylu9n?v2g^?v-+JDDA=V>1pfc9k5ns*VRnD^dMw@oO(63ew#Q4T#HhQK+IEI&xBq}F0yHi8n7>Knkeiv1YV~=XM|5WpT zy#R|{*M7*wyjfwxzWNrn3ZEui%%f8t@^tQ=ZJ;e{3bs<-G0yF#bO_Xce6&54?p)$I zzrHWZ)gvomrgS#c<8ZdAiBp9N@wIlxGG2o-dcDvVQYFOYCM+# zy;cUPk#u8rB~Js@q!_kj!nQ`2Y`tc`$ed3ld0>V?okrAt1npXK)dFc?MmK*XCw$TF zRR$-Y$?Q>Qg}`)=>5_%`fY!5v)(P^U2H!5Kbqhohdj$p zo=EEQ>wQ?JqezzS9R1xaW%;hw33O&p$NfOFQ`wiIJC$ibYx>zI+XPC|wWP}TnJ*U~_oV_j)mzwiC|I~7MVx(ww-8f&e zjr^TntQ-0KPKIO6jP=99Z9H^6Do`{Ik#pfI9z*3SGAM&3LLrBWT_KB znGAZxUifVxJ5z{g@PmBJF{Ls!O}5eod&KWiDOaQQ@qm~+YvzhS^_`l2ulY3Z;;p-{ zly~7Gi0@+@!IKVuQyZ>+?qIgICqY}s<4<3lW}o3c&sa|lb#CBh&TtASnA$$kei6P{ zS0E65U27&T`6Lh0-@#TkXe}4{ICwJ`EYa^J9hdtCWqFm)Gu|d|D>M|93iNLT=x{f< zT8w6Lm27>OQ04o?T$GS00lBC6zZaEdM61H6K&7Rrjg^)`NvTAinAr^JUb%AzPIoNhwQ2{ zdYPhS?k@dC@`}}~5no@R#U|T|FS~^=CmJZm9oc=4cJGTc3A9T4ivavLUgl6*?+&{3QS@{ia?vGQ94Bf+`L=yc+9~ zT_!VzBSb6B8Lsb#Ry)uNUw>wP*!PzM-|;8OK4^%)cAZYfb);<{P7W|l+X&pPPqMc> z>`xb5qN^+nZhDYtXxf9|8>maS;MZ)SrEDWok3cYBB1X+q9s{ zFj8MI8?HAS`E25|VKhqTz%xbf*ud+~^NiSAB+napuElSjqVNTW4_EuGif0nb>5zZ< zZS`No9WQLCZ2H!9wQf2FiN%+mv#cb1f%MnT`Ayv#AvCfMVa@EA=EbuI4+H6^I!T-yA6@Fc0b_wz{L_ zRk#(TTj>)E%e<1FxKVoU8Gu#;A)PrJHLsVJW~SeE`a@=BW=6zB2HiFNzl-|Z z3^n{THtfdrp5=1+fq(MGmHA&|Ir3LS#}v3Jq~BbJMYeq2+E;zEef3;H@a8ruSfZuc zn4^8TlK9T~Qf?O?2UyUd?(g^AmB)pI6HE?8omHT9D;4S2^EMn}t8NK8lYdLi_|Rgf z>J#%7r^KlObpvt}KiL{osIXd>SZ+rUB& z2BLLlaLbB2ZasPgAV+_OWNHzS(wjw)95*xj1FvePSTcLm=urUwdEv4|NyzKh<5Ng%*-R3z5C- z3{t6v?8crYw_)r{WbBotl+@V8sK_#7%f5w3Q)7&s7)xatjD76u_Y8AC-+$ow@tK#` zt51bFbIx_H>wUei_jS&c!uMmgDjqCr)C-SOJY|ck{rS_XMh}xJ;FNwNf)G~#>^V~% zU)B%sGY|;-0%kTU_MjUNtdb&LGt#k)6Z`&8trq#`GwAji7b@z7!@=RrgQ<5BrIr>w z*Lw#BG{alqa-NxTPM3#qbE{v85fqYqZcfhlOYxe7?mU~rk!C*|gJc4vPwYVb&p+O% zOTV+V8J61P9!lMrshW+TAd|2F2Y*ctoIcs$6|-F8fZsUe!xeeQ`6GQi#vY#>FZmJn zU)@0YP>{;xtq8VOJ!ULN312|=)cIVYBeSc^ljp28L?N6^d(dM1vi)a%+cS2hG`yy5 zZB1X6eU;ahANrpM;kIXL>V=fZ=+Zd(R!Ky{U6%byf11ZUnsN&o<5(9~kwM$E)Xc)! zgc@21vslyn8@Ib^jG8sxP$pYV9SdmN$ADe<-@qI7&tJsMp-!tQfz(*}pN*3>Cb6#P z=~)jXBl9#sD%`ekUw?kgr(+BE(4I5dS}kh!cMbmTbJN;GYE}?RKew$l2HS1yClrdh zb97XQTn;t-(K!v0=VnETB70mfP2JAK=kk(o;9y*vTAK3^Es1 zO!WvB$f@p__VzR7U7v?<7Jh<6b!P65x*f~}Txj2dM9VE$WW>)Vo)M&ARcR3>s_$(v zx1r6UVAzQf;IJUU7Z=*wSiOARzrPdw5#6$)W8#>*?I2nlJVjK@J8UKm>=?Gc_Qf@trOz8-(7OqiB%Il zZ!K3*@2Bb$ZYN~A={~`j=6YvL8=h99`{ZJ8Zcmraf`h79g#ppME2aMgc_+LpKqXTuG_RG zwtFxbt4v0C&t6)2Jot)p{H*We{-GODAMYW$PmRsr`eKo(z;UMRMwxCiO?8a-#W6oBa&MEPuQR#;T~ zT#I~gmv=PIt`3z#@ibtUt6-*Wn$WiLhYa>C3Z(Q>+O+tUW+&W3%mLdjAA%|2Ce#njGd3&(kg2gZ+~i)CEy8bID zUoGA|rHfkscojaLxJoK#eG6`~r}dW&E#zmndIqK);uQPqt5)lr`q+$;{X=qMnIg~Z zpVrWlj0UMeEFbJMk&{yF@x9vPtu$9;A8LhnceYTf zZ@`uSJCi71c6H)Op%v+i$NMYH6`P1(q!~|{sZ^UIN5Fu?^34N?k&_8v2<)+=gIC!3SKcU&_`5v(2b0YuDc2)LI_DL&Lf5KdmlhT@#NwCmx-~14(p{^3t&J zzm&s27mvuq;5Xb<A#?37vRl$=Nmcc4`R;~2cnBVbS#@-TuZz%wO0|f5Bq(Q>vo8- zr%SRiB{WlCo8pHP=f-c&d3Jj*8DhrZ92A0sa|>UzLON! z3E)iVuk>~J?Cyg|VYR4X2Y|(#E5NU%i6ql**a^=q;;M(8-a-_KPUGekzbxMjP8<9&oT5^lfgCpWX~PyN)?*HX~c zq2}so+?wXF4u>C0>VdRWJIUtM4TnqAovJx591mCWy`?PNNDSGdYsMST*wV*T{tv=1 zuh3IlPow<-r9aQpEXUcL*|Jz;(_Wo5`=uC^IMqhu-yDmhx2g&)xvT5x1tk&UK z=t_X(W;3-wPs2D7Ph9-lctlM%UA$)Xp=r2;?3JzambP9P6iZ2R50sk~NHQ2SK z(Wcw}p8Kp2Wq>8==P93fP3saOu#5ba>jwGCBDif9$y0x`YHAW1_^#=<5NSF+1O7Iv zU&z7Yb&x?cPY0{Y=6i4@s4ChQ`!aMVEOh7f64`r|lWTEMn(Nrz=es*Q_$vcK840?+ z^a3j{I-#Y!{x&`HlSxCjIWC~PWHy-`|0vDDcd!O`5jjMRlUei}oP=(njE%nG?aaGL5?EBUOYfJpkjt7Rf;{3aLeY9uaN7_@LXF$O9O{AG6~F(5uo&f z);m%&0tAmx3->EEl+HHa8n>~~r1p#X$~m?VZ-BA*_1QJL=SXSVBGmXXfm9w4B$t#= z{-vW1h!vC(g=M7qo#VbM>2dt6xID3)Ko!oXhNXv-DiqN*MBxLXpH3W5itR$B4;H|F z)#GcmIt)OHHZpQsSXkJ1ZQ;{L!FEb2a56R~24_qTW;mEd-+wpyrbQ6$l-CZ&RKXgq z#{YB;La)q$L+90e@G9#B#qBmG-|D%ce7F8`X24y6m&94wrZXP&C#zn0iebQy@A75X zoY8JK7ao7pOgX?ZTWBd7L{>zCoIWk> zHv*~MZ#w7pU7++=rsv0m`U*8b*Pv1S&J?P71VPEq&u@ZxDE=-BZPi`*$Eh%%&AG-i zmiMjm;~UfthG($J>=|ZGOa9w1dArfQ|M3=_ z9d)PIau0Su?laJJf~0C!N`S5N=jDa@W3Z|yG@4i9$Cn=f`ka zki}>Kw^z#O=<(ynBOtF6ufDbc(e2W=4wtgKEOQ1qepdFnm+JO>uUbT&@2)$73`-Wp zB}(%T7;zBKj=a{o1tp{o#THYO$zG%ZxMAB}g=5oGF&5%q5Je_tW@azjrMwVhF#}V_ znN0E=);t=INDB82eRI#@gT?T@P!8P8aw)@sFQa^m2la1NlX43Pwp@?;m7Pkn@+R@l z+tpgWfvm@mFM?A_Id%}>=e+5~7y$eQ1OyI5iEplCEzY8Y+8$fq+{LjFa}l9E9JK!j z*`|a~Nr$86u<(M@*f2j*Jaslqi|iKaO?0|!nRg;bn^3R35?3LlJx znpyqOG4X@gIqm(D0EIUlKPx-_kHQPa62^ZD>;k0+@RC#E&2uL?;}qTxJ13Y3r#^mQ zQi7=p=&qn7liQJRD8M6-D!6T*V79QMagCr!=c9l&=Ja&wQ&`4SxTMKWZuFQ zZP4h48;S0@mV6(Sm}=^t&NjqNfbi-;0uOB-)cv8e8t_$XTtE`T{^sY55<23=@yE?1_yiQWnPM6 zUAnECPVLDz%46z~rN%mBu!~g?-Vo%%4tk3%DD=P$Mo=p=59X@}T+WED?S>h{GV-*E z5o%zobRzY5UlhnKB7M)(2L-Y6UA=jn{&3_w(bT%`O3;jDYIaRDlb`A6JbNj>H0|z$ zRBO>9ILXYR%w-^gNK7>Gk%d<4TA+*uqVg&ar}Dk{D`$8f4`}j68n~x?0%$qy##Rnw z;KR@5DU6(~sU2OZ94fX>eSz8}9wqm@^hxz^s%jd;&GErLx|Eq6bw5u9=QA_U#|$-` z7}P(-z-kB%_TB1_)!~0)y-W#xj?=LjD)4RvM;$dxOA znG`Wp?IiT;qGWypixNpb0327}NVU!_%~J?@N~_AD%D!AMN$g&$BmkFIeG30sA>}pk zJ}~n}f9p4nI-fqauRak7V_20@{(n|g?t>^DP9~#^uOfToA6vGUCYB_7z5_{;O>RC^ zjCDmS=Ab{1rcEuSi*8YRRk?$T6Kj!6fln|Vp|1)&)RQtiNE5^Os7mkYBoG3@Ml`^6 ztsnA5d{H`m-Dt}OM+=leWGnR)aG0lw1f3V&F7SStW5oPkd<(3XNRlPLG&FzAuK9GyvRNO{ocF3QS!ucxyF1pP?WAe8hMd`+9 zd>zF}$?%+cT_~ZgVZu!LT6d6%ti!Qt0_PYsfp;SuAGF(0`o9;3qg)0m*0VPn9Kc$Q7T5Mod8855davln(oMbz|F(gt##(Ko=;}Hi$49I4ENwB=Ml#$jy6wr!rj;JtKZ=oboeT0NJ&ws)}j%x3dVJE(PWWuKdOdM%-~IftP5R zzcQ)fY1jz-Obza1Lwx$COVj^6N!iySeCA$9an_+jyKg&oKy`N@H@?c3 znI3PIqa2SZApmG)wiTyuh{^<^d6+poZ)} zNvA*P{&kV(h0tEWAzt1?4#7{7Jz4n&VvEJIO-?LUe8`z3IVfZKC*Gzz^G>>xl;DQ3 znXa6Isp3bs1c%1HxC`%VP6PfueK^bqy9!^Ogeq4Lo4Is3NOw`a0MEH-FkUfP&{%j- z8S%%LXq!B1>t}gu6as2DlaCZYQq?$Qhvqwgn_G1JJesOV5mLGL-|n;Avkb_A(JpU{ z(2+@~9!C!HV23Qf66pBDq&wSA7k2YcC-i;~c8+}p9n`8BsOj?OpsOZrt3+WoMw5qc zDOsyw7Unj9SyWdAjAoqWJnNnNFBiFjK)n`!Hpk@q1#MjH7R?@^ATNKl4_QKXZge*% zo-zD=)-V#^q96F+_yXJSJ!rl?Kf1e05;{D6YuAHNciZ(T{Rp19t7LtS%K0bn06qt< zYPgT3yJ)6N@vHBcIv}mr`(Kq=aT1sgY`)1rwtdGN9YmIq!F#oVbV;Jirko|7TPv}L z_l~Gp)>z>dxRJ1Nh>VZoeOX%p&bq^}fjLqNdI#o}?*t=3P}P0A8{Z^OQp|6el$_`N z-78U)qNj#w@h`ztSCY7Gohz!{g%(1nLq|)i;Dk+3!#ro6vobq$W_DtJ-d#5mPXug*@8GPI(pEIjsAsDk<{=*g zc5@uDT-=?L%h!sOV6w4r!r@y|iUDIo*NXhm%K98NiI1wx*1XGTy2?7mr!(B6Ys^#d zV6!q}*CPcGzQC6+Z7g!ICO|RrLG5+rVvn=diKsl%Gup-)CDqH#?L7du(b1>2fCB|) z0$%Muf4=COUQT4w?iv~n{y=Gnt(8JrEs5=JAqhMqv*di|ieonz>V-8>;RPK8wL>D@URWA=`8ZJ`eZoO6^F+ z{N@OgZhobd?qx(9^Ak)>{oHF;+Nsiw3GXVfC+)((aZ3&CBHRnh;j4qOwP^e zpd(66024(Y2T2=F6;Tm0$m={++*a5wmw0xU;0Mv<(h_cP*F5STN)&CN+^qE555QQ( z3b=k;wtbSKsF2%Q^oXwB${dK(KD-!k0Q>YT2!z($mY;y*x*Bl_I1aj)q!!$Sx>z-| zl{c#(aV}%4%*Y}&QB<>pmv``OI#>sA0{X{T!&OpDlQLDiPkYg-6vwr~0 zdIsR_$ASO6%q7|7+#9&HN|dy?oZPcbE6DX$y=!Did#Kcik5&E&hxr^$8H zl&cvbIoaXT6nXN}{GX75X2Y5Spji=$PDf&6W7z=>X$oSPsfkG&xNlHKtK2%{C`z&$vhAHa@Nb(;I>%?KVhp6}tP-+G{| z=;`A1O3gba6eUBmhIj45e7QO`c#}SgbZB#08uK^XH!M9a z-Os4f#uIU)O7va&O>N@AtufhJ`67&3)|khMFd&S5 zMf2{xh{MLg&!CdQ(d~^CX-)lr%~f3h@*m||g$*DDGdHU>k-9fN5!$8LF zSOnO8WPzfXv*;urkER!X>|QFMep@{pVf(3@5vp}qB2xncVTJB)| zx8iyF6XIZII+~TD6Aq;2EIev(i>o-&8vg(zL)Zq*UP3VRLpf#TZh8HKr^nx74;%-64hY*| zC*>)NQ@xUVXr_z0gb`ArvHg9Um%;t3I{BUZD_Um1=Sy4TiZ{ZlS`F>8{{DinA{;F8}!P0wd$3jJHink zKVE79n?n0}CN|O#Wyw?w`Y$dWSu`%{%}H^j?0I9oOMCU}1bKQ!j6;;FjCpK5g#t-N zY$(cm0e?psrQLp~Rsz&&NQK3J`#|BENDTe+0vaRHzVuAu$7PuA>7{@CKMLyQ8VZM5 z?yUE$FXab4sK`Sh%SxW#alR-$`<+^DKd1!3K8%Ox>b7aewmy3 zvcN-wJwFMd++nPR#5#yc$YZc$Lt0JpWnvS;wjVGx);yGc8<;HLfaTbYs5{kRY+}RD zpn?9JFsk>-C zrTp+vpQosLHdax30UB`c1`G!+@(SR0Wm>*^ADpAJ_j=I=I}I9FEQZ7rWiqPpMbiM| zXY}i$)tFa5v8d{RAfSLH~aL${WQ8uhCnw%zjh`tM4Cil`60N7RM!ujv2@C=# zO0{EoZbIX!AB7g~--7#q_yky7z%Zo-u4gC69-UvcVId>ekhTFgp~$&@dCDsW;&59Qh&9P1hJxHMF_F%-?JBas(8TudQ4^4tGq2OTelKCu ztI$Avx6J1(d}N_tr~9U(aL_bf70oINBurOoxL$aQHJc?w_PaExu;OY~fzkqCC4iU! z1E%SIUKnFigq-!bfmn zWm?~~_ldvrsoa)c0y%UCE|4YTb>-VFhV8hS^g|!y?2g9j4fF<0r@b=&{hwrV%;JUA zQAtV1(czw0IJM$j&O>0v>Qw)n!S~u4bfN<8q>{YyHhlxAQZTM>>F(v|{40YLfzxUx zGMAjFrkARyTDo%GT%Ta;_c=rhGXt@%RQwOc9v2QuXfJsH%bPwzju)h8bi;fcVq`09>v}Y%!_JQ}6gP7!a^=kh;>5 z>DI;ynq)Yb%`(5bw&}a!EN*Pn0z*o1nG5fd-w>mSm#6k-#}1*0E{%b=^?H$<$3#m; zlF6N%su=%(-1n1F`C~;UB@ZO4WY;h)nP5Oan1r4v-FGGORdW_2YKa=wMX5QM3H1 z^21Xj(?#{RJi?AqUId%j{cb;5XwzP z%rO8RENC8VB}>a~RgBf(Li`k+t^R}rBq!>CQq44))jdQNv!H|PGRW^)im%^cuN+-k6i%UJ00;u0tVn-IN1$lonlP|-I)%B7P+|__u z0_<~1Vd2N2t(nJ4RFlP0GdRWm4J>ek-LNw8Hr;`NqmVL;Q-gLkWx!!58FSC1cAI&? z2S%J_qyc)@_tF5Js$3Y95UvR~+BPu-oEmm|X2!c&gry#OnMwH2LElX(sAgnjgQ{IvYL#XYk|uLs5zr9Jd;toNGHsH7xQ4=;@5n;~S{e9x#B^_uUNpLVsU zrzz2QYP59OCk3N`4j%+Lz!U(oig|T&y{A?gKPVp7WM9JLlGZVC`YR?iXxrrO-Mfv@ z(XVCSSQ?Zl|5J^#!sw{)sv;mTM5*XRU}HKE0||{t5`0(qf6Dg&DUiCa&_td5N~iqe z#~)K3ur(u!0>PD>Nqq4cNWQCQ?bfht_qqUSNDo}J78NRpSCt9RR4svx0TyZL)&Ub{VB zd9H|vfuhj&8c{isRn_?;C)M62dj^CDZIlJ+R}IAR4U~WOPiYaOn!aJ3yGJ(wrbD@e z4Y*5@G|SGm1|!F*+hjnxIyR8t!&qZVdMoV^QR_sbvm>Adg>U-!uiF~!KqY)E!S3qR z^t2ak5?aIFal0#o=^!fE6Y$e&H~}PTzf!zAHwepTD9fORjqdEYgu|YUN(GJai^F8s z&`pp3-mW^Wgx*C5f!rGTkjxFkp=&@NgEuKR>T`tKU;s?EnFyl*`{P>g0<2 zPU!11ZI-t~y|>wH7vYD)tpIX41Ovp6W}CMoPtc;l@lx|pkj$s0T?YOOxK$1yg02!# zp<@EfE3DAB4tuHdjQv!=%m^_O`z6V5ic@OM3=v}Vt0I3+u8A|h=Q}{?n4kYl_o9ZlPk`PyBA;Obv~;Q9{ed?F4Dtk? zHwaV=gh-D;`w+=*wdP(I+&gTZCd+kf{~kkXWzDuL=7Lz=MtEzthZX*APWO$IE;6hk z1;8S!BhPpMGZOm=KuquLCz!wwm;$}~Xg)w*>EXKCLEKtgo;Fj{l{yvcl2@O_?ma@w)4w0U%QVXowHCyP?1-9g-I@Zrt$c;+xN& z>Y@S#G&?$pM1WTrd&~II{~%|Mdm8NyjjIwr^rZ*<_$KlSS)k-kNSvPT2fHBRg|?fZ zVvB>Rg<^oN*`vIH3Dz#v1PN6=X(|03q}j{Wf$LIDdIRO|HV*jf+Jm)0;#U>50kgq8 zx#|w25b1O2w-faKI!N$DMRw7?^P{4l{Sz{;S)q7I75dU` z@@2vkpVKo*6`=O1N;UPNn;<3mU>Az?;6r9V2Ns(|1T7Y6t~G08PhUI8gg954=EeoC zPYA%;zkYo}uM`w$0bi1uiDhe%BZ@4+16{y{d{qG(%f`-~yE6_MOMG|7^3m?I8$rG- zDg1H0J6LoBLrn@IbyS95>4&9Evy*s!9j&}20~CjCOgEHT7ABz{q)%K$`#vF*MP=Q=!TdMh1qA_R9gk3WA2H+fpUvB^yVOKA&3@YpA4!tLcmSRoq z;gK(g<-mGuZ?DwqtL@o>2>)J*8X{pk%BHRCVrJ5qat>u>mk=mg?#<^sQS8ea1EZwF&vPD5{5H@oCj<>dIgj0TKgsI);X$N#^@T2f=&m-7M%yTd#&<%xCZ_* zEYzo7Cni-B?Nv2kpr^i9xq^sDioM#K1=Nhjx_Wztrwg9A944x>@Pk;hNG0Gl@1f~Q z%!HzTUr$AGX{lj+I{k97p9d~q-g*9$NymRRU5Im77}Q!KD1i0?Y6@}7nix+lOaJ-C z;^UO|FaB$Vax(x{7?g4gmyuY67hdVT$P{^P6a6o^#B!)yYcImZ@i4NJaz4cJy>`$O z;LW2*nTbJE_<3jZC|yjcCPY2HY)KL93gj51pH)e!=p^dFY~bT>4GfZA442zwVOxiB zG?1zR;Q-w-_P-u~NGHMaExrg@gvW;0e>ng5Fy(AoN*qkRnTY0tF>FGH;T0i_dO&cK zx3EMsXnkE6O!8G8#5tr$t1IT-u}Ha#_FkB4)pL+}CyZVM4#8MYLCcE=>4p*?cN|3+98_#2fbiXwE1&ergriao)wd!*Xe!;8Fgm+52H_b zD9Mtr68HEO(UQ&I_%4SM%)~b8bjp|kLbTNeL3aZ!2K|R|GplR$1jRpcJJk*YqdybPTq_Um}F42qI|~^G%Po^zZ|jFBip}(hm|zTz+nD z7jep|CG$j#nPx0m9bQ6S_!Dp9P__hailtV~*65@8VsH7Jw|*|*Kb2{zAu6fSEP|AI zC(S^tkSzvX@H--*H!l4h;i)1S4X#1ifDfE)Xs-U)5>}|wf=v%c@xT8RACCGfr5O#2 z%V#Nzc_#j6Riagrv@TAV4cgftOvgS`2b%HG0Icr=mU&}~Ex~4>iuT!4qHsR#rZ;&2 z^-jTIQ^)C%uE5RJJ2jhFdcGDLMV2kXI^5701&qXil;(rx6@1c-(`ssFqP&P~F2K>b z^q03iR2r+Ov~rjU-Z~xH_)bQ3aT=N7Epyd+p{<%MB6Cbo#T+SlA1*9UtsV#+s zZh8}9gX_11$~v$Kcs_XCz=Wr=9*iMDg17~`pszR6rvdWY1BmPRbCY@lQzxQg;lFyX z6-7o&Z12BuP05mS3-&nXpr1>U3H8!)5YHAZb!RFEyzcu>e`9j;r{;hHfs`gl4fH}# zV5EH6_L*QjZDWLR5C10@cq7C5{+6o?X|h*o$0#{roR=3XGZyUT(USO*r+u zr@Nb>FofLKmGa*Mlm%G}!L5ed3nchVQ_eal*9MBd9v;TB$fYqi$iYPm>p6_v&#&AU zE~}Z84aqGgIG6Za;mf9ka$8Ot{5@QMH?Ml!%0_!7_Y0lYqux>VpxQy(VAAi)wJuk^=d6s?!*a7X1;^AbC!+YXRh&j)HzZ@b-jxYX`>}bAJ zBNBZl?GK0oLEPQcrJ!?V1BMl&a5P^d)eYn3U*Vu#u4(xqv-_z>Y|^o6x)42Y0-qWHD|Z?BNoLrq;g^8FrGEd274!SBPopNggnuwzTK zqeo7yVU?draYfqy-t`JV4kBTmu=n{Bo&s`Z=Obu+RMq+ZRQ0QktyQLa#kv-poE!Kj z+4@OJ#Pnn4`Msqu7i9pEg7e;dxt2>>A_sWG=&e_@H_eHV7Bx@OnzM=6!mxjxK>ryA zoP!iSc`3E~<0AV&M)7DCyJC|l^{!bk;*w+$pWQWKzPxdoX>D1s%q~{~jDNmM;*h4_CEI z(0v9)jM=B|p$`dOMn(jIRSHaeh0lH~1U(CC4s>;j-AVbHzgqIY_1Oo{o*w@Yy0^{h z{M{)NKX*$I5RPz97kK+INpjU>fC*w}$#Q22XlH_UR(6z>l`Xa!#RJ>8q-N*m=O;nx z+qbT=L)ob&4-Rhch&l4^Es)!_|K5LZ5g?Mm3k1gF?jLnWFEBm^<6nsohSf3t{(s-F zHxd8)^RT_+J#9??nP&|4VUzVZGqLk3Qe?G2l_+!miwu{zL!w&Hp>I Zu}|ZQoWfG0`tC}=)U Date: Tue, 6 Feb 2024 10:49:33 -0500 Subject: [PATCH 262/540] docs(aws): Update aws.md (#9726) Co-authored-by: david-leifker <114954101+david-leifker@users.noreply.github.com> --- docs/deploy/aws.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/deploy/aws.md b/docs/deploy/aws.md index 6598b93c25e9a1..d060eddd9acc88 100644 --- a/docs/deploy/aws.md +++ b/docs/deploy/aws.md @@ -53,6 +53,8 @@ ip-192-168-64-56.us-west-2.compute.internal Ready 3h v1.18.9-ek ip-192-168-8-126.us-west-2.compute.internal Ready 3h v1.18.9-eks-d1db3c ``` +Once your cluster is running, make sure to install the EBS CSI driver, Core DNS, and VPC CNI plugin for Kubernetes. [add-ons](https://docs.aws.amazon.com/eks/latest/userguide/eks-add-ons.html) + ## Setup DataHub using Helm Once the kubernetes cluster has been set up, you can deploy DataHub and it’s prerequisites using helm. Please follow the From 82035437be4fe32d5ca946531c2abaf095d997e4 Mon Sep 17 00:00:00 2001 From: siladitya <68184387+siladitya2@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:53:51 +0100 Subject: [PATCH 263/540] fix(search): Fix missing mapping of DOUBLE searchable type (#9651) Co-authored-by: si-chakraborty --- .../main/java/com/linkedin/metadata/search/utils/ESUtils.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java index 4d74bfb66b8dbc..86d411e9b5b928 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/utils/ESUtils.java @@ -261,6 +261,8 @@ public static String getElasticTypeForFieldType(SearchableAnnotation.FieldType f return DATE_FIELD_TYPE; } else if (fieldType == SearchableAnnotation.FieldType.OBJECT) { return OBJECT_FIELD_TYPE; + } else if (fieldType == SearchableAnnotation.FieldType.DOUBLE) { + return DOUBLE_FIELD_TYPE; } else { log.warn("FieldType {} has no mappings implemented", fieldType); return null; From 11f7804b1ea2f47495c336e8d91d7dc380ccd228 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:09:54 -0600 Subject: [PATCH 264/540] chore(lint): apply spotless (#9789) --- .../java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 8d9b9a5ad82c84..e3eef0688c2692 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -1643,7 +1643,8 @@ private void configureResolvedAuditStampResolvers(final RuntimeWiring.Builder bu typeWiring.dataFetcher( "actor", new LoadableTypeResolver<>( - corpUserType, (env) -> ((ResolvedAuditStamp) env.getSource()).getActor().getUrn()))); + corpUserType, + (env) -> ((ResolvedAuditStamp) env.getSource()).getActor().getUrn()))); } /** From e1c8ac732098b1b05c381bb33c9c4c2043510d34 Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Wed, 7 Feb 2024 06:58:09 +0530 Subject: [PATCH 265/540] docs(observe): add upsert assertion monitor graphql examples (#9766) --- .../observe/column-assertions.md | 85 +++++++++++++++++++ .../observe/custom-sql-assertions.md | 59 +++++++++++++ .../observe/freshness-assertions.md | 53 ++++++++++++ .../observe/volume-assertions.md | 73 ++++++++++++++++ 4 files changed, 270 insertions(+) diff --git a/docs/managed-datahub/observe/column-assertions.md b/docs/managed-datahub/observe/column-assertions.md index 8ef32e73b4b729..a5dcb02c87badd 100644 --- a/docs/managed-datahub/observe/column-assertions.md +++ b/docs/managed-datahub/observe/column-assertions.md @@ -340,6 +340,91 @@ This entity defines _when_ to run the check (Using CRON format - every 8th hour) After creating the monitor, the new assertion will start to be evaluated every 8 hours in your selected timezone. +Alternatively you can use `upsertDatasetFieldAssertionMonitor` graphql endpoint for creating a Column Assertion and corresponding Monitor for a dataset. + +```json +mutation upsertDatasetFieldAssertionMonitor { + upsertDatasetFieldAssertionMonitor( + input: { + entityUrn: "" + type: FIELD_VALUES, + fieldValuesAssertion: { + field: { + path: "", + type: "NUMBER", + nativeType: "NUMBER(38,0)" + }, + operator: GREATER_THAN, + parameters: { + value: { + type: NUMBER, + value: "10" + } + }, + failThreshold: { + type: COUNT, + value: 0 + }, + excludeNulls: true + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */8 * * *" + } + evaluationParameters: { + sourceType: ALL_ROWS_QUERY + } + mode: ACTIVE + } + ){ + urn + } +} +``` + +You can use same endpoint with assertion urn input to update an existing Column Assertion and corresponding Monitor. + +```json +mutation upsertDatasetFieldAssertionMonitor { + upsertDatasetFieldAssertionMonitor( + assertionUrn: "" + input: { + entityUrn: "" + type: FIELD_VALUES, + fieldValuesAssertion: { + field: { + path: "", + type: "NUMBER", + nativeType: "NUMBER(38,0)" + }, + operator: GREATER_THAN_OR_EQUAL_TO, + parameters: { + value: { + type: NUMBER, + value: "10" + } + }, + failThreshold: { + type: COUNT, + value: 0 + }, + excludeNulls: true + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */8 * * *" + } + evaluationParameters: { + sourceType: ALL_ROWS_QUERY + } + mode: ACTIVE + } + ){ + urn + } +} +``` + You can delete assertions along with their monitors using GraphQL mutations: `deleteAssertion` and `deleteMonitor`. ### Tips diff --git a/docs/managed-datahub/observe/custom-sql-assertions.md b/docs/managed-datahub/observe/custom-sql-assertions.md index 581b5426881347..434788ff45a6fd 100644 --- a/docs/managed-datahub/observe/custom-sql-assertions.md +++ b/docs/managed-datahub/observe/custom-sql-assertions.md @@ -297,6 +297,65 @@ This entity defines _when_ to run the check (Using CRON format - every 8th hour) After creating the monitor, the new assertion will start to be evaluated every 8 hours in your selected timezone. +Alternatively you can use `upsertDatasetSqlAssertionMonitor` graphql endpoint for creating a Custom SQL Assertion and corresponding Monitor for a dataset. + +```json +mutation upsertDatasetSqlAssertionMonitor { + upsertDatasetSqlAssertionMonitor( + input: { + entityUrn: "" + type: METRIC, + description: "", + statement: "", + operator: GREATER_THAN_OR_EQUAL_TO, + parameters: { + value: { + value: "100", + type: NUMBER + } + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */8 * * *" + } + mode: ACTIVE + } + ) { + urn + } +} +``` + +You can use same endpoint with assertion urn input to update an existing Custom SQL Assertion and corresponding Monitor. + +```json +mutation upsertDatasetSqlAssertionMonitor { + upsertDatasetSqlAssertionMonitor( + assertionUrn: "" + input: { + entityUrn: "" + type: METRIC, + description: "", + statement: "", + operator: GREATER_THAN_OR_EQUAL_TO, + parameters: { + value: { + value: "100", + type: NUMBER + } + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */6 * * *" + } + mode: ACTIVE + } + ) { + urn + } +} +``` + You can delete assertions along with their monitors using GraphQL mutations: `deleteAssertion` and `deleteMonitor`. ### Tips diff --git a/docs/managed-datahub/observe/freshness-assertions.md b/docs/managed-datahub/observe/freshness-assertions.md index 9704f475b15873..50cae1567f93a6 100644 --- a/docs/managed-datahub/observe/freshness-assertions.md +++ b/docs/managed-datahub/observe/freshness-assertions.md @@ -346,6 +346,59 @@ This entity defines _when_ to run the check (Using CRON format - every 8th hour) After creating the monitor, the new assertion will start to be evaluated every 8 hours in your selected timezone. +Alternatively you can use `upsertDatasetFreshnessAssertionMonitor` graphql endpoint for creating a Freshness Assertion and corresponding Monitor for a dataset. + +```json +mutation upsertDatasetFreshnessAssertionMonitor { + upsertDatasetFreshnessAssertionMonitor( + input: { + entityUrn: "", + schedule: { + type: FIXED_INTERVAL, + fixedInterval: { unit: HOUR, multiple: 8 } + } + evaluationSchedule: { + timezone: "America/Los_Angeles", + cron: "0 */8 * * *" + } + evaluationParameters: { + sourceType: INFORMATION_SCHEMA + } + mode: ACTIVE + } + ){ + urn + } +} +``` + +You can use same endpoint with assertion urn input to update an existing Freshness Assertion and corresponding Monitor. + +```json +mutation upsertDatasetFreshnessAssertionMonitor { + upsertDatasetFreshnessAssertionMonitor( + assertionUrn: "" + input: { + entityUrn: "", + schedule: { + type: FIXED_INTERVAL, + fixedInterval: { unit: HOUR, multiple: 6 } + } + evaluationSchedule: { + timezone: "America/Los_Angeles", + cron: "0 */6 * * *" + } + evaluationParameters: { + sourceType: INFORMATION_SCHEMA + } + mode: ACTIVE + } + ){ + urn + } +} +``` + You can delete assertions along with their monitors using GraphQL mutations: `deleteAssertion` and `deleteMonitor`. ### Reporting Operations via API diff --git a/docs/managed-datahub/observe/volume-assertions.md b/docs/managed-datahub/observe/volume-assertions.md index 67971d0c20037a..f925920a0c248f 100644 --- a/docs/managed-datahub/observe/volume-assertions.md +++ b/docs/managed-datahub/observe/volume-assertions.md @@ -337,6 +337,79 @@ This entity defines _when_ to run the check (Using CRON format - every 8th hour) After creating the monitor, the new assertion will start to be evaluated every 8 hours in your selected timezone. +Alternatively you can use `upsertDatasetVolumeAssertionMonitor` graphql endpoint for creating a Volume Assertion and corresponding Monitor. + +```json +mutation upsertDatasetVolumeAssertionMonitor { + upsertDatasetVolumeAssertionMonitor( + input: { + entityUrn: "" + type: ROW_COUNT_TOTAL + rowCountTotal: { + operator: BETWEEN + parameters: { + minValue: { + value: "10" + type: NUMBER + } + maxValue: { + value: "20" + type: NUMBER + } + } + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */8 * * *" + } + evaluationParameters: { + sourceType: INFORMATION_SCHEMA + } + mode: ACTIVE + } + ) { + urn + } +} +``` + +You can use same endpoint with assertion urn input to update an existing Volume Assertion and corresponding Monitor. + +```json +mutation upsertDatasetVolumeAssertionMonitor { + upsertDatasetVolumeAssertionMonitor( + assertionUrn: "" + input: { + entityUrn: "" + type: ROW_COUNT_TOTAL + rowCountTotal: { + operator: BETWEEN + parameters: { + minValue: { + value: "10" + type: NUMBER + } + maxValue: { + value: "20" + type: NUMBER + } + } + } + evaluationSchedule: { + timezone: "America/Los_Angeles" + cron: "0 */6 * * *" + } + evaluationParameters: { + sourceType: INFORMATION_SCHEMA + } + mode: ACTIVE + } + ) { + urn + } +} +``` + You can delete assertions along with their monitors using GraphQL mutations: `deleteAssertion` and `deleteMonitor`. ### Tips From e6d706687ab5af6b888176bb775c338ff953bef7 Mon Sep 17 00:00:00 2001 From: Hyejin Yoon <0327jane@gmail.com> Date: Wed, 7 Feb 2024 14:12:00 +0900 Subject: [PATCH 266/540] fix(docs): fix the subtitle on the slack page (#9781) --- docs-website/src/pages/slack/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-website/src/pages/slack/index.js b/docs-website/src/pages/slack/index.js index c85a1eefe55450..59892241911125 100644 --- a/docs-website/src/pages/slack/index.js +++ b/docs-website/src/pages/slack/index.js @@ -36,7 +36,7 @@ function SlackSurvey() {

    Join the DataHub Slack Community!

    -
    We will send the link to join our Slack community to your email.
    +
    We'd love to find out a little more about you!
    From ea0ae8cb54ed307c8d15e15d445f2a36f4812b7c Mon Sep 17 00:00:00 2001 From: Peng G Date: Wed, 7 Feb 2024 00:28:30 -0800 Subject: [PATCH 267/540] feat(airflow) Override datajob external_url (#9681) Co-authored-by: Peng Gao --- docs/lineage/airflow.md | 2 ++ .../src/datahub_airflow_plugin/_config.py | 12 +++++++++ .../client/airflow_generator.py | 25 ++++++++++++++++--- .../datahub_listener.py | 4 +++ .../datahub_plugin_v22.py | 4 +++ .../lineage/_lineage_core.py | 1 + .../datahub_airflow_plugin/lineage/datahub.py | 6 ++++- 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/lineage/airflow.md b/docs/lineage/airflow.md index da3a36bc87be53..94eb69a2ed8273 100644 --- a/docs/lineage/airflow.md +++ b/docs/lineage/airflow.md @@ -135,6 +135,8 @@ conn_id = datahub_rest_default # or datahub_kafka_default | capture_ownership_info | true | If true, the owners field of the DAG will be capture as a DataHub corpuser. | | capture_tags_info | true | If true, the tags field of the DAG will be captured as DataHub tags. | | capture_executions | true | If true, we'll capture task runs in DataHub in addition to DAG definitions. | +| datajob_url_link | taskinstance | If taskinstance, the datajob url will be taskinstance link on airflow. It can also be grid. + | | graceful_exceptions | true | If set to true, most runtime errors in the lineage backend will be suppressed and will not cause the overall task to fail. Note that configuration issues will still throw exceptions. | #### Validate that the plugin is working diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_config.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_config.py index 67843da2ba995d..48d462b85702af 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_config.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/_config.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import TYPE_CHECKING, Optional import datahub.emitter.mce_builder as builder @@ -8,6 +9,11 @@ from datahub_airflow_plugin.hooks.datahub import DatahubGenericHook +class DatajobUrl(Enum): + GRID = "grid" + TASKINSTANCE = "taskinstance" + + class DatahubLineageConfig(ConfigModel): # This class is shared between the lineage backend and the Airflow plugin. # The defaults listed here are only relevant for the lineage backend. @@ -41,6 +47,8 @@ class DatahubLineageConfig(ConfigModel): # The Airflow plugin behaves as if it were set to True. graceful_exceptions: bool = True + datajob_url_link: DatajobUrl = DatajobUrl.TASKINSTANCE + def make_emitter_hook(self) -> "DatahubGenericHook": # This is necessary to avoid issues with circular imports. from datahub_airflow_plugin.hooks.datahub import DatahubGenericHook @@ -65,6 +73,9 @@ def get_lineage_config() -> DatahubLineageConfig: disable_openlineage_plugin = conf.get( "datahub", "disable_openlineage_plugin", fallback=True ) + datajob_url_link = conf.get( + "datahub", "datajob_url_link", fallback=DatajobUrl.TASKINSTANCE.value + ) return DatahubLineageConfig( enabled=enabled, @@ -77,4 +88,5 @@ def get_lineage_config() -> DatahubLineageConfig: log_level=log_level, debug_emitter=debug_emitter, disable_openlineage_plugin=disable_openlineage_plugin, + datajob_url_link=datajob_url_link, ) diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/client/airflow_generator.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/client/airflow_generator.py index e1d53be7bae6b9..2fa15f13e848b5 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/client/airflow_generator.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/client/airflow_generator.py @@ -13,6 +13,7 @@ from datahub.utilities.urns.data_job_urn import DataJobUrn from datahub_airflow_plugin._airflow_compat import AIRFLOW_PATCHED +from datahub_airflow_plugin._config import DatahubLineageConfig, DatajobUrl assert AIRFLOW_PATCHED @@ -208,6 +209,7 @@ def generate_datajob( set_dependencies: bool = True, capture_owner: bool = True, capture_tags: bool = True, + config: Optional[DatahubLineageConfig] = None, ) -> DataJob: """ @@ -217,6 +219,7 @@ def generate_datajob( :param set_dependencies: bool - whether to extract dependencies from airflow task :param capture_owner: bool - whether to extract owner from airflow task :param capture_tags: bool - whether to set tags automatically from airflow task + :param config: DatahubLineageConfig :return: DataJob - returns the generated DataJob object """ dataflow_urn = DataFlowUrn.create_from_ids( @@ -267,7 +270,11 @@ def generate_datajob( datajob.properties = job_property_bag base_url = conf.get("webserver", "base_url") - datajob.url = f"{base_url}/taskinstance/list/?flt1_dag_id_equals={datajob.flow_urn.get_flow_id()}&_flt_3_task_id={task.task_id}" + + if config and config.datajob_url_link == DatajobUrl.GRID: + datajob.url = f"{base_url}/dags/{datajob.flow_urn.get_flow_id()}/grid?task_id={task.task_id}" + else: + datajob.url = f"{base_url}/taskinstance/list/?flt1_dag_id_equals={datajob.flow_urn.get_flow_id()}&_flt_3_task_id={task.task_id}" if capture_owner and dag.owner: datajob.owners.add(dag.owner) @@ -290,9 +297,12 @@ def create_datajob_instance( task: "Operator", dag: "DAG", data_job: Optional[DataJob] = None, + config: Optional[DatahubLineageConfig] = None, ) -> DataProcessInstance: if data_job is None: - data_job = AirflowGenerator.generate_datajob(cluster, task=task, dag=dag) + data_job = AirflowGenerator.generate_datajob( + cluster, task=task, dag=dag, config=config + ) dpi = DataProcessInstance.from_datajob( datajob=data_job, id=task.task_id, clone_inlets=True, clone_outlets=True ) @@ -407,9 +417,12 @@ def run_datajob( datajob: Optional[DataJob] = None, attempt: Optional[int] = None, emit_templates: bool = True, + config: Optional[DatahubLineageConfig] = None, ) -> DataProcessInstance: if datajob is None: - datajob = AirflowGenerator.generate_datajob(cluster, ti.task, dag) + datajob = AirflowGenerator.generate_datajob( + cluster, ti.task, dag, config=config + ) assert dag_run.run_id dpi = DataProcessInstance.from_datajob( @@ -480,6 +493,7 @@ def complete_datajob( end_timestamp_millis: Optional[int] = None, result: Optional[InstanceRunResult] = None, datajob: Optional[DataJob] = None, + config: Optional[DatahubLineageConfig] = None, ) -> DataProcessInstance: """ @@ -491,10 +505,13 @@ def complete_datajob( :param end_timestamp_millis: Optional[int] :param result: Optional[str] One of the result from datahub.metadata.schema_class.RunResultTypeClass :param datajob: Optional[DataJob] + :param config: Optional[DatahubLineageConfig] :return: DataProcessInstance """ if datajob is None: - datajob = AirflowGenerator.generate_datajob(cluster, ti.task, dag) + datajob = AirflowGenerator.generate_datajob( + cluster, ti.task, dag, config=config + ) if end_timestamp_millis is None: if ti.end_date: diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py index a7f588a166dde9..475f3791bc0c80 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_listener.py @@ -376,6 +376,7 @@ def on_task_instance_running( dag=dag, capture_tags=self.config.capture_tags_info, capture_owner=self.config.capture_ownership_info, + config=self.config, ) # TODO: Make use of get_task_location to extract github urls. @@ -397,6 +398,7 @@ def on_task_instance_running( dag_run=dagrun, datajob=datajob, emit_templates=False, + config=self.config, ) logger.debug(f"Emitted DataHub DataProcess Instance start: {dpi}") @@ -419,6 +421,7 @@ def on_task_instance_finish( dag=dag, capture_tags=self.config.capture_tags_info, capture_owner=self.config.capture_ownership_info, + config=self.config, ) # Add lineage info. @@ -436,6 +439,7 @@ def on_task_instance_finish( dag_run=dagrun, datajob=datajob, result=status, + config=self.config, ) logger.debug( f"Emitted DataHub DataProcess Instance with status {status}: {dpi}" diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_plugin_v22.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_plugin_v22.py index 51a4151bc8207e..7b8d719712d107 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_plugin_v22.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/datahub_plugin_v22.py @@ -120,6 +120,7 @@ def datahub_task_status_callback(context, status): dag=dag, capture_tags=config.capture_tags_info, capture_owner=config.capture_ownership_info, + config=config, ) datajob.inlets.extend( entities_to_dataset_urn_list([let.urn for let in task_inlets]) @@ -143,6 +144,7 @@ def datahub_task_status_callback(context, status): dag_run=context["dag_run"], datajob=datajob, start_timestamp_millis=int(ti.start_date.timestamp() * 1000), + config=config, ) task.log.info(f"Emitted Start Datahub Dataprocess Instance: {dpi}") @@ -185,6 +187,7 @@ def datahub_pre_execution(context): dag=dag, capture_tags=config.capture_tags_info, capture_owner=config.capture_ownership_info, + config=config, ) datajob.inlets.extend( entities_to_dataset_urn_list([let.urn for let in task_inlets]) @@ -208,6 +211,7 @@ def datahub_pre_execution(context): dag_run=context["dag_run"], datajob=datajob, start_timestamp_millis=int(ti.start_date.timestamp() * 1000), + config=config, ) task.log.info(f"Emitting Datahub Dataprocess Instance: {dpi}") diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/_lineage_core.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/_lineage_core.py index 75fc79443e49e0..daf45e1cd83f83 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/_lineage_core.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/_lineage_core.py @@ -51,6 +51,7 @@ def send_lineage_to_datahub( dag=dag, capture_tags=config.capture_tags_info, capture_owner=config.capture_ownership_info, + config=config, ) datajob.inlets.extend(entities_to_dataset_urn_list([let.urn for let in inlets])) datajob.outlets.extend(entities_to_dataset_urn_list([let.urn for let in outlets])) diff --git a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/datahub.py b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/datahub.py index 3ebe7831d08f9a..6f81812ea766e1 100644 --- a/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/datahub.py +++ b/metadata-ingestion-modules/airflow-plugin/src/datahub_airflow_plugin/lineage/datahub.py @@ -71,7 +71,11 @@ def send_lineage( try: context = context or {} # ensure not None to satisfy mypy send_lineage_to_datahub( - config, operator, operator.inlets, operator.outlets, context + config, + operator, + operator.inlets, + operator.outlets, + context, ) except Exception as e: operator.log.error(e) From 20b9050732f6a78225c70dc20eaade82e07859a9 Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Wed, 7 Feb 2024 10:37:55 -0600 Subject: [PATCH 268/540] fix(browsev2): align browse and aggregate queries (#9790) --- .../resolvers/chart/BrowseV2Resolver.java | 5 ++- .../src/main/resources/search.graphql | 5 +++ .../browse/BrowseV2ResolverTest.java | 4 +- .../metadata/client/JavaEntityClient.java | 12 ++++-- .../elasticsearch/ElasticSearchService.java | 10 +++-- .../elasticsearch/query/ESBrowseDAO.java | 38 +++++++++++++------ .../query/request/SearchQueryBuilder.java | 33 ++++++++++++---- .../search/custom/QueryConfiguration.java | 7 ++++ .../linkedin/entity/client/EntityClient.java | 6 ++- .../entity/client/RestliEntityClient.java | 6 ++- .../metadata/search/EntitySearchService.java | 8 +++- 11 files changed, 99 insertions(+), 35 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java index d9ce2472c86345..7bcde0ea9bdc12 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/chart/BrowseV2Resolver.java @@ -18,6 +18,7 @@ import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.browse.BrowseResultV2; +import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.ViewService; @@ -52,6 +53,7 @@ public CompletableFuture get(DataFetchingEnvironment environmen final int start = input.getStart() != null ? input.getStart() : DEFAULT_START; final int count = input.getCount() != null ? input.getCount() : DEFAULT_COUNT; final String query = input.getQuery() != null ? input.getQuery() : "*"; + final SearchFlags searchFlags = mapInputFlags(input.getSearchFlags()); // escape forward slash since it is a reserved character in Elasticsearch final String sanitizedQuery = ResolverUtils.escapeForwardSlash(query); @@ -83,7 +85,8 @@ public CompletableFuture get(DataFetchingEnvironment environmen sanitizedQuery, start, count, - context.getAuthentication()); + context.getAuthentication(), + searchFlags); return mapBrowseResults(browseResults); } catch (Exception e) { throw new RuntimeException("Failed to execute browse V2", e); diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql index 2b921601058fbc..a906362cee1854 100644 --- a/datahub-graphql-core/src/main/resources/search.graphql +++ b/datahub-graphql-core/src/main/resources/search.graphql @@ -1230,6 +1230,11 @@ input BrowseV2Input { The search query string """ query: String + + """ + Flags controlling search options + """ + searchFlags: SearchFlags } """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java index c565e771a04755..41797fac636f1d 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/browse/BrowseV2ResolverTest.java @@ -21,6 +21,7 @@ import com.linkedin.metadata.browse.BrowseResultGroupV2Array; import com.linkedin.metadata.browse.BrowseResultMetadata; import com.linkedin.metadata.browse.BrowseResultV2; +import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; import com.linkedin.metadata.query.filter.Criterion; @@ -262,7 +263,8 @@ private static EntityClient initMockEntityClient( Mockito.eq(query), Mockito.eq(start), Mockito.eq(limit), - Mockito.any(Authentication.class))) + Mockito.any(Authentication.class), + Mockito.nullable(SearchFlags.class))) .thenReturn(result); return client; } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java index 9a3bc9e319d2bd..15de029340a3c7 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/client/JavaEntityClient.java @@ -229,9 +229,11 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) { + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) { // TODO: cache browseV2 results - return _entitySearchService.browseV2(entityName, path, filter, input, start, count); + return _entitySearchService.browseV2( + entityName, path, filter, input, start, count, searchFlags); } /** @@ -253,9 +255,11 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) { + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) { // TODO: cache browseV2 results - return _entitySearchService.browseV2(entityNames, path, filter, input, start, count); + return _entitySearchService.browseV2( + entityNames, path, filter, input, start, count, searchFlags); } @SneakyThrows diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java index 7cba2e0ecc8cb4..c20c16e0ea7d1e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/ElasticSearchService.java @@ -215,8 +215,9 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count) { - return esBrowseDAO.browseV2(entityName, path, filter, input, start, count); + int count, + @Nullable SearchFlags searchFlags) { + return esBrowseDAO.browseV2(entityName, path, filter, input, start, count, searchFlags); } @Nonnull @@ -227,8 +228,9 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count) { - return esBrowseDAO.browseV2(entityNames, path, filter, input, start, count); + int count, + @Nullable SearchFlags searchFlags) { + return esBrowseDAO.browseV2(entityNames, path, filter, input, start, count, searchFlags); } @Nonnull diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java index 0a9a9fbbad0867..b8085885200892 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/ESBrowseDAO.java @@ -21,6 +21,7 @@ import com.linkedin.metadata.models.EntitySpec; import com.linkedin.metadata.models.annotation.SearchableAnnotation; import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.search.elasticsearch.query.request.SearchRequestHandler; import com.linkedin.metadata.search.utils.ESUtils; @@ -34,6 +35,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -399,14 +401,15 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count) { + int count, + @Nullable SearchFlags searchFlags) { try { final SearchResponse groupsResponse; try (Timer.Context ignored = MetricUtils.timer(this.getClass(), "esGroupSearch").time()) { final String finalInput = input.isEmpty() ? "*" : input; groupsResponse = client.search( - constructGroupsSearchRequestV2(entityName, path, filter, finalInput), + constructGroupsSearchRequestV2(entityName, path, filter, finalInput, searchFlags), RequestOptions.DEFAULT); } @@ -435,7 +438,8 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count) { + int count, + @Nullable SearchFlags searchFlags) { try { final SearchResponse groupsResponse; @@ -444,7 +448,7 @@ public BrowseResultV2 browseV2( groupsResponse = client.search( constructGroupsSearchRequestBrowseAcrossEntities( - entities, path, filter, finalInput), + entities, path, filter, finalInput, searchFlags), RequestOptions.DEFAULT); } @@ -472,7 +476,8 @@ private SearchRequest constructGroupsSearchRequestV2( @Nonnull String entityName, @Nonnull String path, @Nullable Filter filter, - @Nonnull String input) { + @Nonnull String input, + @Nullable SearchFlags searchFlags) { final String indexName = indexConvention.getIndexName(entityRegistry.getEntitySpec(entityName)); final SearchRequest searchRequest = new SearchRequest(indexName); final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); @@ -482,7 +487,8 @@ private SearchRequest constructGroupsSearchRequestV2( entityName, path, SearchUtil.transformFilterForEntities(filter, indexConvention), - input)); + input, + searchFlags)); searchSourceBuilder.aggregation(buildAggregationsV2(path)); searchRequest.source(searchSourceBuilder); return searchRequest; @@ -493,7 +499,8 @@ private SearchRequest constructGroupsSearchRequestBrowseAcrossEntities( @Nonnull List entities, @Nonnull String path, @Nullable Filter filter, - @Nonnull String input) { + @Nonnull String input, + @Nullable SearchFlags searchFlags) { List entitySpecs = entities.stream().map(entityRegistry::getEntitySpec).collect(Collectors.toList()); @@ -509,7 +516,8 @@ private SearchRequest constructGroupsSearchRequestBrowseAcrossEntities( entitySpecs, path, SearchUtil.transformFilterForEntities(filter, indexConvention), - input)); + input, + searchFlags)); searchSourceBuilder.aggregation(buildAggregationsV2(path)); searchRequest.source(searchSourceBuilder); return searchRequest; @@ -537,7 +545,10 @@ private QueryBuilder buildQueryStringV2( @Nonnull String entityName, @Nonnull String path, @Nullable Filter filter, - @Nonnull String input) { + @Nonnull String input, + @Nullable SearchFlags searchFlags) { + SearchFlags finalSearchFlags = + Optional.ofNullable(searchFlags).orElse(new SearchFlags().setFulltext(true)); final int browseDepthVal = getPathDepthV2(path); final BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); @@ -545,7 +556,7 @@ private QueryBuilder buildQueryStringV2( EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); QueryBuilder query = SearchRequestHandler.getBuilder(entitySpec, searchConfiguration, customSearchConfiguration) - .getQuery(input, false); + .getQuery(input, Boolean.TRUE.equals(finalSearchFlags.isFulltext())); queryBuilder.must(query); filterSoftDeletedByDefault(filter, queryBuilder); @@ -567,14 +578,17 @@ private QueryBuilder buildQueryStringBrowseAcrossEntities( @Nonnull List entitySpecs, @Nonnull String path, @Nullable Filter filter, - @Nonnull String input) { + @Nonnull String input, + @Nullable SearchFlags searchFlags) { + SearchFlags finalSearchFlags = + Optional.ofNullable(searchFlags).orElse(new SearchFlags().setFulltext(true)); final int browseDepthVal = getPathDepthV2(path); final BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); QueryBuilder query = SearchRequestHandler.getBuilder(entitySpecs, searchConfiguration, customSearchConfiguration) - .getQuery(input, false); + .getQuery(input, Boolean.TRUE.equals(finalSearchFlags.isFulltext())); queryBuilder.must(query); if (!path.isEmpty()) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchQueryBuilder.java b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchQueryBuilder.java index 7ddccb0d56724c..4c704f81b4c130 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchQueryBuilder.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/search/elasticsearch/query/request/SearchQueryBuilder.java @@ -135,14 +135,10 @@ private QueryBuilder buildInternalQuery( query.startsWith(STRUCTURED_QUERY_PREFIX) ? query.substring(STRUCTURED_QUERY_PREFIX.length()) : query; - - QueryStringQueryBuilder queryBuilder = QueryBuilders.queryStringQuery(withoutQueryPrefix); - queryBuilder.defaultOperator(Operator.AND); - getStandardFields(entitySpecs) - .forEach(entitySpec -> queryBuilder.field(entitySpec.fieldName(), entitySpec.boost())); - finalQuery.should(queryBuilder); + getStructuredQuery(customQueryConfig, entitySpecs, withoutQueryPrefix) + .ifPresent(finalQuery::should); if (exactMatchConfiguration.isEnableStructured()) { - getPrefixAndExactMatchQuery(null, entitySpecs, withoutQueryPrefix) + getPrefixAndExactMatchQuery(customQueryConfig, entitySpecs, withoutQueryPrefix) .ifPresent(finalQuery::should); } } @@ -415,6 +411,29 @@ private Optional getPrefixAndExactMatchQuery( return finalQuery.should().size() > 0 ? Optional.of(finalQuery) : Optional.empty(); } + private Optional getStructuredQuery( + @Nullable QueryConfiguration customQueryConfig, + List entitySpecs, + String sanitizedQuery) { + Optional result = Optional.empty(); + + final boolean executeStructuredQuery; + if (customQueryConfig != null) { + executeStructuredQuery = customQueryConfig.isStructuredQuery(); + } else { + executeStructuredQuery = !(isQuoted(sanitizedQuery) && exactMatchConfiguration.isExclusive()); + } + + if (executeStructuredQuery) { + QueryStringQueryBuilder queryBuilder = QueryBuilders.queryStringQuery(sanitizedQuery); + queryBuilder.defaultOperator(Operator.AND); + getStandardFields(entitySpecs) + .forEach(entitySpec -> queryBuilder.field(entitySpec.fieldName(), entitySpec.boost())); + result = Optional.of(queryBuilder); + } + return result; + } + private FunctionScoreQueryBuilder buildScoreFunctions( @Nullable QueryConfiguration customQueryConfig, @Nonnull List entitySpecs, diff --git a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/custom/QueryConfiguration.java b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/custom/QueryConfiguration.java index 901bf803d2bca8..e3a9d076dbef2f 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/custom/QueryConfiguration.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/metadata/config/search/custom/QueryConfiguration.java @@ -19,6 +19,13 @@ public class QueryConfiguration { private String queryRegex; @Builder.Default private boolean simpleQuery = true; + + /** + * Used to determine if standard structured query logic should be applied when relevant, i.e. + * fullText flag is false. Will not be added in cases where simpleQuery would be the standard. + */ + @Builder.Default private boolean structuredQuery = true; + @Builder.Default private boolean exactMatchQuery = true; @Builder.Default private boolean prefixMatchQuery = true; private BoolQueryConfiguration boolQuery; diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java index b1b24ac97f0b84..676b80c8bea32f 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/EntityClient.java @@ -154,7 +154,8 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) throws RemoteInvocationException; /** @@ -176,7 +177,8 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) throws RemoteInvocationException; @Deprecated diff --git a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java index 3108345bd39376..653ef046ffc021 100644 --- a/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java +++ b/metadata-service/restli-client/src/main/java/com/linkedin/entity/client/RestliEntityClient.java @@ -378,7 +378,8 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) { + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) { throw new NotImplementedException("BrowseV2 is not implemented in Restli yet"); } @@ -391,7 +392,8 @@ public BrowseResultV2 browseV2( @Nonnull String input, int start, int count, - @Nonnull Authentication authentication) + @Nonnull Authentication authentication, + @Nullable SearchFlags searchFlags) throws RemoteInvocationException { throw new NotImplementedException("BrowseV2 is not implemented in Restli yet"); } diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java index 2fec88ad221fd1..0d1c031db136e4 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/search/EntitySearchService.java @@ -197,6 +197,7 @@ BrowseResult browse( * @param input search query * @param start start offset of first group * @param count max number of results requested + * @param searchFlags configuration options for search */ @Nonnull public BrowseResultV2 browseV2( @@ -205,7 +206,8 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count); + int count, + @Nullable SearchFlags searchFlags); /** * Gets browse snapshot of a given path @@ -216,6 +218,7 @@ public BrowseResultV2 browseV2( * @param input search query * @param start start offset of first group * @param count max number of results requested + * @param searchFlags configuration options for search */ @Nonnull public BrowseResultV2 browseV2( @@ -224,7 +227,8 @@ public BrowseResultV2 browseV2( @Nullable Filter filter, @Nonnull String input, int start, - int count); + int count, + @Nullable SearchFlags searchFlags); /** * Gets a list of paths for a given urn. From 63322225d96c5e19e11b78cb14eba13c30642027 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Wed, 7 Feb 2024 14:41:49 -0600 Subject: [PATCH 269/540] misc: datahub-upgrade improvements, aspect key & default aspects fixes (#9796) --- .../upgrade/UpgradeCliApplication.java | 10 ++- .../config/BackfillBrowsePathsV2Config.java | 10 ++- .../ReindexDataJobViaNodesCLLConfig.java | 8 +- .../upgrade/config/SystemUpdateCondition.java | 14 ++++ .../upgrade/config/SystemUpdateConfig.java | 23 ++++++ .../entity/steps/BackfillBrowsePathsV2.java | 16 +++- .../steps/BackfillBrowsePathsV2Step.java | 66 +++++++++++----- .../system/via/ReindexDataJobViaNodesCLL.java | 9 ++- .../via/ReindexDataJobViaNodesCLLStep.java | 28 ++++--- .../DatahubUpgradeNoSchemaRegistryTest.java | 24 +++++- ...pgradeCliApplicationTestConfiguration.java | 17 ++++- .../com/linkedin/metadata/EventUtils.java | 2 +- .../metadata/entity/EntityServiceImpl.java | 39 +++++++--- .../metadata/entity/EntityServiceTest.java | 48 +++++++++++- .../src/main/resources/application.yml | 8 ++ .../factory/entity/EntityServiceFactory.java | 19 +---- .../DUHESchemaRegistryFactory.java | 40 ---------- .../InternalSchemaRegistryFactory.java | 12 --- .../SchemaRegistryServiceFactory.java | 20 +++++ .../SystemUpdateSchemaRegistryFactory.java | 66 ++++++++++++++++ .../linkedin/metadata/boot/BootstrapStep.java | 21 +---- .../boot/kafka/MockDUHESerializer.java | 57 -------------- ...java => MockSystemUpdateDeserializer.java} | 49 ++++++------ .../kafka/MockSystemUpdateSerializer.java | 76 +++++++++++++++++++ .../resources/entity/AspectResourceTest.java | 2 +- 25 files changed, 451 insertions(+), 233 deletions(-) create mode 100644 datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateCondition.java delete mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/DUHESchemaRegistryFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SchemaRegistryServiceFactory.java create mode 100644 metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SystemUpdateSchemaRegistryFactory.java delete mode 100644 metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHESerializer.java rename metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/{MockDUHEDeserializer.java => MockSystemUpdateDeserializer.java} (57%) create mode 100644 metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateSerializer.java diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java index ff8bd542fbdff8..50847da07be73b 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/UpgradeCliApplication.java @@ -2,6 +2,10 @@ import com.linkedin.gms.factory.auth.AuthorizerChainFactory; import com.linkedin.gms.factory.auth.DataHubAuthorizerFactory; +import com.linkedin.gms.factory.graphql.GraphQLEngineFactory; +import com.linkedin.gms.factory.kafka.KafkaEventConsumerFactory; +import com.linkedin.gms.factory.kafka.SimpleKafkaConsumerFactory; +import com.linkedin.gms.factory.kafka.schemaregistry.InternalSchemaRegistryFactory; import com.linkedin.gms.factory.telemetry.ScheduledAnalyticsFactory; import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -24,7 +28,11 @@ classes = { ScheduledAnalyticsFactory.class, AuthorizerChainFactory.class, - DataHubAuthorizerFactory.class + DataHubAuthorizerFactory.class, + SimpleKafkaConsumerFactory.class, + KafkaEventConsumerFactory.class, + InternalSchemaRegistryFactory.class, + GraphQLEngineFactory.class }) }) public class UpgradeCliApplication { diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java index 406963c58fd71a..2b2f4648f76e73 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/BackfillBrowsePathsV2Config.java @@ -3,6 +3,7 @@ import com.linkedin.datahub.upgrade.system.entity.steps.BackfillBrowsePathsV2; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.search.SearchService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,7 +12,12 @@ public class BackfillBrowsePathsV2Config { @Bean public BackfillBrowsePathsV2 backfillBrowsePathsV2( - EntityService entityService, SearchService searchService) { - return new BackfillBrowsePathsV2(entityService, searchService); + EntityService entityService, + SearchService searchService, + @Value("${systemUpdate.browsePathsV2.enabled}") final boolean enabled, + @Value("${systemUpdate.browsePathsV2.reprocess.enabled}") final boolean reprocessEnabled, + @Value("${systemUpdate.browsePathsV2.batchSize}") final Integer batchSize) { + return new BackfillBrowsePathsV2( + entityService, searchService, enabled, reprocessEnabled, batchSize); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java index 06311e1853874d..83dad80944f5f3 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/ReindexDataJobViaNodesCLLConfig.java @@ -2,6 +2,7 @@ import com.linkedin.datahub.upgrade.system.via.ReindexDataJobViaNodesCLL; import com.linkedin.metadata.entity.EntityService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -9,7 +10,10 @@ public class ReindexDataJobViaNodesCLLConfig { @Bean - public ReindexDataJobViaNodesCLL _reindexDataJobViaNodesCLL(EntityService entityService) { - return new ReindexDataJobViaNodesCLL(entityService); + public ReindexDataJobViaNodesCLL _reindexDataJobViaNodesCLL( + EntityService entityService, + @Value("${systemUpdate.dataJobNodeCLL.enabled}") final boolean enabled, + @Value("${systemUpdate.dataJobNodeCLL.batchSize}") final Integer batchSize) { + return new ReindexDataJobViaNodesCLL(entityService, enabled, batchSize); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateCondition.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateCondition.java new file mode 100644 index 00000000000000..ea432dfa9f7df5 --- /dev/null +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateCondition.java @@ -0,0 +1,14 @@ +package com.linkedin.datahub.upgrade.config; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class SystemUpdateCondition implements Condition { + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return context.getBeanFactory().getBean(ApplicationArguments.class).getNonOptionArgs().stream() + .anyMatch("SystemUpdate"::equals); + } +} diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java index 177d4b531ba86d..cde3a29248fb57 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/config/SystemUpdateConfig.java @@ -8,6 +8,7 @@ import com.linkedin.gms.factory.common.TopicConventionFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.gms.factory.kafka.DataHubKafkaProducerFactory; +import com.linkedin.gms.factory.kafka.schemaregistry.InternalSchemaRegistryFactory; import com.linkedin.gms.factory.kafka.schemaregistry.SchemaRegistryConfig; import com.linkedin.metadata.config.kafka.KafkaConfiguration; import com.linkedin.metadata.dao.producer.KafkaEventProducer; @@ -21,9 +22,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; @Slf4j @Configuration @@ -74,4 +78,23 @@ protected KafkaEventProducer duheKafkaEventProducer( duheSchemaRegistryConfig, kafkaConfiguration, properties)); return new KafkaEventProducer(producer, topicConvention, kafkaHealthChecker); } + + /** + * The ReindexDataJobViaNodesCLLConfig step requires publishing to MCL. Overriding the default + * producer with this special producer which doesn't require an active registry. + * + *

    Use when INTERNAL registry and is SYSTEM_UPDATE + * + *

    This forces this producer into the EntityService + */ + @Primary + @Bean(name = "kafkaEventProducer") + @Conditional(SystemUpdateCondition.class) + @ConditionalOnProperty( + name = "kafka.schemaRegistry.type", + havingValue = InternalSchemaRegistryFactory.TYPE) + protected KafkaEventProducer kafkaEventProducer( + @Qualifier("duheKafkaEventProducer") KafkaEventProducer kafkaEventProducer) { + return kafkaEventProducer; + } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java index 4b9fc5bba0204c..9b023e1e239a2d 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2.java @@ -11,8 +11,20 @@ public class BackfillBrowsePathsV2 implements Upgrade { private final List _steps; - public BackfillBrowsePathsV2(EntityService entityService, SearchService searchService) { - _steps = ImmutableList.of(new BackfillBrowsePathsV2Step(entityService, searchService)); + public BackfillBrowsePathsV2( + EntityService entityService, + SearchService searchService, + boolean enabled, + boolean reprocessEnabled, + Integer batchSize) { + if (enabled) { + _steps = + ImmutableList.of( + new BackfillBrowsePathsV2Step( + entityService, searchService, reprocessEnabled, batchSize)); + } else { + _steps = ImmutableList.of(); + } } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java index 601ce4d25493c1..2d64e0052ae82a 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/entity/steps/BackfillBrowsePathsV2Step.java @@ -16,6 +16,7 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.aspect.utils.DefaultAspectsUtil; +import com.linkedin.metadata.boot.BootstrapStep; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.query.SearchFlags; import com.linkedin.metadata.query.filter.Condition; @@ -37,9 +38,8 @@ @Slf4j public class BackfillBrowsePathsV2Step implements UpgradeStep { - public static final String BACKFILL_BROWSE_PATHS_V2 = "BACKFILL_BROWSE_PATHS_V2"; - public static final String REPROCESS_DEFAULT_BROWSE_PATHS_V2 = - "REPROCESS_DEFAULT_BROWSE_PATHS_V2"; + private static final String UPGRADE_ID = "BackfillBrowsePathsV2Step"; + private static final Urn UPGRADE_ID_URN = BootstrapStep.getUpgradeUrn(UPGRADE_ID); public static final String DEFAULT_BROWSE_PATH_V2 = "␟Default"; private static final Set ENTITY_TYPES_TO_MIGRATE = @@ -53,14 +53,22 @@ public class BackfillBrowsePathsV2Step implements UpgradeStep { Constants.ML_MODEL_GROUP_ENTITY_NAME, Constants.ML_FEATURE_TABLE_ENTITY_NAME, Constants.ML_FEATURE_ENTITY_NAME); - private static final Integer BATCH_SIZE = 5000; - private final EntityService _entityService; - private final SearchService _searchService; - - public BackfillBrowsePathsV2Step(EntityService entityService, SearchService searchService) { - _searchService = searchService; - _entityService = entityService; + private final EntityService entityService; + private final SearchService searchService; + + private final boolean reprocessEnabled; + private final Integer batchSize; + + public BackfillBrowsePathsV2Step( + EntityService entityService, + SearchService searchService, + boolean reprocessEnabled, + Integer batchSize) { + this.searchService = searchService; + this.entityService = entityService; + this.reprocessEnabled = reprocessEnabled; + this.batchSize = batchSize; } @Override @@ -78,11 +86,14 @@ public Function executable() { log.info( String.format( "Upgrading batch %s-%s of browse paths for entity type %s", - migratedCount, migratedCount + BATCH_SIZE, entityType)); + migratedCount, migratedCount + batchSize, entityType)); scrollId = backfillBrowsePathsV2(entityType, auditStamp, scrollId); - migratedCount += BATCH_SIZE; + migratedCount += batchSize; } while (scrollId != null); } + + BootstrapStep.setUpgradeResult(UPGRADE_ID_URN, entityService); + return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.SUCCEEDED); }; } @@ -91,27 +102,27 @@ private String backfillBrowsePathsV2(String entityType, AuditStamp auditStamp, S final Filter filter; - if (System.getenv().containsKey(REPROCESS_DEFAULT_BROWSE_PATHS_V2) - && Boolean.parseBoolean(System.getenv(REPROCESS_DEFAULT_BROWSE_PATHS_V2))) { + if (reprocessEnabled) { filter = backfillDefaultBrowsePathsV2Filter(); } else { filter = backfillBrowsePathsV2Filter(); } final ScrollResult scrollResult = - _searchService.scrollAcrossEntities( + searchService.scrollAcrossEntities( ImmutableList.of(entityType), "*", filter, null, scrollId, null, - BATCH_SIZE, + batchSize, new SearchFlags() .setFulltext(true) .setSkipCache(true) .setSkipHighlighting(true) .setSkipAggregates(true)); + if (scrollResult.getNumEntities() == 0 || scrollResult.getEntities().size() == 0) { return null; } @@ -183,7 +194,7 @@ private Filter backfillDefaultBrowsePathsV2Filter() { private void ingestBrowsePathsV2(Urn urn, AuditStamp auditStamp) throws Exception { BrowsePathsV2 browsePathsV2 = - DefaultAspectsUtil.buildDefaultBrowsePathV2(urn, true, _entityService); + DefaultAspectsUtil.buildDefaultBrowsePathV2(urn, true, entityService); log.debug(String.format("Adding browse path v2 for urn %s with value %s", urn, browsePathsV2)); MetadataChangeProposal proposal = new MetadataChangeProposal(); proposal.setEntityUrn(urn); @@ -193,12 +204,12 @@ private void ingestBrowsePathsV2(Urn urn, AuditStamp auditStamp) throws Exceptio proposal.setSystemMetadata( new SystemMetadata().setRunId(DEFAULT_RUN_ID).setLastObserved(System.currentTimeMillis())); proposal.setAspect(GenericRecordUtils.serializeAspect(browsePathsV2)); - _entityService.ingestProposal(proposal, auditStamp, true); + entityService.ingestProposal(proposal, auditStamp, true); } @Override public String id() { - return "BackfillBrowsePathsV2Step"; + return UPGRADE_ID; } /** @@ -211,7 +222,22 @@ public boolean isOptional() { } @Override + /** + * Returns whether the upgrade should be skipped. Uses previous run history or the environment + * variables REPROCESS_DEFAULT_BROWSE_PATHS_V2 & BACKFILL_BROWSE_PATHS_V2 to determine whether to + * skip. + */ public boolean skip(UpgradeContext context) { - return !Boolean.parseBoolean(System.getenv(BACKFILL_BROWSE_PATHS_V2)); + boolean envEnabled = Boolean.parseBoolean(System.getenv("BACKFILL_BROWSE_PATHS_V2")); + + if (reprocessEnabled && envEnabled) { + return false; + } + + boolean previouslyRun = entityService.exists(UPGRADE_ID_URN, true); + if (previouslyRun) { + log.info("{} was already run. Skipping.", id()); + } + return (previouslyRun || !envEnabled); } } diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java index 41179a50c4b541..59975693322d10 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLL.java @@ -18,8 +18,13 @@ public class ReindexDataJobViaNodesCLL implements Upgrade { private final List _steps; - public ReindexDataJobViaNodesCLL(EntityService entityService) { - _steps = ImmutableList.of(new ReindexDataJobViaNodesCLLStep(entityService)); + public ReindexDataJobViaNodesCLL( + EntityService entityService, boolean enabled, Integer batchSize) { + if (enabled) { + _steps = ImmutableList.of(new ReindexDataJobViaNodesCLLStep(entityService, batchSize)); + } else { + _steps = ImmutableList.of(); + } } @Override diff --git a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java index 70afbc3d205b25..56166caf5b57ea 100644 --- a/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java +++ b/datahub-upgrade/src/main/java/com/linkedin/datahub/upgrade/system/via/ReindexDataJobViaNodesCLLStep.java @@ -11,7 +11,6 @@ import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesResult; -import java.net.URISyntaxException; import java.util.function.Function; import lombok.extern.slf4j.Slf4j; @@ -21,12 +20,12 @@ public class ReindexDataJobViaNodesCLLStep implements UpgradeStep { private static final String UPGRADE_ID = "via-node-cll-reindex-datajob"; private static final Urn UPGRADE_ID_URN = BootstrapStep.getUpgradeUrn(UPGRADE_ID); - private static final Integer BATCH_SIZE = 5000; + private final EntityService entityService; + private final Integer batchSize; - private final EntityService _entityService; - - public ReindexDataJobViaNodesCLLStep(EntityService entityService) { - _entityService = entityService; + public ReindexDataJobViaNodesCLLStep(EntityService entityService, Integer batchSize) { + this.entityService = entityService; + this.batchSize = batchSize; } @Override @@ -35,17 +34,16 @@ public Function executable() { RestoreIndicesArgs args = new RestoreIndicesArgs() .setAspectName(DATA_JOB_INPUT_OUTPUT_ASPECT_NAME) - .setUrnLike("urn:li:" + DATA_JOB_ENTITY_NAME + ":%"); + .setUrnLike("urn:li:" + DATA_JOB_ENTITY_NAME + ":%") + .setBatchSize(batchSize); RestoreIndicesResult result = - _entityService.restoreIndices(args, x -> context.report().addLine((String) x)); + entityService.restoreIndices(args, x -> context.report().addLine((String) x)); context.report().addLine("Rows migrated: " + result.rowsMigrated); context.report().addLine("Rows ignored: " + result.ignored); - try { - BootstrapStep.setUpgradeResult(UPGRADE_ID_URN, _entityService); - context.report().addLine("State updated: " + UPGRADE_ID_URN); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } + + BootstrapStep.setUpgradeResult(UPGRADE_ID_URN, entityService); + context.report().addLine("State updated: " + UPGRADE_ID_URN); + return new DefaultUpgradeStepResult(id(), UpgradeStepResult.Result.SUCCEEDED); }; } @@ -70,7 +68,7 @@ public boolean isOptional() { * variable SKIP_REINDEX_DATA_JOB_INPUT_OUTPUT to determine whether to skip. */ public boolean skip(UpgradeContext context) { - boolean previouslyRun = _entityService.exists(UPGRADE_ID_URN, true); + boolean previouslyRun = entityService.exists(UPGRADE_ID_URN, true); boolean envFlagRecommendsSkip = Boolean.parseBoolean(System.getenv("SKIP_REINDEX_DATA_JOB_INPUT_OUTPUT")); if (previouslyRun) { diff --git a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/DatahubUpgradeNoSchemaRegistryTest.java b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/DatahubUpgradeNoSchemaRegistryTest.java index 83b8e028727cea..4c9e12c0ed1511 100644 --- a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/DatahubUpgradeNoSchemaRegistryTest.java +++ b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/DatahubUpgradeNoSchemaRegistryTest.java @@ -4,6 +4,8 @@ import static org.testng.AssertJUnit.assertNotNull; import com.linkedin.datahub.upgrade.system.SystemUpdate; +import com.linkedin.metadata.dao.producer.KafkaEventProducer; +import com.linkedin.metadata.entity.EntityServiceImpl; import java.util.List; import java.util.Map; import java.util.Optional; @@ -19,19 +21,37 @@ classes = {UpgradeCliApplication.class, UpgradeCliApplicationTestConfiguration.class}, properties = { "kafka.schemaRegistry.type=INTERNAL", - "DATAHUB_UPGRADE_HISTORY_TOPIC_NAME=test_due_topic" - }) + "DATAHUB_UPGRADE_HISTORY_TOPIC_NAME=test_due_topic", + "METADATA_CHANGE_LOG_VERSIONED_TOPIC_NAME=test_mcl_versioned_topic" + }, + args = {"-u", "SystemUpdate"}) public class DatahubUpgradeNoSchemaRegistryTest extends AbstractTestNGSpringContextTests { @Autowired @Named("systemUpdate") private SystemUpdate systemUpdate; + @Autowired + @Named("kafkaEventProducer") + private KafkaEventProducer kafkaEventProducer; + + @Autowired + @Named("duheKafkaEventProducer") + private KafkaEventProducer duheKafkaEventProducer; + + @Autowired private EntityServiceImpl entityService; + @Test public void testSystemUpdateInit() { assertNotNull(systemUpdate); } + @Test + public void testSystemUpdateKafkaProducerOverride() { + assertEquals(kafkaEventProducer, duheKafkaEventProducer); + assertEquals(entityService.get_producer(), duheKafkaEventProducer); + } + @Test public void testSystemUpdateSend() { UpgradeStepResult.Result result = diff --git a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java index be28b7f739cf58..5c2d6fff0f07c7 100644 --- a/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java +++ b/datahub-upgrade/src/test/java/com/linkedin/datahub/upgrade/UpgradeCliApplicationTestConfiguration.java @@ -1,15 +1,21 @@ package com.linkedin.datahub.upgrade; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import com.linkedin.gms.factory.auth.SystemAuthenticationFactory; -import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.graph.GraphService; import com.linkedin.metadata.models.registry.ConfigEntityRegistry; import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.registry.SchemaRegistryService; import com.linkedin.metadata.search.SearchService; import com.linkedin.metadata.search.elasticsearch.indexbuilder.EntityIndexBuilders; import io.ebean.Database; +import java.util.Optional; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @TestConfiguration @@ -20,8 +26,6 @@ public class UpgradeCliApplicationTestConfiguration { @MockBean private Database ebeanServer; - @MockBean private EntityService _entityService; - @MockBean private SearchService searchService; @MockBean private GraphService graphService; @@ -31,4 +35,11 @@ public class UpgradeCliApplicationTestConfiguration { @MockBean ConfigEntityRegistry configEntityRegistry; @MockBean public EntityIndexBuilders entityIndexBuilders; + + @Bean + public SchemaRegistryService schemaRegistryService() { + SchemaRegistryService mockService = mock(SchemaRegistryService.class); + when(mockService.getSchemaIdForTopic(anyString())).thenReturn(Optional.of(0)); + return mockService; + } } diff --git a/metadata-events/mxe-utils-avro/src/main/java/com/linkedin/metadata/EventUtils.java b/metadata-events/mxe-utils-avro/src/main/java/com/linkedin/metadata/EventUtils.java index 645c2fe210e095..adff32d5d336d0 100644 --- a/metadata-events/mxe-utils-avro/src/main/java/com/linkedin/metadata/EventUtils.java +++ b/metadata-events/mxe-utils-avro/src/main/java/com/linkedin/metadata/EventUtils.java @@ -57,7 +57,7 @@ public class EventUtils { private static final Schema ORIGINAL_MCP_AVRO_SCHEMA = getAvroSchemaFromResource("avro/com/linkedin/mxe/MetadataChangeProposal.avsc"); - private static final Schema ORIGINAL_MCL_AVRO_SCHEMA = + public static final Schema ORIGINAL_MCL_AVRO_SCHEMA = getAvroSchemaFromResource("avro/com/linkedin/mxe/MetadataChangeLog.avsc"); private static final Schema ORIGINAL_FMCL_AVRO_SCHEMA = diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index 7f15e3a7fd8fc9..eec5c6120886dd 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -15,6 +15,7 @@ import com.codahale.metrics.Timer; import com.datahub.util.RecordUtils; import com.datahub.util.exception.ModelConversionException; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterators; @@ -146,7 +147,8 @@ public class EntityServiceImpl implements EntityService { private static final int DEFAULT_MAX_TRANSACTION_RETRY = 3; protected final AspectDao _aspectDao; - private final EventProducer _producer; + + @VisibleForTesting @Getter private final EventProducer _producer; private final EntityRegistry _entityRegistry; private final Map> _entityToValidAspects; private RetentionService _retentionService; @@ -637,10 +639,15 @@ public List ingestAspects( @Override public List ingestAspects( @Nonnull final AspectsBatch aspectsBatch, boolean emitMCL, boolean overwrite) { + Set items = new HashSet<>(aspectsBatch.getItems()); + + // Generate additional items as needed + items.addAll(DefaultAspectsUtil.getAdditionalChanges(aspectsBatch, this, enableBrowseV2)); + AspectsBatch withDefaults = AspectsBatchImpl.builder().items(items).build(); Timer.Context ingestToLocalDBTimer = MetricUtils.timer(this.getClass(), "ingestAspectsToLocalDB").time(); - List ingestResults = ingestAspectsToLocalDB(aspectsBatch, overwrite); + List ingestResults = ingestAspectsToLocalDB(withDefaults, overwrite); List mclResults = emitMCL(ingestResults, emitMCL); ingestToLocalDBTimer.stop(); @@ -964,7 +971,7 @@ public IngestResult ingestProposal( */ @Override public Set ingestProposal(AspectsBatch aspectsBatch, final boolean async) { - Stream timeseriesIngestResults = ingestTimeseriesProposal(aspectsBatch); + Stream timeseriesIngestResults = ingestTimeseriesProposal(aspectsBatch, async); Stream nonTimeseriesIngestResults = async ? ingestProposalAsync(aspectsBatch) : ingestProposalSync(aspectsBatch); @@ -978,7 +985,8 @@ public Set ingestProposal(AspectsBatch aspectsBatch, final boolean * @param aspectsBatch timeseries upserts batch * @return returns ingest proposal result, however was never in the MCP topic */ - private Stream ingestTimeseriesProposal(AspectsBatch aspectsBatch) { + private Stream ingestTimeseriesProposal( + AspectsBatch aspectsBatch, final boolean async) { List unsupported = aspectsBatch.getItems().stream() .filter( @@ -992,6 +1000,20 @@ private Stream ingestTimeseriesProposal(AspectsBatch aspectsBatch) + unsupported.stream().map(BatchItem::getChangeType).collect(Collectors.toSet())); } + if (!async) { + // Create default non-timeseries aspects for timeseries aspects + List timeseriesItems = + aspectsBatch.getItems().stream() + .filter(item -> item.getAspectSpec().isTimeseries()) + .collect(Collectors.toList()); + + List defaultAspects = + DefaultAspectsUtil.getAdditionalChanges( + AspectsBatchImpl.builder().items(timeseriesItems).build(), this, enableBrowseV2); + ingestProposalSync(AspectsBatchImpl.builder().items(defaultAspects).build()); + } + + // Emit timeseries MCLs List, Boolean>>>> timeseriesResults = aspectsBatch.getItems().stream() .filter(item -> item.getAspectSpec().isTimeseries()) @@ -1080,17 +1102,10 @@ private Stream ingestProposalAsync(AspectsBatch aspectsBatch) { } private Stream ingestProposalSync(AspectsBatch aspectsBatch) { - Set items = new HashSet<>(aspectsBatch.getItems()); - - // Generate additional items as needed - items.addAll(DefaultAspectsUtil.getAdditionalChanges(aspectsBatch, this, enableBrowseV2)); - - AspectsBatch withDefaults = AspectsBatchImpl.builder().items(items).build(); - AspectsBatchImpl nonTimeseries = AspectsBatchImpl.builder() .items( - withDefaults.getItems().stream() + aspectsBatch.getItems().stream() .filter(item -> !item.getAspectSpec().isTimeseries()) .collect(Collectors.toList())) .build(); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java index ea4e97d264bca3..384b54c7a1c8d3 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java @@ -479,7 +479,7 @@ public void testIngestAspectsGetLatestAspects() throws Exception { assertTrue(DataTemplateUtil.areEqual(writeAspect1, latestAspects.get(aspectName1))); assertTrue(DataTemplateUtil.areEqual(writeAspect2, latestAspects.get(aspectName2))); - verify(_mockProducer, times(2)) + verify(_mockProducer, times(3)) .produceMetadataChangeLog(Mockito.eq(entityUrn), Mockito.any(), Mockito.any()); verifyNoMoreInteractions(_mockProducer); @@ -772,6 +772,12 @@ public void testUpdateGetAspect() throws AssertionError { .produceMetadataChangeLog( Mockito.eq(entityUrn), Mockito.eq(corpUserInfoSpec), Mockito.any()); + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserKey")), + Mockito.any()); + verifyNoMoreInteractions(_mockProducer); } @@ -824,6 +830,13 @@ public void testGetAspectAtVersion() throws AssertionError { readAspect1 = _entityServiceImpl.getVersionedAspect(entityUrn, aspectName, -1); assertFalse(DataTemplateUtil.areEqual(writtenVersionedAspect1, readAspect1)); + // check key aspect + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpuser").getAspectSpec("corpUserKey")), + Mockito.any()); + verifyNoMoreInteractions(_mockProducer); } @@ -1094,13 +1107,22 @@ public void testIngestGetLatestAspect() throws AssertionError { ArgumentCaptor mclCaptor = ArgumentCaptor.forClass(MetadataChangeLog.class); verify(_mockProducer, times(1)) - .produceMetadataChangeLog(Mockito.eq(entityUrn), Mockito.any(), mclCaptor.capture()); + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserInfo")), + mclCaptor.capture()); MetadataChangeLog mcl = mclCaptor.getValue(); assertEquals(mcl.getEntityType(), "corpuser"); assertNull(mcl.getPreviousAspectValue()); assertNull(mcl.getPreviousSystemMetadata()); assertEquals(mcl.getChangeType(), ChangeType.UPSERT); + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserKey")), + Mockito.any()); + verifyNoMoreInteractions(_mockProducer); reset(_mockProducer); @@ -1201,7 +1223,16 @@ public void testIngestGetLatestEnvelopedAspect() throws Exception { EntityUtils.parseSystemMetadata(readAspectDao1.getSystemMetadata()), metadata1)); verify(_mockProducer, times(2)) - .produceMetadataChangeLog(Mockito.eq(entityUrn), Mockito.any(), Mockito.any()); + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserInfo")), + Mockito.any()); + + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserKey")), + Mockito.any()); verifyNoMoreInteractions(_mockProducer); } @@ -1234,9 +1265,18 @@ public void testIngestSameAspect() throws AssertionError { RecordTemplate readAspect1 = _entityServiceImpl.getLatestAspect(entityUrn, aspectName); assertTrue(DataTemplateUtil.areEqual(writeAspect1, readAspect1)); + verify(_mockProducer, times(1)) + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserKey")), + Mockito.any()); + ArgumentCaptor mclCaptor = ArgumentCaptor.forClass(MetadataChangeLog.class); verify(_mockProducer, times(1)) - .produceMetadataChangeLog(Mockito.eq(entityUrn), Mockito.any(), mclCaptor.capture()); + .produceMetadataChangeLog( + Mockito.eq(entityUrn), + Mockito.eq(_testEntityRegistry.getEntitySpec("corpUser").getAspectSpec("corpUserInfo")), + mclCaptor.capture()); MetadataChangeLog mcl = mclCaptor.getValue(); assertEquals(mcl.getEntityType(), "corpuser"); assertNull(mcl.getPreviousAspectValue()); diff --git a/metadata-service/configuration/src/main/resources/application.yml b/metadata-service/configuration/src/main/resources/application.yml index d4c11d4aa53bd0..c2a0d508b57d6b 100644 --- a/metadata-service/configuration/src/main/resources/application.yml +++ b/metadata-service/configuration/src/main/resources/application.yml @@ -314,6 +314,14 @@ systemUpdate: maxBackOffs: ${BOOTSTRAP_SYSTEM_UPDATE_MAX_BACK_OFFS:50} backOffFactor: ${BOOTSTRAP_SYSTEM_UPDATE_BACK_OFF_FACTOR:2} # Multiplicative factor for back off, default values will result in waiting 5min 15s waitForSystemUpdate: ${BOOTSTRAP_SYSTEM_UPDATE_WAIT_FOR_SYSTEM_UPDATE:true} + dataJobNodeCLL: + enabled: ${BOOTSTRAP_SYSTEM_UPDATE_DATA_JOB_NODE_CLL_ENABLED:true} + batchSize: ${BOOTSTRAP_SYSTEM_UPDATE_DATA_JOB_NODE_CLL_BATCH_SIZE:200} + browsePathsV2: + enabled: ${BOOTSTRAP_SYSTEM_UPDATE_BROWSE_PATHS_V2_ENABLED:true} + batchSize: ${BOOTSTRAP_SYSTEM_UPDATE_BROWSE_PATHS_V2_BATCH_SIZE:5000} + reprocess: + enabled: ${REPROCESS_DEFAULT_BROWSE_PATHS_V2:false} structuredProperties: enabled: ${ENABLE_STRUCTURED_PROPERTIES_HOOK:true} # applies structured properties mappings diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java index 871f16d97be33c..2ccdee5fb1dbf5 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/entity/EntityServiceFactory.java @@ -1,20 +1,15 @@ package com.linkedin.gms.factory.entity; import com.linkedin.datahub.graphql.featureflags.FeatureFlags; -import com.linkedin.gms.factory.common.TopicConventionFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.dao.producer.KafkaEventProducer; -import com.linkedin.metadata.dao.producer.KafkaHealthChecker; import com.linkedin.metadata.entity.AspectDao; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityServiceImpl; import com.linkedin.metadata.entity.ebean.batch.MCPUpsertBatchItem; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.service.UpdateIndicesService; -import com.linkedin.mxe.TopicConvention; import javax.annotation.Nonnull; -import org.apache.avro.generic.IndexedRecord; -import org.apache.kafka.clients.producer.Producer; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -28,26 +23,16 @@ public class EntityServiceFactory { private Integer _ebeanMaxTransactionRetry; @Bean(name = "entityService") - @DependsOn({ - "entityAspectDao", - "kafkaEventProducer", - "kafkaHealthChecker", - TopicConventionFactory.TOPIC_CONVENTION_BEAN, - "entityRegistry" - }) + @DependsOn({"entityAspectDao", "kafkaEventProducer", "entityRegistry"}) @Nonnull protected EntityService createInstance( - Producer producer, - TopicConvention convention, - KafkaHealthChecker kafkaHealthChecker, + @Qualifier("kafkaEventProducer") final KafkaEventProducer eventProducer, @Qualifier("entityAspectDao") AspectDao aspectDao, EntityRegistry entityRegistry, ConfigurationProvider configurationProvider, UpdateIndicesService updateIndicesService, @Value("${featureFlags.showBrowseV2}") final boolean enableBrowsePathV2) { - final KafkaEventProducer eventProducer = - new KafkaEventProducer(producer, convention, kafkaHealthChecker); FeatureFlags featureFlags = configurationProvider.getFeatureFlags(); return new EntityServiceImpl( diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/DUHESchemaRegistryFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/DUHESchemaRegistryFactory.java deleted file mode 100644 index 4819984307af93..00000000000000 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/DUHESchemaRegistryFactory.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.linkedin.gms.factory.kafka.schemaregistry; - -import static com.linkedin.metadata.boot.kafka.DataHubUpgradeKafkaListener.TOPIC_NAME; - -import com.linkedin.gms.factory.config.ConfigurationProvider; -import com.linkedin.metadata.boot.kafka.MockDUHEDeserializer; -import com.linkedin.metadata.boot.kafka.MockDUHESerializer; -import com.linkedin.metadata.config.kafka.KafkaConfiguration; -import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig; -import java.util.HashMap; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Slf4j -@Configuration -public class DUHESchemaRegistryFactory { - - public static final String DUHE_SCHEMA_REGISTRY_TOPIC_KEY = "duheTopicName"; - - @Value(TOPIC_NAME) - private String duheTopicName; - - /** Configure Kafka Producer/Consumer processes with a custom schema registry. */ - @Bean("duheSchemaRegistryConfig") - protected SchemaRegistryConfig duheSchemaRegistryConfig(ConfigurationProvider provider) { - Map props = new HashMap<>(); - KafkaConfiguration kafkaConfiguration = provider.getKafka(); - - props.put( - AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG, - kafkaConfiguration.getSchemaRegistry().getUrl()); - props.put(DUHE_SCHEMA_REGISTRY_TOPIC_KEY, duheTopicName); - - log.info("DataHub System Update Registry"); - return new SchemaRegistryConfig(MockDUHESerializer.class, MockDUHEDeserializer.class, props); - } -} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/InternalSchemaRegistryFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/InternalSchemaRegistryFactory.java index 8c814e5054758d..46b27195ecc675 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/InternalSchemaRegistryFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/InternalSchemaRegistryFactory.java @@ -1,11 +1,7 @@ package com.linkedin.gms.factory.kafka.schemaregistry; -import com.linkedin.gms.factory.common.TopicConventionFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.config.kafka.KafkaConfiguration; -import com.linkedin.metadata.registry.SchemaRegistryService; -import com.linkedin.metadata.registry.SchemaRegistryServiceImpl; -import com.linkedin.mxe.TopicConvention; import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig; import io.confluent.kafka.serializers.KafkaAvroDeserializer; import io.confluent.kafka.serializers.KafkaAvroSerializer; @@ -17,7 +13,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; @Slf4j @Configuration @@ -45,11 +40,4 @@ protected SchemaRegistryConfig getInstance( kafkaConfiguration.getSchemaRegistry().getUrl()); return new SchemaRegistryConfig(KafkaAvroSerializer.class, KafkaAvroDeserializer.class, props); } - - @Bean(name = "schemaRegistryService") - @Nonnull - @DependsOn({TopicConventionFactory.TOPIC_CONVENTION_BEAN}) - protected SchemaRegistryService schemaRegistryService(TopicConvention convention) { - return new SchemaRegistryServiceImpl(convention); - } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SchemaRegistryServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SchemaRegistryServiceFactory.java new file mode 100644 index 00000000000000..a6869321d796f7 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SchemaRegistryServiceFactory.java @@ -0,0 +1,20 @@ +package com.linkedin.gms.factory.kafka.schemaregistry; + +import com.linkedin.gms.factory.common.TopicConventionFactory; +import com.linkedin.metadata.registry.SchemaRegistryService; +import com.linkedin.metadata.registry.SchemaRegistryServiceImpl; +import com.linkedin.mxe.TopicConvention; +import javax.annotation.Nonnull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +@Configuration +public class SchemaRegistryServiceFactory { + @Bean(name = "schemaRegistryService") + @Nonnull + @DependsOn({TopicConventionFactory.TOPIC_CONVENTION_BEAN}) + protected SchemaRegistryService schemaRegistryService(TopicConvention convention) { + return new SchemaRegistryServiceImpl(convention); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SystemUpdateSchemaRegistryFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SystemUpdateSchemaRegistryFactory.java new file mode 100644 index 00000000000000..d02cdc0e68f529 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/kafka/schemaregistry/SystemUpdateSchemaRegistryFactory.java @@ -0,0 +1,66 @@ +package com.linkedin.gms.factory.kafka.schemaregistry; + +import static com.linkedin.metadata.boot.kafka.DataHubUpgradeKafkaListener.TOPIC_NAME; + +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.metadata.boot.kafka.MockSystemUpdateDeserializer; +import com.linkedin.metadata.boot.kafka.MockSystemUpdateSerializer; +import com.linkedin.metadata.config.kafka.KafkaConfiguration; +import com.linkedin.metadata.registry.SchemaRegistryService; +import com.linkedin.mxe.Topics; +import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class SystemUpdateSchemaRegistryFactory { + + public static final String SYSTEM_UPDATE_TOPIC_KEY_PREFIX = "data-hub.system-update.topic-key."; + public static final String SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX = ".id"; + + public static final String DUHE_SCHEMA_REGISTRY_TOPIC_KEY = + SYSTEM_UPDATE_TOPIC_KEY_PREFIX + "duhe"; + public static final String MCL_VERSIONED_SCHEMA_REGISTRY_TOPIC_KEY = + SYSTEM_UPDATE_TOPIC_KEY_PREFIX + "mcl-versioned"; + + @Value(TOPIC_NAME) + private String duheTopicName; + + @Value("${METADATA_CHANGE_LOG_VERSIONED_TOPIC_NAME:" + Topics.METADATA_CHANGE_LOG_VERSIONED + "}") + private String mclTopicName; + + /** Configure Kafka Producer/Consumer processes with a custom schema registry. */ + @Bean("duheSchemaRegistryConfig") + protected SchemaRegistryConfig duheSchemaRegistryConfig( + final ConfigurationProvider provider, final SchemaRegistryService schemaRegistryService) { + Map props = new HashMap<>(); + KafkaConfiguration kafkaConfiguration = provider.getKafka(); + + props.put( + AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG, + kafkaConfiguration.getSchemaRegistry().getUrl()); + + // topic names + props.putAll( + Map.of( + DUHE_SCHEMA_REGISTRY_TOPIC_KEY, duheTopicName, + MCL_VERSIONED_SCHEMA_REGISTRY_TOPIC_KEY, mclTopicName)); + + // topic ordinals + props.putAll( + Map.of( + DUHE_SCHEMA_REGISTRY_TOPIC_KEY + SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX, + schemaRegistryService.getSchemaIdForTopic(duheTopicName).get().toString(), + MCL_VERSIONED_SCHEMA_REGISTRY_TOPIC_KEY + SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX, + schemaRegistryService.getSchemaIdForTopic(mclTopicName).get().toString())); + + log.info("DataHub System Update Registry"); + return new SchemaRegistryConfig( + MockSystemUpdateSerializer.class, MockSystemUpdateDeserializer.class, props); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java index a79bdacfc55e93..2dccda4243bca1 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/BootstrapStep.java @@ -1,16 +1,15 @@ package com.linkedin.metadata.boot; -import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.Constants; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.key.DataHubUpgradeKey; +import com.linkedin.metadata.utils.AuditStampUtils; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.upgrade.DataHubUpgradeResult; -import java.net.URISyntaxException; import javax.annotation.Nonnull; /** A single step in the Bootstrap process. */ @@ -40,24 +39,10 @@ static Urn getUpgradeUrn(String upgradeId) { new DataHubUpgradeKey().setId(upgradeId), Constants.DATA_HUB_UPGRADE_ENTITY_NAME); } - static void setUpgradeResult(Urn urn, EntityService entityService) throws URISyntaxException { - final AuditStamp auditStamp = - new AuditStamp() - .setActor(Urn.createFromString(Constants.SYSTEM_ACTOR)) - .setTime(System.currentTimeMillis()); + static void setUpgradeResult(Urn urn, EntityService entityService) { final DataHubUpgradeResult upgradeResult = new DataHubUpgradeResult().setTimestampMs(System.currentTimeMillis()); - // Workaround because entity service does not auto-generate the key aspect for us - final MetadataChangeProposal keyProposal = new MetadataChangeProposal(); - final DataHubUpgradeKey upgradeKey = new DataHubUpgradeKey().setId(urn.getId()); - keyProposal.setEntityUrn(urn); - keyProposal.setEntityType(Constants.DATA_HUB_UPGRADE_ENTITY_NAME); - keyProposal.setAspectName(Constants.DATA_HUB_UPGRADE_KEY_ASPECT_NAME); - keyProposal.setAspect(GenericRecordUtils.serializeAspect(upgradeKey)); - keyProposal.setChangeType(ChangeType.UPSERT); - entityService.ingestProposal(keyProposal, auditStamp, false); - // Ingest the upgrade result final MetadataChangeProposal upgradeProposal = new MetadataChangeProposal(); upgradeProposal.setEntityUrn(urn); @@ -65,6 +50,6 @@ static void setUpgradeResult(Urn urn, EntityService entityService) throws URI upgradeProposal.setAspectName(Constants.DATA_HUB_UPGRADE_RESULT_ASPECT_NAME); upgradeProposal.setAspect(GenericRecordUtils.serializeAspect(upgradeResult)); upgradeProposal.setChangeType(ChangeType.UPSERT); - entityService.ingestProposal(upgradeProposal, auditStamp, false); + entityService.ingestProposal(upgradeProposal, AuditStampUtils.createDefaultAuditStamp(), false); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHESerializer.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHESerializer.java deleted file mode 100644 index 36fe514d5536f5..00000000000000 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHESerializer.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.linkedin.metadata.boot.kafka; - -import static com.linkedin.gms.factory.kafka.schemaregistry.DUHESchemaRegistryFactory.DUHE_SCHEMA_REGISTRY_TOPIC_KEY; - -import com.linkedin.metadata.EventUtils; -import io.confluent.kafka.schemaregistry.avro.AvroSchema; -import io.confluent.kafka.schemaregistry.client.MockSchemaRegistryClient; -import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; -import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; -import io.confluent.kafka.serializers.KafkaAvroSerializer; -import java.io.IOException; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; - -/** Used for early bootstrap to avoid contact with not yet existing schema registry */ -@Slf4j -public class MockDUHESerializer extends KafkaAvroSerializer { - - private static final String DATAHUB_UPGRADE_HISTORY_EVENT_SUBJECT_SUFFIX = "-value"; - - private String topicName; - - public MockDUHESerializer() { - this.schemaRegistry = buildMockSchemaRegistryClient(); - } - - public MockDUHESerializer(SchemaRegistryClient client) { - super(client); - this.schemaRegistry = buildMockSchemaRegistryClient(); - } - - public MockDUHESerializer(SchemaRegistryClient client, Map props) { - super(client, props); - this.schemaRegistry = buildMockSchemaRegistryClient(); - } - - @Override - public void configure(Map configs, boolean isKey) { - super.configure(configs, isKey); - topicName = configs.get(DUHE_SCHEMA_REGISTRY_TOPIC_KEY).toString(); - } - - private MockSchemaRegistryClient buildMockSchemaRegistryClient() { - MockSchemaRegistryClient schemaRegistry = new MockSchemaRegistryClient(); - try { - schemaRegistry.register( - topicToSubjectName(topicName), new AvroSchema(EventUtils.ORIGINAL_DUHE_AVRO_SCHEMA)); - return schemaRegistry; - } catch (IOException | RestClientException e) { - throw new RuntimeException(e); - } - } - - public static String topicToSubjectName(String topicName) { - return topicName + DATAHUB_UPGRADE_HISTORY_EVENT_SUBJECT_SUFFIX; - } -} diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHEDeserializer.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateDeserializer.java similarity index 57% rename from metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHEDeserializer.java rename to metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateDeserializer.java index e631f776abd08d..74a20cdacbb213 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockDUHEDeserializer.java +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateDeserializer.java @@ -1,50 +1,49 @@ package com.linkedin.metadata.boot.kafka; -import static com.linkedin.gms.factory.kafka.schemaregistry.DUHESchemaRegistryFactory.DUHE_SCHEMA_REGISTRY_TOPIC_KEY; -import static com.linkedin.metadata.boot.kafka.MockDUHESerializer.topicToSubjectName; +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.DUHE_SCHEMA_REGISTRY_TOPIC_KEY; +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX; +import static com.linkedin.metadata.boot.kafka.MockSystemUpdateSerializer.topicToSubjectName; import com.linkedin.metadata.EventUtils; import io.confluent.kafka.schemaregistry.ParsedSchema; import io.confluent.kafka.schemaregistry.avro.AvroSchema; import io.confluent.kafka.schemaregistry.client.MockSchemaRegistryClient; -import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; import io.confluent.kafka.serializers.KafkaAvroDeserializer; import java.io.IOException; import java.util.Map; import lombok.extern.slf4j.Slf4j; -/** Used for early bootstrap to avoid contact with not yet existing schema registry */ +/** + * Used for early bootstrap to avoid contact with not yet existing schema registry Only supports the + * DUHE topic + */ @Slf4j -public class MockDUHEDeserializer extends KafkaAvroDeserializer { +public class MockSystemUpdateDeserializer extends KafkaAvroDeserializer { private String topicName; - - public MockDUHEDeserializer() { - this.schemaRegistry = buildMockSchemaRegistryClient(); - } - - public MockDUHEDeserializer(SchemaRegistryClient client) { - super(client); - this.schemaRegistry = buildMockSchemaRegistryClient(); - } - - public MockDUHEDeserializer(SchemaRegistryClient client, Map props) { - super(client, props); - this.schemaRegistry = buildMockSchemaRegistryClient(); - } + private Integer schemaId; @Override public void configure(Map configs, boolean isKey) { super.configure(configs, isKey); topicName = configs.get(DUHE_SCHEMA_REGISTRY_TOPIC_KEY).toString(); + schemaId = + Integer.valueOf( + configs + .get(DUHE_SCHEMA_REGISTRY_TOPIC_KEY + SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX) + .toString()); + this.schemaRegistry = buildMockSchemaRegistryClient(); } private MockSchemaRegistryClient buildMockSchemaRegistryClient() { - MockSchemaRegistryClient schemaRegistry = new MockSchemaRegistryClient2(); + MockSchemaRegistryClient schemaRegistry = new MockSchemaRegistryClient2(schemaId); try { schemaRegistry.register( - topicToSubjectName(topicName), new AvroSchema(EventUtils.ORIGINAL_DUHE_AVRO_SCHEMA)); + topicToSubjectName(topicName), + new AvroSchema(EventUtils.ORIGINAL_DUHE_AVRO_SCHEMA), + 0, + schemaId); return schemaRegistry; } catch (IOException | RestClientException e) { throw new RuntimeException(e); @@ -52,13 +51,19 @@ private MockSchemaRegistryClient buildMockSchemaRegistryClient() { } public static class MockSchemaRegistryClient2 extends MockSchemaRegistryClient { + private final int schemaId; + + public MockSchemaRegistryClient2(int schemaId) { + this.schemaId = schemaId; + } + /** * Previously used topics can have schema ids > 1 which fully match however we are replacing * that registry so force schema id to 1 */ @Override public synchronized ParsedSchema getSchemaById(int id) throws IOException, RestClientException { - return super.getSchemaById(1); + return super.getSchemaById(schemaId); } } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateSerializer.java b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateSerializer.java new file mode 100644 index 00000000000000..14aac2758a69d3 --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/metadata/boot/kafka/MockSystemUpdateSerializer.java @@ -0,0 +1,76 @@ +package com.linkedin.metadata.boot.kafka; + +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.DUHE_SCHEMA_REGISTRY_TOPIC_KEY; +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.MCL_VERSIONED_SCHEMA_REGISTRY_TOPIC_KEY; +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX; +import static com.linkedin.gms.factory.kafka.schemaregistry.SystemUpdateSchemaRegistryFactory.SYSTEM_UPDATE_TOPIC_KEY_PREFIX; + +import com.linkedin.metadata.EventUtils; +import com.linkedin.util.Pair; +import io.confluent.kafka.schemaregistry.avro.AvroSchema; +import io.confluent.kafka.schemaregistry.client.MockSchemaRegistryClient; +import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; +import io.confluent.kafka.serializers.KafkaAvroSerializer; +import java.io.IOException; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + +/** Used for early bootstrap to avoid contact with not yet existing schema registry */ +@Slf4j +public class MockSystemUpdateSerializer extends KafkaAvroSerializer { + + private static final String DATAHUB_SYSTEM_UPDATE_SUBJECT_SUFFIX = "-value"; + + private static final Map AVRO_SCHEMA_MAP = + Map.of( + DUHE_SCHEMA_REGISTRY_TOPIC_KEY, new AvroSchema(EventUtils.ORIGINAL_DUHE_AVRO_SCHEMA), + MCL_VERSIONED_SCHEMA_REGISTRY_TOPIC_KEY, + new AvroSchema(EventUtils.ORIGINAL_MCL_AVRO_SCHEMA)); + + private Map> topicNameToAvroSchemaMap; + + @Override + public void configure(Map configs, boolean isKey) { + super.configure(configs, isKey); + topicNameToAvroSchemaMap = + configs.entrySet().stream() + .filter( + e -> + e.getKey().startsWith(SYSTEM_UPDATE_TOPIC_KEY_PREFIX) + && !e.getKey().endsWith(SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX) + && e.getValue() instanceof String) + .map( + e -> { + Integer id = + Integer.valueOf( + (String) configs.get(e.getKey() + SYSTEM_UPDATE_TOPIC_KEY_ID_SUFFIX)); + return Pair.of( + (String) e.getValue(), Pair.of(AVRO_SCHEMA_MAP.get(e.getKey()), id)); + }) + .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); + this.schemaRegistry = buildMockSchemaRegistryClient(); + } + + private MockSchemaRegistryClient buildMockSchemaRegistryClient() { + MockSchemaRegistryClient schemaRegistry = new MockSchemaRegistryClient(); + + if (topicNameToAvroSchemaMap != null) { + topicNameToAvroSchemaMap.forEach( + (topicName, schemaId) -> { + try { + schemaRegistry.register( + topicToSubjectName(topicName), schemaId.getFirst(), 0, schemaId.getSecond()); + } catch (IOException | RestClientException e) { + throw new RuntimeException(e); + } + }); + } + + return schemaRegistry; + } + + public static String topicToSubjectName(String topicName) { + return topicName + DATAHUB_SYSTEM_UPDATE_SUBJECT_SUFFIX; + } +} diff --git a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java index 1678fe92ec70e3..17c51604947223 100644 --- a/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java +++ b/metadata-service/restli-servlet-impl/src/test/java/com/linkedin/metadata/resources/entity/AspectResourceTest.java @@ -122,7 +122,7 @@ public void testAsyncDefaultAspects() throws URISyntaxException { .request(req) .build()))); _aspectResource.ingestProposal(mcp, "false"); - verify(_producer, times(5)) + verify(_producer, times(10)) .produceMetadataChangeLog(eq(urn), any(AspectSpec.class), any(MetadataChangeLog.class)); verifyNoMoreInteractions(_producer); } From 09d4260b18f63e33eda930bd067b4e9e65067478 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:03:22 -0600 Subject: [PATCH 270/540] chore(pegasus): upgrade pegasus, remove gradle 8 hacks for pegasus plugin (#9798) --- build.gradle | 15 +- buildSrc/build.gradle | 3 - .../pegasus/gradle/PegasusPlugin.java | 2444 ----------------- .../gradle/tasks/ChangedFileReportTask.java | 124 - 4 files changed, 8 insertions(+), 2578 deletions(-) delete mode 100644 buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java delete mode 100644 buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java diff --git a/build.gradle b/build.gradle index ba61d97f0ed6e9..ea81d26355027d 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ buildscript { ext.junitJupiterVersion = '5.6.1' // Releases: https://github.com/linkedin/rest.li/blob/master/CHANGELOG.md - ext.pegasusVersion = '29.48.4' + ext.pegasusVersion = '29.51.0' ext.mavenVersion = '3.6.3' ext.springVersion = '6.1.2' ext.springBootVersion = '3.2.1' @@ -269,13 +269,14 @@ allprojects { apply plugin: 'eclipse' // apply plugin: 'org.gradlex.java-ecosystem-capabilities' - tasks.withType(Test).configureEach { - // https://docs.gradle.org/current/userguide/performance.html - maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 + tasks.withType(Test).configureEach { task -> if (task.project.name != "metadata-io") { + // https://docs.gradle.org/current/userguide/performance.html + maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 - if (project.configurations.getByName("testImplementation").getDependencies() - .any{ it.getName().contains("testng") }) { - useTestNG() + if (project.configurations.getByName("testImplementation").getDependencies() + .any { it.getName().contains("testng") }) { + useTestNG() + } } } diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 0c2d91e1f7ac1b..88900e06d48451 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -25,7 +25,4 @@ dependencies { compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' - - // pegasus dependency, overrides for tasks - implementation 'com.linkedin.pegasus:gradle-plugins:29.48.4' } \ No newline at end of file diff --git a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java deleted file mode 100644 index 2460abcad6f9e9..00000000000000 --- a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/PegasusPlugin.java +++ /dev/null @@ -1,2444 +0,0 @@ -/* - * Copyright (c) 2019 LinkedIn Corp. - * - * 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. - */ - -package com.linkedin.pegasus.gradle; - -import com.linkedin.pegasus.gradle.PegasusOptions.IdlOptions; -import com.linkedin.pegasus.gradle.internal.CompatibilityLogChecker; -import com.linkedin.pegasus.gradle.tasks.ChangedFileReportTask; -import com.linkedin.pegasus.gradle.tasks.CheckIdlTask; -import com.linkedin.pegasus.gradle.tasks.CheckPegasusSnapshotTask; -import com.linkedin.pegasus.gradle.tasks.CheckRestModelTask; -import com.linkedin.pegasus.gradle.tasks.CheckSnapshotTask; -import com.linkedin.pegasus.gradle.tasks.GenerateAvroSchemaTask; -import com.linkedin.pegasus.gradle.tasks.GenerateDataTemplateTask; -import com.linkedin.pegasus.gradle.tasks.GeneratePegasusSnapshotTask; -import com.linkedin.pegasus.gradle.tasks.GenerateRestClientTask; -import com.linkedin.pegasus.gradle.tasks.GenerateRestModelTask; -import com.linkedin.pegasus.gradle.tasks.PublishRestModelTask; -import com.linkedin.pegasus.gradle.tasks.TranslateSchemasTask; -import com.linkedin.pegasus.gradle.tasks.ValidateExtensionSchemaTask; -import com.linkedin.pegasus.gradle.tasks.ValidateSchemaAnnotationTask; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Method; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import java.util.TreeSet; -import java.util.function.Function; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import org.gradle.api.Action; -import org.gradle.api.GradleException; -import org.gradle.api.Plugin; -import org.gradle.api.Project; -import org.gradle.api.Task; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.ConfigurationContainer; -import org.gradle.api.file.FileCollection; -import org.gradle.api.plugins.JavaBasePlugin; -import org.gradle.api.plugins.JavaPlugin; -import org.gradle.api.plugins.JavaPluginConvention; -import org.gradle.api.plugins.JavaPluginExtension; -import org.gradle.api.publish.PublishingExtension; -import org.gradle.api.publish.ivy.IvyPublication; -import org.gradle.api.publish.ivy.plugins.IvyPublishPlugin; -import org.gradle.api.tasks.Copy; -import org.gradle.api.tasks.Delete; -import org.gradle.api.tasks.SourceSet; -import org.gradle.api.tasks.SourceSetContainer; -import org.gradle.api.tasks.Sync; -import org.gradle.api.tasks.TaskProvider; -import org.gradle.api.tasks.bundling.Jar; -import org.gradle.api.tasks.compile.JavaCompile; -import org.gradle.api.tasks.javadoc.Javadoc; -import org.gradle.language.base.plugins.LifecycleBasePlugin; -import org.gradle.language.jvm.tasks.ProcessResources; -import org.gradle.plugins.ide.eclipse.EclipsePlugin; -import org.gradle.plugins.ide.eclipse.model.EclipseModel; -import org.gradle.plugins.ide.idea.IdeaPlugin; -import org.gradle.plugins.ide.idea.model.IdeaModule; -import org.gradle.util.GradleVersion; - - -/** - * Pegasus code generation plugin. - * The supported project layout for this plugin is as follows: - * - *

    - *   --- api/
    - *   |   --- build.gradle
    - *   |   --- src/
    - *   |       --- <sourceSet>/
    - *   |       |   --- idl/
    - *   |       |   |   --- <published idl (.restspec.json) files>
    - *   |       |   --- java/
    - *   |       |   |   --- <packageName>/
    - *   |       |   |       --- <common java files>
    - *   |       |   --- pegasus/
    - *   |       |       --- <packageName>/
    - *   |       |           --- <data schema (.pdsc) files>
    - *   |       --- <sourceSet>GeneratedDataTemplate/
    - *   |       |   --- java/
    - *   |       |       --- <packageName>/
    - *   |       |           --- <data template source files generated from data schema (.pdsc) files>
    - *   |       --- <sourceSet>GeneratedAvroSchema/
    - *   |       |   --- avro/
    - *   |       |       --- <packageName>/
    - *   |       |           --- <avsc avro schema files (.avsc) generated from pegasus schema files>
    - *   |       --- <sourceSet>GeneratedRest/
    - *   |           --- java/
    - *   |               --- <packageName>/
    - *   |                   --- <rest client source (.java) files generated from published idl>
    - *   --- impl/
    - *   |   --- build.gradle
    - *   |   --- src/
    - *   |       --- <sourceSet>/
    - *   |       |   --- java/
    - *   |       |       --- <packageName>/
    - *   |       |           --- <resource class source (.java) files>
    - *   |       --- <sourceSet>GeneratedRest/
    - *   |           --- idl/
    - *   |               --- <generated idl (.restspec.json) files>
    - *   --- <other projects>/
    - * 
    - *
      - *
    • - * api: contains all the files which are commonly depended by the server and - * client implementation. The common files include the data schema (.pdsc) files, - * the idl (.restspec.json) files and potentially Java interface files used by both sides. - *
    • - *
    • - * impl: contains the resource class for server implementation. - *
    • - *
    - *

    Performs the following functions:

    - * - *

    Generate data model and data template jars for each source set.

    - * - *

    Overview:

    - * - *

    - * In the api project, the plugin generates the data template source (.java) files from the - * data schema (.pdsc) files, and furthermore compiles the source files and packages them - * to jar files. Details of jar contents will be explained in following paragraphs. - * In general, data schema files should exist only in api projects. - *

    - * - *

    - * Configure the server and client implementation projects to depend on the - * api project's dataTemplate configuration to get access to the generated data templates - * from within these projects. This allows api classes to be built first so that implementation - * projects can consume them. We recommend this structure to avoid circular dependencies - * (directly or indirectly) among implementation projects. - *

    - * - *

    Detail:

    - * - *

    - * Generates data template source (.java) files from data schema (.pdsc) files, - * compiles the data template source (.java) files into class (.class) files, - * creates a data model jar file and a data template jar file. - * The data model jar file contains the source data schema (.pdsc) files. - * The data template jar file contains both the source data schema (.pdsc) files - * and the generated data template class (.class) files. - *

    - * - *

    - * In the data template generation phase, the plugin creates a new target source set - * for the generated files. The new target source set's name is the input source set name's - * suffixed with "GeneratedDataTemplate", e.g. "mainGeneratedDataTemplate". - * The plugin invokes PegasusDataTemplateGenerator to generate data template source (.java) files - * for all data schema (.pdsc) files present in the input source set's pegasus - * directory, e.g. "src/main/pegasus". The generated data template source (.java) files - * will be in the new target source set's java source directory, e.g. - * "src/mainGeneratedDataTemplate/java". In addition to - * the data schema (.pdsc) files in the pegasus directory, the dataModel configuration - * specifies resolver path for the PegasusDataTemplateGenerator. The resolver path - * provides the data schemas and previously generated data template classes that - * may be referenced by the input source set's data schemas. In most cases, the dataModel - * configuration should contain data template jars. - *

    - * - *

    - * The next phase is the data template compilation phase, the plugin compiles the generated - * data template source (.java) files into class files. The dataTemplateCompile configuration - * specifies the pegasus jars needed to compile these classes. The compileClasspath of the - * target source set is a composite of the dataModel configuration which includes the data template - * classes that were previously generated and included in the dependent data template jars, - * and the dataTemplateCompile configuration. - * This configuration should specify a dependency on the Pegasus data jar. - *

    - * - *

    - * The following phase is creating the the data model jar and the data template jar. - * This plugin creates the data model jar that includes the contents of the - * input source set's pegasus directory, and sets the jar file's classification to - * "data-model". Hence, the resulting jar file's name should end with "-data-model.jar". - * It adds the data model jar as an artifact to the dataModel configuration. - * This jar file should only contain data schema (.pdsc) files. - *

    - * - *

    - * This plugin also create the data template jar that includes the contents of the input - * source set's pegasus directory and the java class output directory of the - * target source set. It sets the jar file's classification to "data-template". - * Hence, the resulting jar file's name should end with "-data-template.jar". - * It adds the data template jar file as an artifact to the dataTemplate configuration. - * This jar file contains both data schema (.pdsc) files and generated data template - * class (.class) files. - *

    - * - *

    - * This plugin will ensure that data template source files are generated before - * compiling the input source set and before the idea and eclipse tasks. It - * also adds the generated classes to the compileClasspath of the input source set. - *

    - * - *

    - * The configurations that apply to generating the data model and data template jars - * are as follow: - *

      - *
    • - * The dataTemplateCompile configuration specifies the classpath for compiling - * the generated data template source (.java) files. In most cases, - * it should be the Pegasus data jar. - * (The default compile configuration is not used for compiling data templates because - * it is not desirable to include non data template dependencies in the data template jar.) - * The configuration should not directly include data template jars. Data template jars - * should be included in the dataModel configuration. - *
    • - *
    • - * The dataModel configuration provides the value of the "generator.resolver.path" - * system property that is passed to PegasusDataTemplateGenerator. In most cases, - * this configuration should contain only data template jars. The data template jars - * contain both data schema (.pdsc) files and generated data template (.class) files. - * PegasusDataTemplateGenerator will not generate data template (.java) files for - * classes that can be found in the resolver path. This avoids redundant generation - * of the same classes, and inclusion of these classes in multiple jars. - * The dataModel configuration is also used to publish the data model jar which - * contains only data schema (.pdsc) files. - *
    • - *
    • - * The testDataModel configuration is similar to the dataModel configuration - * except it is used when generating data templates from test source sets. - * It extends from the dataModel configuration. It is also used to publish - * the data model jar from test source sets. - *
    • - *
    • - * The dataTemplate configuration is used to publish the data template - * jar which contains both data schema (.pdsc) files and the data template class - * (.class) files generated from these data schema (.pdsc) files. - *
    • - *
    • - * The testDataTemplate configuration is similar to the dataTemplate configuration - * except it is used when publishing the data template jar files generated from - * test source sets. - *
    • - *
    - *

    - * - *

    Performs the following functions:

    - * - *

    Generate avro schema jars for each source set.

    - * - *

    Overview:

    - * - *

    - * In the api project, the task 'generateAvroSchema' generates the avro schema (.avsc) - * files from pegasus schema (.pdsc) files. In general, data schema files should exist - * only in api projects. - *

    - * - *

    - * Configure the server and client implementation projects to depend on the - * api project's avroSchema configuration to get access to the generated avro schemas - * from within these projects. - *

    - * - *

    - * This plugin also create the avro schema jar that includes the contents of the input - * source set's avro directory and the avsc schema files. - * The resulting jar file's name should end with "-avro-schema.jar". - *

    - * - *

    Generate rest model and rest client jars for each source set.

    - * - *

    Overview:

    - * - *

    - * In the api project, generates rest client source (.java) files from the idl, - * compiles the rest client source (.java) files to rest client class (.class) files - * and puts them in jar files. In general, the api project should be only place that - * contains the publishable idl files. If the published idl changes an existing idl - * in the api project, the plugin will emit message indicating this has occurred and - * suggest that the entire project be rebuilt if it is desirable for clients of the - * idl to pick up the newly published changes. - *

    - * - *

    - * In the impl project, generates the idl (.restspec.json) files from the input - * source set's resource class files, then compares them against the existing idl - * files in the api project for compatibility checking. If incompatible changes are - * found, the build fails (unless certain flag is specified, see below). If the - * generated idl passes compatibility checks (see compatibility check levels below), - * publishes the generated idl (.restspec.json) to the api project. - *

    - * - *

    Detail:

    - * - *

    rest client generation phase: in api project

    - * - *

    - * In this phase, the rest client source (.java) files are generated from the - * api project idl (.restspec.json) files using RestRequestBuilderGenerator. - * The generated rest client source files will be in the new target source set's - * java source directory, e.g. "src/mainGeneratedRest/java". - *

    - * - *

    - * RestRequestBuilderGenerator requires access to the data schemas referenced - * by the idl. The dataModel configuration specifies the resolver path needed - * by RestRequestBuilderGenerator to access the data schemas referenced by - * the idl that is not in the source set's pegasus directory. - * This plugin automatically includes the data schema (.pdsc) files in the - * source set's pegasus directory in the resolver path. - * In most cases, the dataModel configuration should contain data template jars. - * The data template jars contains both data schema (.pdsc) files and generated - * data template class (.class) files. By specifying data template jars instead - * of data model jars, redundant generation of data template classes is avoided - * as classes that can be found in the resolver path are not generated. - *

    - * - *

    rest client compilation phase: in api project

    - * - *

    - * In this phase, the plugin compiles the generated rest client source (.java) - * files into class files. The restClientCompile configuration specifies the - * pegasus jars needed to compile these classes. The compile classpath is a - * composite of the dataModel configuration which includes the data template - * classes that were previously generated and included in the dependent data template - * jars, and the restClientCompile configuration. - * This configuration should specify a dependency on the Pegasus restli-client jar. - *

    - * - *

    - * The following stage is creating the the rest model jar and the rest client jar. - * This plugin creates the rest model jar that includes the - * generated idl (.restspec.json) files, and sets the jar file's classification to - * "rest-model". Hence, the resulting jar file's name should end with "-rest-model.jar". - * It adds the rest model jar as an artifact to the restModel configuration. - * This jar file should only contain idl (.restspec.json) files. - *

    - * - *

    - * This plugin also create the rest client jar that includes the generated - * idl (.restspec.json) files and the java class output directory of the - * target source set. It sets the jar file's classification to "rest-client". - * Hence, the resulting jar file's name should end with "-rest-client.jar". - * It adds the rest client jar file as an artifact to the restClient configuration. - * This jar file contains both idl (.restspec.json) files and generated rest client - * class (.class) files. - *

    - * - *

    idl generation phase: in server implementation project

    - * - *

    - * Before entering this phase, the plugin will ensure that generating idl will - * occur after compiling the input source set. It will also ensure that IDEA - * and Eclipse tasks runs after rest client source (.java) files are generated. - *

    - * - *

    - * In this phase, the plugin creates a new target source set for the generated files. - * The new target source set's name is the input source set name's* suffixed with - * "GeneratedRest", e.g. "mainGeneratedRest". The plugin invokes - * RestLiResourceModelExporter to generate idl (.restspec.json) files for each - * IdlItem in the input source set's pegasus IdlOptions. The generated idl files - * will be in target source set's idl directory, e.g. "src/mainGeneratedRest/idl". - * For example, the following adds an IdlItem to the source set's pegasus IdlOptions. - * This line should appear in the impl project's build.gradle. If no IdlItem is added, - * this source set will be excluded from generating idl and checking idl compatibility, - * even there are existing idl files. - *

    - *   pegasus.main.idlOptions.addIdlItem(["com.linkedin.restli.examples.groups.server"])
    - * 
    - *

    - * - *

    - * After the idl generation phase, each included idl file is checked for compatibility against - * those in the api project. In case the current interface breaks compatibility, - * by default the build fails and reports all compatibility errors and warnings. Otherwise, - * the build tasks in the api project later will package the resource classes into jar files. - * User can change the compatibility requirement between the current and published idl by - * setting the "rest.model.compatibility" project property, i.e. - * "gradle -Prest.model.compatibility= ..." The following levels are supported: - *

      - *
    • ignore: idl compatibility check will occur but its result will be ignored. - * The result will be aggregated and printed at the end of the build.
    • - *
    • backwards: build fails if there are backwards incompatible changes in idl. - * Build continues if there are only compatible changes.
    • - *
    • equivalent (default): build fails if there is any functional changes (compatible or - * incompatible) in the current idl. Only docs and comments are allowed to be different.
    • - *
    - * The plugin needs to know where the api project is. It searches the api project in the - * following steps. If all searches fail, the build fails. - *
      - *
    1. - * Use the specified project from the impl project build.gradle file. The ext.apiProject - * property explicitly assigns the api project. E.g. - *
      - *       ext.apiProject = project(':groups:groups-server-api')
      - *     
      - * If multiple such statements exist, the last will be used. Wrong project path causes Gradle - * evaluation error. - *
    2. - *
    3. - * If no ext.apiProject property is defined, the plugin will try to guess the - * api project name with the following conventions. The search stops at the first successful match. - *
        - *
      1. - * If the impl project name ends with the following suffixes, substitute the suffix with "-api". - *
          - *
        1. -impl
        2. - *
        3. -service
        4. - *
        5. -server
        6. - *
        7. -server-impl
        8. - *
        - * This list can be overridden by inserting the following line to the project build.gradle: - *
        - *           ext.apiProjectSubstitutionSuffixes = ['-new-suffix-1', '-new-suffix-2']
        - *         
        - * Alternatively, this setting could be applied globally to all projects by putting it in - * the subprojects section of the root build.gradle.
        - *
      2. - *
      3. - * Append "-api" to the impl project name. - *
      4. - *
      - *
    4. - *
    - * The plugin invokes RestLiResourceModelCompatibilityChecker to check compatibility. - *

    - * - *

    - * The idl files in the api project are not generated by the plugin, but rather - * "published" from the impl project. The publishRestModel task is used to copy the - * idl files to the api project. This task is invoked automatically if the idls are - * verified to be "safe". "Safe" is determined by the "rest.model.compatibility" - * property. Because this task is skipped if the idls are functionally equivalent - * (not necessarily identical, e.g. differ in doc fields), if the default "equivalent" - * compatibility level is used, no file will be copied. If such automatic publishing - * is intended to be skip, set the "rest.model.skipPublish" property to true. - * Note that all the properties are per-project and can be overridden in each project's - * build.gradle file. - *

    - * - *

    - * Please always keep in mind that if idl publishing is happened, a subsequent whole-project - * rebuild is necessary to pick up the changes. Otherwise, the Hudson job will fail and - * the source code commit will fail. - *

    - * - *

    - * The configurations that apply to generating the rest model and rest client jars - * are as follow: - *

      - *
    • - * The restClientCompile configuration specifies the classpath for compiling - * the generated rest client source (.java) files. In most cases, - * it should be the Pegasus restli-client jar. - * (The default compile configuration is not used for compiling rest client because - * it is not desirable to include non rest client dependencies, such as - * the rest server implementation classes, in the data template jar.) - * The configuration should not directly include data template jars. Data template jars - * should be included in the dataModel configuration. - *
    • - *
    • - * The dataModel configuration provides the value of the "generator.resolver.path" - * system property that is passed to RestRequestBuilderGenerator. - * This configuration should contain only data template jars. The data template jars - * contain both data schema (.pdsc) files and generated data template (.class) files. - * The RestRequestBuilderGenerator will only generate rest client classes. - * The dataModel configuration is also included in the compile classpath for the - * generated rest client source files. The dataModel configuration does not - * include generated data template classes, then the Java compiler may not able to - * find the data template classes referenced by the generated rest client. - *
    • - *
    • - * The testDataModel configuration is similar to the dataModel configuration - * except it is used when generating rest client source files from - * test source sets. - *
    • - *
    • - * The restModel configuration is used to publish the rest model jar - * which contains generated idl (.restspec.json) files. - *
    • - *
    • - * The testRestModel configuration is similar to the restModel configuration - * except it is used to publish rest model jar files generated from - * test source sets. - *
    • - *
    • - * The restClient configuration is used to publish the rest client jar - * which contains both generated idl (.restspec.json) files and - * the rest client class (.class) files generated from from these - * idl (.restspec.json) files. - *
    • - *
    • - * The testRestClient configuration is similar to the restClient configuration - * except it is used to publish rest client jar files generated from - * test source sets. - *
    • - *
    - *

    - * - *

    - * This plugin considers test source sets whose names begin with 'test' or 'integTest' to be - * test source sets. - *

    - */ -public class PegasusPlugin implements Plugin -{ - public static boolean debug = false; - - private static final GradleVersion MIN_REQUIRED_VERSION = GradleVersion.version("1.0"); // Next: 5.2.1 - private static final GradleVersion MIN_SUGGESTED_VERSION = GradleVersion.version("5.2.1"); // Next: 5.3 - - // - // Constants for generating sourceSet names and corresponding directory names - // for generated code - // - private static final String DATA_TEMPLATE_GEN_TYPE = "DataTemplate"; - private static final String REST_GEN_TYPE = "Rest"; - private static final String AVRO_SCHEMA_GEN_TYPE = "AvroSchema"; - - public static final String DATA_TEMPLATE_FILE_SUFFIX = ".pdsc"; - public static final String PDL_FILE_SUFFIX = ".pdl"; - // gradle property to opt OUT schema annotation validation, by default this feature is enabled. - private static final String DISABLE_SCHEMA_ANNOTATION_VALIDATION = "schema.annotation.validation.disable"; - // gradle property to opt in for destroying stale files from the build directory, - // by default it is disabled, because it triggers hot-reload (even if it results in a no-op) - private static final String DESTROY_STALE_FILES_ENABLE = "enableDestroyStaleFiles"; - public static final Collection DATA_TEMPLATE_FILE_SUFFIXES = new ArrayList<>(); - - public static final String IDL_FILE_SUFFIX = ".restspec.json"; - public static final String SNAPSHOT_FILE_SUFFIX = ".snapshot.json"; - public static final String SNAPSHOT_COMPAT_REQUIREMENT = "rest.model.compatibility"; - public static final String IDL_COMPAT_REQUIREMENT = "rest.idl.compatibility"; - // Pegasus schema compatibility level configuration, which is used to define the {@link CompatibilityLevel}. - public static final String PEGASUS_SCHEMA_SNAPSHOT_REQUIREMENT = "pegasusPlugin.pegasusSchema.compatibility"; - // Pegasus extension schema compatibility level configuration, which is used to define the {@link CompatibilityLevel} - public static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_REQUIREMENT = "pegasusPlugin.extensionSchema.compatibility"; - // CompatibilityOptions Mode configuration, which is used to define the {@link CompatibilityOptions#Mode} in the compatibility checker. - private static final String PEGASUS_COMPATIBILITY_MODE = "pegasusPlugin.pegasusSchemaCompatibilityCheckMode"; - - private static final Pattern TEST_DIR_REGEX = Pattern.compile("^(integ)?[Tt]est"); - private static final String SNAPSHOT_NO_PUBLISH = "rest.model.noPublish"; - private static final String SNAPSHOT_FORCE_PUBLISH = "rest.model.forcePublish"; - private static final String PROCESS_EMPTY_IDL_DIR = "rest.idl.processEmptyIdlDir"; - private static final String IDL_NO_PUBLISH = "rest.idl.noPublish"; - private static final String IDL_FORCE_PUBLISH = "rest.idl.forcePublish"; - private static final String SKIP_IDL_CHECK = "rest.idl.skipCheck"; - // gradle property to skip running GenerateRestModel task. - // Note it affects GenerateRestModel task only, and does not skip tasks depends on GenerateRestModel. - private static final String SKIP_GENERATE_REST_MODEL= "rest.model.skipGenerateRestModel"; - private static final String SUPPRESS_REST_CLIENT_RESTLI_2 = "rest.client.restli2.suppress"; - private static final String SUPPRESS_REST_CLIENT_RESTLI_1 = "rest.client.restli1.suppress"; - - private static final String GENERATOR_CLASSLOADER_NAME = "pegasusGeneratorClassLoader"; - - private static final String CONVERT_TO_PDL_REVERSE = "convertToPdl.reverse"; - private static final String CONVERT_TO_PDL_KEEP_ORIGINAL = "convertToPdl.keepOriginal"; - private static final String CONVERT_TO_PDL_SKIP_VERIFICATION = "convertToPdl.skipVerification"; - private static final String CONVERT_TO_PDL_PRESERVE_SOURCE_CMD = "convertToPdl.preserveSourceCmd"; - - // Below variables are used to collect data across all pegasus projects (sub-projects) and then print information - // to the user at the end after build is finished. - private static StringBuffer _restModelCompatMessage = new StringBuffer(); - private static final Collection _needCheckinFiles = new ArrayList<>(); - private static final Collection _needBuildFolders = new ArrayList<>(); - private static final Collection _possibleMissingFilesInEarlierCommit = new ArrayList<>(); - - private static final String RUN_ONCE = "runOnce"; - private static final Object STATIC_PROJECT_EVALUATED_LOCK = new Object(); - - private static final List UNUSED_CONFIGURATIONS = Arrays.asList( - "dataTemplateGenerator", "restTools", "avroSchemaGenerator"); - // Directory in the dataTemplate jar that holds schemas translated from PDL to PDSC. - private static final String TRANSLATED_SCHEMAS_DIR = "legacyPegasusSchemas"; - // Enable the use of argFiles for the tasks that support them - private static final String ENABLE_ARG_FILE = "pegasusPlugin.enableArgFile"; - // Enable the generation of fluent APIs - private static final String ENABLE_FLUENT_API = "pegasusPlugin.enableFluentApi"; - - // This config impacts GenerateDataTemplateTask and GenerateRestClientTask; - // If not set, by default all paths generated in these two tasks will be lower-case. - // This default behavior is needed because Linux, MacOS, Windows treat case sensitive paths differently, - // and we want to be consistent, so we choose lower-case as default case for path generated - private static final String CODE_GEN_PATH_CASE_SENSITIVE = "pegasusPlugin.generateCaseSensitivePath"; - - private static final String PEGASUS_PLUGIN_CONFIGURATION = "pegasusPlugin"; - - // Enable the use of generic pegasus schema compatibility checker - private static final String ENABLE_PEGASUS_SCHEMA_COMPATIBILITY_CHECK = "pegasusPlugin.enablePegasusSchemaCompatibilityCheck"; - - private static final String PEGASUS_SCHEMA_SNAPSHOT = "PegasusSchemaSnapshot"; - - private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT = "PegasusExtensionSchemaSnapshot"; - - private static final String PEGASUS_SCHEMA_SNAPSHOT_DIR = "pegasusSchemaSnapshot"; - - private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR = "pegasusExtensionSchemaSnapshot"; - - private static final String PEGASUS_SCHEMA_SNAPSHOT_DIR_OVERRIDE = "overridePegasusSchemaSnapshotDir"; - - private static final String PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR_OVERRIDE = "overridePegasusExtensionSchemaSnapshotDir"; - - private static final String SRC = "src"; - - private static final String SCHEMA_ANNOTATION_HANDLER_CONFIGURATION = "schemaAnnotationHandler"; - - private static final String COMPATIBILITY_OPTIONS_MODE_EXTENSION = "EXTENSION"; - - - @SuppressWarnings("unchecked") - private Class> _thisPluginType = (Class>) - getClass().asSubclass(Plugin.class); - - private Task _generateSourcesJarTask; - private Javadoc _generateJavadocTask; - private Task _generateJavadocJarTask; - private boolean _configureIvyPublications = true; - - public void setPluginType(Class> pluginType) - { - _thisPluginType = pluginType; - } - - public void setSourcesJarTask(Task sourcesJarTask) - { - _generateSourcesJarTask = sourcesJarTask; - } - - public void setJavadocJarTask(Task javadocJarTask) - { - _generateJavadocJarTask = javadocJarTask; - } - - public void setConfigureIvyPublications(boolean configureIvyPublications) { - _configureIvyPublications = configureIvyPublications; - } - - @Override - public void apply(Project project) - { - checkGradleVersion(project); - - project.getPlugins().apply(JavaPlugin.class); - - // this HashMap will have a PegasusOptions per sourceSet - project.getExtensions().getExtraProperties().set("pegasus", new HashMap<>()); - // this map will extract PegasusOptions.GenerationMode to project property - project.getExtensions().getExtraProperties().set("PegasusGenerationMode", - Arrays.stream(PegasusOptions.GenerationMode.values()) - .collect(Collectors.toMap(PegasusOptions.GenerationMode::name, Function.identity()))); - - synchronized (STATIC_PROJECT_EVALUATED_LOCK) - { - // Check if this is the first time the block will run. Pegasus plugin can run multiple times in a build if - // multiple sub-projects applied the plugin. - if (!project.getRootProject().hasProperty(RUN_ONCE) - || !Boolean.parseBoolean(String.valueOf(project.getRootProject().property(RUN_ONCE)))) - { - project.getGradle().projectsEvaluated(gradle -> - gradle.getRootProject().subprojects(subproject -> - UNUSED_CONFIGURATIONS.forEach(configurationName -> { - Configuration conf = subproject.getConfigurations().findByName(configurationName); - if (conf != null && !conf.getDependencies().isEmpty()) { - subproject.getLogger().warn("*** Project {} declares dependency to unused configuration \"{}\". " - + "This configuration is deprecated and you can safely remove the dependency. ***", - subproject.getPath(), configurationName); - } - }) - ) - ); - - // Re-initialize the static variables as they might have stale values from previous run. With Gradle 3.0 and - // gradle daemon enabled, the plugin class might not be loaded for every run. - DATA_TEMPLATE_FILE_SUFFIXES.clear(); - DATA_TEMPLATE_FILE_SUFFIXES.add(DATA_TEMPLATE_FILE_SUFFIX); - DATA_TEMPLATE_FILE_SUFFIXES.add(PDL_FILE_SUFFIX); - - _restModelCompatMessage = new StringBuffer(); - _needCheckinFiles.clear(); - _needBuildFolders.clear(); - _possibleMissingFilesInEarlierCommit.clear(); - - project.getGradle().buildFinished(result -> - { - StringBuilder endOfBuildMessage = new StringBuilder(); - if (_restModelCompatMessage.length() > 0) - { - endOfBuildMessage.append(_restModelCompatMessage); - } - - if (!_needCheckinFiles.isEmpty()) - { - endOfBuildMessage.append(createModifiedFilesMessage(_needCheckinFiles, _needBuildFolders)); - } - - if (!_possibleMissingFilesInEarlierCommit.isEmpty()) - { - endOfBuildMessage.append(createPossibleMissingFilesMessage(_possibleMissingFilesInEarlierCommit)); - } - - if (endOfBuildMessage.length() > 0) - { - result.getGradle().getRootProject().getLogger().quiet(endOfBuildMessage.toString()); - } - }); - - // Set an extra property on the root project to indicate the initialization is complete for the current build. - project.getRootProject().getExtensions().getExtraProperties().set(RUN_ONCE, true); - } - } - - ConfigurationContainer configurations = project.getConfigurations(); - - // configuration for getting the required classes to make pegasus call main methods - configurations.maybeCreate(PEGASUS_PLUGIN_CONFIGURATION); - - // configuration for compiling generated data templates - Configuration dataTemplateCompile = configurations.maybeCreate("dataTemplateCompile"); - dataTemplateCompile.setVisible(false); - - // configuration for running rest client generator - Configuration restClientCompile = configurations.maybeCreate("restClientCompile"); - restClientCompile.setVisible(false); - - // configuration for running data template generator - // DEPRECATED! This configuration is no longer used. Please stop using it. - Configuration dataTemplateGenerator = configurations.maybeCreate("dataTemplateGenerator"); - dataTemplateGenerator.setVisible(false); - - // configuration for running rest client generator - // DEPRECATED! This configuration is no longer used. Please stop using it. - Configuration restTools = configurations.maybeCreate("restTools"); - restTools.setVisible(false); - - // configuration for running Avro schema generator - // DEPRECATED! To skip avro schema generation, use PegasusOptions.generationModes - Configuration avroSchemaGenerator = configurations.maybeCreate("avroSchemaGenerator"); - avroSchemaGenerator.setVisible(false); - - // configuration for depending on data schemas and potentially generated data templates - // and for publishing jars containing data schemas to the project artifacts for including in the ivy.xml - Configuration dataModel = configurations.maybeCreate("dataModel"); - Configuration testDataModel = configurations.maybeCreate("testDataModel"); - testDataModel.extendsFrom(dataModel); - - // configuration for depending on data schemas and potentially generated data templates - // and for publishing jars containing data schemas to the project artifacts for including in the ivy.xml - Configuration avroSchema = configurations.maybeCreate("avroSchema"); - Configuration testAvroSchema = configurations.maybeCreate("testAvroSchema"); - testAvroSchema.extendsFrom(avroSchema); - - // configuration for depending on rest idl and potentially generated client builders - // and for publishing jars containing rest idl to the project artifacts for including in the ivy.xml - Configuration restModel = configurations.maybeCreate("restModel"); - Configuration testRestModel = configurations.maybeCreate("testRestModel"); - testRestModel.extendsFrom(restModel); - - // configuration for publishing jars containing data schemas and generated data templates - // to the project artifacts for including in the ivy.xml - // - // published data template jars depends on the configurations used to compile the classes - // in the jar, this includes the data models/templates used by the data template generator - // and the classes used to compile the generated classes. - Configuration dataTemplate = configurations.maybeCreate("dataTemplate"); - dataTemplate.extendsFrom(dataTemplateCompile, dataModel); - Configuration testDataTemplate = configurations.maybeCreate("testDataTemplate"); - testDataTemplate.extendsFrom(dataTemplate, testDataModel); - - // configuration for processing and validating schema annotation during build time. - // - // The configuration contains dependencies to schema annotation handlers which would process schema annotations - // and validate. - Configuration schemaAnnotationHandler = configurations.maybeCreate(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION); - - // configuration for publishing jars containing rest idl and generated client builders - // to the project artifacts for including in the ivy.xml - // - // published client builder jars depends on the configurations used to compile the classes - // in the jar, this includes the data models/templates (potentially generated by this - // project and) used by the data template generator and the classes used to compile - // the generated classes. - Configuration restClient = configurations.maybeCreate("restClient"); - restClient.extendsFrom(restClientCompile, dataTemplate); - Configuration testRestClient = configurations.maybeCreate("testRestClient"); - testRestClient.extendsFrom(restClient, testDataTemplate); - - Properties properties = new Properties(); - InputStream inputStream = getClass().getResourceAsStream("/pegasus-version.properties"); - if (inputStream != null) - { - try - { - properties.load(inputStream); - } - catch (IOException e) - { - throw new GradleException("Unable to read pegasus-version.properties file.", e); - } - - String version = properties.getProperty("pegasus.version"); - - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:data:" + version); - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:data-avro-generator:" + version); - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:generator:" + version); - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "com.linkedin.pegasus:restli-tools:" + version); - } - else - { - project.getLogger().lifecycle("Unable to add pegasus dependencies to {}. Please be sure that " - + "'com.linkedin.pegasus:data', 'com.linkedin.pegasus:data-avro-generator', 'com.linkedin.pegasus:generator', 'com.linkedin.pegasus:restli-tools'" - + " are available on the configuration pegasusPlugin", - project.getPath()); - } - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, "org.slf4j:slf4j-simple:1.7.2"); - project.getDependencies().add(PEGASUS_PLUGIN_CONFIGURATION, project.files(System.getProperty("java.home") + "/../lib/tools.jar")); - - // this call has to be here because: - // 1) artifact cannot be published once projects has been evaluated, so we need to first - // create the tasks and artifact handler, then progressively append sources - // 2) in order to append sources progressively, the source and documentation tasks and artifacts must be - // configured/created before configuring and creating the code generation tasks. - - configureGeneratedSourcesAndJavadoc(project); - - ChangedFileReportTask changedFileReportTask = project.getTasks() - .create("changedFilesReport", ChangedFileReportTask.class); - - project.getTasks().getByName("check").dependsOn(changedFileReportTask); - - SourceSetContainer sourceSets = project.getConvention() - .getPlugin(JavaPluginConvention.class).getSourceSets(); - - sourceSets.all(sourceSet -> - { - if (sourceSet.getName().toLowerCase(Locale.US).contains("generated")) - { - return; - } - - checkAvroSchemaExist(project, sourceSet); - - // the idl Generator input options will be inside the PegasusOptions class. Users of the - // plugin can set the inputOptions in their build.gradle - @SuppressWarnings("unchecked") - Map pegasusOptions = (Map) project - .getExtensions().getExtraProperties().get("pegasus"); - - pegasusOptions.put(sourceSet.getName(), new PegasusOptions()); - - // rest model generation could fail on incompatibility - // if it can fail, fail it early - configureRestModelGeneration(project, sourceSet); - - // Do compatibility check for schemas under "pegasus" directory if the configuration property is provided. - if (isPropertyTrue(project, ENABLE_PEGASUS_SCHEMA_COMPATIBILITY_CHECK)) - { - configurePegasusSchemaSnapshotGeneration(project, sourceSet, false); - } - - configurePegasusSchemaSnapshotGeneration(project, sourceSet, true); - - configureConversionUtilities(project, sourceSet); - - GenerateDataTemplateTask generateDataTemplateTask = configureDataTemplateGeneration(project, sourceSet); - - configureAvroSchemaGeneration(project, sourceSet); - - configureRestClientGeneration(project, sourceSet); - - if (!isPropertyTrue(project, DISABLE_SCHEMA_ANNOTATION_VALIDATION)) - { - configureSchemaAnnotationValidation(project, sourceSet, generateDataTemplateTask); - } - - Task cleanGeneratedDirTask = project.task(sourceSet.getTaskName("clean", "GeneratedDir")); - cleanGeneratedDirTask.doLast(new CacheableAction<>(task -> - { - deleteGeneratedDir(project, sourceSet, REST_GEN_TYPE); - deleteGeneratedDir(project, sourceSet, AVRO_SCHEMA_GEN_TYPE); - deleteGeneratedDir(project, sourceSet, DATA_TEMPLATE_GEN_TYPE); - })); - - // make clean depends on deleting the generated directories - project.getTasks().getByName("clean").dependsOn(cleanGeneratedDirTask); - - // Set data schema directories as resource roots - configureDataSchemaResourcesRoot(project, sourceSet); - }); - - project.getExtensions().getExtraProperties().set(GENERATOR_CLASSLOADER_NAME, getClass().getClassLoader()); - } - - protected void configureSchemaAnnotationValidation(Project project, - SourceSet sourceSet, - GenerateDataTemplateTask generateDataTemplatesTask) - { - // Task would execute based on the following order. - // generateDataTemplatesTask -> validateSchemaAnnotationTask - - // Create ValidateSchemaAnnotation task - ValidateSchemaAnnotationTask validateSchemaAnnotationTask = project.getTasks() - .create(sourceSet.getTaskName("validate", "schemaAnnotation"), ValidateSchemaAnnotationTask.class, task -> - { - task.setInputDir(generateDataTemplatesTask.getInputDir()); - task.setResolverPath(getDataModelConfig(project, sourceSet)); // same resolver path as generateDataTemplatesTask - task.setClassPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION) - .plus(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)) - .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME))); - task.setHandlerJarPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - } - ); - - // validateSchemaAnnotationTask depend on generateDataTemplatesTask - validateSchemaAnnotationTask.dependsOn(generateDataTemplatesTask); - - // Check depends on validateSchemaAnnotationTask. - project.getTasks().getByName("check").dependsOn(validateSchemaAnnotationTask); - } - - - - @SuppressWarnings("deprecation") - protected void configureGeneratedSourcesAndJavadoc(Project project) - { - _generateJavadocTask = project.getTasks().create("generateJavadoc", Javadoc.class); - - if (_generateSourcesJarTask == null) - { - // - // configuration for publishing jars containing sources for generated classes - // to the project artifacts for including in the ivy.xml - // - ConfigurationContainer configurations = project.getConfigurations(); - Configuration generatedSources = configurations.maybeCreate("generatedSources"); - Configuration testGeneratedSources = configurations.maybeCreate("testGeneratedSources"); - testGeneratedSources.extendsFrom(generatedSources); - - _generateSourcesJarTask = project.getTasks().create("generateSourcesJar", Jar.class, jarTask -> { - jarTask.setGroup(JavaBasePlugin.DOCUMENTATION_GROUP); - jarTask.setDescription("Generates a jar file containing the sources for the generated Java classes."); - // FIXME change to #getArchiveClassifier().set("sources"); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - jarTask.getArchiveClassifier().set("sources"); - }); - - project.getArtifacts().add("generatedSources", _generateSourcesJarTask); - } - - if (_generateJavadocJarTask == null) - { - // - // configuration for publishing jars containing Javadoc for generated classes - // to the project artifacts for including in the ivy.xml - // - ConfigurationContainer configurations = project.getConfigurations(); - Configuration generatedJavadoc = configurations.maybeCreate("generatedJavadoc"); - Configuration testGeneratedJavadoc = configurations.maybeCreate("testGeneratedJavadoc"); - testGeneratedJavadoc.extendsFrom(generatedJavadoc); - - _generateJavadocJarTask = project.getTasks().create("generateJavadocJar", Jar.class, jarTask -> { - jarTask.dependsOn(_generateJavadocTask); - jarTask.setGroup(JavaBasePlugin.DOCUMENTATION_GROUP); - jarTask.setDescription("Generates a jar file containing the Javadoc for the generated Java classes."); - // FIXME change to #getArchiveClassifier().set("sources"); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - jarTask.getArchiveClassifier().set("javadoc"); - jarTask.from(_generateJavadocTask.getDestinationDir()); - }); - - project.getArtifacts().add("generatedJavadoc", _generateJavadocJarTask); - } - else - { - // TODO: Tighten the types so that _generateJavadocJarTask must be of type Jar. - ((Jar) _generateJavadocJarTask).from(_generateJavadocTask.getDestinationDir()); - _generateJavadocJarTask.dependsOn(_generateJavadocTask); - } - } - - private static void deleteGeneratedDir(Project project, SourceSet sourceSet, String dirType) - { - String generatedDirPath = getGeneratedDirPath(project, sourceSet, dirType); - project.getLogger().info("Delete generated directory {}", generatedDirPath); - project.delete(generatedDirPath); - } - - private static > Class getCompatibilityLevelClass(Project project) - { - ClassLoader generatorClassLoader = (ClassLoader) project.property(GENERATOR_CLASSLOADER_NAME); - - String className = "com.linkedin.restli.tools.idlcheck.CompatibilityLevel"; - try - { - @SuppressWarnings("unchecked") - Class enumClass = (Class) generatorClassLoader.loadClass(className).asSubclass(Enum.class); - return enumClass; - } - catch (ClassNotFoundException e) - { - throw new RuntimeException("Could not load class " + className); - } - } - - private static void addGeneratedDir(Project project, SourceSet sourceSet, Collection configurations) - { - project.getPlugins().withType(IdeaPlugin.class, ideaPlugin -> { - IdeaModule ideaModule = ideaPlugin.getModel().getModule(); - // stupid if block needed because of stupid assignment required to update source dirs - if (isTestSourceSet(sourceSet)) - { - Set sourceDirs = ideaModule.getTestSourceDirs(); - sourceDirs.addAll(sourceSet.getJava().getSrcDirs()); - // this is stupid but assignment is required - ideaModule.setTestSourceDirs(sourceDirs); - if (debug) - { - System.out.println("Added " + sourceSet.getJava().getSrcDirs() + " to IdeaModule testSourceDirs " - + ideaModule.getTestSourceDirs()); - } - } - else - { - Set sourceDirs = ideaModule.getSourceDirs(); - sourceDirs.addAll(sourceSet.getJava().getSrcDirs()); - // this is stupid but assignment is required - ideaModule.setSourceDirs(sourceDirs); - if (debug) - { - System.out.println("Added " + sourceSet.getJava().getSrcDirs() + " to IdeaModule sourceDirs " - + ideaModule.getSourceDirs()); - } - } - Collection compilePlus = ideaModule.getScopes().get("COMPILE").get("plus"); - compilePlus.addAll(configurations); - ideaModule.getScopes().get("COMPILE").put("plus", compilePlus); - }); - } - - private static void checkAvroSchemaExist(Project project, SourceSet sourceSet) - { - String sourceDir = "src" + File.separatorChar + sourceSet.getName(); - File avroSourceDir = project.file(sourceDir + File.separatorChar + "avro"); - if (avroSourceDir.exists()) - { - project.getLogger().lifecycle("{}'s {} has non-empty avro directory. pegasus plugin does not process avro directory", - project.getName(), sourceDir); - } - } - - // Compute the name of the source set that will contain a type of an input generated code. - // e.g. genType may be 'DataTemplate' or 'Rest' - private static String getGeneratedSourceSetName(SourceSet sourceSet, String genType) - { - return sourceSet.getName() + "Generated" + genType; - } - - // Compute the directory name that will contain a type generated code of an input source set. - // e.g. genType may be 'DataTemplate' or 'Rest' - public static String getGeneratedDirPath(Project project, SourceSet sourceSet, String genType) - { - String override = getOverridePath(project, sourceSet, "overrideGeneratedDir"); - String sourceSetName = getGeneratedSourceSetName(sourceSet, genType); - String base = override == null ? "src" : override; - - return base + File.separatorChar + sourceSetName; - } - - public static String getDataSchemaPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, "overridePegasusDir"); - if (override == null) - { - return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "pegasus"; - } - else - { - return override; - } - } - - private static String getExtensionSchemaPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, "overrideExtensionSchemaDir"); - if(override == null) - { - return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "extensions"; - } - else - { - return override; - } - } - - private static String getSnapshotPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, "overrideSnapshotDir"); - if (override == null) - { - return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "snapshot"; - } - else - { - return override; - } - } - - private static String getIdlPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, "overrideIdlDir"); - if (override == null) - { - return "src" + File.separatorChar + sourceSet.getName() + File.separatorChar + "idl"; - } - else - { - return override; - } - } - - private static String getPegasusSchemaSnapshotPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, PEGASUS_SCHEMA_SNAPSHOT_DIR_OVERRIDE); - if (override == null) - { - return SRC + File.separatorChar + sourceSet.getName() + File.separatorChar + PEGASUS_SCHEMA_SNAPSHOT_DIR; - } - else - { - return override; - } - } - - private static String getPegasusExtensionSchemaSnapshotPath(Project project, SourceSet sourceSet) - { - String override = getOverridePath(project, sourceSet, PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR_OVERRIDE); - if (override == null) - { - return SRC + File.separatorChar + sourceSet.getName() + File.separatorChar + PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_DIR; - } - else - { - return override; - } - } - - private static String getOverridePath(Project project, SourceSet sourceSet, String overridePropertyName) - { - String sourceSetPropertyName = sourceSet.getName() + '.' + overridePropertyName; - String override = getNonEmptyProperty(project, sourceSetPropertyName); - - if (override == null && sourceSet.getName().equals("main")) - { - override = getNonEmptyProperty(project, overridePropertyName); - } - - return override; - } - - private static boolean isTestSourceSet(SourceSet sourceSet) - { - return TEST_DIR_REGEX.matcher(sourceSet.getName()).find(); - } - - private static Configuration getDataModelConfig(Project project, SourceSet sourceSet) - { - return isTestSourceSet(sourceSet) - ? project.getConfigurations().getByName("testDataModel") - : project.getConfigurations().getByName("dataModel"); - } - - private static boolean isTaskSuccessful(Task task) - { - return task.getState().getExecuted() - // Task is not successful if it is not upto date and is skipped. - && !(task.getState().getSkipped() && !task.getState().getUpToDate()) - && task.getState().getFailure() == null; - } - - private static boolean isResultEquivalent(File compatibilityLogFile) - { - return isResultEquivalent(compatibilityLogFile, false); - } - - private static boolean isResultEquivalent(File compatibilityLogFile, boolean restSpecOnly) - { - CompatibilityLogChecker logChecker = new CompatibilityLogChecker(); - try - { - logChecker.write(Files.readAllBytes(compatibilityLogFile.toPath())); - } - catch (IOException e) - { - throw new GradleException("Error while processing compatibility report: " + e.getMessage()); - } - return logChecker.getRestSpecCompatibility().isEmpty() && - (restSpecOnly || logChecker.getModelCompatibility().isEmpty()); - } - - protected void configureRestModelGeneration(Project project, SourceSet sourceSet) - { - if (sourceSet.getAllSource().isEmpty()) - { - project.getLogger().info("No source files found for sourceSet {}. Skipping idl generation.", sourceSet.getName()); - return; - } - - // afterEvaluate needed so that api project can be overridden via ext.apiProject - project.afterEvaluate(p -> - { - // find api project here instead of in each project's plugin configuration - // this allows api project relation options (ext.api*) to be specified anywhere in the build.gradle file - // alternatively, pass closures to task configuration, and evaluate the closures when task is executed - Project apiProject = getCheckedApiProject(project); - - // make sure the api project is evaluated. Important for configure-on-demand mode. - if (apiProject != null) - { - project.evaluationDependsOn(apiProject.getPath()); - - if (!apiProject.getPlugins().hasPlugin(_thisPluginType)) - { - apiProject = null; - } - } - - if (apiProject == null) - { - return; - } - - Task untypedJarTask = project.getTasks().findByName(sourceSet.getJarTaskName()); - if (!(untypedJarTask instanceof Jar)) - { - return; - } - Jar jarTask = (Jar) untypedJarTask; - - String snapshotCompatPropertyName = findProperty(FileCompatibilityType.SNAPSHOT); - if (project.hasProperty(snapshotCompatPropertyName) && "off".equalsIgnoreCase((String) project.property(snapshotCompatPropertyName))) - { - project.getLogger().lifecycle("Project {} snapshot compatibility level \"OFF\" is deprecated. Default to \"IGNORE\".", - project.getPath()); - } - - // generate the rest model - FileCollection restModelCodegenClasspath = project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION) - .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME)) - .plus(sourceSet.getRuntimeClasspath()); - String destinationDirPrefix = getGeneratedDirPath(project, sourceSet, REST_GEN_TYPE) + File.separatorChar; - FileCollection restModelResolverPath = apiProject.files(getDataSchemaPath(project, sourceSet)) - .plus(getDataModelConfig(apiProject, sourceSet)); - Set watchedRestModelInputDirs = buildWatchedRestModelInputDirs(project, sourceSet); - Set restModelInputDirs = difference(sourceSet.getAllSource().getSrcDirs(), - sourceSet.getResources().getSrcDirs()); - - Task generateRestModelTask = project.getTasks() - .create(sourceSet.getTaskName("generate", "restModel"), GenerateRestModelTask.class, task -> - { - task.dependsOn(project.getTasks().getByName(sourceSet.getClassesTaskName())); - task.setCodegenClasspath(restModelCodegenClasspath); - task.setWatchedCodegenClasspath(restModelCodegenClasspath - .filter(file -> !"main".equals(file.getName()) && !"classes".equals(file.getName()))); - task.setInputDirs(restModelInputDirs); - task.setWatchedInputDirs(watchedRestModelInputDirs.isEmpty() - ? restModelInputDirs : watchedRestModelInputDirs); - // we need all the artifacts from runtime for any private implementation classes the server code might need. - task.setSnapshotDestinationDir(project.file(destinationDirPrefix + "snapshot")); - task.setIdlDestinationDir(project.file(destinationDirPrefix + "idl")); - - @SuppressWarnings("unchecked") - Map pegasusOptions = (Map) project - .getExtensions().getExtraProperties().get("pegasus"); - task.setIdlOptions(pegasusOptions.get(sourceSet.getName()).idlOptions); - - task.setResolverPath(restModelResolverPath); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - - task.onlyIf(t -> !isPropertyTrue(project, SKIP_GENERATE_REST_MODEL)); - - task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, REST_GEN_TYPE))); - }); - - File apiSnapshotDir = apiProject.file(getSnapshotPath(apiProject, sourceSet)); - File apiIdlDir = apiProject.file(getIdlPath(apiProject, sourceSet)); - apiSnapshotDir.mkdirs(); - - if (!isPropertyTrue(project, SKIP_IDL_CHECK)) - { - apiIdlDir.mkdirs(); - } - - CheckRestModelTask checkRestModelTask = project.getTasks() - .create(sourceSet.getTaskName("check", "RestModel"), CheckRestModelTask.class, task -> - { - task.dependsOn(generateRestModelTask); - task.setCurrentSnapshotFiles(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); - task.setPreviousSnapshotDirectory(apiSnapshotDir); - task.setCurrentIdlFiles(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); - task.setPreviousIdlDirectory(apiIdlDir); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setModelCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.SNAPSHOT)); - task.onlyIf(t -> !isPropertyTrue(project, SKIP_IDL_CHECK)); - - task.doLast(new CacheableAction<>(t -> - { - if (!task.isEquivalent()) - { - _restModelCompatMessage.append(task.getWholeMessage()); - } - })); - }); - - CheckSnapshotTask checkSnapshotTask = project.getTasks() - .create(sourceSet.getTaskName("check", "Snapshot"), CheckSnapshotTask.class, task -> { - task.dependsOn(generateRestModelTask); - task.setCurrentSnapshotFiles(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); - task.setPreviousSnapshotDirectory(apiSnapshotDir); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setSnapshotCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.SNAPSHOT)); - - task.onlyIf(t -> isPropertyTrue(project, SKIP_IDL_CHECK)); - }); - - CheckIdlTask checkIdlTask = project.getTasks() - .create(sourceSet.getTaskName("check", "Idl"), CheckIdlTask.class, task -> - { - task.dependsOn(generateRestModelTask); - task.setCurrentIdlFiles(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); - task.setPreviousIdlDirectory(apiIdlDir); - task.setResolverPath(restModelResolverPath); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setIdlCompatLevel(PropertyUtil.findCompatLevel(project, FileCompatibilityType.IDL)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - - - task.onlyIf(t -> !isPropertyTrue(project, SKIP_IDL_CHECK) - && !"OFF".equals(PropertyUtil.findCompatLevel(project, FileCompatibilityType.IDL))); - }); - - // rest model publishing involves cross-project reference - // configure after all projects have been evaluated - // the file copy can be turned off by "rest.model.noPublish" flag - Task publishRestliSnapshotTask = project.getTasks() - .create(sourceSet.getTaskName("publish", "RestliSnapshot"), PublishRestModelTask.class, task -> - { - task.dependsOn(checkRestModelTask, checkSnapshotTask, checkIdlTask); - task.from(SharedFileUtils.getSnapshotFiles(project, destinationDirPrefix)); - task.into(apiSnapshotDir); - task.setSuffix(SNAPSHOT_FILE_SUFFIX); - - task.onlyIf(t -> - isPropertyTrue(project, SNAPSHOT_FORCE_PUBLISH) || - ( - !isPropertyTrue(project, SNAPSHOT_NO_PUBLISH) && - ( - ( - isPropertyTrue(project, SKIP_IDL_CHECK) && - isTaskSuccessful(checkSnapshotTask) && - checkSnapshotTask.getSummaryTarget().exists() && - !isResultEquivalent(checkSnapshotTask.getSummaryTarget()) - ) || - ( - !isPropertyTrue(project, SKIP_IDL_CHECK) && - isTaskSuccessful(checkRestModelTask) && - checkRestModelTask.getSummaryTarget().exists() && - !isResultEquivalent(checkRestModelTask.getSummaryTarget()) - ) - )) - ); - }); - - Task publishRestliIdlTask = project.getTasks() - .create(sourceSet.getTaskName("publish", "RestliIdl"), PublishRestModelTask.class, task -> { - task.dependsOn(checkRestModelTask, checkIdlTask, checkSnapshotTask); - task.from(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); - task.into(apiIdlDir); - task.setSuffix(IDL_FILE_SUFFIX); - - task.onlyIf(t -> - isPropertyTrue(project, IDL_FORCE_PUBLISH) || - ( - !isPropertyTrue(project, IDL_NO_PUBLISH) && - ( - ( - isPropertyTrue(project, SKIP_IDL_CHECK) && - isTaskSuccessful(checkSnapshotTask) && - checkSnapshotTask.getSummaryTarget().exists() && - !isResultEquivalent(checkSnapshotTask.getSummaryTarget(), true) - ) || - ( - !isPropertyTrue(project, SKIP_IDL_CHECK) && - ( - (isTaskSuccessful(checkRestModelTask) && - checkRestModelTask.getSummaryTarget().exists() && - !isResultEquivalent(checkRestModelTask.getSummaryTarget(), true)) || - (isTaskSuccessful(checkIdlTask) && - checkIdlTask.getSummaryTarget().exists() && - !isResultEquivalent(checkIdlTask.getSummaryTarget())) - ) - ) - )) - ); - }); - - project.getLogger().info("API project selected for {} is {}", - publishRestliIdlTask.getPath(), apiProject.getPath()); - - jarTask.from(SharedFileUtils.getIdlFiles(project, destinationDirPrefix)); - // add generated .restspec.json files as resources to the jar - jarTask.dependsOn(publishRestliSnapshotTask, publishRestliIdlTask); - - ChangedFileReportTask changedFileReportTask = (ChangedFileReportTask) project.getTasks() - .getByName("changedFilesReport"); - - // Use the files from apiDir for generating the changed files report as we need to notify user only when - // source system files are modified. - changedFileReportTask.setIdlFiles(SharedFileUtils.getSuffixedFiles(project, apiIdlDir, IDL_FILE_SUFFIX)); - changedFileReportTask.setSnapshotFiles(SharedFileUtils.getSuffixedFiles(project, apiSnapshotDir, - SNAPSHOT_FILE_SUFFIX)); - changedFileReportTask.mustRunAfter(publishRestliSnapshotTask, publishRestliIdlTask); - changedFileReportTask.doLast(new CacheableAction<>(t -> - { - if (!changedFileReportTask.getNeedCheckinFiles().isEmpty()) - { - project.getLogger().info("Adding modified files to need checkin list..."); - _needCheckinFiles.addAll(changedFileReportTask.getNeedCheckinFiles()); - _needBuildFolders.add(getCheckedApiProject(project).getPath()); - } - })); - }); - } - - protected void configurePegasusSchemaSnapshotGeneration(Project project, SourceSet sourceSet, boolean isExtensionSchema) - { - File schemaDir = isExtensionSchema? project.file(getExtensionSchemaPath(project, sourceSet)) - : project.file(getDataSchemaPath(project, sourceSet)); - - if ((isExtensionSchema && SharedFileUtils.getSuffixedFiles(project, schemaDir, PDL_FILE_SUFFIX).isEmpty()) || - (!isExtensionSchema && SharedFileUtils.getSuffixedFiles(project, schemaDir, DATA_TEMPLATE_FILE_SUFFIXES).isEmpty())) - { - return; - } - - Path publishablePegasusSchemaSnapshotDir = project.getBuildDir().toPath().resolve(sourceSet.getName() + - (isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT)); - - Task generatePegasusSchemaSnapshot = generatePegasusSchemaSnapshot(project, sourceSet, - isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT, schemaDir, - publishablePegasusSchemaSnapshotDir.toFile(), isExtensionSchema); - - File pegasusSchemaSnapshotDir = project.file(isExtensionSchema ? getPegasusExtensionSchemaSnapshotPath(project, sourceSet) - : getPegasusSchemaSnapshotPath(project, sourceSet)); - pegasusSchemaSnapshotDir.mkdirs(); - - Task checkSchemaSnapshot = project.getTasks().create(sourceSet.getTaskName("check", - isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT), - CheckPegasusSnapshotTask.class, task -> - { - task.dependsOn(generatePegasusSchemaSnapshot); - task.setCurrentSnapshotDirectory(publishablePegasusSchemaSnapshotDir.toFile()); - task.setPreviousSnapshotDirectory(pegasusSchemaSnapshotDir); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION) - .plus(project.getConfigurations().getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)) - .plus(project.getConfigurations().getByName(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME))); - task.setCompatibilityLevel(isExtensionSchema ? - PropertyUtil.findCompatLevel(project, FileCompatibilityType.PEGASUS_EXTENSION_SCHEMA_SNAPSHOT) - :PropertyUtil.findCompatLevel(project, FileCompatibilityType.PEGASUS_SCHEMA_SNAPSHOT)); - task.setCompatibilityMode(isExtensionSchema ? COMPATIBILITY_OPTIONS_MODE_EXTENSION : - PropertyUtil.findCompatMode(project, PEGASUS_COMPATIBILITY_MODE)); - task.setExtensionSchema(isExtensionSchema); - task.setHandlerJarPath(project.getConfigurations() .getByName(SCHEMA_ANNOTATION_HANDLER_CONFIGURATION)); - - task.onlyIf(t -> - { - String pegasusSnapshotCompatPropertyName = isExtensionSchema ? - findProperty(FileCompatibilityType.PEGASUS_EXTENSION_SCHEMA_SNAPSHOT) - : findProperty(FileCompatibilityType.PEGASUS_SCHEMA_SNAPSHOT); - return !project.hasProperty(pegasusSnapshotCompatPropertyName) || - !"off".equalsIgnoreCase((String) project.property(pegasusSnapshotCompatPropertyName)); - }); - }); - - Task publishPegasusSchemaSnapshot = publishPegasusSchemaSnapshot(project, sourceSet, - isExtensionSchema ? PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: PEGASUS_SCHEMA_SNAPSHOT, checkSchemaSnapshot, - publishablePegasusSchemaSnapshotDir.toFile(), pegasusSchemaSnapshotDir); - - project.getTasks().getByName(LifecycleBasePlugin.ASSEMBLE_TASK_NAME).dependsOn(publishPegasusSchemaSnapshot); - } - - @SuppressWarnings("deprecation") - protected void configureAvroSchemaGeneration(Project project, SourceSet sourceSet) - { - File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); - File avroDir = project.file(getGeneratedDirPath(project, sourceSet, AVRO_SCHEMA_GEN_TYPE) - + File.separatorChar + "avro"); - - // generate avro schema files from data schema - Task generateAvroSchemaTask = project.getTasks() - .create(sourceSet.getTaskName("generate", "avroSchema"), GenerateAvroSchemaTask.class, task -> { - task.setInputDir(dataSchemaDir); - task.setDestinationDir(avroDir); - task.setResolverPath(getDataModelConfig(project, sourceSet)); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - - task.onlyIf(t -> - { - if (task.getInputDir().exists()) - { - @SuppressWarnings("unchecked") - Map pegasusOptions = (Map) project - .getExtensions().getExtraProperties().get("pegasus"); - - if (pegasusOptions.get(sourceSet.getName()).hasGenerationMode(PegasusOptions.GenerationMode.AVRO)) - { - return true; - } - } - - return !project.getConfigurations().getByName("avroSchemaGenerator").isEmpty(); - }); - - task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, AVRO_SCHEMA_GEN_TYPE))); - }); - - project.getTasks().getByName(sourceSet.getCompileJavaTaskName()).dependsOn(generateAvroSchemaTask); - - // create avro schema jar file - - Task avroSchemaJarTask = project.getTasks().create(sourceSet.getName() + "AvroSchemaJar", Jar.class, task -> - { - // add path prefix to each file in the data schema directory - task.from(avroDir, copySpec -> - copySpec.eachFile(fileCopyDetails -> - fileCopyDetails.setPath("avro" + File.separatorChar + fileCopyDetails.getPath()))); - - // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - task.getArchiveAppendix().set(getAppendix(sourceSet, "avro-schema")); - task.setDescription("Generate an avro schema jar"); - }); - - if (!isTestSourceSet(sourceSet)) - { - project.getArtifacts().add("avroSchema", avroSchemaJarTask); - } - else - { - project.getArtifacts().add("testAvroSchema", avroSchemaJarTask); - } - } - - protected void configureConversionUtilities(Project project, SourceSet sourceSet) - { - File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); - boolean reverse = isPropertyTrue(project, CONVERT_TO_PDL_REVERSE); - boolean keepOriginal = isPropertyTrue(project, CONVERT_TO_PDL_KEEP_ORIGINAL); - boolean skipVerification = isPropertyTrue(project, CONVERT_TO_PDL_SKIP_VERIFICATION); - String preserveSourceCmd = getNonEmptyProperty(project, CONVERT_TO_PDL_PRESERVE_SOURCE_CMD); - - // Utility task for migrating between PDSC and PDL. - project.getTasks().create(sourceSet.getTaskName("convert", "ToPdl"), TranslateSchemasTask.class, task -> - { - task.setInputDir(dataSchemaDir); - task.setDestinationDir(dataSchemaDir); - task.setResolverPath(getDataModelConfig(project, sourceSet)); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setPreserveSourceCmd(preserveSourceCmd); - if (reverse) - { - task.setSourceFormat(SchemaFileType.PDL); - task.setDestinationFormat(SchemaFileType.PDSC); - } - else - { - task.setSourceFormat(SchemaFileType.PDSC); - task.setDestinationFormat(SchemaFileType.PDL); - } - task.setKeepOriginal(keepOriginal); - task.setSkipVerification(skipVerification); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - - task.onlyIf(t -> task.getInputDir().exists()); - task.doLast(new CacheableAction<>(t -> - { - project.getLogger().lifecycle("Pegasus schema conversion complete."); - project.getLogger().lifecycle("All pegasus schema files in " + dataSchemaDir + " have been converted"); - project.getLogger().lifecycle("You can use '-PconvertToPdl.reverse=true|false' to change the direction of conversion."); - })); - }); - - // Helper task for reformatting existing PDL schemas by generating them again. - project.getTasks().create(sourceSet.getTaskName("reformat", "Pdl"), TranslateSchemasTask.class, task -> - { - task.setInputDir(dataSchemaDir); - task.setDestinationDir(dataSchemaDir); - task.setResolverPath(getDataModelConfig(project, sourceSet)); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setSourceFormat(SchemaFileType.PDL); - task.setDestinationFormat(SchemaFileType.PDL); - task.setKeepOriginal(true); - task.setSkipVerification(true); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - - task.onlyIf(t -> task.getInputDir().exists()); - task.doLast(new CacheableAction<>(t -> project.getLogger().lifecycle("PDL reformat complete."))); - }); - } - - @SuppressWarnings("deprecation") - protected GenerateDataTemplateTask configureDataTemplateGeneration(Project project, SourceSet sourceSet) - { - File dataSchemaDir = project.file(getDataSchemaPath(project, sourceSet)); - File generatedDataTemplateDir = project.file(getGeneratedDirPath(project, sourceSet, DATA_TEMPLATE_GEN_TYPE) - + File.separatorChar + "java"); - File publishableSchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() - + File.separatorChar + sourceSet.getName() + "Schemas"); - File publishableLegacySchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() - + File.separatorChar + sourceSet.getName() + "LegacySchemas"); - File publishableExtensionSchemasBuildDir = project.file(project.getBuildDir().getAbsolutePath() - + File.separatorChar + sourceSet.getName() + "ExtensionSchemas"); - - // generate data template source files from data schema - GenerateDataTemplateTask generateDataTemplatesTask = project.getTasks() - .create(sourceSet.getTaskName("generate", "dataTemplate"), GenerateDataTemplateTask.class, task -> - { - task.setInputDir(dataSchemaDir); - task.setDestinationDir(generatedDataTemplateDir); - task.setResolverPath(getDataModelConfig(project, sourceSet)); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - if (isPropertyTrue(project, CODE_GEN_PATH_CASE_SENSITIVE)) - { - task.setGenerateLowercasePath(false); - } - - task.onlyIf(t -> - { - if (task.getInputDir().exists()) - { - @SuppressWarnings("unchecked") - Map pegasusOptions = (Map) project - .getExtensions().getExtraProperties().get("pegasus"); - - return pegasusOptions.get(sourceSet.getName()).hasGenerationMode(PegasusOptions.GenerationMode.PEGASUS); - } - - return false; - }); - - task.doFirst(new CacheableAction<>(t -> deleteGeneratedDir(project, sourceSet, DATA_TEMPLATE_GEN_TYPE))); - }); - - // TODO: Tighten the types so that _generateSourcesJarTask must be of type Jar. - ((Jar) _generateSourcesJarTask).from(generateDataTemplatesTask.getDestinationDir()); - _generateSourcesJarTask.dependsOn(generateDataTemplatesTask); - - _generateJavadocTask.source(generateDataTemplatesTask.getDestinationDir()); - _generateJavadocTask.setClasspath(_generateJavadocTask.getClasspath() - .plus(project.getConfigurations().getByName("dataTemplateCompile")) - .plus(generateDataTemplatesTask.getResolverPath())); - _generateJavadocTask.dependsOn(generateDataTemplatesTask); - - // Add extra dependencies for data model compilation - project.getDependencies().add("dataTemplateCompile", "com.google.code.findbugs:jsr305:3.0.2"); - - // create new source set for generated java source and class files - String targetSourceSetName = getGeneratedSourceSetName(sourceSet, DATA_TEMPLATE_GEN_TYPE); - - SourceSetContainer sourceSets = project.getConvention() - .getPlugin(JavaPluginConvention.class).getSourceSets(); - - SourceSet targetSourceSet = sourceSets.create(targetSourceSetName, ss -> - { - ss.java(sourceDirectorySet -> sourceDirectorySet.srcDir(generatedDataTemplateDir)); - ss.setCompileClasspath(getDataModelConfig(project, sourceSet) - .plus(project.getConfigurations().getByName("dataTemplateCompile"))); - }); - - // idea plugin needs to know about new generated java source directory and its dependencies - addGeneratedDir(project, targetSourceSet, Arrays.asList( - getDataModelConfig(project, sourceSet), - project.getConfigurations().getByName("dataTemplateCompile"))); - - // Set source compatibility to 1.8 as the data-templates now generate code with Java 8 features. - JavaCompile compileTask = project.getTasks() - .withType(JavaCompile.class).getByName(targetSourceSet.getCompileJavaTaskName()); - compileTask.doFirst(new CacheableAction<>(task -> { - ((JavaCompile) task).setSourceCompatibility("1.8"); - ((JavaCompile) task).setTargetCompatibility("1.8"); - })); - // make sure that java source files have been generated before compiling them - compileTask.dependsOn(generateDataTemplatesTask); - - // Dummy task to maintain backward compatibility - // TODO: Delete this task once use cases have had time to reference the new task - Task destroyStaleFiles = project.getTasks().create(sourceSet.getName() + "DestroyStaleFiles", Delete.class); - destroyStaleFiles.onlyIf(task -> { - project.getLogger().lifecycle("{} task is a NO-OP task.", task.getPath()); - return false; - }); - - // Dummy task to maintain backward compatibility, as this task was replaced by CopySchemas - // TODO: Delete this task once use cases have had time to reference the new task - Task copyPdscSchemasTask = project.getTasks().create(sourceSet.getName() + "CopyPdscSchemas", Copy.class); - copyPdscSchemasTask.dependsOn(destroyStaleFiles); - copyPdscSchemasTask.onlyIf(task -> { - project.getLogger().lifecycle("{} task is a NO-OP task.", task.getPath()); - return false; - }); - - // Prepare schema files for publication by syncing schema folders. - Task prepareSchemasForPublishTask = project.getTasks() - .create(sourceSet.getName() + "CopySchemas", Sync.class, task -> - { - task.from(dataSchemaDir, syncSpec -> DATA_TEMPLATE_FILE_SUFFIXES.forEach(suffix -> syncSpec.include("**/*" + suffix))); - task.into(publishableSchemasBuildDir); - }); - prepareSchemasForPublishTask.dependsOn(copyPdscSchemasTask); - - Collection dataTemplateJarDepends = new ArrayList<>(); - dataTemplateJarDepends.add(compileTask); - dataTemplateJarDepends.add(prepareSchemasForPublishTask); - - // Convert all PDL files back to PDSC for publication - // TODO: Remove this conversion permanently once translated PDSCs are no longer needed. - Task prepareLegacySchemasForPublishTask = project.getTasks() - .create(sourceSet.getName() + "TranslateSchemas", TranslateSchemasTask.class, task -> - { - task.setInputDir(dataSchemaDir); - task.setDestinationDir(publishableLegacySchemasBuildDir); - task.setResolverPath(getDataModelConfig(project, sourceSet)); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setSourceFormat(SchemaFileType.PDL); - task.setDestinationFormat(SchemaFileType.PDSC); - task.setKeepOriginal(true); - task.setSkipVerification(true); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - }); - - prepareLegacySchemasForPublishTask.dependsOn(destroyStaleFiles); - dataTemplateJarDepends.add(prepareLegacySchemasForPublishTask); - - // extension schema directory - File extensionSchemaDir = project.file(getExtensionSchemaPath(project, sourceSet)); - - if (!SharedFileUtils.getSuffixedFiles(project, extensionSchemaDir, PDL_FILE_SUFFIX).isEmpty()) - { - // Validate extension schemas if extension schemas are provided. - ValidateExtensionSchemaTask validateExtensionSchemaTask = project.getTasks() - .create(sourceSet.getTaskName("validate", "ExtensionSchemas"), ValidateExtensionSchemaTask.class, task -> - { - task.setInputDir(extensionSchemaDir); - task.setResolverPath( - getDataModelConfig(project, sourceSet).plus(project.files(getDataSchemaPath(project, sourceSet)))); - task.setClassPath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - }); - - Task prepareExtensionSchemasForPublishTask = project.getTasks() - .create(sourceSet.getName() + "CopyExtensionSchemas", Sync.class, task -> - { - task.from(extensionSchemaDir, syncSpec -> syncSpec.include("**/*" + PDL_FILE_SUFFIX)); - task.into(publishableExtensionSchemasBuildDir); - }); - - prepareExtensionSchemasForPublishTask.dependsOn(validateExtensionSchemaTask); - prepareExtensionSchemasForPublishTask.dependsOn(copyPdscSchemasTask); - dataTemplateJarDepends.add(prepareExtensionSchemasForPublishTask); - } - - // include pegasus files in the output of this SourceSet - project.getTasks().withType(ProcessResources.class).getByName(targetSourceSet.getProcessResourcesTaskName(), it -> - { - it.from(prepareSchemasForPublishTask, copy -> copy.into("pegasus")); - // TODO: Remove this permanently once translated PDSCs are no longer needed. - it.from(prepareLegacySchemasForPublishTask, copy -> copy.into(TRANSLATED_SCHEMAS_DIR)); - Sync copyExtensionSchemasTask = project.getTasks().withType(Sync.class).findByName(sourceSet.getName() + "CopyExtensionSchemas"); - if (copyExtensionSchemasTask != null) - { - it.from(copyExtensionSchemasTask, copy -> copy.into("extensions")); - } - }); - - // create data template jar file - Jar dataTemplateJarTask = project.getTasks() - .create(sourceSet.getName() + "DataTemplateJar", Jar.class, task -> - { - task.dependsOn(dataTemplateJarDepends); - task.from(targetSourceSet.getOutput()); - - // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - task.getArchiveAppendix().set(getAppendix(sourceSet, "data-template")); - task.setDescription("Generate a data template jar"); - }); - - // add the data model and date template jars to the list of project artifacts. - if (!isTestSourceSet(sourceSet)) - { - project.getArtifacts().add("dataTemplate", dataTemplateJarTask); - } - else - { - project.getArtifacts().add("testDataTemplate", dataTemplateJarTask); - } - - // include additional dependencies into the appropriate configuration used to compile the input source set - // must include the generated data template classes and their dependencies the configuration. - // "compile" and "testCompile" configurations have been removed in Gradle 7, - // but to keep the maximum backward compatibility, here we handle Gradle 7 and earlier version differently - // Once MIN_REQUIRED_VERSION reaches 7.0, we can remove the check of isAtLeastGradle7() - String compileConfigName; - if (isAtLeastGradle7()) { - compileConfigName = isTestSourceSet(sourceSet) ? "testImplementation" : project.getConfigurations().findByName("api") != null ? "api" : "implementation"; - } - else - { - compileConfigName = isTestSourceSet(sourceSet) ? "testCompile" : "compile"; - } - - Configuration compileConfig = project.getConfigurations().maybeCreate(compileConfigName); - compileConfig.extendsFrom( - getDataModelConfig(project, sourceSet), - project.getConfigurations().getByName("dataTemplateCompile")); - - // The getArchivePath() API doesn’t carry any task dependency and has been deprecated. - // Replace it with getArchiveFile() on Gradle 7, - // but keep getArchivePath() to be backwards-compatibility with Gradle version older than 5.1 - // DataHub Note - applied FIXME - project.getDependencies().add(compileConfigName, project.files( - isAtLeastGradle7() ? dataTemplateJarTask.getArchiveFile() : dataTemplateJarTask.getArchivePath())); - - if (_configureIvyPublications) { - // The below Action is only applied when the 'ivy-publish' is applied by the consumer. - // If the consumer does not use ivy-publish, this is a noop. - // this Action prepares the project applying the pegasus plugin to publish artifacts using these steps: - // 1. Registers "feature variants" for pegasus-specific artifacts; - // see https://docs.gradle.org/6.1/userguide/feature_variants.html - // 2. Wires legacy configurations like `dataTemplateCompile` to auto-generated feature variant *Api and - // *Implementation configurations for backwards compatibility. - // 3. Configures the Ivy Publication to include auto-generated feature variant *Api and *Implementation - // configurations and their dependencies. - project.getPlugins().withType(IvyPublishPlugin.class, ivyPublish -> { - if (!isAtLeastGradle61()) - { - throw new GradleException("Using the ivy-publish plugin with the pegasus plugin requires Gradle 6.1 or higher " + - "at build time. Please upgrade."); - } - - JavaPluginExtension java = project.getExtensions().getByType(JavaPluginExtension.class); - // create new capabilities per source set; automatically creates api and implementation configurations - String featureName = mapSourceSetToFeatureName(targetSourceSet); - try - { - /* - reflection is required to preserve compatibility with Gradle 5.2.1 and below - TODO once Gradle 5.3+ is required, remove reflection and replace with: - java.registerFeature(featureName, featureSpec -> { - featureSpec.usingSourceSet(targetSourceSet); - }); - */ - Method registerFeature = JavaPluginExtension.class.getDeclaredMethod("registerFeature", String.class, Action.class); - Action/**/ featureSpecAction = createFeatureVariantFromSourceSet(targetSourceSet); - registerFeature.invoke(java, featureName, featureSpecAction); - } - catch (ReflectiveOperationException e) - { - throw new GradleException("Unable to register new feature variant", e); - } - - // expose transitive dependencies to consumers via variant configurations - Configuration featureConfiguration = project.getConfigurations().getByName(featureName); - Configuration mainGeneratedDataTemplateApi = project.getConfigurations().getByName(targetSourceSet.getApiConfigurationName()); - featureConfiguration.extendsFrom(mainGeneratedDataTemplateApi); - mainGeneratedDataTemplateApi.extendsFrom( - getDataModelConfig(project, targetSourceSet), - project.getConfigurations().getByName("dataTemplateCompile")); - - // Configure the existing IvyPublication - // For backwards-compatibility, make the legacy dataTemplate/testDataTemplate configurations extend - // their replacements, auto-created when we registered the new feature variant - project.afterEvaluate(p -> { - PublishingExtension publishing = p.getExtensions().getByType(PublishingExtension.class); - // When configuring a Gradle Publication, use this value to find the name of the publication to configure. Defaults to "ivy". - String publicationName = p.getExtensions().getExtraProperties().getProperties().getOrDefault("PegasusPublicationName", "ivy").toString(); - IvyPublication ivyPublication = publishing.getPublications().withType(IvyPublication.class).getByName(publicationName); - ivyPublication.configurations(configurations -> configurations.create(featureName, legacyConfiguration -> { - legacyConfiguration.extend(p.getConfigurations().getByName(targetSourceSet.getApiElementsConfigurationName()).getName()); - legacyConfiguration.extend(p.getConfigurations().getByName(targetSourceSet.getRuntimeElementsConfigurationName()).getName()); - })); - }); - }); - } - - if (debug) - { - System.out.println("configureDataTemplateGeneration sourceSet " + sourceSet.getName()); - System.out.println(compileConfigName + ".allDependencies : " - + project.getConfigurations().getByName(compileConfigName).getAllDependencies()); - System.out.println(compileConfigName + ".extendsFrom: " - + project.getConfigurations().getByName(compileConfigName).getExtendsFrom()); - System.out.println(compileConfigName + ".transitive: " - + project.getConfigurations().getByName(compileConfigName).isTransitive()); - } - - project.getTasks().getByName(sourceSet.getCompileJavaTaskName()).dependsOn(dataTemplateJarTask); - return generateDataTemplatesTask; - } - - private String mapSourceSetToFeatureName(SourceSet sourceSet) { - String featureName = ""; - switch (sourceSet.getName()) { - case "mainGeneratedDataTemplate": - featureName = "dataTemplate"; - break; - case "testGeneratedDataTemplate": - featureName = "testDataTemplate"; - break; - case "mainGeneratedRest": - featureName = "restClient"; - break; - case "testGeneratedRest": - featureName = "testRestClient"; - break; - case "mainGeneratedAvroSchema": - featureName = "avroSchema"; - break; - case "testGeneratedAvroSchema": - featureName = "testAvroSchema"; - break; - default: - String msg = String.format("Unable to map %s to an appropriate feature name", sourceSet); - throw new GradleException(msg); - } - return featureName; - } - - // Generate rest client from idl files generated from java source files in the specified source set. - // - // This generates rest client source files from idl file generated from java source files - // in the source set. The generated rest client source files will be in a new source set. - // It also compiles the rest client source files into classes, and creates both the - // rest model and rest client jar files. - // - @SuppressWarnings("deprecation") - protected void configureRestClientGeneration(Project project, SourceSet sourceSet) - { - // idl directory for api project - File idlDir = project.file(getIdlPath(project, sourceSet)); - if (SharedFileUtils.getSuffixedFiles(project, idlDir, IDL_FILE_SUFFIX).isEmpty() && !isPropertyTrue(project, - PROCESS_EMPTY_IDL_DIR)) - { - return; - } - File generatedRestClientDir = project.file(getGeneratedDirPath(project, sourceSet, REST_GEN_TYPE) - + File.separatorChar + "java"); - - // always include imported data template jars in compileClasspath of rest client - FileCollection dataModelConfig = getDataModelConfig(project, sourceSet); - - // if data templates generated from this source set, add the generated data template jar to compileClasspath - // of rest client. - String dataTemplateSourceSetName = getGeneratedSourceSetName(sourceSet, DATA_TEMPLATE_GEN_TYPE); - - Jar dataTemplateJarTask = null; - - SourceSetContainer sourceSets = project.getConvention() - .getPlugin(JavaPluginConvention.class).getSourceSets(); - - FileCollection dataModels; - if (sourceSets.findByName(dataTemplateSourceSetName) != null) - { - if (debug) - { - System.out.println("sourceSet " + sourceSet.getName() + " has generated sourceSet " + dataTemplateSourceSetName); - } - dataTemplateJarTask = (Jar) project.getTasks().getByName(sourceSet.getName() + "DataTemplateJar"); - // The getArchivePath() API doesn’t carry any task dependency and has been deprecated. - // Replace it with getArchiveFile() on Gradle 7, - // but keep getArchivePath() to be backwards-compatibility with Gradle version older than 5.1 - // DataHub Note - applied FIXME - dataModels = dataModelConfig.plus(project.files( - isAtLeastGradle7() ? dataTemplateJarTask.getArchiveFile() : dataTemplateJarTask.getArchivePath())); - } - else - { - dataModels = dataModelConfig; - } - - // create source set for generated rest model, rest client source and class files. - String targetSourceSetName = getGeneratedSourceSetName(sourceSet, REST_GEN_TYPE); - SourceSet targetSourceSet = sourceSets.create(targetSourceSetName, ss -> - { - ss.java(sourceDirectorySet -> sourceDirectorySet.srcDir(generatedRestClientDir)); - ss.setCompileClasspath(dataModels.plus(project.getConfigurations().getByName("restClientCompile"))); - }); - - project.getPlugins().withType(EclipsePlugin.class, eclipsePlugin -> { - EclipseModel eclipseModel = (EclipseModel) project.getExtensions().findByName("eclipse"); - eclipseModel.getClasspath().getPlusConfigurations() - .add(project.getConfigurations().getByName("restClientCompile")); - }); - - // idea plugin needs to know about new rest client source directory and its dependencies - addGeneratedDir(project, targetSourceSet, Arrays.asList( - getDataModelConfig(project, sourceSet), - project.getConfigurations().getByName("restClientCompile"))); - - // generate the rest client source files - GenerateRestClientTask generateRestClientTask = project.getTasks() - .create(targetSourceSet.getTaskName("generate", "restClient"), GenerateRestClientTask.class, task -> - { - task.dependsOn(project.getConfigurations().getByName("dataTemplate")); - task.setInputDir(idlDir); - task.setResolverPath(dataModels.plus(project.getConfigurations().getByName("restClientCompile"))); - task.setRuntimeClasspath(project.getConfigurations().getByName("dataModel") - .plus(project.getConfigurations().getByName("dataTemplate").getArtifacts().getFiles())); - task.setCodegenClasspath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setDestinationDir(generatedRestClientDir); - task.setRestli2FormatSuppressed(project.hasProperty(SUPPRESS_REST_CLIENT_RESTLI_2)); - task.setRestli1FormatSuppressed(project.hasProperty(SUPPRESS_REST_CLIENT_RESTLI_1)); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - if (isPropertyTrue(project, CODE_GEN_PATH_CASE_SENSITIVE)) - { - task.setGenerateLowercasePath(false); - } - if (isPropertyTrue(project, ENABLE_FLUENT_API)) - { - task.setGenerateFluentApi(true); - } - task.doFirst(new CacheableAction<>(t -> project.delete(generatedRestClientDir))); - }); - - if (dataTemplateJarTask != null) - { - generateRestClientTask.dependsOn(dataTemplateJarTask); - } - - // TODO: Tighten the types so that _generateSourcesJarTask must be of type Jar. - ((Jar) _generateSourcesJarTask).from(generateRestClientTask.getDestinationDir()); - _generateSourcesJarTask.dependsOn(generateRestClientTask); - - _generateJavadocTask.source(generateRestClientTask.getDestinationDir()); - _generateJavadocTask.setClasspath(_generateJavadocTask.getClasspath() - .plus(project.getConfigurations().getByName("restClientCompile")) - .plus(generateRestClientTask.getResolverPath())); - _generateJavadocTask.dependsOn(generateRestClientTask); - - // make sure rest client source files have been generated before compiling them - JavaCompile compileGeneratedRestClientTask = (JavaCompile) project.getTasks() - .getByName(targetSourceSet.getCompileJavaTaskName()); - compileGeneratedRestClientTask.dependsOn(generateRestClientTask); - compileGeneratedRestClientTask.getOptions().getCompilerArgs().add("-Xlint:-deprecation"); - - // create the rest model jar file - Task restModelJarTask = project.getTasks().create(sourceSet.getName() + "RestModelJar", Jar.class, task -> - { - task.from(idlDir, copySpec -> - { - copySpec.eachFile(fileCopyDetails -> project.getLogger() - .info("Add idl file: {}", fileCopyDetails)); - copySpec.setIncludes(Collections.singletonList('*' + IDL_FILE_SUFFIX)); - }); - // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - task.getArchiveAppendix().set(getAppendix(sourceSet, "rest-model")); - task.setDescription("Generate rest model jar"); - }); - - // create the rest client jar file - Task restClientJarTask = project.getTasks() - .create(sourceSet.getName() + "RestClientJar", Jar.class, task -> - { - task.dependsOn(compileGeneratedRestClientTask); - task.from(idlDir, copySpec -> { - copySpec.eachFile(fileCopyDetails -> { - project.getLogger().info("Add interface file: {}", fileCopyDetails); - fileCopyDetails.setPath("idl" + File.separatorChar + fileCopyDetails.getPath()); - }); - copySpec.setIncludes(Collections.singletonList('*' + IDL_FILE_SUFFIX)); - }); - task.from(targetSourceSet.getOutput()); - // FIXME change to #getArchiveAppendix().set(...); breaks backwards-compatibility before 5.1 - // DataHub Note - applied FIXME - task.getArchiveAppendix().set(getAppendix(sourceSet, "rest-client")); - task.setDescription("Generate rest client jar"); - }); - - // add the rest model jar and the rest client jar to the list of project artifacts. - if (!isTestSourceSet(sourceSet)) - { - project.getArtifacts().add("restModel", restModelJarTask); - project.getArtifacts().add("restClient", restClientJarTask); - } - else - { - project.getArtifacts().add("testRestModel", restModelJarTask); - project.getArtifacts().add("testRestClient", restClientJarTask); - } - } - - // Return the appendix for generated jar files. - // The source set name is not included for the main source set. - private static String getAppendix(SourceSet sourceSet, String suffix) - { - return sourceSet.getName().equals("main") ? suffix : sourceSet.getName() + '-' + suffix; - } - - private static Project getApiProject(Project project) - { - if (project.getExtensions().getExtraProperties().has("apiProject")) - { - return (Project) project.getExtensions().getExtraProperties().get("apiProject"); - } - - List subsSuffixes; - if (project.getExtensions().getExtraProperties().has("apiProjectSubstitutionSuffixes")) - { - @SuppressWarnings("unchecked") - List suffixValue = (List) project.getExtensions() - .getExtraProperties().get("apiProjectSubstitutionSuffixes"); - - subsSuffixes = suffixValue; - } - else - { - subsSuffixes = Arrays.asList("-impl", "-service", "-server", "-server-impl"); - } - - for (String suffix : subsSuffixes) - { - if (project.getPath().endsWith(suffix)) - { - String searchPath = project.getPath().substring(0, project.getPath().length() - suffix.length()) + "-api"; - Project apiProject = project.findProject(searchPath); - if (apiProject != null) - { - return apiProject; - } - } - } - - return project.findProject(project.getPath() + "-api"); - } - - private static Project getCheckedApiProject(Project project) - { - Project apiProject = getApiProject(project); - - if (apiProject == project) - { - throw new GradleException("The API project of ${project.path} must not be itself."); - } - - return apiProject; - } - - /** - * return the property value if the property exists and is not empty (-Pname=value) - * return null if property does not exist or the property is empty (-Pname) - * - * @param project the project where to look for the property - * @param propertyName the name of the property - */ - public static String getNonEmptyProperty(Project project, String propertyName) - { - if (!project.hasProperty(propertyName)) - { - return null; - } - - String propertyValue = project.property(propertyName).toString(); - if (propertyValue.isEmpty()) - { - return null; - } - - return propertyValue; - } - - /** - * Return true if the given property exists and its value is true - * - * @param project the project where to look for the property - * @param propertyName the name of the property - */ - public static boolean isPropertyTrue(Project project, String propertyName) - { - return project.hasProperty(propertyName) && Boolean.valueOf(project.property(propertyName).toString()); - } - - private static String createModifiedFilesMessage(Collection nonEquivExpectedFiles, - Collection foldersToBeBuilt) - { - StringBuilder builder = new StringBuilder(); - builder.append("\nRemember to checkin the changes to the following new or modified files:\n"); - for (String file : nonEquivExpectedFiles) - { - builder.append(" "); - builder.append(file); - builder.append("\n"); - } - - if (!foldersToBeBuilt.isEmpty()) - { - builder.append("\nThe file modifications include service interface changes, you can build the the following projects " - + "to re-generate the client APIs accordingly:\n"); - for (String folder : foldersToBeBuilt) - { - builder.append(" "); - builder.append(folder); - builder.append("\n"); - } - } - - return builder.toString(); - } - - private static String createPossibleMissingFilesMessage(Collection missingFiles) - { - StringBuilder builder = new StringBuilder(); - builder.append("If this is the result of an automated build, then you may have forgotten to check in some snapshot or idl files:\n"); - for (String file : missingFiles) - { - builder.append(" "); - builder.append(file); - builder.append("\n"); - } - - return builder.toString(); - } - - private static String findProperty(FileCompatibilityType type) - { - String property; - switch (type) - { - case SNAPSHOT: - property = SNAPSHOT_COMPAT_REQUIREMENT; - break; - case IDL: - property = IDL_COMPAT_REQUIREMENT; - break; - case PEGASUS_SCHEMA_SNAPSHOT: - property = PEGASUS_SCHEMA_SNAPSHOT_REQUIREMENT; - break; - case PEGASUS_EXTENSION_SCHEMA_SNAPSHOT: - property = PEGASUS_EXTENSION_SCHEMA_SNAPSHOT_REQUIREMENT; - break; - default: - throw new GradleException("No property defined for compatibility type " + type); - } - return property; - } - - private static Set buildWatchedRestModelInputDirs(Project project, SourceSet sourceSet) { - @SuppressWarnings("unchecked") - Map pegasusOptions = (Map) project - .getExtensions().getExtraProperties().get("pegasus"); - - File rootPath = new File(project.getProjectDir(), - pegasusOptions.get(sourceSet.getName()).restModelOptions.getRestResourcesRootPath()); - - IdlOptions idlOptions = pegasusOptions.get(sourceSet.getName()).idlOptions; - - // if idlItems exist, only watch the smaller subset - return idlOptions.getIdlItems().stream() - .flatMap(idlItem -> Arrays.stream(idlItem.packageNames)) - .map(packageName -> new File(rootPath, packageName.replace('.', '/'))) - .collect(Collectors.toCollection(TreeSet::new)); - } - - private static Set difference(Set left, Set right) - { - Set result = new HashSet<>(left); - result.removeAll(right); - return result; - } - - /** - * Configures the given source set so that its data schema directory (usually 'pegasus') is marked as a resource root. - * The purpose of this is to improve the IDE experience. Makes sure to exclude this directory from being packaged in - * with the default Jar task. - */ - private static void configureDataSchemaResourcesRoot(Project project, SourceSet sourceSet) - { - sourceSet.resources(sourceDirectorySet -> { - final String dataSchemaPath = getDataSchemaPath(project, sourceSet); - final File dataSchemaRoot = project.file(dataSchemaPath); - sourceDirectorySet.srcDir(dataSchemaPath); - project.getLogger().info("Adding resource root '{}'", dataSchemaPath); - - final String extensionsSchemaPath = getExtensionSchemaPath(project, sourceSet); - final File extensionsSchemaRoot = project.file(extensionsSchemaPath); - sourceDirectorySet.srcDir(extensionsSchemaPath); - project.getLogger().info("Adding resource root '{}'", extensionsSchemaPath); - - // Exclude the data schema and extensions schema directory from being copied into the default Jar task - sourceDirectorySet.getFilter().exclude(fileTreeElement -> { - final File file = fileTreeElement.getFile(); - // Traversal starts with the children of a resource root, so checking the direct parent is sufficient - final boolean underDataSchemaRoot = dataSchemaRoot.equals(file.getParentFile()); - final boolean underExtensionsSchemaRoot = extensionsSchemaRoot.equals(file.getParentFile()); - final boolean exclude = (underDataSchemaRoot || underExtensionsSchemaRoot); - if (exclude) - { - project.getLogger().info("Excluding resource directory '{}'", file); - } - return exclude; - }); - }); - } - - private Task generatePegasusSchemaSnapshot(Project project, SourceSet sourceSet, String taskName, File inputDir, File outputDir, - boolean isExtensionSchema) - { - return project.getTasks().create(sourceSet.getTaskName("generate", taskName), - GeneratePegasusSnapshotTask.class, task -> - { - task.setInputDir(inputDir); - task.setResolverPath(getDataModelConfig(project, sourceSet).plus(project.files(getDataSchemaPath(project, sourceSet)))); - task.setClassPath(project.getConfigurations().getByName(PEGASUS_PLUGIN_CONFIGURATION)); - task.setPegasusSchemaSnapshotDestinationDir(outputDir); - task.setExtensionSchema(isExtensionSchema); - if (isPropertyTrue(project, ENABLE_ARG_FILE)) - { - task.setEnableArgFile(true); - } - }); - } - - private Task publishPegasusSchemaSnapshot(Project project, SourceSet sourceSet, String taskName, Task checkPegasusSnapshotTask, - File inputDir, File outputDir) - { - return project.getTasks().create(sourceSet.getTaskName("publish", taskName), - Sync.class, task -> - { - task.dependsOn(checkPegasusSnapshotTask); - task.from(inputDir); - task.into(outputDir); - task.onlyIf(t -> !SharedFileUtils.getSuffixedFiles(project, inputDir, PDL_FILE_SUFFIX).isEmpty()); - }); - } - - private void checkGradleVersion(Project project) - { - if (MIN_REQUIRED_VERSION.compareTo(GradleVersion.current()) > 0) - { - throw new GradleException(String.format("This plugin does not support %s. Please use %s or later.", - GradleVersion.current(), - MIN_REQUIRED_VERSION)); - } - if (MIN_SUGGESTED_VERSION.compareTo(GradleVersion.current()) > 0) - { - project.getLogger().warn(String.format("Pegasus supports %s, but it may not be supported in the next major release. Please use %s or later.", - GradleVersion.current(), - MIN_SUGGESTED_VERSION)); - } - } - - /** - * Reflection is necessary to obscure types introduced in Gradle 5.3 - * - * @param sourceSet the target sourceset upon which to create a new feature variant - * @return an Action which modifies a org.gradle.api.plugins.FeatureSpec instance - */ - private Action/**/ createFeatureVariantFromSourceSet(SourceSet sourceSet) - { - return featureSpec -> { - try - { - Class clazz = Class.forName("org.gradle.api.plugins.FeatureSpec"); - Method usingSourceSet = clazz.getDeclaredMethod("usingSourceSet", SourceSet.class); - usingSourceSet.invoke(featureSpec, sourceSet); - } - catch (ReflectiveOperationException e) - { - throw new GradleException("Unable to invoke FeatureSpec#usingSourceSet(SourceSet)", e); - } - }; - } - - protected static boolean isAtLeastGradle61() - { - return GradleVersion.current().getBaseVersion().compareTo(GradleVersion.version("6.1")) >= 0; - } - - public static boolean isAtLeastGradle7() { - return GradleVersion.current().getBaseVersion().compareTo(GradleVersion.version("7.0")) >= 0; - } -} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java b/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java deleted file mode 100644 index a2aafaf1be0172..00000000000000 --- a/buildSrc/src/main/java/com/linkedin/pegasus/gradle/tasks/ChangedFileReportTask.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.linkedin.pegasus.gradle.tasks; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import org.gradle.api.DefaultTask; -import org.gradle.api.file.FileCollection; -import org.gradle.api.specs.Specs; -import org.gradle.api.tasks.InputFiles; -import org.gradle.api.tasks.Internal; -import org.gradle.api.tasks.SkipWhenEmpty; -import org.gradle.api.tasks.TaskAction; -import org.gradle.work.FileChange; -import org.gradle.work.InputChanges; - - -public class ChangedFileReportTask extends DefaultTask -{ - private final Collection _needCheckinFiles = new ArrayList<>(); - - private FileCollection _idlFiles = getProject().files(); - private FileCollection _snapshotFiles = getProject().files(); - - public ChangedFileReportTask() - { - //with Gradle 6.0, Declaring an incremental task without outputs is not allowed. - getOutputs().upToDateWhen(Specs.satisfyNone()); - } - - // DataHub Note - updated for InputChanges - @TaskAction - public void checkFilesForChanges(InputChanges inputChanges) - { - getLogger().lifecycle("Checking idl and snapshot files for changes..."); - getLogger().info("idlFiles: " + _idlFiles.getAsPath()); - getLogger().info("snapshotFiles: " + _snapshotFiles.getAsPath()); - - Set filesRemoved = new HashSet<>(); - Set filesAdded = new HashSet<>(); - Set filesChanged = new HashSet<>(); - - if (inputChanges.isIncremental()) - { - Consumer handleChange = change -> - { - switch (change.getChangeType()) { - case ADDED: - filesAdded.add(change.getFile().getAbsolutePath()); - break; - case REMOVED: - filesRemoved.add(change.getFile().getAbsolutePath()); - break; - case MODIFIED: - filesChanged.add(change.getFile().getAbsolutePath()); - break; - } - }; - - inputChanges.getFileChanges(_idlFiles).forEach(handleChange); - inputChanges.getFileChanges(_snapshotFiles).forEach(handleChange); - - if (!filesRemoved.isEmpty()) - { - String files = joinByComma(filesRemoved); - _needCheckinFiles.add(files); - getLogger().lifecycle( - "The following files have been removed, be sure to remove them from source control: {}", files); - } - - if (!filesAdded.isEmpty()) - { - String files = joinByComma(filesAdded); - _needCheckinFiles.add(files); - getLogger().lifecycle("The following files have been added, be sure to add them to source control: {}", files); - } - - if (!filesChanged.isEmpty()) - { - String files = joinByComma(filesChanged); - _needCheckinFiles.add(files); - getLogger().lifecycle( - "The following files have been changed, be sure to commit the changes to source control: {}", files); - } - } - } - - private String joinByComma(Set files) - { - return files.stream().collect(Collectors.joining(", ")); - } - - @InputFiles - @SkipWhenEmpty - public FileCollection getSnapshotFiles() - { - return _snapshotFiles; - } - - public void setSnapshotFiles(FileCollection snapshotFiles) - { - _snapshotFiles = snapshotFiles; - } - - @InputFiles - @SkipWhenEmpty - public FileCollection getIdlFiles() - { - return _idlFiles; - } - - public void setIdlFiles(FileCollection idlFiles) - { - _idlFiles = idlFiles; - } - - @Internal - public Collection getNeedCheckinFiles() - { - return _needCheckinFiles; - } -} \ No newline at end of file From 7ae310d3a761887d0ac7c8a3720be545f661a89f Mon Sep 17 00:00:00 2001 From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com> Date: Thu, 8 Feb 2024 06:32:23 +0530 Subject: [PATCH 271/540] feat(graphql): get raw aspects for assertions, allow aspectNames filter (#9792) --- .../linkedin/datahub/graphql/GmsGraphQLEngine.java | 4 +++- .../datahub/graphql/WeaklyTypedAspectsResolver.java | 7 ++++++- .../src/main/resources/entity.graphql | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index e3eef0688c2692..28b3a982c7b283 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -2436,7 +2436,9 @@ private void configureAssertionResolvers(final RuntimeWiring.Builder builder) { ? assertion.getDataPlatformInstance().getUrn() : null; })) - .dataFetcher("runEvents", new AssertionRunEventResolver(entityClient))); + .dataFetcher("runEvents", new AssertionRunEventResolver(entityClient)) + .dataFetcher( + "aspects", new WeaklyTypedAspectsResolver(entityClient, entityRegistry))); } private void configurePolicyResolvers(final RuntimeWiring.Builder builder) { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java index d8665ae784bd1e..fd23cd5fdda450 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/WeaklyTypedAspectsResolver.java @@ -37,7 +37,12 @@ public class WeaklyTypedAspectsResolver implements DataFetcher Date: Thu, 8 Feb 2024 14:05:26 +0530 Subject: [PATCH 272/540] feat(ingest/slack): source to get user info from slack (#9776) --- metadata-ingestion/setup.py | 8 + .../ingestion/source/slack/__init__.py | 0 .../datahub/ingestion/source/slack/slack.py | 181 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 metadata-ingestion/src/datahub/ingestion/source/slack/__init__.py create mode 100644 metadata-ingestion/src/datahub/ingestion/source/slack/slack.py diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index bbbab73fd1cf54..74dcde5e066b3c 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -245,6 +245,10 @@ powerbi_report_server = {"requests", "requests_ntlm"} +slack = { + "slack-sdk==3.18.1" +} + databricks = { # 0.1.11 appears to have authentication issues with azure databricks "databricks-sdk>=0.9.0", @@ -367,6 +371,7 @@ "snowflake": snowflake_common | usage_common | sqlglot_lib, "sqlalchemy": sql_common, "sql-queries": usage_common | sqlglot_lib, + "slack": slack, "superset": { "requests", "sqlalchemy", @@ -503,6 +508,7 @@ "redshift", "s3", "snowflake", + "slack", "tableau", "teradata", "trino", @@ -543,6 +549,7 @@ "kafka-connect", "ldap", "mongodb", + "slack", "mssql", "mysql", "mariadb", @@ -597,6 +604,7 @@ "postgres = datahub.ingestion.source.sql.postgres:PostgresSource", "redash = datahub.ingestion.source.redash:RedashSource", "redshift = datahub.ingestion.source.redshift.redshift:RedshiftSource", + "slack = datahub.ingestion.source.slack.slack:SlackSource", "snowflake = datahub.ingestion.source.snowflake.snowflake_v2:SnowflakeV2Source", "superset = datahub.ingestion.source.superset:SupersetSource", "tableau = datahub.ingestion.source.tableau:TableauSource", diff --git a/metadata-ingestion/src/datahub/ingestion/source/slack/__init__.py b/metadata-ingestion/src/datahub/ingestion/source/slack/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/metadata-ingestion/src/datahub/ingestion/source/slack/slack.py b/metadata-ingestion/src/datahub/ingestion/source/slack/slack.py new file mode 100644 index 00000000000000..ed425cc25d98fc --- /dev/null +++ b/metadata-ingestion/src/datahub/ingestion/source/slack/slack.py @@ -0,0 +1,181 @@ +import logging +import textwrap +from dataclasses import dataclass +from typing import Iterable, Optional + +from pydantic import Field, SecretStr +from slack_sdk import WebClient + +from datahub.configuration.common import ConfigModel +from datahub.emitter.mcp import MetadataChangeProposalWrapper +from datahub.ingestion.api.common import PipelineContext +from datahub.ingestion.api.decorators import ( + SupportStatus, + config_class, + platform_name, + support_status, +) +from datahub.ingestion.api.source import ( + SourceReport, + TestableSource, + TestConnectionReport, +) +from datahub.ingestion.api.workunit import MetadataWorkUnit +from datahub.metadata.schema_classes import CorpUserEditableInfoClass +from datahub.utilities.urns.urn import Urn + +logger: logging.Logger = logging.getLogger(__name__) + + +@dataclass +class CorpUser: + urn: Optional[str] = None + email: Optional[str] = None + slack_id: Optional[str] = None + title: Optional[str] = None + image_url: Optional[str] = None + phone: Optional[str] = None + + +class SlackSourceConfig(ConfigModel): + bot_token: SecretStr = Field( + description="Bot token for the Slack workspace. Needs `users:read`, `users:read.email` and `users.profile:read` scopes.", + ) + + +@platform_name("Slack") +@config_class(SlackSourceConfig) +@support_status(SupportStatus.TESTING) +class SlackSource(TestableSource): + def __init__(self, ctx: PipelineContext, config: SlackSourceConfig): + self.ctx = ctx + self.config = config + self.report = SourceReport() + + @classmethod + def create(cls, config_dict, ctx): + config = SlackSourceConfig.parse_obj(config_dict) + return cls(ctx, config) + + @staticmethod + def test_connection(config_dict: dict) -> TestConnectionReport: + raise NotImplementedError("This class does not implement this method") + + def get_slack_client(self) -> WebClient: + return WebClient(token=self.config.bot_token.get_secret_value()) + + def get_workunits_internal( + self, + ) -> Iterable[MetadataWorkUnit]: + assert self.ctx.graph is not None + auth_resp = self.get_slack_client().auth_test() + logger.info("Successfully connected to Slack") + logger.info(auth_resp.data) + for user_obj in self.get_user_to_be_updated(): + self.populate_slack_id_from_email(user_obj) + if user_obj.slack_id is None: + continue + self.populate_user_profile(user_obj) + if user_obj.urn is None: + continue + logger.info(f"User: {user_obj}") + corpuser_editable_info = ( + self.ctx.graph.get_aspect( + entity_urn=user_obj.urn, aspect_type=CorpUserEditableInfoClass + ) + or CorpUserEditableInfoClass() + ) + corpuser_editable_info.email = user_obj.email + corpuser_editable_info.slack = user_obj.slack_id + corpuser_editable_info.title = user_obj.title + if user_obj.image_url: + corpuser_editable_info.pictureLink = user_obj.image_url + if user_obj.phone: + corpuser_editable_info.phone = user_obj.phone + yield MetadataWorkUnit( + id=f"{user_obj.urn}", + mcp=MetadataChangeProposalWrapper( + entityUrn=user_obj.urn, + aspect=corpuser_editable_info, + ), + ) + + def populate_user_profile(self, user_obj: CorpUser) -> None: + try: + # https://api.slack.com/methods/users.profile.get + user_profile_res = self.get_slack_client().users_profile_get( + user=user_obj.slack_id + ) + user_profile = user_profile_res.get("profile", {}) + user_obj.title = user_profile.get("title") + user_obj.image_url = user_profile.get("image_192") + user_obj.phone = user_profile.get("phone") + except Exception as e: + if "missing_scope" in str(e): + raise e + return + + def populate_slack_id_from_email(self, user_obj: CorpUser) -> None: + if user_obj.email is None: + return + try: + # https://api.slack.com/methods/users.lookupByEmail + user_info_res = self.get_slack_client().users_lookupByEmail( + email=user_obj.email + ) + user_info = user_info_res.get("user", {}) + user_obj.slack_id = user_info.get("id") + except Exception as e: + if "users_not_found" in str(e): + return + raise e + + def get_user_to_be_updated(self) -> Iterable[CorpUser]: + graphql_query = textwrap.dedent( + """ + query listUsers($input: ListUsersInput!) { + listUsers(input: $input) { + total + users { + urn + editableProperties { + email + slack + } + } + } + } + """ + ) + start = 0 + count = 10 + total = count + + assert self.ctx.graph is not None + + while start < total: + variables = {"input": {"start": start, "count": count}} + response = self.ctx.graph.execute_graphql( + query=graphql_query, variables=variables + ) + list_users = response.get("listUsers", {}) + total = list_users.get("total", 0) + users = list_users.get("users", []) + for user in users: + user_obj = CorpUser() + editable_properties = user.get("editableProperties", {}) + user_obj.urn = user.get("urn") + if user_obj.urn is None: + continue + if editable_properties is not None: + user_obj.email = editable_properties.get("email") + if user_obj.email is None: + urn_id = Urn.from_string(user_obj.urn).get_entity_id_as_string() + if "@" in urn_id: + user_obj.email = urn_id + if user_obj.email is not None: + yield user_obj + start += count + + def get_report(self) -> SourceReport: + return self.report From a5e473812ec9152b875236213938a8d51f6b52c4 Mon Sep 17 00:00:00 2001 From: Kunal-kankriya <127090035+Kunal-kankriya@users.noreply.github.com> Date: Thu, 8 Feb 2024 14:25:36 +0530 Subject: [PATCH 273/540] tests(domains): added multiple cypress tests scenarios (#9750) --- .../cypress/e2e/domains/nested_domains.js | 205 ++++++++++++++---- .../tests/cypress/cypress/support/commands.js | 48 +++- 2 files changed, 207 insertions(+), 46 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js b/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js index 59af6daf9b8f6c..3910a280a303c0 100644 --- a/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js +++ b/smoke-test/tests/cypress/cypress/e2e/domains/nested_domains.js @@ -1,10 +1,26 @@ const domainName = "CypressNestedDomain"; -const domainDescription = "CypressNestedDomainDescription"; + +//Delete Unecessary Existing Domains +const deleteExisitingDomain = () => { + cy.get('a[href*="urn:li"] span[class^="ant-typography"]') + .should('be.visible') + .its('length') + .then((length) => { + for (let i = 0; i < length - 1; i++) { + cy.get('a[href*="urn:li"] span[class^="ant-typography"]') + .should('be.visible') + .first() + .click({ force: true }); + deleteFromDomainDropdown(); + } + }); + cy.waitTextVisible('Marketing'); + } const createDomain = () => { - cy.clickOptionWithTestId("domains-new-domain-button"); + cy.get('.anticon-plus').first().click() + cy.waitTextVisible('Create New Domain') cy.get('[data-testid="create-domain-name"]').click().type(domainName); - cy.get('[data-testid="create-domain-description"]').click().type(domainDescription); cy.clickOptionWithTestId("create-domain-button"); cy.waitTextVisible("Created domain!"); } @@ -16,7 +32,7 @@ const moveDomaintoRootLevel = () => { cy.get('[data-testid="move-domain-modal"]').contains("Marketing").click({force: true}); cy.waitTextVisible('Marketing') cy.clickOptionWithTestId("move-domain-modal-move-button") - } + } const moveDomaintoParent = () => { cy.get('[data-testid="domain-list-item"]').contains("Marketing").prev().click(); @@ -25,51 +41,67 @@ const moveDomaintoParent = () => { cy.openThreeDotDropdown(); cy.clickOptionWithTestId("entity-menu-move-button"); cy.clickOptionWithTestId("move-domain-modal-move-button") - } + } - const deleteFromDomainDropdown = () => { - cy.clickOptionWithText('Filters') - cy.openThreeDotDropdown(); - cy.clickOptionWithTestId("entity-menu-delete-button"); - cy.waitTextVisible("Are you sure you want to remove this Domain?"); - cy.clickOptionWithText("Yes"); - } +const getDomainList = (domainName) =>{ + cy.contains('span.ant-typography-ellipsis', domainName) + .parent('[data-testid="domain-list-item"]') + .find('[aria-label="right"]') + .click(); + } + +const deleteFromDomainDropdown = () => { + cy.clickOptionWithText('Filters') + cy.openThreeDotDropdown(); + cy.clickOptionWithTestId("entity-menu-delete-button"); + cy.waitTextVisible("Are you sure you want to remove this Domain?"); + cy.clickOptionWithText("Yes"); + } const deleteDomain = () => { cy.clickOptionWithText(domainName).waitTextVisible('Domains'); deleteFromDomainDropdown() - } + } -//Delete Unecessary Existing Domains - const deleteExisitingDomain = () => { - cy.get('a[href*="urn:li"] span[class^="ant-typography"]') - .should('be.visible') - .its('length') - .then((length) => { - for (let i = 0; i < length - 1; i++) { - cy.get('a[href*="urn:li"] span[class^="ant-typography"]') - .should('be.visible') - .first() - .click({ force: true }); - deleteFromDomainDropdown(); - } - }); - cy.waitTextVisible('My custom domain'); - } +const verifyEditAndPerformAddAndRemoveActionForDomain = (entity, action, text, body) =>{ + cy.clickOptionWithText(entity) + cy.clickOptionWithText(action) + cy.get('[data-testid="tag-term-modal-input"]').type(text) + cy.get('[data-testid="tag-term-option"]').contains(text).click() + cy.clickOptionWithText(body) + cy.get('[data-testid="add-tag-term-from-modal-btn"]').click() + cy.waitTextVisible(text) + } -describe("nested domains test", () => { +const clearAndType = (text) =>{ + cy.get('[role="textbox"]').click().clear().type(text) + } + +const clearAndDelete = () =>{ + cy.clickOptionWithText("Edit") + cy.get('[role="textbox"]').click().clear() + cy.clickOptionWithTestId("description-editor-save-button") + cy.waitTextVisible('No documentation') + cy.mouseover('.ant-list-item-meta-content') + cy.get('[aria-label="delete"]').click() + cy.waitTextVisible('Link Removed') + } + +describe("Verify nested domains test functionalities", () => { beforeEach (() => { cy.loginWithCredentials(); cy.goToDomainList(); }); - it("Create a new domain", () => { - deleteExisitingDomain() + it("Verify Create a new domain", () => { + deleteExisitingDomain() + cy.get('a[href*="urn:li"] span[class^="ant-typography"]') + .should('be.visible') createDomain(); cy.waitTextVisible("Domains"); }); - it("Move domain root level to parent level", () => { + it ("verify Move domain root level to parent level", () => { cy.waitTextVisible(domainName) moveDomaintoRootLevel(); cy.waitTextVisible("Moved Domain!") @@ -77,18 +109,117 @@ describe("nested domains test", () => { cy.waitTextVisible("1 sub-domain"); }); - it("Move domain parent level to root level", () => { + it("Verify Move domain parent level to root level", () => { moveDomaintoParent(); cy.waitTextVisible("Moved Domain!") cy.goToDomainList(); cy.waitTextVisible(domainName); - cy.waitTextVisible(domainDescription); }); - it("Remove the domain", () => { + it("Verify Documentation tab by adding editing Description and adding link", () => { + cy.clickOptionWithText(domainName) + cy.clickOptionWithId('#rc-tabs-0-tab-Documentation') + cy.clickFirstOptionWithText("Add Documentation") + clearAndType("Test added") + cy.clickOptionWithTestId("description-editor-save-button") + cy.waitTextVisible('Description Updated') + cy.waitTextVisible('Test added') + cy.clickFirstOptionWithTestId("add-link-button") + cy.waitTextVisible("Add Link") + cy.enterTextInTestId("add-link-modal-url", 'www.test.com') + cy.enterTextInTestId("add-link-modal-label", 'Test Label') + cy.clickOptionWithTestId("add-link-modal-add-button") + cy.waitTextVisible("Test Label") + cy.goToDomainList(); + cy.waitTextVisible("Test added") + cy.clickOptionWithText(domainName) + cy.clickOptionWithText("Documentation") + clearAndDelete() + }) + + it("Verify Right side panel functionalities", () => { + cy.clickOptionWithText(domainName) + cy.waitTextVisible("Filters") + cy.clickOptionWithText("Add Documentation") + clearAndType("Test documentation") + cy.clickOptionWithTestId("description-editor-save-button") + cy.ensureTextNotPresent("Add Documentation") + cy.waitTextVisible('Test documentation') + cy.clickFirstOptionWithSpecificTestId("add-link-button", 1) + cy.waitTextVisible("URL") + cy.enterTextInTestId("add-link-modal-url", 'www.test.com') + cy.enterTextInTestId("add-link-modal-label", 'Test Label') + cy.clickOptionWithTestId("add-link-modal-add-button") + cy.waitTextVisible("Test Label") + cy.clickOptionWithTestId("add-owners-button") + cy.waitTextVisible("Find a user or group") + cy.clickTextOptionWithClass(".rc-virtual-list-holder-inner", "DataHub") + cy.clickOptionWithText("Find a user or group") + cy.clickOptionWithId('#addOwnerButton') + cy.waitTextVisible("DataHub") + cy.goToDomainList(); + cy.waitTextVisible("Test documentation") + cy.waitTextVisible("DataHub") + cy.clickOptionWithText(domainName) + cy.clickOptionWithText("Documentation") + clearAndDelete() + }) + + it("Verify Edit Domain Name", () => { + cy.clickFirstOptionWithText(domainName) + cy.clickOptionWithText('Filters') + + //edit name + cy.get('.anticon-edit').eq(0).click().then(() => { + cy.get('.ant-typography-edit-content').type(" Edited").type('{enter}'); + }); + cy.waitTextVisible(domainName + " Edited") + }) + + it("Verify Remove the domain", () => { deleteDomain(); cy.goToDomainList(); cy.ensureTextNotPresent(domainName); - cy.ensureTextNotPresent(domainDescription); }); + + it('Verify Add and delete sub domain', () => { + cy.clickFirstOptionWithText('Marketing') + cy.clickOptionWithText('Filters') + createDomain(); + cy.ensureTextNotPresent('Created domain!') + getDomainList('Marketing') + cy.clickOptionWithText(domainName) + deleteFromDomainDropdown() + cy.ensureTextNotPresent(domainName) + }) + + it('Verify entities tab with adding and deleting assets and performing some actions', () => { + cy.clickFirstOptionWithText('Marketing'); + cy.clickOptionWithText('Add assets'); + cy.waitTextVisible("Add assets to Domain"); + cy.enterTextInSpecificTestId("search-bar", 3, 'Baz Chart 1') + cy.clickOptionWithSpecificClass('.ant-checkbox', 1) + cy.clickOptionWithId('#continueButton') + cy.waitTextVisible("Added assets to Domain!") + cy.openThreeDotMenu() + cy.clickOptionWithText("Edit") + cy.clickOptionWithSpecificClass('.ant-checkbox', 1) + verifyEditAndPerformAddAndRemoveActionForDomain('Tags', 'Add tags', 'Cypress', 'Add Tags') + cy.clickOptionWithText('Baz Chart 1') + cy.waitTextVisible("Cypress") + cy.waitTextVisible("Marketing") + cy.go('back') + cy.openThreeDotMenu() + cy.clickOptionWithText("Edit") + cy.clickOptionWithSpecificClass('.ant-checkbox', 1) + verifyEditAndPerformAddAndRemoveActionForDomain('Tags', 'Remove tags', 'Cypress', 'Remove Tags') + cy.clickTextOptionWithClass('.ant-dropdown-trigger', 'Domain') + cy.clickOptionWithText('Unset Domain') + cy.clickOptionWithText("Yes"); + cy.clickOptionWithText('Baz Chart 1') + cy.waitTextVisible('Dashboards') + cy.reload() + cy.ensureTextNotPresent("Cypress") + cy.ensureTextNotPresent("Marketing") + }) }); diff --git a/smoke-test/tests/cypress/cypress/support/commands.js b/smoke-test/tests/cypress/cypress/support/commands.js index 51b06a24c19211..3f3a8fe94f962f 100644 --- a/smoke-test/tests/cypress/cypress/support/commands.js +++ b/smoke-test/tests/cypress/cypress/support/commands.js @@ -23,15 +23,15 @@ export function getTimestampMillisNumDaysAgo (numDays) { Cypress.Commands.add('login', () => { - cy.request({ - method: 'POST', - url: '/logIn', - body: { - username: Cypress.env('ADMIN_USERNAME'), - password: Cypress.env('ADMIN_PASSWORD'), - }, - retryOnStatusCodeFailure: true, - }); + cy.request({ + method: 'POST', + url: '/logIn', + body: { + username: Cypress.env('ADMIN_USERNAME'), + password: Cypress.env('ADMIN_PASSWORD'), + }, + retryOnStatusCodeFailure: true, + }); }) Cypress.Commands.add("loginWithCredentials", (username, password) => { @@ -163,10 +163,18 @@ Cypress.Commands.add("openThreeDotDropdown", () => { cy.clickOptionWithTestId("entity-header-dropdown") }); +Cypress.Commands.add("openThreeDotMenu", () => { + cy.clickOptionWithTestId("three-dot-menu") +}); + Cypress.Commands.add("clickOptionWithText", (text) => { cy.contains(text).should('be.visible').click(); }); +Cypress.Commands.add("clickFirstOptionWithText", (text) => { + cy.contains(text).first().click(); +}); + Cypress.Commands.add("clickOptionWithTextToScrollintoView", (text) => { cy.contains(text).scrollIntoView().click(); }); @@ -222,6 +230,13 @@ Cypress.Commands.add("getWithTestId", (id) => { return cy.get(selectorWithtestId(id)); }); +Cypress.Commands.add("clickOptionWithId", (id) => { + cy.get(id).click() +}) + +Cypress.Commands.add("enterTextInSpecificTestId", (id, value, text) => { + cy.get(selectorWithtestId(id)).eq(value).type(text); +}) Cypress.Commands.add("enterTextInTestId", (id, text) => { cy.get(selectorWithtestId(id)).type(text); }) @@ -238,6 +253,21 @@ Cypress.Commands.add("clickFirstOptionWithTestId", (id) => { }); }) +Cypress.Commands.add("clickFirstOptionWithSpecificTestId", (id,value) => { + cy.get(selectorWithtestId(id)).eq(value).click({ + force: true, + }); +}) + +Cypress.Commands.add("clickOptionWithSpecificClass", (locator, value) => { + cy.get(locator).should('be.visible') + cy.get(locator).eq(value).click(); +}) + +Cypress.Commands.add("clickTextOptionWithClass", (locator, text) => { + cy.get(locator).should('be.visible').contains(text).click({force:true}) +}) + Cypress.Commands.add("hideOnboardingTour", () => { cy.get('body').type("{ctrl} {meta} h"); }); From f6e96dc0d3b280a693530ab3ae545fbde1943146 Mon Sep 17 00:00:00 2001 From: Aseem Bansal Date: Thu, 8 Feb 2024 20:51:12 +0530 Subject: [PATCH 274/540] feat(token): helper to debug owner of raw token (#9793) --- .../datahub/graphql/GmsGraphQLEngine.java | 8 +- .../auth/GetAccessTokenMetadataResolver.java | 59 ++++++++++ .../src/main/resources/auth.graphql | 6 + .../token/StatefulTokenService.java | 16 ++- .../tokens/revokable_access_token_test.py | 108 +++++++++++------- 5 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/auth/GetAccessTokenMetadataResolver.java diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 28b3a982c7b283..3cb1d7ab9e7dec 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -109,10 +109,7 @@ import com.linkedin.datahub.graphql.resolvers.assertion.AssertionRunEventResolver; import com.linkedin.datahub.graphql.resolvers.assertion.DeleteAssertionResolver; import com.linkedin.datahub.graphql.resolvers.assertion.EntityAssertionsResolver; -import com.linkedin.datahub.graphql.resolvers.auth.CreateAccessTokenResolver; -import com.linkedin.datahub.graphql.resolvers.auth.GetAccessTokenResolver; -import com.linkedin.datahub.graphql.resolvers.auth.ListAccessTokensResolver; -import com.linkedin.datahub.graphql.resolvers.auth.RevokeAccessTokenResolver; +import com.linkedin.datahub.graphql.resolvers.auth.*; import com.linkedin.datahub.graphql.resolvers.browse.BrowsePathsResolver; import com.linkedin.datahub.graphql.resolvers.browse.BrowseResolver; import com.linkedin.datahub.graphql.resolvers.browse.EntityBrowsePathsResolver; @@ -931,6 +928,9 @@ private void configureQueryResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("getEntityCounts", new EntityCountsResolver(this.entityClient)) .dataFetcher("getAccessToken", new GetAccessTokenResolver(statefulTokenService)) .dataFetcher("listAccessTokens", new ListAccessTokensResolver(this.entityClient)) + .dataFetcher( + "getAccessTokenMetadata", + new GetAccessTokenMetadataResolver(statefulTokenService, this.entityClient)) .dataFetcher("container", getResolver(containerType)) .dataFetcher("listDomains", new ListDomainsResolver(this.entityClient)) .dataFetcher("listSecrets", new ListSecretsResolver(this.entityClient)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/auth/GetAccessTokenMetadataResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/auth/GetAccessTokenMetadataResolver.java new file mode 100644 index 00000000000000..c3e14565e0e27f --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/auth/GetAccessTokenMetadataResolver.java @@ -0,0 +1,59 @@ +package com.linkedin.datahub.graphql.resolvers.auth; + +import com.datahub.authentication.token.StatefulTokenService; +import com.google.common.collect.ImmutableList; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AccessTokenMetadata; +import com.linkedin.datahub.graphql.types.auth.AccessTokenMetadataType; +import com.linkedin.entity.client.EntityClient; +import graphql.execution.DataFetcherResult; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class GetAccessTokenMetadataResolver + implements DataFetcher> { + + private final StatefulTokenService _tokenService; + private final EntityClient _entityClient; + + public GetAccessTokenMetadataResolver( + final StatefulTokenService tokenService, EntityClient entityClient) { + _tokenService = tokenService; + _entityClient = entityClient; + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + return CompletableFuture.supplyAsync( + () -> { + final QueryContext context = environment.getContext(); + final String token = environment.getArgument("token"); + log.info("User {} requesting access token metadata information.", context.getActorUrn()); + if (!AuthorizationUtils.canManageTokens(context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + AccessTokenMetadataType metadataType = new AccessTokenMetadataType(_entityClient); + final String tokenHash = _tokenService.hash(token); + final String tokenUrn = _tokenService.tokenUrnFromKey(tokenHash).toString(); + try { + List> batchLoad = + metadataType.batchLoad(ImmutableList.of(tokenUrn), context); + if (batchLoad.isEmpty()) { + return null; + } + return batchLoad.get(0).getData(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/resources/auth.graphql b/datahub-graphql-core/src/main/resources/auth.graphql index b76aa132c219c9..c7dc6be137beac 100644 --- a/datahub-graphql-core/src/main/resources/auth.graphql +++ b/datahub-graphql-core/src/main/resources/auth.graphql @@ -11,6 +11,12 @@ extend type Query { List access tokens stored in DataHub. """ listAccessTokens(input: ListAccessTokenInput!): ListAccessTokenResult! + + """ + Fetches the metadata of an access token. + This is useful to debug when you have a raw token but don't know the actor. + """ + getAccessTokenMetadata(token: String!): AccessTokenMetadata! } extend type Mutation { diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java index 0d1da4a7687bae..50e357331bb8f9 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/token/StatefulTokenService.java @@ -16,12 +16,7 @@ import com.linkedin.metadata.utils.AuditStampUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.MetadataChangeProposal; -import java.util.Base64; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; @@ -58,8 +53,7 @@ public StatefulTokenService( new CacheLoader() { @Override public Boolean load(final String key) { - final Urn accessUrn = - Urn.createFromTuple(Constants.ACCESS_TOKEN_ENTITY_NAME, key); + final Urn accessUrn = tokenUrnFromKey(key); return !_entityService.exists(accessUrn, true); } }); @@ -173,10 +167,14 @@ public TokenClaims validateAccessToken(@Nonnull String accessToken) throws Token } } + public Urn tokenUrnFromKey(String tokenHash) { + return Urn.createFromTuple(Constants.ACCESS_TOKEN_ENTITY_NAME, tokenHash); + } + public void revokeAccessToken(@Nonnull String hashedToken) throws TokenException { try { if (!_revokedTokenCache.get(hashedToken)) { - final Urn tokenUrn = Urn.createFromTuple(Constants.ACCESS_TOKEN_ENTITY_NAME, hashedToken); + final Urn tokenUrn = tokenUrnFromKey(hashedToken); _entityService.deleteUrn(tokenUrn); _revokedTokenCache.put(hashedToken, true); return; diff --git a/smoke-test/tests/tokens/revokable_access_token_test.py b/smoke-test/tests/tokens/revokable_access_token_test.py index 10332b32b9cafc..6e8deb41f177ea 100644 --- a/smoke-test/tests/tokens/revokable_access_token_test.py +++ b/smoke-test/tests/tokens/revokable_access_token_test.py @@ -40,10 +40,10 @@ def custom_user_setup(): # Test getting the invite token get_invite_token_json = { - "query": """query getInviteToken($input: GetInviteTokenInput!) {\n - getInviteToken(input: $input){\n - inviteToken\n - }\n + "query": """query getInviteToken($input: GetInviteTokenInput!) { + getInviteToken(input: $input){ + inviteToken + } }""", "variables": {"input": {}}, } @@ -131,6 +131,7 @@ def access_token_setup(): @pytest.mark.dependency(depends=["test_healthchecks"]) def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): admin_session = login_as(admin_user, admin_pass) + admin_user_urn = f"urn:li:corpuser:{admin_user}" # Using a super account, there should be no tokens res_data = listAccessTokens(admin_session) @@ -140,19 +141,26 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 0 # Using a super account, generate a token for itself. - res_data = generateAccessToken_v2(admin_session, f"urn:li:corpuser:{admin_user}") + res_data = generateAccessToken_v2(admin_session, admin_user_urn) assert res_data assert res_data["data"] assert res_data["data"]["createAccessToken"] assert res_data["data"]["createAccessToken"]["accessToken"] assert ( - res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] - == f"urn:li:corpuser:{admin_user}" + res_data["data"]["createAccessToken"]["metadata"]["actorUrn"] == admin_user_urn ) + access_token = res_data["data"]["createAccessToken"]["accessToken"] admin_tokenId = res_data["data"]["createAccessToken"]["metadata"]["id"] # Sleep for eventual consistency wait_for_writes_to_sync() + res_data = getAccessTokenMetadata(admin_session, access_token) + assert res_data + assert res_data["data"] + assert res_data["data"]["getAccessTokenMetadata"] + assert res_data["data"]["getAccessTokenMetadata"]["ownerUrn"] == admin_user_urn + assert res_data["data"]["getAccessTokenMetadata"]["actorUrn"] == admin_user_urn + # Using a super account, list the previously created token. res_data = listAccessTokens(admin_session) assert res_data @@ -160,12 +168,10 @@ def test_admin_can_create_list_and_revoke_tokens(wait_for_healthchecks): assert res_data["data"]["listAccessTokens"]["total"] is not None assert len(res_data["data"]["listAccessTokens"]["tokens"]) == 1 assert ( - res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] - == f"urn:li:corpuser:{admin_user}" + res_data["data"]["listAccessTokens"]["tokens"][0]["actorUrn"] == admin_user_urn ) assert ( - res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] - == f"urn:li:corpuser:{admin_user}" + res_data["data"]["listAccessTokens"]["tokens"][0]["ownerUrn"] == admin_user_urn ) # Check that the super account can revoke tokens that it created @@ -396,17 +402,17 @@ def test_non_admin_can_not_generate_tokens_for_others(wait_for_healthchecks): def generateAccessToken_v2(session, actorUrn): # Create new token json = { - "query": """mutation createAccessToken($input: CreateAccessTokenInput!) {\n - createAccessToken(input: $input) {\n - accessToken\n - metadata {\n - id\n - actorUrn\n - ownerUrn\n - name\n - description\n + "query": """mutation createAccessToken($input: CreateAccessTokenInput!) { + createAccessToken(input: $input) { + accessToken + metadata { + id + actorUrn + ownerUrn + name + description } - }\n + } }""", "variables": { "input": { @@ -434,18 +440,18 @@ def listAccessTokens(session, filters=[]): input["filters"] = filters json = { - "query": """query listAccessTokens($input: ListAccessTokenInput!) {\n - listAccessTokens(input: $input) {\n - start\n - count\n - total\n - tokens {\n - urn\n - id\n - actorUrn\n - ownerUrn\n - }\n - }\n + "query": """query listAccessTokens($input: ListAccessTokenInput!) { + listAccessTokens(input: $input) { + start + count + total + tokens { + urn + id + actorUrn + ownerUrn + } + } }""", "variables": {"input": input}, } @@ -458,7 +464,7 @@ def listAccessTokens(session, filters=[]): def revokeAccessToken(session, tokenId): # Revoke token json = { - "query": """mutation revokeAccessToken($tokenId: String!) {\n + "query": """mutation revokeAccessToken($tokenId: String!) { revokeAccessToken(tokenId: $tokenId) }""", "variables": {"tokenId": tokenId}, @@ -470,10 +476,28 @@ def revokeAccessToken(session, tokenId): return response.json() +def getAccessTokenMetadata(session, token): + json = { + "query": """ + query getAccessTokenMetadata($token: String!) { + getAccessTokenMetadata(token: $token) { + id + ownerUrn + actorUrn + } + }""", + "variables": {"token": token}, + } + response = session.post(f"{get_frontend_url()}/api/v2/graphql", json=json) + response.raise_for_status() + + return response.json() + + def removeUser(session, urn): # Remove user json = { - "query": """mutation removeUser($urn: String!) {\n + "query": """mutation removeUser($urn: String!) { removeUser(urn: $urn) }""", "variables": {"urn": urn}, @@ -493,13 +517,13 @@ def listUsers(session): # list users json = { - "query": """query listUsers($input: ListUsersInput!) {\n - listUsers(input: $input) {\n - start\n - count\n - total\n - users {\n - username\n + "query": """query listUsers($input: ListUsersInput!) { + listUsers(input: $input) { + start + count + total + users { + username } } }""", From 33dc96bc24bb7e07cb1aeaefc593d1bf1bff4546 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Thu, 8 Feb 2024 11:42:02 -0500 Subject: [PATCH 275/540] fix(forms) Update styles of submit/confirm button and fix num input bug (#9797) --- .../profile/sidebar/FormInfo/utils.ts | 2 - .../MultiSelectInput.tsx | 8 +- .../MultipleStringInput.tsx | 4 +- .../StructuredPropertyPrompt/NumberInput.tsx | 8 +- .../StructuredPropertyPrompt.tsx | 180 ++++++++---------- .../useStructuredPropertyPrompt.ts | 17 +- .../prompts/usePromptCompletionInfo.ts | 62 ++++++ .../Dataset/Schema/useGetEntitySchema.tsx | 4 +- 8 files changed, 163 insertions(+), 122 deletions(-) create mode 100644 datahub-web-react/src/app/entity/shared/entityForm/prompts/usePromptCompletionInfo.ts diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts index 48206b411374c4..4a0ae2131623f2 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/FormInfo/utils.ts @@ -140,8 +140,6 @@ export function getCompletedPrompts(entityData: GenericEntityProperties | null) completedPrompts = completedPrompts.concat( forms?.completedForms?.flatMap((form) => (form.completedPrompts ? form.completedPrompts : [])) || [], ); - console.log('entityData', entityData); - console.log('getCompletedPrompts', completedPrompts); return completedPrompts; } diff --git a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx index 606430e68400fe..f7d0ed2d211259 100644 --- a/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx +++ b/datahub-web-react/src/app/entity/shared/entityForm/prompts/StructuredPropertyPrompt/MultiSelectInput.tsx @@ -40,7 +40,9 @@ export default function MultiSelectInput({ allowedValues, selectedValues, }: Props) { - return allowedValues.length > 5 ? ( + const shouldShowSelectDropdown = allowedValues.length > 5; + + return shouldShowSelectDropdown ? ( + {incidentTypes.map((incidentType) => ( + + {incidentType.name} + + ))} + +
    + + {isOtherTypeSelected && ( + + + + )} + + + + +