From b091e4615d915783818c1750599723b933d41267 Mon Sep 17 00:00:00 2001
From: skrydal
Date: Wed, 11 Dec 2024 17:02:31 +0100
Subject: [PATCH 01/16] feat(ingest/kafka): Flag for optional schemas ingestion
(#12077)
---
docs/how/updating-datahub.md | 3 +-
.../datahub/ingestion/source/kafka/kafka.py | 29 +-
.../tests/integration/kafka/kafka_to_file.yml | 1 +
.../kafka_without_schemas_mces_golden.json | 575 ++++++++++++++++++
.../kafka/kafka_without_schemas_to_file.yml | 16 +
.../tests/integration/kafka/test_kafka.py | 9 +-
.../tests/unit/test_kafka_source.py | 5 +-
7 files changed, 620 insertions(+), 18 deletions(-)
create mode 100644 metadata-ingestion/tests/integration/kafka/kafka_without_schemas_mces_golden.json
create mode 100644 metadata-ingestion/tests/integration/kafka/kafka_without_schemas_to_file.yml
diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md
index 8ba83768512a5f..5bc0e66fa2ff1d 100644
--- a/docs/how/updating-datahub.md
+++ b/docs/how/updating-datahub.md
@@ -31,7 +31,7 @@ This file documents any backwards-incompatible changes in DataHub and assists pe
urn:li:dataset:(urn:li:dataPlatform:powerbi,[.]...,)
```
- The config `include_workspace_name_in_dataset_urn` is default to `false` for backward compatiblity, However, we recommend enabling this flag after performing the necessary cleanup.
+ The config `include_workspace_name_in_dataset_urn` is default to `false` for backward compatibility, However, we recommend enabling this flag after performing the necessary cleanup.
If stateful ingestion is enabled, running ingestion with the latest CLI version will handle the cleanup automatically. Otherwise, we recommend soft deleting all powerbi data via the DataHub CLI:
`datahub delete --platform powerbi --soft` and then re-ingest with the latest CLI version, ensuring the `include_workspace_name_in_dataset_urn` configuration is set to true.
@@ -39,6 +39,7 @@ This file documents any backwards-incompatible changes in DataHub and assists pe
- #11742: For PowerBi ingestion, `use_powerbi_email` is now enabled by default when extracting ownership information.
- #12056: The DataHub Airflow plugin no longer supports Airflow 2.1 and Airflow 2.2.
- #12056: The DataHub Airflow plugin now defaults to the v2 plugin implementation.
+- #12077: `Kafka` source no longer ingests schemas from schema registry as separate entities by default, set `ingest_schemas_as_entities` to `true` to ingest them
- OpenAPI Update: PIT Keep Alive parameter added to scroll. NOTE: This parameter requires the `pointInTimeCreationEnabled` feature flag to be enabled and the `elasticSearch.implementation` configuration to be `elasticsearch`. This feature is not supported for OpenSearch at this time and the parameter will not be respected without both of these set.
- OpenAPI Update 2: Previously there was an incorrectly marked parameter named `sort` on the generic list entities endpoint for v3. This parameter is deprecated and only supports a single string value while the documentation indicates it supports a list of strings. This documentation error has been fixed and the correct field, `sortCriteria`, is now documented which supports a list of strings.
diff --git a/metadata-ingestion/src/datahub/ingestion/source/kafka/kafka.py b/metadata-ingestion/src/datahub/ingestion/source/kafka/kafka.py
index 709ba431f0f87b..fa842a15ba7328 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/kafka/kafka.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/kafka/kafka.py
@@ -141,6 +141,10 @@ class KafkaSourceConfig(
default=False,
description="Disables the utilization of the TopicRecordNameStrategy for Schema Registry subjects. For more information, visit: https://docs.confluent.io/platform/current/schema-registry/serdes-develop/index.html#handling-differences-between-preregistered-and-client-derived-schemas:~:text=io.confluent.kafka.serializers.subject.TopicRecordNameStrategy",
)
+ ingest_schemas_as_entities: bool = pydantic.Field(
+ default=False,
+ description="Enables ingesting schemas from schema registry as separate entities, in addition to the topics",
+ )
def get_kafka_consumer(
@@ -343,17 +347,20 @@ def get_workunits_internal(self) -> Iterable[MetadataWorkUnit]:
else:
self.report.report_dropped(topic)
- # Get all subjects from schema registry and ingest them as SCHEMA DatasetSubTypes
- for subject in self.schema_registry_client.get_subjects():
- try:
- yield from self._extract_record(
- subject, True, topic_detail=None, extra_topic_config=None
- )
- except Exception as e:
- logger.warning(f"Failed to extract subject {subject}", exc_info=True)
- self.report.report_warning(
- "subject", f"Exception while extracting topic {subject}: {e}"
- )
+ if self.source_config.ingest_schemas_as_entities:
+ # Get all subjects from schema registry and ingest them as SCHEMA DatasetSubTypes
+ for subject in self.schema_registry_client.get_subjects():
+ try:
+ yield from self._extract_record(
+ subject, True, topic_detail=None, extra_topic_config=None
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to extract subject {subject}", exc_info=True
+ )
+ self.report.report_warning(
+ "subject", f"Exception while extracting topic {subject}: {e}"
+ )
def _extract_record(
self,
diff --git a/metadata-ingestion/tests/integration/kafka/kafka_to_file.yml b/metadata-ingestion/tests/integration/kafka/kafka_to_file.yml
index 380df845e737c6..cde21d85ed2d94 100644
--- a/metadata-ingestion/tests/integration/kafka/kafka_to_file.yml
+++ b/metadata-ingestion/tests/integration/kafka/kafka_to_file.yml
@@ -3,6 +3,7 @@ run_id: kafka-test
source:
type: kafka
config:
+ ingest_schemas_as_entities: true
connection:
bootstrap: "localhost:29092"
schema_registry_url: "http://localhost:28081"
diff --git a/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_mces_golden.json b/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_mces_golden.json
new file mode 100644
index 00000000000000..7810c8077b31d5
--- /dev/null
+++ b/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_mces_golden.json
@@ -0,0 +1,575 @@
+[
+{
+ "proposedSnapshot": {
+ "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": {
+ "urn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_topic,PROD)",
+ "aspects": [
+ {
+ "com.linkedin.pegasus2avro.common.Status": {
+ "removed": false
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.schema.SchemaMetadata": {
+ "schemaName": "key_topic",
+ "platform": "urn:li:dataPlatform:kafka",
+ "version": 0,
+ "created": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "lastModified": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "hash": "44fd7a7b325d6fdd4275b1f02a79c1a8",
+ "platformSchema": {
+ "com.linkedin.pegasus2avro.schema.KafkaSchema": {
+ "documentSchema": "",
+ "keySchema": "{\"type\":\"record\",\"name\":\"UserKey\",\"namespace\":\"io.codebrews.createuserrequest\",\"doc\":\"Key schema for kafka topic\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},{\"name\":\"namespace\",\"type\":\"string\"}]}",
+ "keySchemaType": "AVRO"
+ }
+ },
+ "fields": [
+ {
+ "fieldPath": "[version=2.0].[key=True].[type=UserKey].[type=long].id",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.NumberType": {}
+ }
+ },
+ "nativeDataType": "id",
+ "recursive": false,
+ "isPartOfKey": true
+ },
+ {
+ "fieldPath": "[version=2.0].[key=True].[type=UserKey].[type=string].namespace",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "namespace",
+ "recursive": false,
+ "isPartOfKey": true
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.BrowsePaths": {
+ "paths": [
+ "/prod/kafka"
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.dataset.DatasetProperties": {
+ "customProperties": {
+ "Partitions": "1",
+ "Replication Factor": "1",
+ "min.insync.replicas": "1",
+ "retention.bytes": "-1",
+ "retention.ms": "604800000",
+ "cleanup.policy": "delete",
+ "max.message.bytes": "1048588",
+ "unclean.leader.election.enable": "false"
+ },
+ "name": "key_topic",
+ "tags": []
+ }
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "subTypes",
+ "aspect": {
+ "json": {
+ "typeNames": [
+ "Topic"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "browsePathsV2",
+ "aspect": {
+ "json": {
+ "path": []
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "proposedSnapshot": {
+ "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": {
+ "urn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_value_topic,PROD)",
+ "aspects": [
+ {
+ "com.linkedin.pegasus2avro.common.Status": {
+ "removed": false
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.schema.SchemaMetadata": {
+ "schemaName": "key_value_topic",
+ "platform": "urn:li:dataPlatform:kafka",
+ "version": 0,
+ "created": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "lastModified": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "hash": "a79a2fe3adab60b21d272a9cc3e93595",
+ "platformSchema": {
+ "com.linkedin.pegasus2avro.schema.KafkaSchema": {
+ "documentSchema": "{\"type\":\"record\",\"name\":\"CreateUserRequest\",\"namespace\":\"io.codebrews.createuserrequest\",\"doc\":\"Value schema for kafka topic\",\"fields\":[{\"name\":\"email\",\"type\":\"string\",\"tags\":[\"Email\"]},{\"name\":\"firstName\",\"type\":\"string\",\"tags\":[\"Name\"]},{\"name\":\"lastName\",\"type\":\"string\",\"tags\":[\"Name\"]}],\"tags\":[\"PII\"]}",
+ "documentSchemaType": "AVRO",
+ "keySchema": "{\"type\":\"record\",\"name\":\"UserKey\",\"namespace\":\"io.codebrews.createuserrequest\",\"doc\":\"Key schema for kafka topic\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},{\"name\":\"namespace\",\"type\":\"string\"}]}",
+ "keySchemaType": "AVRO"
+ }
+ },
+ "fields": [
+ {
+ "fieldPath": "[version=2.0].[key=True].[type=UserKey].[type=long].id",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.NumberType": {}
+ }
+ },
+ "nativeDataType": "id",
+ "recursive": false,
+ "isPartOfKey": true
+ },
+ {
+ "fieldPath": "[version=2.0].[key=True].[type=UserKey].[type=string].namespace",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "namespace",
+ "recursive": false,
+ "isPartOfKey": true
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].email",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "email",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Email"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Email\"]}"
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].firstName",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "firstName",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Name"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Name\"]}"
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].lastName",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "lastName",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Name"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Name\"]}"
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.BrowsePaths": {
+ "paths": [
+ "/prod/kafka"
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.GlobalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:PII"
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.dataset.DatasetProperties": {
+ "customProperties": {
+ "Partitions": "1",
+ "Replication Factor": "1",
+ "min.insync.replicas": "1",
+ "retention.bytes": "-1",
+ "retention.ms": "604800000",
+ "cleanup.policy": "delete",
+ "max.message.bytes": "1048588",
+ "unclean.leader.election.enable": "false",
+ "Schema Name": "key_value_topic-value"
+ },
+ "name": "key_value_topic",
+ "description": "Value schema for kafka topic",
+ "tags": []
+ }
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_value_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "subTypes",
+ "aspect": {
+ "json": {
+ "typeNames": [
+ "Topic"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_value_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "domains",
+ "aspect": {
+ "json": {
+ "domains": [
+ "urn:li:domain:sales"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,key_value_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "browsePathsV2",
+ "aspect": {
+ "json": {
+ "path": []
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "proposedSnapshot": {
+ "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": {
+ "urn": "urn:li:dataset:(urn:li:dataPlatform:kafka,value_topic,PROD)",
+ "aspects": [
+ {
+ "com.linkedin.pegasus2avro.common.Status": {
+ "removed": false
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.schema.SchemaMetadata": {
+ "schemaName": "value_topic",
+ "platform": "urn:li:dataPlatform:kafka",
+ "version": 0,
+ "created": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "lastModified": {
+ "time": 0,
+ "actor": "urn:li:corpuser:unknown"
+ },
+ "hash": "62c7c400ec5760797a59c45e59c2f2dc",
+ "platformSchema": {
+ "com.linkedin.pegasus2avro.schema.KafkaSchema": {
+ "documentSchema": "{\"type\":\"record\",\"name\":\"CreateUserRequest\",\"namespace\":\"io.codebrews.createuserrequest\",\"doc\":\"Value schema for kafka topic\",\"fields\":[{\"name\":\"email\",\"type\":\"string\",\"tags\":[\"Email\"]},{\"name\":\"firstName\",\"type\":\"string\",\"tags\":[\"Name\"]},{\"name\":\"lastName\",\"type\":\"string\",\"tags\":[\"Name\"]}],\"tags\":[\"PII\"]}",
+ "documentSchemaType": "AVRO",
+ "keySchema": "\"string\"",
+ "keySchemaType": "AVRO"
+ }
+ },
+ "fields": [
+ {
+ "fieldPath": "[version=2.0].[key=True].[type=string]",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "string",
+ "recursive": false,
+ "isPartOfKey": true
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].email",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "email",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Email"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Email\"]}"
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].firstName",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "firstName",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Name"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Name\"]}"
+ },
+ {
+ "fieldPath": "[version=2.0].[type=CreateUserRequest].[type=string].lastName",
+ "nullable": false,
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "lastName",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Name"
+ }
+ ]
+ },
+ "isPartOfKey": false,
+ "jsonProps": "{\"tags\": [\"Name\"]}"
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.BrowsePaths": {
+ "paths": [
+ "/prod/kafka"
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.GlobalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:PII"
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.dataset.DatasetProperties": {
+ "customProperties": {
+ "Partitions": "1",
+ "Replication Factor": "1",
+ "min.insync.replicas": "1",
+ "retention.bytes": "-1",
+ "retention.ms": "604800000",
+ "cleanup.policy": "delete",
+ "max.message.bytes": "1048588",
+ "unclean.leader.election.enable": "false",
+ "Schema Name": "value_topic-value"
+ },
+ "name": "value_topic",
+ "description": "Value schema for kafka topic",
+ "tags": []
+ }
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,value_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "subTypes",
+ "aspect": {
+ "json": {
+ "typeNames": [
+ "Topic"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:kafka,value_topic,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "browsePathsV2",
+ "aspect": {
+ "json": {
+ "path": []
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "tag",
+ "entityUrn": "urn:li:tag:Email",
+ "changeType": "UPSERT",
+ "aspectName": "tagKey",
+ "aspect": {
+ "json": {
+ "name": "Email"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "tag",
+ "entityUrn": "urn:li:tag:Name",
+ "changeType": "UPSERT",
+ "aspectName": "tagKey",
+ "aspect": {
+ "json": {
+ "name": "Name"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "tag",
+ "entityUrn": "urn:li:tag:PII",
+ "changeType": "UPSERT",
+ "aspectName": "tagKey",
+ "aspect": {
+ "json": {
+ "name": "PII"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "kafka-test",
+ "lastRunId": "no-run-id-provided"
+ }
+}
+]
\ No newline at end of file
diff --git a/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_to_file.yml b/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_to_file.yml
new file mode 100644
index 00000000000000..7f44e43c3c4908
--- /dev/null
+++ b/metadata-ingestion/tests/integration/kafka/kafka_without_schemas_to_file.yml
@@ -0,0 +1,16 @@
+run_id: kafka-test
+
+source:
+ type: kafka
+ config:
+ connection:
+ bootstrap: "localhost:29092"
+ schema_registry_url: "http://localhost:28081"
+ domain:
+ "urn:li:domain:sales":
+ allow:
+ - "key_value_topic"
+sink:
+ type: file
+ config:
+ filename: "./kafka_without_schemas_mces.json"
diff --git a/metadata-ingestion/tests/integration/kafka/test_kafka.py b/metadata-ingestion/tests/integration/kafka/test_kafka.py
index bf0ec1845a66c2..0d9a714625e96b 100644
--- a/metadata-ingestion/tests/integration/kafka/test_kafka.py
+++ b/metadata-ingestion/tests/integration/kafka/test_kafka.py
@@ -43,20 +43,21 @@ def mock_kafka_service(docker_compose_runner, test_resources_dir):
yield docker_compose_runner
+@pytest.mark.parametrize("approach", ["kafka_without_schemas", "kafka"])
@freeze_time(FROZEN_TIME)
@pytest.mark.integration
def test_kafka_ingest(
- mock_kafka_service, test_resources_dir, pytestconfig, tmp_path, mock_time
+ mock_kafka_service, test_resources_dir, pytestconfig, tmp_path, mock_time, approach
):
# Run the metadata ingestion pipeline.
- config_file = (test_resources_dir / "kafka_to_file.yml").resolve()
+ config_file = (test_resources_dir / f"{approach}_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",
+ output_path=tmp_path / f"{approach}_mces.json",
+ golden_path=test_resources_dir / f"{approach}_mces_golden.json",
ignore_paths=[],
)
diff --git a/metadata-ingestion/tests/unit/test_kafka_source.py b/metadata-ingestion/tests/unit/test_kafka_source.py
index cab0a2bce7ba8c..1a8afe1b956fae 100644
--- a/metadata-ingestion/tests/unit/test_kafka_source.py
+++ b/metadata-ingestion/tests/unit/test_kafka_source.py
@@ -330,6 +330,7 @@ def mock_get_latest_version(subject_name: str) -> Optional[RegisteredSchema]:
"topic2-key": "test.acryl.Topic2Key",
"topic2-value": "test.acryl.Topic2Value",
},
+ "ingest_schemas_as_entities": True,
}
ctx = PipelineContext(run_id="test")
kafka_source = KafkaSource.create(source_config, ctx)
@@ -478,8 +479,7 @@ def mock_get_latest_version(subject_name: str) -> Optional[RegisteredSchema]:
kafka_source = KafkaSource.create(source_config, ctx)
workunits = list(kafka_source.get_workunits())
-
- assert len(workunits) == 6
+ assert len(workunits) == 2
if ignore_warnings_on_schema_type:
assert not kafka_source.report.warnings
else:
@@ -622,6 +622,7 @@ def mock_get_latest_version(subject_name: str) -> Optional[RegisteredSchema]:
kafka_source = KafkaSource.create(
{
"connection": {"bootstrap": "localhost:9092"},
+ "ingest_schemas_as_entities": True,
"meta_mapping": {
"owner": {
"match": "^@(.*)",
From f1ef4f8e5f7e4facee5d80e71c7ce6c8051bafcc Mon Sep 17 00:00:00 2001
From: Chris Collins
Date: Wed, 11 Dec 2024 13:59:14 -0500
Subject: [PATCH 02/16] feat(structuredProperties) Add new settings aspect plus
graphql changes for structured props (#12052)
---
.../CreateStructuredPropertyResolver.java | 119 ++++++++---
.../DeleteStructuredPropertyResolver.java | 18 ++
.../RemoveStructuredPropertiesResolver.java | 2 +-
.../UpdateStructuredPropertyResolver.java | 196 +++++++++++++++---
.../UpsertStructuredPropertiesResolver.java | 2 +-
.../types/chart/mappers/ChartMapper.java | 3 +-
.../container/mappers/ContainerMapper.java | 4 +-
.../corpgroup/mappers/CorpGroupMapper.java | 3 +-
.../corpuser/mappers/CorpUserMapper.java | 3 +-
.../dashboard/mappers/DashboardMapper.java | 3 +-
.../dataflow/mappers/DataFlowMapper.java | 3 +-
.../types/datajob/mappers/DataJobMapper.java | 3 +-
.../mappers/DataProductMapper.java | 3 +-
.../types/dataset/mappers/DatasetMapper.java | 3 +-
.../graphql/types/domain/DomainMapper.java | 4 +-
.../glossary/mappers/GlossaryNodeMapper.java | 3 +-
.../glossary/mappers/GlossaryTermMapper.java | 3 +-
.../mlmodel/mappers/MLFeatureMapper.java | 3 +-
.../mlmodel/mappers/MLFeatureTableMapper.java | 3 +-
.../mlmodel/mappers/MLModelGroupMapper.java | 3 +-
.../types/mlmodel/mappers/MLModelMapper.java | 3 +-
.../mlmodel/mappers/MLPrimaryKeyMapper.java | 3 +-
.../types/schemafield/SchemaFieldMapper.java | 3 +-
.../StructuredPropertiesMapper.java | 17 +-
.../StructuredPropertyMapper.java | 18 ++
.../StructuredPropertyType.java | 3 +-
.../src/main/resources/properties.graphql | 84 +++++++-
.../CreateStructuredPropertyResolverTest.java | 120 ++++++++++-
.../DeleteStructuredPropertyResolverTest.java | 91 ++++++++
.../StructuredPropertyUtilsTest.java | 42 ++++
.../UpdateStructuredPropertyResolverTest.java | 102 ++++++++-
.../models/StructuredPropertyUtils.java | 50 +++++
.../java/com/linkedin/metadata/Constants.java | 2 +
.../structuredproperties.py | 12 +-
.../StructuredPropertyDefinition.pdl | 38 ++--
.../structured/StructuredPropertySettings.pdl | 64 ++++++
.../src/main/resources/entity-registry.yml | 1 +
37 files changed, 913 insertions(+), 124 deletions(-)
create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java
create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java
create mode 100644 metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java
index 328f63b893d06f..7d232748f0d93c 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolver.java
@@ -1,7 +1,8 @@
package com.linkedin.datahub.graphql.resolvers.structuredproperties;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
-import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_ENTITY_NAME;
+import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.buildMetadataChangeProposalWithUrn;
+import static com.linkedin.metadata.Constants.*;
import com.linkedin.common.urn.Urn;
import com.linkedin.data.template.SetMode;
@@ -12,20 +13,24 @@
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreateStructuredPropertyInput;
import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity;
+import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput;
import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertyMapper;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.aspect.patch.builder.StructuredPropertyDefinitionPatchBuilder;
+import com.linkedin.metadata.models.StructuredPropertyUtils;
import com.linkedin.metadata.utils.EntityKeyUtils;
import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.structured.PrimitivePropertyValue;
import com.linkedin.structured.PropertyCardinality;
import com.linkedin.structured.PropertyValue;
import com.linkedin.structured.StructuredPropertyKey;
+import com.linkedin.structured.StructuredPropertySettings;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Objects;
-import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
@@ -54,40 +59,28 @@ public CompletableFuture get(final DataFetchingEnviron
"Unable to create structured property. Please contact your admin.");
}
final StructuredPropertyKey key = new StructuredPropertyKey();
- final String id = input.getId() != null ? input.getId() : UUID.randomUUID().toString();
+ final String id =
+ StructuredPropertyUtils.getPropertyId(input.getId(), input.getQualifiedName());
key.setId(id);
final Urn propertyUrn =
EntityKeyUtils.convertEntityKeyToUrn(key, STRUCTURED_PROPERTY_ENTITY_NAME);
- StructuredPropertyDefinitionPatchBuilder builder =
- new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn);
-
- builder.setQualifiedName(input.getQualifiedName());
- builder.setValueType(input.getValueType());
- input.getEntityTypes().forEach(builder::addEntityType);
- if (input.getDisplayName() != null) {
- builder.setDisplayName(input.getDisplayName());
- }
- if (input.getDescription() != null) {
- builder.setDescription(input.getDescription());
- }
- if (input.getImmutable() != null) {
- builder.setImmutable(input.getImmutable());
- }
- if (input.getTypeQualifier() != null) {
- buildTypeQualifier(input, builder);
- }
- if (input.getAllowedValues() != null) {
- buildAllowedValues(input, builder);
+
+ if (_entityClient.exists(context.getOperationContext(), propertyUrn)) {
+ throw new IllegalArgumentException(
+ "A structured property already exists with this urn");
}
- if (input.getCardinality() != null) {
- builder.setCardinality(
- PropertyCardinality.valueOf(input.getCardinality().toString()));
+
+ List mcps = new ArrayList<>();
+
+ // first, create the property definition itself
+ mcps.add(createPropertyDefinition(context, propertyUrn, id, input));
+
+ // then add the settings aspect if we're adding any settings inputs
+ if (input.getSettings() != null) {
+ mcps.add(createPropertySettings(context, propertyUrn, input.getSettings()));
}
- builder.setCreated(context.getOperationContext().getAuditStamp());
- builder.setLastModified(context.getOperationContext().getAuditStamp());
- MetadataChangeProposal mcp = builder.build();
- _entityClient.ingestProposal(context.getOperationContext(), mcp, false);
+ _entityClient.batchIngestProposals(context.getOperationContext(), mcps, false);
EntityResponse response =
_entityClient.getV2(
@@ -103,6 +96,72 @@ public CompletableFuture get(final DataFetchingEnviron
});
}
+ private MetadataChangeProposal createPropertySettings(
+ @Nonnull final QueryContext context,
+ @Nonnull final Urn propertyUrn,
+ final StructuredPropertySettingsInput settingsInput)
+ throws Exception {
+ StructuredPropertySettings settings = new StructuredPropertySettings();
+
+ if (settingsInput.getIsHidden() != null) {
+ settings.setIsHidden(settingsInput.getIsHidden());
+ }
+ if (settingsInput.getShowInSearchFilters() != null) {
+ settings.setShowInSearchFilters(settingsInput.getShowInSearchFilters());
+ }
+ if (settingsInput.getShowInAssetSummary() != null) {
+ settings.setShowInAssetSummary(settingsInput.getShowInAssetSummary());
+ }
+ if (settingsInput.getShowAsAssetBadge() != null) {
+ settings.setShowAsAssetBadge(settingsInput.getShowAsAssetBadge());
+ }
+ if (settingsInput.getShowInColumnsTable() != null) {
+ settings.setShowInColumnsTable(settingsInput.getShowInColumnsTable());
+ }
+ settings.setLastModified(context.getOperationContext().getAuditStamp());
+
+ StructuredPropertyUtils.validatePropertySettings(settings, true);
+
+ return buildMetadataChangeProposalWithUrn(
+ propertyUrn, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, settings);
+ }
+
+ private MetadataChangeProposal createPropertyDefinition(
+ @Nonnull final QueryContext context,
+ @Nonnull final Urn propertyUrn,
+ @Nonnull final String id,
+ final CreateStructuredPropertyInput input)
+ throws Exception {
+ StructuredPropertyDefinitionPatchBuilder builder =
+ new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn);
+
+ builder.setQualifiedName(id);
+ builder.setValueType(input.getValueType());
+ input.getEntityTypes().forEach(builder::addEntityType);
+ if (input.getDisplayName() != null) {
+ builder.setDisplayName(input.getDisplayName());
+ }
+ if (input.getDescription() != null) {
+ builder.setDescription(input.getDescription());
+ }
+ if (input.getImmutable() != null) {
+ builder.setImmutable(input.getImmutable());
+ }
+ if (input.getTypeQualifier() != null) {
+ buildTypeQualifier(input, builder);
+ }
+ if (input.getAllowedValues() != null) {
+ buildAllowedValues(input, builder);
+ }
+ if (input.getCardinality() != null) {
+ builder.setCardinality(PropertyCardinality.valueOf(input.getCardinality().toString()));
+ }
+ builder.setCreated(context.getOperationContext().getAuditStamp());
+ builder.setLastModified(context.getOperationContext().getAuditStamp());
+
+ return builder.build();
+ }
+
private void buildTypeQualifier(
@Nonnull final CreateStructuredPropertyInput input,
@Nonnull final StructuredPropertyDefinitionPatchBuilder builder) {
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java
index e7d59494654fdd..58f8d340fcc074 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolver.java
@@ -6,6 +6,7 @@
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
+import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.DeleteStructuredPropertyInput;
import com.linkedin.entity.client.EntityClient;
@@ -42,6 +43,23 @@ public CompletableFuture get(final DataFetchingEnvironment environment)
"Unable to delete structured property. Please contact your admin.");
}
_entityClient.deleteEntity(context.getOperationContext(), propertyUrn);
+ // Asynchronously Delete all references to the entity (to return quickly)
+ GraphQLConcurrencyUtils.supplyAsync(
+ () -> {
+ try {
+ _entityClient.deleteEntityReferences(
+ context.getOperationContext(), propertyUrn);
+ } catch (Exception e) {
+ log.error(
+ String.format(
+ "Caught exception while attempting to clear all entity references for Structured Property with urn %s",
+ propertyUrn),
+ e);
+ }
+ return null;
+ },
+ this.getClass().getSimpleName(),
+ "get");
return true;
} catch (Exception e) {
throw new RuntimeException(
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java
index ea8c6dac36a4af..313e0a16d8916d 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/RemoveStructuredPropertiesResolver.java
@@ -93,7 +93,7 @@ public CompletableFuture get(final DataFetchingEnviron
"Unable to update structured property. Please contact your admin.");
}
final Urn propertyUrn = UrnUtils.getUrn(input.getUrn());
- StructuredPropertyDefinition existingDefinition =
+ final EntityResponse entityResponse =
getExistingStructuredProperty(context, propertyUrn);
- StructuredPropertyDefinitionPatchBuilder builder =
- new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn);
- if (input.getDisplayName() != null) {
- builder.setDisplayName(input.getDisplayName());
- }
- if (input.getDescription() != null) {
- builder.setDescription(input.getDescription());
- }
- if (input.getImmutable() != null) {
- builder.setImmutable(input.getImmutable());
- }
- if (input.getTypeQualifier() != null) {
- buildTypeQualifier(input, builder, existingDefinition);
- }
- if (input.getNewAllowedValues() != null) {
- buildAllowedValues(input, builder);
- }
- if (input.getSetCardinalityAsMultiple() != null) {
- builder.setCardinality(PropertyCardinality.MULTIPLE);
+ List mcps = new ArrayList<>();
+
+ // first update the definition aspect if we need to
+ MetadataChangeProposal definitionMcp =
+ updateDefinition(input, context, propertyUrn, entityResponse);
+ if (definitionMcp != null) {
+ mcps.add(definitionMcp);
}
- if (input.getNewEntityTypes() != null) {
- input.getNewEntityTypes().forEach(builder::addEntityType);
+
+ // then update the settings aspect if we need to
+ if (input.getSettings() != null) {
+ mcps.add(updateSettings(context, input.getSettings(), propertyUrn, entityResponse));
}
- builder.setLastModified(context.getOperationContext().getAuditStamp());
- MetadataChangeProposal mcp = builder.build();
- _entityClient.ingestProposal(context.getOperationContext(), mcp, false);
+ _entityClient.batchIngestProposals(context.getOperationContext(), mcps, false);
EntityResponse response =
_entityClient.getV2(
@@ -102,6 +95,120 @@ public CompletableFuture get(final DataFetchingEnviron
});
}
+ private boolean hasSettingsChanged(
+ StructuredPropertySettings existingSettings, StructuredPropertySettingsInput settingsInput) {
+ if (settingsInput.getIsHidden() != null
+ && !existingSettings.isIsHidden().equals(settingsInput.getIsHidden())) {
+ return true;
+ }
+ if (settingsInput.getShowInSearchFilters() != null
+ && !existingSettings
+ .isShowInSearchFilters()
+ .equals(settingsInput.getShowInSearchFilters())) {
+ return true;
+ }
+ if (settingsInput.getShowInAssetSummary() != null
+ && !existingSettings.isShowInAssetSummary().equals(settingsInput.getShowInAssetSummary())) {
+ return true;
+ }
+ if (settingsInput.getShowAsAssetBadge() != null
+ && !existingSettings.isShowAsAssetBadge().equals(settingsInput.getShowAsAssetBadge())) {
+ return true;
+ }
+ if (settingsInput.getShowInColumnsTable() != null
+ && !existingSettings.isShowInColumnsTable().equals(settingsInput.getShowInColumnsTable())) {
+ return true;
+ }
+ return false;
+ }
+
+ private MetadataChangeProposal updateSettings(
+ @Nonnull final QueryContext context,
+ @Nonnull final StructuredPropertySettingsInput settingsInput,
+ @Nonnull final Urn propertyUrn,
+ @Nonnull final EntityResponse entityResponse)
+ throws Exception {
+ StructuredPropertySettings existingSettings =
+ getExistingStructuredPropertySettings(entityResponse);
+ // check if settings has changed to determine if we should update the timestamp
+ boolean hasChanged = hasSettingsChanged(existingSettings, settingsInput);
+ if (hasChanged) {
+ existingSettings.setLastModified(context.getOperationContext().getAuditStamp());
+ }
+
+ if (settingsInput.getIsHidden() != null) {
+ existingSettings.setIsHidden(settingsInput.getIsHidden());
+ }
+ if (settingsInput.getShowInSearchFilters() != null) {
+ existingSettings.setShowInSearchFilters(settingsInput.getShowInSearchFilters());
+ }
+ if (settingsInput.getShowInAssetSummary() != null) {
+ existingSettings.setShowInAssetSummary(settingsInput.getShowInAssetSummary());
+ }
+ if (settingsInput.getShowAsAssetBadge() != null) {
+ existingSettings.setShowAsAssetBadge(settingsInput.getShowAsAssetBadge());
+ }
+ if (settingsInput.getShowInColumnsTable() != null) {
+ existingSettings.setShowInColumnsTable(settingsInput.getShowInColumnsTable());
+ }
+
+ StructuredPropertyUtils.validatePropertySettings(existingSettings, true);
+
+ return buildMetadataChangeProposalWithUrn(
+ propertyUrn, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, existingSettings);
+ }
+
+ private MetadataChangeProposal updateDefinition(
+ @Nonnull final UpdateStructuredPropertyInput input,
+ @Nonnull final QueryContext context,
+ @Nonnull final Urn propertyUrn,
+ @Nonnull final EntityResponse entityResponse)
+ throws Exception {
+ StructuredPropertyDefinition existingDefinition =
+ getExistingStructuredPropertyDefinition(entityResponse);
+ StructuredPropertyDefinitionPatchBuilder builder =
+ new StructuredPropertyDefinitionPatchBuilder().urn(propertyUrn);
+
+ boolean hasUpdatedDefinition = false;
+
+ if (input.getDisplayName() != null) {
+ builder.setDisplayName(input.getDisplayName());
+ hasUpdatedDefinition = true;
+ }
+ if (input.getDescription() != null) {
+ builder.setDescription(input.getDescription());
+ hasUpdatedDefinition = true;
+ }
+ if (input.getImmutable() != null) {
+ builder.setImmutable(input.getImmutable());
+ hasUpdatedDefinition = true;
+ }
+ if (input.getTypeQualifier() != null) {
+ buildTypeQualifier(input, builder, existingDefinition);
+ hasUpdatedDefinition = true;
+ }
+ if (input.getNewAllowedValues() != null) {
+ buildAllowedValues(input, builder);
+ hasUpdatedDefinition = true;
+ }
+ if (input.getSetCardinalityAsMultiple() != null
+ && input.getSetCardinalityAsMultiple().equals(true)) {
+ builder.setCardinality(PropertyCardinality.MULTIPLE);
+ hasUpdatedDefinition = true;
+ }
+ if (input.getNewEntityTypes() != null) {
+ input.getNewEntityTypes().forEach(builder::addEntityType);
+ hasUpdatedDefinition = true;
+ }
+
+ if (hasUpdatedDefinition) {
+ builder.setLastModified(context.getOperationContext().getAuditStamp());
+
+ return builder.build();
+ }
+ return null;
+ }
+
private void buildTypeQualifier(
@Nonnull final UpdateStructuredPropertyInput input,
@Nonnull final StructuredPropertyDefinitionPatchBuilder builder,
@@ -141,17 +248,40 @@ private void buildAllowedValues(
});
}
- private StructuredPropertyDefinition getExistingStructuredProperty(
+ private EntityResponse getExistingStructuredProperty(
@Nonnull final QueryContext context, @Nonnull final Urn propertyUrn) throws Exception {
- EntityResponse response =
- _entityClient.getV2(
- context.getOperationContext(), STRUCTURED_PROPERTY_ENTITY_NAME, propertyUrn, null);
+ return _entityClient.getV2(
+ context.getOperationContext(), STRUCTURED_PROPERTY_ENTITY_NAME, propertyUrn, null);
+ }
+ private StructuredPropertyDefinition getExistingStructuredPropertyDefinition(
+ EntityResponse response) throws Exception {
if (response != null
&& response.getAspects().containsKey(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME)) {
return new StructuredPropertyDefinition(
- response.getAspects().get(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME).getValue().data());
+ response
+ .getAspects()
+ .get(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME)
+ .getValue()
+ .data()
+ .copy());
}
- return null;
+ throw new IllegalArgumentException(
+ "Attempting to update a structured property with no definition aspect.");
+ }
+
+ private StructuredPropertySettings getExistingStructuredPropertySettings(EntityResponse response)
+ throws Exception {
+ if (response != null
+ && response.getAspects().containsKey(STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME)) {
+ return new StructuredPropertySettings(
+ response
+ .getAspects()
+ .get(STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME)
+ .getValue()
+ .data()
+ .copy());
+ }
+ return new StructuredPropertySettings();
}
}
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java
index 770c8a0d749c38..6c1d7949332fbe 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpsertStructuredPropertiesResolver.java
@@ -103,7 +103,7 @@ public CompletableFuture
chart.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java
index 02357b3ddc349e..7ac00c46475bce 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/container/mappers/ContainerMapper.java
@@ -161,7 +161,9 @@ public static Container map(
if (envelopedStructuredProps != null) {
result.setStructuredProperties(
StructuredPropertiesMapper.map(
- context, new StructuredProperties(envelopedStructuredProps.getValue().data())));
+ context,
+ new StructuredProperties(envelopedStructuredProps.getValue().data()),
+ entityUrn));
}
final EnvelopedAspect envelopedForms = aspects.get(FORMS_ASPECT_NAME);
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java
index 6246cf64bbf7f8..010816431f54de 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpgroup/mappers/CorpGroupMapper.java
@@ -59,7 +59,8 @@ public CorpGroup apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java
index 4fa278983399b1..a94b555daebdfb 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/corpuser/mappers/CorpUserMapper.java
@@ -88,7 +88,8 @@ public CorpUser apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java
index 4fa52b11365641..fd1c7a5db2a79d 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dashboard/mappers/DashboardMapper.java
@@ -142,7 +142,8 @@ public Dashboard apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((dashboard, dataMap) ->
dashboard.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java
index 9e2612f60abda1..44bc6a99eae4bb 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataflow/mappers/DataFlowMapper.java
@@ -114,7 +114,8 @@ public DataFlow apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java
index d7da875bc2a29f..772871d77f2175 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/datajob/mappers/DataJobMapper.java
@@ -135,7 +135,8 @@ public DataJob apply(
result.setSubTypes(SubTypesMapper.map(context, new SubTypes(data)));
} else if (STRUCTURED_PROPERTIES_ASPECT_NAME.equals(name)) {
result.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(data)));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(data), entityUrn));
} else if (FORMS_ASPECT_NAME.equals(name)) {
result.setForms(FormsMapper.map(new Forms(data), entityUrn.toString()));
}
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java
index 08637dbfd01edc..8693ec97f1a2ee 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataproduct/mappers/DataProductMapper.java
@@ -92,7 +92,8 @@ public DataProduct apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
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 0869463ba73ac2..e411014c23c89b 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
@@ -173,7 +173,8 @@ public Dataset apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((dataset, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java
index 7d05e0862a96da..ffcb94a0b7e29e 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/domain/DomainMapper.java
@@ -71,7 +71,9 @@ public static Domain map(@Nullable QueryContext context, final EntityResponse en
if (envelopedStructuredProps != null) {
result.setStructuredProperties(
StructuredPropertiesMapper.map(
- context, new StructuredProperties(envelopedStructuredProps.getValue().data())));
+ context,
+ new StructuredProperties(envelopedStructuredProps.getValue().data()),
+ entityUrn));
}
final EnvelopedAspect envelopedForms = aspects.get(FORMS_ASPECT_NAME);
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 4912d18614f415..a694b62999080e 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
@@ -59,7 +59,8 @@ public GlossaryNode apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java
index 1274646f45ec49..e309ffad84df58 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermMapper.java
@@ -90,7 +90,8 @@ public GlossaryTerm apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java
index a4f3aa7a0e2261..d5eb1a15624dc3 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureMapper.java
@@ -115,7 +115,8 @@ public MLFeature apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((mlFeature, dataMap) ->
mlFeature.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java
index 30bf4dda1cf4fd..51d3004d97a619 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLFeatureTableMapper.java
@@ -117,7 +117,8 @@ public MLFeatureTable apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((mlFeatureTable, dataMap) ->
mlFeatureTable.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java
index 7e99040e44c82e..6e3da1c1533926 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelGroupMapper.java
@@ -112,7 +112,8 @@ public MLModelGroup apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((mlModelGroup, dataMap) ->
mlModelGroup.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java
index a3bc5c663c89ae..7102fd4aed9743 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLModelMapper.java
@@ -174,7 +174,8 @@ public MLModel apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((dataset, dataMap) ->
dataset.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java
index 36784f96ea30ea..c446c892cb2231 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/mlmodel/mappers/MLPrimaryKeyMapper.java
@@ -112,7 +112,8 @@ public MLPrimaryKey apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((entity, dataMap) ->
entity.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
FORMS_ASPECT_NAME,
((entity, dataMap) ->
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
index b1f27357d45504..30eac54aede9bb 100644
--- 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
@@ -41,7 +41,8 @@ public SchemaFieldEntity apply(
STRUCTURED_PROPERTIES_ASPECT_NAME,
((schemaField, dataMap) ->
schemaField.setStructuredProperties(
- StructuredPropertiesMapper.map(context, new StructuredProperties(dataMap)))));
+ StructuredPropertiesMapper.map(
+ context, new StructuredProperties(dataMap), entityUrn))));
mappingHelper.mapToResult(
BUSINESS_ATTRIBUTE_ASPECT,
(((schemaField, dataMap) ->
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
index dc1ff7ca329714..4f155903c055b1 100644
--- 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
@@ -25,23 +25,29 @@ public class StructuredPropertiesMapper {
public static final StructuredPropertiesMapper INSTANCE = new StructuredPropertiesMapper();
public static com.linkedin.datahub.graphql.generated.StructuredProperties map(
- @Nullable QueryContext context, @Nonnull final StructuredProperties structuredProperties) {
- return INSTANCE.apply(context, structuredProperties);
+ @Nullable QueryContext context,
+ @Nonnull final StructuredProperties structuredProperties,
+ @Nonnull final Urn entityUrn) {
+ return INSTANCE.apply(context, structuredProperties, entityUrn);
}
public com.linkedin.datahub.graphql.generated.StructuredProperties apply(
- @Nullable QueryContext context, @Nonnull final StructuredProperties structuredProperties) {
+ @Nullable QueryContext context,
+ @Nonnull final StructuredProperties structuredProperties,
+ @Nonnull final Urn entityUrn) {
com.linkedin.datahub.graphql.generated.StructuredProperties result =
new com.linkedin.datahub.graphql.generated.StructuredProperties();
result.setProperties(
structuredProperties.getProperties().stream()
- .map(p -> mapStructuredProperty(context, p))
+ .map(p -> mapStructuredProperty(context, p, entityUrn))
.collect(Collectors.toList()));
return result;
}
private StructuredPropertiesEntry mapStructuredProperty(
- @Nullable QueryContext context, StructuredPropertyValueAssignment valueAssignment) {
+ @Nullable QueryContext context,
+ StructuredPropertyValueAssignment valueAssignment,
+ @Nonnull final Urn entityUrn) {
StructuredPropertiesEntry entry = new StructuredPropertiesEntry();
entry.setStructuredProperty(createStructuredPropertyEntity(valueAssignment));
final List values = new ArrayList<>();
@@ -58,6 +64,7 @@ private StructuredPropertiesEntry mapStructuredProperty(
});
entry.setValues(values);
entry.setValueEntities(entities);
+ entry.setAssociatedUrn(entityUrn.toString());
return entry;
}
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
index c539c65118ac6d..5dc73d9ad09388 100644
--- 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
@@ -17,6 +17,7 @@
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.StructuredPropertySettings;
import com.linkedin.datahub.graphql.generated.TypeQualifier;
import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper;
import com.linkedin.datahub.graphql.types.mappers.MapperUtils;
@@ -55,6 +56,8 @@ public StructuredPropertyEntity apply(
MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result);
mappingHelper.mapToResult(
STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, (this::mapStructuredPropertyDefinition));
+ mappingHelper.mapToResult(
+ STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME, (this::mapStructuredPropertySettings));
return mappingHelper.getResult();
}
@@ -112,6 +115,21 @@ private List mapAllowedValues(@Nonnull PropertyValueArray gmsValue
return allowedValues;
}
+ private void mapStructuredPropertySettings(
+ @Nonnull StructuredPropertyEntity extendedProperty, @Nonnull DataMap dataMap) {
+ com.linkedin.structured.StructuredPropertySettings gmsSettings =
+ new com.linkedin.structured.StructuredPropertySettings(dataMap);
+ StructuredPropertySettings settings = new StructuredPropertySettings();
+
+ settings.setIsHidden(gmsSettings.isIsHidden());
+ settings.setShowInSearchFilters(gmsSettings.isShowInSearchFilters());
+ settings.setShowInAssetSummary(gmsSettings.isShowInAssetSummary());
+ settings.setShowAsAssetBadge(gmsSettings.isShowAsAssetBadge());
+ settings.setShowInColumnsTable(gmsSettings.isShowInColumnsTable());
+
+ extendedProperty.setSettings(settings);
+ }
+
private DataTypeEntity createDataTypeEntity(final Urn dataTypeUrn) {
final DataTypeEntity dataType = new DataTypeEntity();
dataType.setUrn(dataTypeUrn.toString());
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
index 22e161d320f215..e451e96a3e84d9 100644
--- 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
@@ -27,7 +27,8 @@ public class StructuredPropertyType
implements com.linkedin.datahub.graphql.types.EntityType {
public static final Set ASPECTS_TO_FETCH =
- ImmutableSet.of(STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME);
+ ImmutableSet.of(
+ STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME, STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME);
private final EntityClient _entityClient;
@Override
diff --git a/datahub-graphql-core/src/main/resources/properties.graphql b/datahub-graphql-core/src/main/resources/properties.graphql
index 292381d064f362..ff20caa50bf036 100644
--- a/datahub-graphql-core/src/main/resources/properties.graphql
+++ b/datahub-graphql-core/src/main/resources/properties.graphql
@@ -49,6 +49,11 @@ type StructuredPropertyEntity implements Entity {
"""
definition: StructuredPropertyDefinition!
+ """
+ Definition of this structured property including its name
+ """
+ settings: StructuredPropertySettings
+
"""
Granular API for querying edges extending from this entity
"""
@@ -117,6 +122,36 @@ type StructuredPropertyDefinition {
lastModified: ResolvedAuditStamp
}
+"""
+Settings specific to a structured property entity
+"""
+type StructuredPropertySettings {
+ """
+ Whether or not this asset should be hidden in the main application
+ """
+ isHidden: Boolean!
+
+ """
+ Whether or not this asset should be displayed as a search filter
+ """
+ showInSearchFilters: Boolean!
+
+ """
+ Whether or not this asset should be displayed in the asset sidebar
+ """
+ showInAssetSummary: Boolean!
+
+ """
+ Whether or not this asset should be displayed as an asset badge on other asset's headers
+ """
+ showAsAssetBadge: Boolean!
+
+ """
+ Whether or not this asset should be displayed as a column in the schema field table in a Dataset's "Columns" tab.
+ """
+ showInColumnsTable: Boolean!
+}
+
"""
An entry for an allowed value for a structured property
"""
@@ -202,6 +237,11 @@ type StructuredPropertiesEntry {
The optional entities associated with the values if the values are entity urns
"""
valueEntities: [Entity]
+
+ """
+ The urn of the entity this property came from for tracking purposes e.g. when sibling nodes are merged together
+ """
+ associatedUrn: String!
}
"""
@@ -330,8 +370,9 @@ input CreateStructuredPropertyInput {
"""
The unique fully qualified name of this structured property, dot delimited.
+ This will be required to match the ID of this structured property.
"""
- qualifiedName: String!
+ qualifiedName: String
"""
The optional display name for this property
@@ -375,6 +416,11 @@ input CreateStructuredPropertyInput {
For example: ["urn:li:entityType:datahub.dataset"]
"""
entityTypes: [String!]!
+
+ """
+ Settings for this structured property
+ """
+ settings: StructuredPropertySettingsInput
}
"""
@@ -455,6 +501,11 @@ input UpdateStructuredPropertyInput {
For backwards compatibility, this is append only.
"""
newEntityTypes: [String!]
+
+ """
+ Settings for this structured property
+ """
+ settings: StructuredPropertySettingsInput
}
"""
@@ -477,3 +528,34 @@ input DeleteStructuredPropertyInput {
"""
urn: String!
}
+
+"""
+Settings for a structured property
+"""
+input StructuredPropertySettingsInput {
+ """
+ Whether or not this asset should be hidden in the main application
+ """
+ isHidden: Boolean
+
+ """
+ Whether or not this asset should be displayed as a search filter
+ """
+ showInSearchFilters: Boolean
+
+ """
+ Whether or not this asset should be displayed in the asset sidebar
+ """
+ showInAssetSummary: Boolean
+
+ """
+ Whether or not this asset should be displayed as an asset badge on other asset's headers
+ """
+ showAsAssetBadge: Boolean
+
+ """
+ Whether or not this asset should be displayed as a column in the schema field table in a Dataset's "Columns" tab.
+ """
+ showInColumnsTable: Boolean
+}
+
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java
index 72cdb78542e414..fec2251f92b63f 100644
--- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/CreateStructuredPropertyResolverTest.java
@@ -10,11 +10,11 @@
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.CreateStructuredPropertyInput;
import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity;
+import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.Constants;
-import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.r2.RemoteInvocationException;
import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList;
@@ -36,7 +36,8 @@ public class CreateStructuredPropertyResolverTest {
null,
null,
null,
- new ArrayList<>());
+ new ArrayList<>(),
+ null);
@Test
public void testGetSuccess() throws Exception {
@@ -56,7 +57,40 @@ public void testGetSuccess() throws Exception {
// Validate that we called ingest
Mockito.verify(mockEntityClient, Mockito.times(1))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
+ }
+
+ @Test
+ public void testGetMismatchIdAndQualifiedName() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ CreateStructuredPropertyResolver resolver =
+ new CreateStructuredPropertyResolver(mockEntityClient);
+
+ CreateStructuredPropertyInput testInput =
+ new CreateStructuredPropertyInput(
+ "mismatched",
+ "io.acryl.test",
+ "Display Name",
+ "description",
+ true,
+ null,
+ null,
+ null,
+ null,
+ new ArrayList<>(),
+ null);
+
+ // Execute resolver
+ QueryContext mockContext = getMockAllowContext();
+ DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
+ Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput);
+ Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
+
+ assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
+
+ // Validate ingest is not called
+ Mockito.verify(mockEntityClient, Mockito.times(0))
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
@Test
@@ -75,7 +109,7 @@ public void testGetUnauthorized() throws Exception {
// Validate that we did NOT call ingest
Mockito.verify(mockEntityClient, Mockito.times(0))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
@Test
@@ -94,7 +128,83 @@ public void testGetFailure() throws Exception {
// Validate that ingest was called, but that caused a failure
Mockito.verify(mockEntityClient, Mockito.times(1))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
+ }
+
+ @Test
+ public void testGetInvalidSettingsInput() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ CreateStructuredPropertyResolver resolver =
+ new CreateStructuredPropertyResolver(mockEntityClient);
+
+ // if isHidden is true, other fields should not be true
+ StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput();
+ settingsInput.setIsHidden(true);
+ settingsInput.setShowAsAssetBadge(true);
+
+ CreateStructuredPropertyInput testInput =
+ new CreateStructuredPropertyInput(
+ null,
+ "io.acryl.test",
+ "Display Name",
+ "description",
+ true,
+ null,
+ null,
+ null,
+ null,
+ new ArrayList<>(),
+ settingsInput);
+
+ // Execute resolver
+ QueryContext mockContext = getMockAllowContext();
+ DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
+ Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput);
+ Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
+
+ assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
+
+ // Validate ingest is not called
+ Mockito.verify(mockEntityClient, Mockito.times(0))
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
+ }
+
+ @Test
+ public void testGetSuccessWithSettings() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ CreateStructuredPropertyResolver resolver =
+ new CreateStructuredPropertyResolver(mockEntityClient);
+
+ StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput();
+ settingsInput.setShowAsAssetBadge(true);
+
+ CreateStructuredPropertyInput testInput =
+ new CreateStructuredPropertyInput(
+ null,
+ "io.acryl.test",
+ "Display Name",
+ "description",
+ true,
+ null,
+ null,
+ null,
+ null,
+ new ArrayList<>(),
+ settingsInput);
+
+ // Execute resolver
+ QueryContext mockContext = getMockAllowContext();
+ DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
+ Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput);
+ Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
+
+ StructuredPropertyEntity prop = resolver.get(mockEnv).get();
+
+ assertEquals(prop.getUrn(), TEST_STRUCTURED_PROPERTY_URN);
+
+ // Validate that we called ingest
+ Mockito.verify(mockEntityClient, Mockito.times(1))
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception {
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java
new file mode 100644
index 00000000000000..7ecec25708f2d5
--- /dev/null
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/DeleteStructuredPropertyResolverTest.java
@@ -0,0 +1,91 @@
+package com.linkedin.datahub.graphql.resolvers.structuredproperties;
+
+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.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+import com.linkedin.common.urn.UrnUtils;
+import com.linkedin.datahub.graphql.QueryContext;
+import com.linkedin.datahub.graphql.generated.DeleteStructuredPropertyInput;
+import com.linkedin.entity.client.EntityClient;
+import com.linkedin.r2.RemoteInvocationException;
+import graphql.schema.DataFetchingEnvironment;
+import java.util.concurrent.CompletionException;
+import org.mockito.Mockito;
+import org.testng.annotations.Test;
+
+public class DeleteStructuredPropertyResolverTest {
+ private static final String TEST_PROP_URN = "urn:li:structuredProperty:test";
+
+ private static final DeleteStructuredPropertyInput TEST_INPUT =
+ new DeleteStructuredPropertyInput(TEST_PROP_URN);
+
+ @Test
+ public void testGetSuccess() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ DeleteStructuredPropertyResolver resolver =
+ new DeleteStructuredPropertyResolver(mockEntityClient);
+
+ // 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 delete
+ Mockito.verify(mockEntityClient, Mockito.times(1))
+ .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN)));
+ }
+
+ @Test
+ public void testGetUnauthorized() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ DeleteStructuredPropertyResolver resolver =
+ new DeleteStructuredPropertyResolver(mockEntityClient);
+
+ // Execute resolver
+ QueryContext mockContext = getMockDenyContext();
+ 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 did NOT call delete
+ Mockito.verify(mockEntityClient, Mockito.times(0))
+ .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN)));
+ }
+
+ @Test
+ public void testGetFailure() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(false);
+ DeleteStructuredPropertyResolver resolver =
+ new DeleteStructuredPropertyResolver(mockEntityClient);
+
+ // 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 deleteEntity was called, but since it's the thing that failed it was called
+ // once still
+ Mockito.verify(mockEntityClient, Mockito.times(1))
+ .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_PROP_URN)));
+ }
+
+ private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception {
+ EntityClient client = Mockito.mock(EntityClient.class);
+ if (!shouldSucceed) {
+ Mockito.doThrow(new RemoteInvocationException()).when(client).deleteEntity(any(), any());
+ }
+ return client;
+ }
+}
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java
new file mode 100644
index 00000000000000..0e9d064b3c7af7
--- /dev/null
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/StructuredPropertyUtilsTest.java
@@ -0,0 +1,42 @@
+package com.linkedin.datahub.graphql.resolvers.structuredproperties;
+
+import static org.testng.Assert.*;
+
+import com.linkedin.metadata.models.StructuredPropertyUtils;
+import java.util.UUID;
+import org.testng.annotations.Test;
+
+public class StructuredPropertyUtilsTest {
+
+ @Test
+ public void testGetIdMismatchedInput() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> StructuredPropertyUtils.getPropertyId("test1", "test2"));
+ }
+
+ @Test
+ public void testGetIdConsistentInput() throws Exception {
+ assertEquals(StructuredPropertyUtils.getPropertyId("test1", "test1"), "test1");
+ }
+
+ @Test
+ public void testGetIdNullQualifiedName() throws Exception {
+ assertEquals(StructuredPropertyUtils.getPropertyId("test1", null), "test1");
+ }
+
+ @Test
+ public void testGetIdNullId() throws Exception {
+ assertEquals(StructuredPropertyUtils.getPropertyId(null, "test1"), "test1");
+ }
+
+ @Test
+ public void testGetIdNullForBoth() throws Exception {
+ try {
+ String id = StructuredPropertyUtils.getPropertyId(null, null);
+ UUID.fromString(id);
+ } catch (Exception e) {
+ fail("ID produced is not a UUID");
+ }
+ }
+}
diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java
index b818bcfb7d7f4f..2b0e7fd83b7cee 100644
--- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java
+++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/structuredproperties/UpdateStructuredPropertyResolverTest.java
@@ -2,20 +2,25 @@
import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext;
import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext;
+import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME;
import static org.mockito.ArgumentMatchers.any;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertThrows;
+import com.linkedin.common.UrnArray;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.StructuredPropertyEntity;
+import com.linkedin.datahub.graphql.generated.StructuredPropertySettingsInput;
import com.linkedin.datahub.graphql.generated.UpdateStructuredPropertyInput;
+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.Constants;
-import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.r2.RemoteInvocationException;
+import com.linkedin.structured.StructuredPropertyDefinition;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletionException;
import org.mockito.Mockito;
@@ -33,6 +38,7 @@ public class UpdateStructuredPropertyResolverTest {
null,
null,
null,
+ null,
null);
@Test
@@ -53,7 +59,7 @@ public void testGetSuccess() throws Exception {
// Validate that we called ingest
Mockito.verify(mockEntityClient, Mockito.times(1))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
@Test
@@ -72,7 +78,7 @@ public void testGetUnauthorized() throws Exception {
// Validate that we did NOT call ingest
Mockito.verify(mockEntityClient, Mockito.times(0))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
@Test
@@ -91,7 +97,80 @@ public void testGetFailure() throws Exception {
// Validate that ingest was not called since there was a get failure before ingesting
Mockito.verify(mockEntityClient, Mockito.times(0))
- .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false));
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
+ }
+
+ @Test
+ public void testGetInvalidSettingsInput() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ UpdateStructuredPropertyResolver resolver =
+ new UpdateStructuredPropertyResolver(mockEntityClient);
+
+ // if isHidden is true, other fields should not be true
+ StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput();
+ settingsInput.setIsHidden(true);
+ settingsInput.setShowInSearchFilters(true);
+
+ final UpdateStructuredPropertyInput testInput =
+ new UpdateStructuredPropertyInput(
+ TEST_STRUCTURED_PROPERTY_URN,
+ "New Display Name",
+ "new description",
+ true,
+ null,
+ null,
+ null,
+ null,
+ settingsInput);
+
+ // Execute resolver
+ QueryContext mockContext = getMockAllowContext();
+ DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
+ Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput);
+ Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
+
+ assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
+
+ // Validate that ingest was not called since there was a get failure before ingesting
+ Mockito.verify(mockEntityClient, Mockito.times(0))
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
+ }
+
+ @Test
+ public void testGetValidSettingsInput() throws Exception {
+ EntityClient mockEntityClient = initMockEntityClient(true);
+ UpdateStructuredPropertyResolver resolver =
+ new UpdateStructuredPropertyResolver(mockEntityClient);
+
+ // if isHidden is true, other fields should not be true
+ StructuredPropertySettingsInput settingsInput = new StructuredPropertySettingsInput();
+ settingsInput.setIsHidden(true);
+
+ final UpdateStructuredPropertyInput testInput =
+ new UpdateStructuredPropertyInput(
+ TEST_STRUCTURED_PROPERTY_URN,
+ "New Display Name",
+ "new description",
+ true,
+ null,
+ null,
+ null,
+ null,
+ settingsInput);
+
+ // Execute resolver
+ QueryContext mockContext = getMockAllowContext();
+ DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
+ Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput);
+ Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
+
+ StructuredPropertyEntity prop = resolver.get(mockEnv).get();
+
+ assertEquals(prop.getUrn(), TEST_STRUCTURED_PROPERTY_URN);
+
+ // Validate that we called ingest
+ Mockito.verify(mockEntityClient, Mockito.times(1))
+ .batchIngestProposals(any(), Mockito.anyList(), Mockito.eq(false));
}
private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception {
@@ -99,7 +178,11 @@ private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exceptio
EntityResponse response = new EntityResponse();
response.setEntityName(Constants.STRUCTURED_PROPERTY_ENTITY_NAME);
response.setUrn(UrnUtils.getUrn(TEST_STRUCTURED_PROPERTY_URN));
- response.setAspects(new EnvelopedAspectMap());
+ final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap();
+ aspectMap.put(
+ STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME,
+ new EnvelopedAspect().setValue(new Aspect(createDefinition().data())));
+ response.setAspects(aspectMap);
if (shouldSucceed) {
Mockito.when(
client.getV2(
@@ -120,4 +203,13 @@ private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exceptio
return client;
}
+
+ private StructuredPropertyDefinition createDefinition() {
+ StructuredPropertyDefinition definition = new StructuredPropertyDefinition();
+ definition.setDisplayName("test");
+ definition.setQualifiedName("test");
+ definition.setValueType(UrnUtils.getUrn("urn:li:dataType:datahub.string"));
+ definition.setEntityTypes(new UrnArray());
+ return definition;
+ }
}
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
index e9ee7789550c6c..1b12f540badfb8 100644
--- a/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
+++ b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
@@ -20,6 +20,7 @@
import com.linkedin.structured.PrimitivePropertyValue;
import com.linkedin.structured.StructuredProperties;
import com.linkedin.structured.StructuredPropertyDefinition;
+import com.linkedin.structured.StructuredPropertySettings;
import com.linkedin.structured.StructuredPropertyValueAssignment;
import com.linkedin.structured.StructuredPropertyValueAssignmentArray;
import com.linkedin.util.Pair;
@@ -32,6 +33,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -45,6 +47,11 @@ private StructuredPropertyUtils() {}
static final Date MIN_DATE = Date.valueOf("1000-01-01");
static final Date MAX_DATE = Date.valueOf("9999-12-31");
+ public static final String INVALID_SETTINGS_MESSAGE =
+ "Cannot have property isHidden = true while other display location settings are also true.";
+ public static final String ONLY_ONE_BADGE =
+ "Cannot have more than one property set with show as badge. Property urns currently set: ";
+
public static LogicalValueType getLogicalValueType(
StructuredPropertyDefinition structuredPropertyDefinition) {
return getLogicalValueType(structuredPropertyDefinition.getValueType());
@@ -355,4 +362,47 @@ private static Pair filterValue
true);
}
}
+
+ /*
+ * We accept both ID and qualifiedName as inputs when creating a structured property. However,
+ * these two fields should ALWAYS be the same. If they don't provide either, use a UUID for both.
+ * If they provide both, ensure they are the same otherwise throw. Otherwise, use what is provided.
+ */
+ public static String getPropertyId(
+ @Nullable final String inputId, @Nullable final String inputQualifiedName) {
+ if (inputId != null && inputQualifiedName != null && !inputId.equals(inputQualifiedName)) {
+ throw new IllegalArgumentException(
+ "Qualified name and the ID of a structured property must match");
+ }
+
+ String id = UUID.randomUUID().toString();
+
+ if (inputQualifiedName != null) {
+ id = inputQualifiedName;
+ } else if (inputId != null) {
+ id = inputId;
+ }
+
+ return id;
+ }
+
+ /*
+ * Ensure that a structured property settings aspect is valid by ensuring that if isHidden is true,
+ * the other fields concerning display locations are false;
+ */
+ public static boolean validatePropertySettings(
+ StructuredPropertySettings settings, boolean shouldThrow) {
+ if (settings.isIsHidden()) {
+ if (settings.isShowInSearchFilters()
+ || settings.isShowInAssetSummary()
+ || settings.isShowAsAssetBadge()) {
+ if (shouldThrow) {
+ throw new IllegalArgumentException(INVALID_SETTINGS_MESSAGE);
+ } else {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
}
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 9c608187342e8c..797055d5fb6a93 100644
--- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java
+++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java
@@ -363,6 +363,8 @@ public class Constants {
// Structured Property
public static final String STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME = "propertyDefinition";
public static final String STRUCTURED_PROPERTY_KEY_ASPECT_NAME = "structuredPropertyKey";
+ public static final String STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME =
+ "structuredPropertySettings";
// Form
public static final String FORM_INFO_ASPECT_NAME = "formInfo";
diff --git a/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
index 181c70adc640a6..013efbdf6a2f6b 100644
--- a/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
+++ b/metadata-ingestion/src/datahub/api/entities/structuredproperties/structuredproperties.py
@@ -118,11 +118,13 @@ def validate_entity_types(cls, v):
@property
def fqn(self) -> str:
assert self.urn is not None
- return (
- self.qualified_name
- or self.id
- or Urn.from_string(self.urn).get_entity_id()[0]
- )
+ id = Urn.create_from_string(self.urn).get_entity_id()[0]
+ if self.qualified_name is not None:
+ # ensure that qualified name and ID match
+ assert (
+ self.qualified_name == id
+ ), "ID in the urn and the qualified_name must match"
+ return id
@validator("urn", pre=True, always=True)
def urn_must_be_present(cls, v, values):
diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
index 3ddb2d2e571da3..416e2c5c11e228 100644
--- a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
+++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertyDefinition.pdl
@@ -89,25 +89,25 @@ record StructuredPropertyDefinition {
version: optional string
/**
- * Created Audit stamp
- */
- @Searchable = {
- "/time": {
- "fieldName": "createdTime",
- "fieldType": "DATETIME"
- }
- }
- created: optional AuditStamp
+ * Created Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "createdTime",
+ "fieldType": "DATETIME"
+ }
+ }
+ created: optional AuditStamp
- /**
- * Created Audit stamp
- */
- @Searchable = {
- "/time": {
- "fieldName": "lastModified",
- "fieldType": "DATETIME"
- }
- }
- lastModified: optional AuditStamp
+ /**
+ * Last Modified Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "lastModified",
+ "fieldType": "DATETIME"
+ }
+ }
+ lastModified: optional AuditStamp
}
diff --git a/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl
new file mode 100644
index 00000000000000..fadcdfa5204e14
--- /dev/null
+++ b/metadata-models/src/main/pegasus/com/linkedin/structured/StructuredPropertySettings.pdl
@@ -0,0 +1,64 @@
+namespace com.linkedin.structured
+
+import com.linkedin.common.AuditStamp
+
+/**
+ * Settings specific to a structured property entity
+ */
+@Aspect = {
+ "name": "structuredPropertySettings"
+}
+record StructuredPropertySettings {
+ /**
+ * Whether or not this asset should be hidden in the main application
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ isHidden: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as a search filter
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInSearchFilters: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed in the asset sidebar
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInAssetSummary: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as an asset badge on other
+ * asset's headers
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showAsAssetBadge: boolean = false
+
+ /**
+ * Whether or not this asset should be displayed as a column in the schema field table
+ * in a Dataset's "Columns" tab.
+ */
+ @Searchable = {
+ "fieldType": "BOOLEAN"
+ }
+ showInColumnsTable: boolean = false
+
+ /**
+ * Last Modified Audit stamp
+ */
+ @Searchable = {
+ "/time": {
+ "fieldName": "lastModifiedSettings",
+ "fieldType": "DATETIME"
+ }
+ }
+ lastModified: optional AuditStamp
+}
diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml
index ee1481f29f7e9f..1c3eb5b574e204 100644
--- a/metadata-models/src/main/resources/entity-registry.yml
+++ b/metadata-models/src/main/resources/entity-registry.yml
@@ -602,6 +602,7 @@ entities:
keyAspect: structuredPropertyKey
aspects:
- propertyDefinition
+ - structuredPropertySettings
- institutionalMemory
- status
- name: form
From d062411c6893d2d34c4cf14b48eb6f5acb7d1658 Mon Sep 17 00:00:00 2001
From: sid-acryl <155424659+sid-acryl@users.noreply.github.com>
Date: Thu, 12 Dec 2024 01:08:52 +0530
Subject: [PATCH 03/16] fix(ingest/tableau): project_path_pattern use in
_is_denied_project (#12010)
---
.../src/datahub/configuration/common.py | 6 +--
.../ingestion/source/tableau/tableau.py | 51 +++++++++++++------
2 files changed, 38 insertions(+), 19 deletions(-)
diff --git a/metadata-ingestion/src/datahub/configuration/common.py b/metadata-ingestion/src/datahub/configuration/common.py
index 4fdf564162410c..7df007e087979c 100644
--- a/metadata-ingestion/src/datahub/configuration/common.py
+++ b/metadata-ingestion/src/datahub/configuration/common.py
@@ -258,7 +258,7 @@ def allow_all(cls) -> "AllowDenyPattern":
return AllowDenyPattern()
def allowed(self, string: str) -> bool:
- if self._denied(string):
+ if self.denied(string):
return False
return any(
@@ -266,7 +266,7 @@ def allowed(self, string: str) -> bool:
for allow_pattern in self.allow
)
- def _denied(self, string: str) -> bool:
+ def denied(self, string: str) -> bool:
for deny_pattern in self.deny:
if re.match(deny_pattern, string, self.regex_flags):
return True
@@ -290,7 +290,7 @@ def get_allowed_list(self) -> List[str]:
raise ValueError(
"allow list must be fully specified to get list of allowed strings"
)
- return [a for a in self.allow if not self._denied(a)]
+ return [a for a in self.allow if not self.denied(a)]
def __eq__(self, other): # type: ignore
return isinstance(other, self.__class__) and self.__dict__ == other.__dict__
diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
index 68c38d4d064612..6844b8a425a7b6 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau.py
@@ -353,7 +353,7 @@ class TableauConfig(
project_path_separator: str = Field(
default="/",
- description="The separator used for the project_pattern field between project names. By default, we use a slash. "
+ description="The separator used for the project_path_pattern field between project names. By default, we use a slash. "
"You can change this if your Tableau projects contain slashes in their names, and you'd like to filter by project.",
)
@@ -959,19 +959,36 @@ def _is_allowed_project(self, project: TableauProject) -> bool:
return is_allowed
def _is_denied_project(self, project: TableauProject) -> bool:
- # Either project name or project path should exist in deny
- for deny_pattern in self.config.project_pattern.deny:
- # Either name or project path is denied
- if re.match(
- deny_pattern, project.name, self.config.project_pattern.regex_flags
- ) or re.match(
- deny_pattern,
- self._get_project_path(project),
- self.config.project_pattern.regex_flags,
- ):
- return True
- logger.info(f"project({project.name}) is not denied as per project_pattern")
- return False
+ """
+ Why use an explicit denial check instead of the `AllowDenyPattern.allowed` method?
+
+ Consider a scenario where a Tableau site contains four projects: A, B, C, and D, with the following hierarchical relationship:
+
+ - **A**
+ - **B** (Child of A)
+ - **C** (Child of A)
+ - **D**
+
+ In this setup:
+
+ - `project_pattern` is configured with `allow: ["A"]` and `deny: ["B"]`.
+ - `extract_project_hierarchy` is set to `True`.
+
+ The goal is to extract assets from project A and its children while explicitly denying the child project B.
+
+ If we rely solely on the `project_pattern.allowed()` method, project C's assets will not be ingested.
+ This happens because project C is not explicitly included in the `allow` list, nor is it part of the `deny` list.
+ However, since `extract_project_hierarchy` is enabled, project C should ideally be included in the ingestion process unless explicitly denied.
+
+ To address this, the function explicitly checks the deny regex to ensure that project C’s assets are ingested if it is not specifically denied in the deny list. This approach ensures that the hierarchy is respected while adhering to the configured allow/deny rules.
+ """
+
+ # Either project_pattern or project_path_pattern is set in a recipe
+ # TableauConfig.projects_backward_compatibility ensures that at least one of these properties is configured.
+
+ return self.config.project_pattern.denied(
+ project.name
+ ) or self.config.project_path_pattern.denied(self._get_project_path(project))
def _init_tableau_project_registry(self, all_project_map: dict) -> None:
list_of_skip_projects: List[TableauProject] = []
@@ -999,9 +1016,11 @@ def _init_tableau_project_registry(self, all_project_map: dict) -> None:
for project in list_of_skip_projects:
if (
project.parent_id in projects_to_ingest
- and self._is_denied_project(project) is False
+ and not self._is_denied_project(project)
):
- logger.debug(f"Project {project.name} is added in project registry")
+ logger.debug(
+ f"Project {project.name} is added in project registry as it's a child project and not explicitly denied in `deny` list"
+ )
projects_to_ingest[project.id] = project
# We rely on automatic browse paths (v2) when creating containers. That's why we need to sort the projects here.
From 11c49ec6be3c2145151a91924799138d4b56bb3b Mon Sep 17 00:00:00 2001
From: Chris Collins
Date: Wed, 11 Dec 2024 17:31:06 -0500
Subject: [PATCH 04/16] fix(ui) Add backwards compatibility to the UI for old
policy filters (#12017)
---
.../permissions/policy/PolicyDetailsModal.tsx | 8 +-
.../policy/PolicyPrivilegeForm.tsx | 29 +++-
.../policy/_tests_/policyUtils.test.tsx | 160 +++++++++++++++++-
.../src/app/permissions/policy/constants.ts | 4 +
.../src/app/permissions/policy/policyUtils.ts | 24 ++-
5 files changed, 209 insertions(+), 16 deletions(-)
create mode 100644 datahub-web-react/src/app/permissions/policy/constants.ts
diff --git a/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx b/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
index 37349585fa4c92..1988fea3496992 100644
--- a/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
+++ b/datahub-web-react/src/app/permissions/policy/PolicyDetailsModal.tsx
@@ -12,6 +12,7 @@ import {
mapResourceTypeToDisplayName,
} from './policyUtils';
import AvatarsGroup from '../AvatarsGroup';
+import { RESOURCE_TYPE, RESOURCE_URN, TYPE, URN } from './constants';
type PrivilegeOptionType = {
type?: string;
@@ -72,10 +73,11 @@ export default function PolicyDetailsModal({ policy, open, onClose, privileges }
const isMetadataPolicy = policy?.type === PolicyType.Metadata;
const resources = convertLegacyResourceFilter(policy?.resources);
- const resourceTypes = getFieldValues(resources?.filter, 'TYPE') || [];
+ const resourceTypes = getFieldValues(resources?.filter, TYPE, RESOURCE_TYPE) || [];
const dataPlatformInstances = getFieldValues(resources?.filter, 'DATA_PLATFORM_INSTANCE') || [];
- const resourceEntities = getFieldValues(resources?.filter, 'URN') || [];
- const resourceFilterCondition = getFieldCondition(resources?.filter, 'URN') || PolicyMatchCondition.Equals;
+ const resourceEntities = getFieldValues(resources?.filter, URN, RESOURCE_URN) || [];
+ const resourceFilterCondition =
+ getFieldCondition(resources?.filter, URN, RESOURCE_URN) || PolicyMatchCondition.Equals;
const domains = getFieldValues(resources?.filter, 'DOMAIN') || [];
const {
diff --git a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
index 7a0de67f414192..414346c2776db8 100644
--- a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
+++ b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx
@@ -28,6 +28,7 @@ import ClickOutside from '../../shared/ClickOutside';
import { TagTermLabel } from '../../shared/tags/TagTermLabel';
import { ENTER_KEY_CODE } from '../../shared/constants';
import { useGetRecommendations } from '../../shared/recommendation';
+import { RESOURCE_TYPE, RESOURCE_URN, TYPE, URN } from './constants';
type Props = {
policyType: PolicyType;
@@ -102,8 +103,9 @@ export default function PolicyPrivilegeForm({
} = useAppConfig();
const resources: ResourceFilter = convertLegacyResourceFilter(maybeResources) || EMPTY_POLICY.resources;
- const resourceTypes = getFieldValues(resources.filter, 'TYPE') || [];
- const resourceEntities = getFieldValues(resources.filter, 'URN') || [];
+ // RESOURCE_TYPE and RESOURCE_URN are deprecated, but need to get them for backwards compatibility
+ const resourceTypes = getFieldValues(resources.filter, TYPE, RESOURCE_TYPE) || [];
+ const resourceEntities = getFieldValues(resources.filter, URN, RESOURCE_URN) || [];
const getDisplayName = (entity) => {
if (!entity) {
@@ -178,9 +180,14 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_TYPE field and replace with TYPE field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_TYPE, []);
setResources({
...resources,
- filter: setFieldValues(filter, 'TYPE', [...resourceTypes, createCriterionValue(selectedResourceType)]),
+ filter: setFieldValues(filterWithoutDeprecatedField, TYPE, [
+ ...resourceTypes,
+ createCriterionValue(selectedResourceType),
+ ]),
});
};
@@ -188,11 +195,13 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_TYPE field and replace with TYPE field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_TYPE, []);
setResources({
...resources,
filter: setFieldValues(
- filter,
- 'TYPE',
+ filterWithoutDeprecatedField,
+ TYPE,
resourceTypes?.filter((criterionValue) => criterionValue.value !== deselectedResourceType),
),
});
@@ -203,9 +212,11 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_URN field and replace with URN field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_URN, []);
setResources({
...resources,
- filter: setFieldValues(filter, 'URN', [
+ filter: setFieldValues(filterWithoutDeprecatedField, URN, [
...resourceEntities,
createCriterionValueWithEntity(
resource,
@@ -220,11 +231,13 @@ export default function PolicyPrivilegeForm({
const filter = resources.filter || {
criteria: [],
};
+ // remove the deprecated RESOURCE_URN field and replace with URN field
+ const filterWithoutDeprecatedField = setFieldValues(filter, RESOURCE_URN, []);
setResources({
...resources,
filter: setFieldValues(
- filter,
- 'URN',
+ filterWithoutDeprecatedField,
+ URN,
resourceEntities?.filter((criterionValue) => criterionValue.value !== resource),
),
});
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
index 1c9884e5fcf09c..eae735b3477f0f 100644
--- a/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx
+++ b/datahub-web-react/src/app/permissions/policy/_tests_/policyUtils.test.tsx
@@ -1,4 +1,12 @@
-import { addOrUpdatePoliciesInList, updateListPoliciesCache, removeFromListPoliciesCache } from '../policyUtils';
+import { PolicyMatchCondition } from '../../../../types.generated';
+import {
+ addOrUpdatePoliciesInList,
+ updateListPoliciesCache,
+ removeFromListPoliciesCache,
+ getFieldValues,
+ getFieldCondition,
+ setFieldValues,
+} from '../policyUtils';
// Mock the Apollo Client readQuery and writeQuery methods
const mockReadQuery = vi.fn();
@@ -103,3 +111,153 @@ describe('removeFromListPoliciesCache', () => {
});
});
});
+
+describe('getFieldValues', () => {
+ it('should get field values for a given field', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(getFieldValues(filter, 'TYPE')).toMatchObject([{ value: 'dataset' }, { value: 'dataJob' }]);
+ });
+
+ it('should get field values for a alternate field (for deprecated fields)', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(getFieldValues(filter, 'TYPE', 'RESOURCE_TYPE')).toMatchObject([
+ { value: 'dataset' },
+ { value: 'dataJob' },
+ ]);
+ });
+
+ it('should get field values for main field with alternative field given and has values', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'container' }, { value: 'dataFlow' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }, { value: 'dataJob' }],
+ },
+ ],
+ };
+
+ // should only return values from main field
+ expect(getFieldValues(filter, 'TYPE', 'RESOURCE_TYPE')).toMatchObject([
+ { value: 'dataset' },
+ { value: 'dataJob' },
+ ]);
+ });
+});
+
+describe('getFieldCondition', () => {
+ it('should get field values for a given field', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ expect(getFieldCondition(filter, 'TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+
+ it('should get field values for a alternate field (for deprecated fields)', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ expect(getFieldCondition(filter, 'TYPE', 'RESOURCE_TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+
+ it('should get field values for main field with alternative field given and has values', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.StartsWith,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'container' }, { value: 'dataFlow' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ ],
+ };
+
+ // should only return values from main field
+ expect(getFieldCondition(filter, 'TYPE', 'RESOURCE_TYPE')).toBe(PolicyMatchCondition.Equals);
+ });
+});
+describe('setFieldValues', () => {
+ it('should remove a field if you pass in an empty array', () => {
+ const filter = {
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'RESOURCE_TYPE',
+ values: [{ value: 'dataset' }],
+ },
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataJob' }],
+ },
+ ],
+ };
+
+ expect(setFieldValues(filter, 'RESOURCE_TYPE', [])).toMatchObject({
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataJob' }],
+ },
+ ],
+ });
+ });
+
+ it('should set values for a field properly', () => {
+ const filter = {
+ criteria: [],
+ };
+
+ expect(setFieldValues(filter, 'TYPE', [{ value: 'dataFlow' }])).toMatchObject({
+ criteria: [
+ {
+ condition: PolicyMatchCondition.Equals,
+ field: 'TYPE',
+ values: [{ value: 'dataFlow' }],
+ },
+ ],
+ });
+ });
+});
diff --git a/datahub-web-react/src/app/permissions/policy/constants.ts b/datahub-web-react/src/app/permissions/policy/constants.ts
new file mode 100644
index 00000000000000..cdd20bf9b50d6a
--- /dev/null
+++ b/datahub-web-react/src/app/permissions/policy/constants.ts
@@ -0,0 +1,4 @@
+export const TYPE = 'TYPE';
+export const RESOURCE_TYPE = 'RESOURCE_TYPE';
+export const URN = 'URN';
+export const RESOURCE_URN = 'RESOURCE_URN';
diff --git a/datahub-web-react/src/app/permissions/policy/policyUtils.ts b/datahub-web-react/src/app/permissions/policy/policyUtils.ts
index b71a38f80fc256..d6221a0a9293af 100644
--- a/datahub-web-react/src/app/permissions/policy/policyUtils.ts
+++ b/datahub-web-react/src/app/permissions/policy/policyUtils.ts
@@ -114,12 +114,28 @@ export const convertLegacyResourceFilter = (resourceFilter: Maybe | undefined, resourceFieldType: string) => {
- return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || [];
+export const getFieldValues = (
+ filter: Maybe | undefined,
+ resourceFieldType: string,
+ alternateResourceFieldType?: string,
+) => {
+ return (
+ filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values ||
+ filter?.criteria?.find((criterion) => criterion.field === alternateResourceFieldType)?.values ||
+ []
+ );
};
-export const getFieldCondition = (filter: Maybe | undefined, resourceFieldType: string) => {
- return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.condition || null;
+export const getFieldCondition = (
+ filter: Maybe | undefined,
+ resourceFieldType: string,
+ alternateResourceFieldType?: string,
+) => {
+ return (
+ filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.condition ||
+ filter?.criteria?.find((criterion) => criterion.field === alternateResourceFieldType)?.condition ||
+ null
+ );
};
export const getFieldValuesOfTags = (filter: Maybe | undefined, resourceFieldType: string) => {
From 8b5fb711929e465c45c58e6db8c657c2623cb5e9 Mon Sep 17 00:00:00 2001
From: Chris Collins
Date: Wed, 11 Dec 2024 18:45:46 -0500
Subject: [PATCH 05/16] feat(structuredProps) Add frontend for managing
structured props and filtering by them (#12097)
---
.../datahub/graphql/GmsGraphQLEngine.java | 14 +
.../authorization/AuthorizationUtils.java | 5 +
.../datahub/graphql/resolvers/MeResolver.java | 4 +
.../resolvers/config/AppConfigResolver.java | 1 +
.../AggregateAcrossEntitiesResolver.java | 8 +-
.../search/SearchAcrossEntitiesResolver.java | 80 ++-
.../graphql/resolvers/search/SearchUtils.java | 22 +
.../graphql/types/mappers/MapperUtils.java | 3 +
.../src/main/resources/app.graphql | 14 +
.../src/main/resources/search.graphql | 10 +
.../SearchAcrossEntitiesResolverTest.java | 6 +-
datahub-web-react/src/Mocks.tsx | 66 ++-
.../SearchBar/SearchBar.stories.tsx | 99 ++++
.../components/SearchBar/SearchBar.tsx | 30 ++
.../components/SearchBar/components.ts | 33 ++
.../components/SearchBar/index.ts | 1 +
.../components/SearchBar/types.ts | 7 +
.../src/alchemy-components/index.ts | 1 +
datahub-web-react/src/app/SearchRoutes.tsx | 18 +-
datahub-web-react/src/app/analytics/event.ts | 86 +++-
.../src/app/buildEntityRegistry.ts | 2 +
.../src/app/entity/Access/RoleEntity.tsx | 4 +
datahub-web-react/src/app/entity/Entity.tsx | 5 +
.../src/app/entity/EntityRegistry.tsx | 4 +
.../BusinessAttributeEntity.tsx | 2 +
.../src/app/entity/chart/ChartEntity.tsx | 2 +
.../app/entity/container/ContainerEntity.tsx | 2 +
.../app/entity/dashboard/DashboardEntity.tsx | 2 +
.../app/entity/dataFlow/DataFlowEntity.tsx | 2 +
.../src/app/entity/dataJob/DataJobEntity.tsx | 2 +
.../dataPlatform/DataPlatformEntity.tsx | 4 +
.../DataPlatformInstanceEntity.tsx | 4 +
.../entity/dataProduct/DataProductEntity.tsx | 4 +
.../src/app/entity/dataset/DatasetEntity.tsx | 2 +
.../src/app/entity/domain/DomainEntity.tsx | 2 +
.../ERModelRelationshipEntity.tsx | 2 +
.../glossaryNode/GlossaryNodeEntity.tsx | 2 +
.../glossaryTerm/GlossaryTermEntity.tsx | 2 +
.../src/app/entity/group/Group.tsx | 2 +
.../app/entity/mlFeature/MLFeatureEntity.tsx | 2 +
.../mlFeatureTable/MLFeatureTableEntity.tsx | 2 +
.../src/app/entity/mlModel/MLModelEntity.tsx | 2 +
.../mlModelGroup/MLModelGroupEntity.tsx | 2 +
.../mlPrimaryKey/MLPrimaryKeyEntity.tsx | 2 +
.../SchemaFieldPropertiesEntity.tsx | 2 +
.../Properties/useStructuredProperties.tsx | 19 +
.../StructuredPropertyEntity.tsx | 86 ++++
datahub-web-react/src/app/entity/tag/Tag.tsx | 2 +
.../src/app/entity/user/User.tsx | 2 +
.../structuredProperties/AdvancedOptions.tsx | 64 +++
.../AllowedValuesDrawer.tsx | 142 ++++++
.../AllowedValuesField.tsx | 77 +++
.../DisplayPreferences.tsx | 165 ++++++
.../EmptyStructuredProperties.tsx | 29 ++
.../structuredProperties/RequiredAsterisk.tsx | 12 +
.../StructuredProperties.tsx | 115 +++++
.../StructuredPropsDrawer.tsx | 396 +++++++++++++++
.../StructuredPropsForm.tsx | 139 +++++
.../StructuredPropsFormSection.tsx | 176 +++++++
.../StructuredPropsTable.tsx | 376 ++++++++++++++
.../ViewAdvancedOptions.tsx | 43 ++
.../ViewDisplayPreferences.tsx | 86 ++++
.../ViewStructuredPropsDrawer.tsx | 142 ++++++
.../govern/structuredProperties/cacheUtils.ts | 102 ++++
.../structuredProperties/styledComponents.ts | 473 ++++++++++++++++++
.../structuredProperties/useStructuredProp.ts | 135 +++++
.../app/govern/structuredProperties/utils.ts | 249 +++++++++
.../src/app/search/SearchPage.tsx | 2 +-
.../src/app/search/filters/ActiveFilter.tsx | 11 +-
.../filters/DateRangeMenu/DateRangeMenu.tsx | 88 ++++
.../DateRangeMenu/useDateRangeFilterValues.ts | 48 ++
.../EntityTypeFilter/entityTypeFilterUtils.ts | 2 +-
.../src/app/search/filters/FilterOption.tsx | 9 +-
.../app/search/filters/MoreFilterOption.tsx | 17 +-
.../search/filters/OptionsDropdownMenu.tsx | 15 +
.../src/app/search/filters/SearchFilter.tsx | 8 +-
.../app/search/filters/SearchFilterView.tsx | 12 +-
.../app/search/filters/mapFilterOption.tsx | 2 +-
.../src/app/search/filters/types.ts | 1 +
.../filters/useSearchFilterDropdown.tsx | 18 +-
.../src/app/search/filters/utils.tsx | 62 ++-
.../src/app/search/utils/constants.ts | 5 +
.../src/app/shared/admin/HeaderLinks.tsx | 25 +
datahub-web-react/src/app/shared/constants.ts | 3 +
.../app/sharedV2/modals/ConfirmationModal.tsx | 76 +++
.../src/app/sharedV2/toastMessageUtils.ts | 18 +
datahub-web-react/src/appConfigContext.tsx | 1 +
datahub-web-react/src/conf/Global.ts | 1 +
datahub-web-react/src/graphql/app.graphql | 1 +
.../src/graphql/fragments.graphql | 55 ++
datahub-web-react/src/graphql/lineage.graphql | 35 ++
datahub-web-react/src/graphql/me.graphql | 2 +
datahub-web-react/src/graphql/search.graphql | 90 ++++
.../src/graphql/structuredProperties.graphql | 137 +++++
datahub-web-react/src/images/empty-forms.svg | 48 ++
datahub-web-react/src/images/table-icon.svg | 3 +
.../request/AggregationQueryBuilder.java | 54 +-
.../fixtures/SampleDataFixtureTestBase.java | 17 +-
.../request/AggregationQueryBuilderTest.java | 117 +++--
.../request/SearchRequestHandlerTest.java | 3 +-
.../linkedin/metadata/query/SearchFlags.pdl | 6 +
.../metadata/search/AggregationMetadata.pdl | 5 +
.../graphql/featureflags/FeatureFlags.java | 1 +
.../src/main/resources/application.yaml | 1 +
...linkedin.analytics.analytics.snapshot.json | 2 +-
.../com.linkedin.entity.aspects.snapshot.json | 54 +-
...com.linkedin.entity.entities.snapshot.json | 75 ++-
.../com.linkedin.entity.runs.snapshot.json | 52 +-
...nkedin.operations.operations.snapshot.json | 52 +-
...m.linkedin.platform.platform.snapshot.json | 60 ++-
.../datahubusage/DataHubUsageEventType.java | 10 +-
.../war/src/main/resources/boot/policies.json | 6 +-
.../authorization/PoliciesConfig.java | 6 +
113 files changed, 4694 insertions(+), 223 deletions(-)
create mode 100644 datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx
create mode 100644 datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx
create mode 100644 datahub-web-react/src/alchemy-components/components/SearchBar/components.ts
create mode 100644 datahub-web-react/src/alchemy-components/components/SearchBar/index.ts
create mode 100644 datahub-web-react/src/alchemy-components/components/SearchBar/types.ts
create mode 100644 datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts
create mode 100644 datahub-web-react/src/app/govern/structuredProperties/utils.ts
create mode 100644 datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx
create mode 100644 datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts
create mode 100644 datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx
create mode 100644 datahub-web-react/src/app/sharedV2/toastMessageUtils.ts
create mode 100644 datahub-web-react/src/images/empty-forms.svg
create mode 100644 datahub-web-react/src/images/table-icon.svg
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 d1da55268a50d5..079a20619d1eab 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
@@ -67,6 +67,7 @@
import com.linkedin.datahub.graphql.generated.EntityPath;
import com.linkedin.datahub.graphql.generated.EntityRelationship;
import com.linkedin.datahub.graphql.generated.EntityRelationshipLegacy;
+import com.linkedin.datahub.graphql.generated.FacetMetadata;
import com.linkedin.datahub.graphql.generated.ForeignKeyConstraint;
import com.linkedin.datahub.graphql.generated.FormActorAssignment;
import com.linkedin.datahub.graphql.generated.FreshnessContract;
@@ -1474,6 +1475,19 @@ private void configureGenericEntityResolvers(final RuntimeWiring.Builder builder
"entity",
new EntityTypeResolver(
entityTypes, (env) -> ((BrowsePathEntry) env.getSource()).getEntity())))
+ .type(
+ "FacetMetadata",
+ typeWiring ->
+ typeWiring.dataFetcher(
+ "entity",
+ new EntityTypeResolver(
+ entityTypes,
+ (env) -> {
+ FacetMetadata facetMetadata = env.getSource();
+ return facetMetadata.getEntity() != null
+ ? facetMetadata.getEntity()
+ : null;
+ })))
.type(
"LineageRelationship",
typeWiring ->
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java
index ca60acaa805387..c25d6af75fe76d 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java
@@ -339,6 +339,11 @@ public static boolean canManageStructuredProperties(@Nonnull QueryContext contex
context.getOperationContext(), PoliciesConfig.MANAGE_STRUCTURED_PROPERTIES_PRIVILEGE);
}
+ public static boolean canViewStructuredPropertiesPage(@Nonnull QueryContext context) {
+ return AuthUtil.isAuthorized(
+ context.getOperationContext(), PoliciesConfig.VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE);
+ }
+
public static boolean canManageForms(@Nonnull QueryContext context) {
return AuthUtil.isAuthorized(
context.getOperationContext(), PoliciesConfig.MANAGE_DOCUMENTATION_FORMS_PRIVILEGE);
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java
index b1101ae3ee8657..8297392e642d51 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/MeResolver.java
@@ -93,6 +93,10 @@ public CompletableFuture get(DataFetchingEnvironment environm
BusinessAttributeAuthorizationUtils.canCreateBusinessAttribute(context));
platformPrivileges.setManageBusinessAttributes(
BusinessAttributeAuthorizationUtils.canManageBusinessAttribute(context));
+ platformPrivileges.setManageStructuredProperties(
+ AuthorizationUtils.canManageStructuredProperties(context));
+ platformPrivileges.setViewStructuredPropertiesPage(
+ AuthorizationUtils.canViewStructuredPropertiesPage(context));
// Construct and return authenticated user object.
final AuthenticatedUser authUser = new AuthenticatedUser();
authUser.setCorpUser(corpUser);
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 259d05c631557d..3647eb55b2583a 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
@@ -188,6 +188,7 @@ public CompletableFuture get(final DataFetchingEnvironment environmen
.setDataContractsEnabled(_featureFlags.isDataContractsEnabled())
.setEditableDatasetNameEnabled(_featureFlags.isEditableDatasetNameEnabled())
.setShowSeparateSiblings(_featureFlags.isShowSeparateSiblings())
+ .setShowManageStructuredProperties(_featureFlags.isShowManageStructuredProperties())
.build();
appConfig.setFeatureFlags(featureFlagsConfig);
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 29b71d95ad9749..31ed2de7a6d513 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
@@ -66,11 +66,17 @@ public CompletableFuture get(DataFetchingEnvironment environme
final Filter inputFilter = ResolverUtils.buildFilter(null, input.getOrFilters());
- final SearchFlags searchFlags = mapInputFlags(context, input.getSearchFlags());
+ final SearchFlags searchFlags =
+ input.getSearchFlags() != null
+ ? mapInputFlags(context, input.getSearchFlags())
+ : new SearchFlags();
final List facets =
input.getFacets() != null && input.getFacets().size() > 0 ? input.getFacets() : null;
+ // do not include default facets if we're requesting any facets specifically
+ searchFlags.setIncludeDefaultFacets(facets == null || facets.size() <= 0);
+
List finalEntities =
maybeResolvedView != null
? SearchUtils.intersectEntityTypes(
diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java
index d103704146d399..29bc3a82a16498 100644
--- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java
+++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/search/SearchAcrossEntitiesResolver.java
@@ -2,19 +2,28 @@
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.*;
+import static com.linkedin.datahub.graphql.resolvers.search.SearchUtils.getEntityNames;
+import com.google.common.collect.ImmutableList;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
+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.ResolverUtils;
import com.linkedin.datahub.graphql.types.mappers.UrnSearchResultsMapper;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.query.SearchFlags;
+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.CriterionArray;
import com.linkedin.metadata.query.filter.Filter;
import com.linkedin.metadata.query.filter.SortCriterion;
+import com.linkedin.metadata.search.SearchResult;
import com.linkedin.metadata.service.ViewService;
+import com.linkedin.metadata.utils.CriterionUtils;
import com.linkedin.view.DataHubViewInfo;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
@@ -64,24 +73,7 @@ public CompletableFuture get(DataFetchingEnvironment environment)
ResolverUtils.buildFilter(input.getFilters(), input.getOrFilters());
SearchFlags searchFlags = mapInputFlags(context, input.getSearchFlags());
- List sortCriteria;
- if (input.getSortInput() != null) {
- if (input.getSortInput().getSortCriteria() != null) {
- sortCriteria =
- input.getSortInput().getSortCriteria().stream()
- .map(SearchUtils::mapSortCriterion)
- .collect(Collectors.toList());
- } else {
- sortCriteria =
- input.getSortInput().getSortCriterion() != null
- ? Collections.singletonList(
- mapSortCriterion(input.getSortInput().getSortCriterion()))
- : Collections.emptyList();
- }
-
- } else {
- sortCriteria = Collections.emptyList();
- }
+ List sortCriteria = SearchUtils.getSortCriteria(input.getSortInput());
try {
log.debug(
@@ -101,6 +93,14 @@ public CompletableFuture get(DataFetchingEnvironment environment)
return SearchUtils.createEmptySearchResults(start, count);
}
+ boolean shouldIncludeStructuredPropertyFacets =
+ input.getSearchFlags() != null
+ && input.getSearchFlags().getIncludeStructuredPropertyFacets() != null
+ ? input.getSearchFlags().getIncludeStructuredPropertyFacets()
+ : false;
+ List structuredPropertyFacets =
+ shouldIncludeStructuredPropertyFacets ? getStructuredPropertyFacets(context) : null;
+
return UrnSearchResultsMapper.map(
context,
_entityClient.searchAcrossEntities(
@@ -113,7 +113,8 @@ public CompletableFuture get(DataFetchingEnvironment environment)
: baseFilter,
start,
count,
- sortCriteria));
+ sortCriteria,
+ structuredPropertyFacets));
} catch (Exception e) {
log.error(
"Failed to execute search for multiple entities: entity types {}, query {}, filters: {}, start: {}, count: {}",
@@ -133,4 +134,45 @@ public CompletableFuture get(DataFetchingEnvironment environment)
this.getClass().getSimpleName(),
"get");
}
+
+ private List getStructuredPropertyFacets(final QueryContext context) {
+ try {
+ SearchFlags searchFlags = new SearchFlags().setSkipCache(true);
+ SearchResult result =
+ _entityClient.searchAcrossEntities(
+ context.getOperationContext().withSearchFlags(flags -> searchFlags),
+ getEntityNames(ImmutableList.of(EntityType.STRUCTURED_PROPERTY)),
+ "*",
+ createStructuredPropertyFilter(),
+ 0,
+ 100,
+ Collections.emptyList(),
+ null);
+ return result.getEntities().stream()
+ .map(entity -> String.format("structuredProperties.%s", entity.getEntity().getId()))
+ .collect(Collectors.toList());
+ } catch (Exception e) {
+ log.error("Failed to get structured property facets to filter on", e);
+ return Collections.emptyList();
+ }
+ }
+
+ private Filter createStructuredPropertyFilter() {
+ return new Filter()
+ .setOr(
+ new ConjunctiveCriterionArray(
+ ImmutableList.of(
+ new ConjunctiveCriterion()
+ .setAnd(
+ new CriterionArray(
+ ImmutableList.of(
+ CriterionUtils.buildCriterion(
+ "filterStatus", Condition.EQUAL, "ENABLED")))),
+ new ConjunctiveCriterion()
+ .setAnd(
+ new CriterionArray(
+ ImmutableList.of(
+ CriterionUtils.buildCriterion(
+ "showInSearchFilters", Condition.EQUAL, "true")))))));
+ }
}
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 04777c3fcdb4e2..a01b3aaec9c982 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
@@ -22,6 +22,7 @@
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.FacetFilterInput;
import com.linkedin.datahub.graphql.generated.SearchResults;
+import com.linkedin.datahub.graphql.generated.SearchSortInput;
import com.linkedin.datahub.graphql.types.common.mappers.SearchFlagsInputMapper;
import com.linkedin.datahub.graphql.types.entitytype.EntityTypeMapper;
import com.linkedin.metadata.query.SearchFlags;
@@ -326,4 +327,25 @@ public static SearchResults createEmptySearchResults(final int start, final int
result.setFacets(new ArrayList<>());
return result;
}
+
+ public static List getSortCriteria(@Nullable final SearchSortInput sortInput) {
+ List sortCriteria;
+ if (sortInput != null) {
+ if (sortInput.getSortCriteria() != null) {
+ sortCriteria =
+ sortInput.getSortCriteria().stream()
+ .map(SearchUtils::mapSortCriterion)
+ .collect(Collectors.toList());
+ } else {
+ sortCriteria =
+ sortInput.getSortCriterion() != null
+ ? Collections.singletonList(mapSortCriterion(sortInput.getSortCriterion()))
+ : new ArrayList<>();
+ }
+ } else {
+ sortCriteria = new ArrayList<>();
+ }
+
+ return sortCriteria;
+ }
}
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 0d69e62c621a60..8fe58df2d2edec 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
@@ -70,6 +70,9 @@ public static FacetMetadata mapFacet(
aggregationFacets.stream()
.map(facet -> facet.equals("entity") || facet.contains("_entityType"))
.collect(Collectors.toList());
+ if (aggregationMetadata.getEntity() != null) {
+ facetMetadata.setEntity(UrnToEntityMapper.map(context, aggregationMetadata.getEntity()));
+ }
facetMetadata.setField(aggregationMetadata.getName());
facetMetadata.setDisplayName(
Optional.ofNullable(aggregationMetadata.getDisplayName())
diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql
index 262d2384d84ada..28688903687235 100644
--- a/datahub-graphql-core/src/main/resources/app.graphql
+++ b/datahub-graphql-core/src/main/resources/app.graphql
@@ -156,6 +156,15 @@ type PlatformPrivileges {
"""
manageBusinessAttributes: Boolean!
+ """
+ Whether the user can create, edit, and delete structured properties.
+ """
+ manageStructuredProperties: Boolean!
+
+ """
+ Whether the user can view the manage structured properties page.
+ """
+ viewStructuredPropertiesPage: Boolean!
}
"""
@@ -517,6 +526,11 @@ type FeatureFlagsConfig {
If turned on, all siblings will be separated with no way to get to a "combined" sibling view
"""
showSeparateSiblings: Boolean!
+
+ """
+ If turned on, show the manage structured properties tab in the govern dropdown
+ """
+ showManageStructuredProperties: Boolean!
}
"""
diff --git a/datahub-graphql-core/src/main/resources/search.graphql b/datahub-graphql-core/src/main/resources/search.graphql
index d0f669f05f9598..82bfb9ee26fc42 100644
--- a/datahub-graphql-core/src/main/resources/search.graphql
+++ b/datahub-graphql-core/src/main/resources/search.graphql
@@ -167,6 +167,11 @@ input SearchFlags {
fields to include for custom Highlighting
"""
customHighlightingFields: [String!]
+
+ """
+ Whether or not to fetch and request for structured property facets when doing a search
+ """
+ includeStructuredPropertyFacets: Boolean
}
"""
@@ -872,6 +877,11 @@ type FacetMetadata {
"""
displayName: String
+ """
+ Entity corresponding to the facet
+ """
+ entity: Entity
+
"""
Aggregated search result counts by value of the field
"""
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 42768b8a2de21b..89d218683e33ec 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
@@ -471,7 +471,8 @@ private static EntityClient initMockEntityClient(
Mockito.eq(filter),
Mockito.eq(start),
Mockito.eq(limit),
- Mockito.eq(Collections.emptyList())))
+ Mockito.eq(Collections.emptyList()),
+ Mockito.eq(null)))
.thenReturn(result);
return client;
}
@@ -496,7 +497,8 @@ private static void verifyMockEntityClient(
Mockito.eq(filter),
Mockito.eq(start),
Mockito.eq(limit),
- Mockito.eq(Collections.emptyList()));
+ Mockito.eq(Collections.emptyList()),
+ Mockito.eq(null));
}
private static void verifyMockViewService(ViewService mockService, Urn viewUrn) {
diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx
index 329d6250e576ab..73a789030ce6fb 100644
--- a/datahub-web-react/src/Mocks.tsx
+++ b/datahub-web-react/src/Mocks.tsx
@@ -2204,7 +2204,7 @@ export const mocks = [
count: 10,
filters: [],
orFilters: [],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -2244,6 +2244,7 @@ export const mocks = [
field: 'origin',
displayName: 'origin',
aggregations: [{ value: 'PROD', count: 3, entity: null }],
+ entity: null,
},
{
field: '_entityType',
@@ -2252,6 +2253,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -2261,6 +2263,7 @@ export const mocks = [
{ value: 'MySQL', count: 1, entity: null },
{ value: 'Kafka', count: 1, entity: null },
],
+ entity: null,
},
],
suggestions: [],
@@ -2290,7 +2293,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -2325,6 +2328,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -2333,6 +2337,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -2343,6 +2348,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
suggestions: [],
@@ -2393,6 +2399,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -2401,6 +2408,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -2410,6 +2418,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -2464,7 +2473,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -2501,6 +2510,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -2510,6 +2520,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -2520,6 +2531,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' },
{ value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' },
],
+ entity: null,
},
],
},
@@ -2669,6 +2681,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -2677,6 +2690,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -2686,6 +2700,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -2743,6 +2758,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -2751,6 +2767,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -2760,6 +2777,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -2809,6 +2827,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: 'platform',
@@ -2822,6 +2841,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -2953,6 +2973,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: 'platform',
@@ -2966,6 +2987,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -3013,7 +3035,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3050,6 +3072,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
// {
// displayName: 'Domain',
@@ -3071,6 +3094,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -3096,6 +3120,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
],
},
@@ -3181,7 +3206,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3215,6 +3240,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: 'platform',
@@ -3228,6 +3254,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -3256,7 +3283,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3290,6 +3317,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -3298,6 +3326,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -3307,6 +3336,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -3335,7 +3365,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3377,6 +3407,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -3385,6 +3416,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -3394,6 +3426,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -3428,7 +3461,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3465,6 +3498,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -3474,6 +3508,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -3484,6 +3519,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' },
{ value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' },
],
+ entity: null,
},
],
},
@@ -3518,7 +3554,7 @@ export const mocks = [
],
},
],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3555,6 +3591,7 @@ export const mocks = [
__typename: 'AggregationMetadata',
},
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -3564,6 +3601,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
__typename: 'FacetMetadata',
@@ -3574,6 +3612,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null, __typename: 'AggregationMetadata' },
{ value: 'kafka', count: 1, entity: null, __typename: 'AggregationMetadata' },
],
+ entity: null,
},
],
},
@@ -3635,6 +3674,8 @@ export const mocks = [
manageGlobalAnnouncements: true,
createBusinessAttributes: true,
manageBusinessAttributes: true,
+ manageStructuredProperties: true,
+ viewStructuredPropertiesPage: true,
},
},
},
@@ -3722,7 +3763,7 @@ export const mocks = [
count: 10,
filters: [],
orFilters: [],
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
},
@@ -3821,6 +3862,7 @@ export const mocks = [
entity: null,
},
],
+ entity: null,
},
{
field: '_entityType',
@@ -3829,6 +3871,7 @@ export const mocks = [
{ count: 37, entity: null, value: 'DATASET', __typename: 'AggregationMetadata' },
{ count: 7, entity: null, value: 'CHART', __typename: 'AggregationMetadata' },
],
+ entity: null,
},
{
field: 'platform',
@@ -3838,6 +3881,7 @@ export const mocks = [
{ value: 'mysql', count: 1, entity: null },
{ value: 'kafka', count: 1, entity: null },
],
+ entity: null,
},
],
},
@@ -3912,4 +3956,6 @@ export const platformPrivileges: PlatformPrivileges = {
manageGlobalAnnouncements: true,
createBusinessAttributes: true,
manageBusinessAttributes: true,
+ manageStructuredProperties: true,
+ viewStructuredPropertiesPage: true,
};
diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx
new file mode 100644
index 00000000000000..9ae34356a71d6a
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.stories.tsx
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import { BADGE } from '@geometricpanda/storybook-addon-badges';
+import type { Meta, StoryObj } from '@storybook/react';
+import { GridList } from '@components/.docs/mdx-components';
+import { SearchBar, searchBarDefaults } from './SearchBar';
+import { SearchBarProps } from './types';
+
+const meta = {
+ title: 'Components / Search Bar',
+ component: SearchBar,
+
+ // Display Properties
+ parameters: {
+ layout: 'centered',
+ badges: [BADGE.STABLE, 'readyForDesignReview'],
+ docs: {
+ subtitle: 'A component that is used to get search bar',
+ },
+ },
+
+ // Component-level argTypes
+ argTypes: {
+ placeholder: {
+ description: 'Placeholder of search bar.',
+ table: {
+ defaultValue: { summary: searchBarDefaults.placeholder },
+ },
+ control: {
+ type: 'text',
+ },
+ },
+ value: {
+ description: 'Value of the search bar.',
+ table: {
+ defaultValue: { summary: searchBarDefaults.value },
+ },
+ control: false,
+ },
+ width: {
+ description: 'Width of the search bar.',
+ table: {
+ defaultValue: { summary: searchBarDefaults.width },
+ },
+ control: {
+ type: 'text',
+ },
+ },
+ allowClear: {
+ description: 'Whether clear button should be present.',
+ table: {
+ defaultValue: { summary: searchBarDefaults.allowClear?.toString() },
+ },
+ control: {
+ type: 'boolean',
+ },
+ },
+ onChange: {
+ description: 'On change function for the search bar.',
+ },
+ },
+
+ // Define defaults
+ args: {
+ placeholder: searchBarDefaults.placeholder,
+ value: searchBarDefaults.value,
+ allowClear: searchBarDefaults.allowClear,
+ width: searchBarDefaults.width,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+const SandboxWrapper = (props: SearchBarProps) => {
+ const [value, setValue] = useState('');
+
+ const handleChange = (newValue: string) => {
+ setValue(newValue);
+ };
+
+ return ;
+};
+
+export const sandbox: Story = {
+ tags: ['dev'],
+ render: (props) => {
+ return ;
+ },
+};
+
+export const customWidths = () => (
+
+
+
+
+
+
+);
diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx
new file mode 100644
index 00000000000000..f39f761058d8c7
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/SearchBar/SearchBar.tsx
@@ -0,0 +1,30 @@
+import { SearchOutlined } from '@ant-design/icons';
+import React from 'react';
+import { StyledSearchBar } from './components';
+import { SearchBarProps } from './types';
+
+export const searchBarDefaults: SearchBarProps = {
+ placeholder: 'Search..',
+ value: '',
+ width: '272px',
+ allowClear: true,
+};
+
+export const SearchBar = ({
+ placeholder = searchBarDefaults.placeholder,
+ value = searchBarDefaults.value,
+ width = searchBarDefaults.width,
+ allowClear = searchBarDefaults.allowClear,
+ onChange,
+}: SearchBarProps) => {
+ return (
+ onChange?.(e.target.value)}
+ value={value}
+ prefix={ }
+ allowClear={allowClear}
+ $width={width}
+ />
+ );
+};
diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts
new file mode 100644
index 00000000000000..7045801ddf092b
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/SearchBar/components.ts
@@ -0,0 +1,33 @@
+import { colors, typography } from '@src/alchemy-components/theme';
+import { Input } from 'antd';
+import styled from 'styled-components';
+
+export const StyledSearchBar = styled(Input)<{ $width?: string }>`
+ height: 40px;
+ width: ${(props) => props.$width};
+ display: flex;
+ align-items: center;
+ border-radius: 8px;
+
+ input {
+ color: ${colors.gray[600]};
+ font-size: ${typography.fontSizes.md} !important;
+ }
+
+ .ant-input-prefix {
+ width: 20px;
+ color: ${colors.gray[1800]};
+
+ svg {
+ height: 16px;
+ width: 16px;
+ }
+ }
+
+ &:hover,
+ &:focus,
+ &:focus-within {
+ border-color: ${colors.violet[300]} !important;
+ box-shadow: none !important;
+ }
+`;
diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts
new file mode 100644
index 00000000000000..8c1933163b29fd
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/SearchBar/index.ts
@@ -0,0 +1 @@
+export { SearchBar } from './SearchBar';
diff --git a/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts
new file mode 100644
index 00000000000000..04ac218dc2cc6c
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/SearchBar/types.ts
@@ -0,0 +1,7 @@
+export interface SearchBarProps {
+ placeholder?: string;
+ value?: string;
+ width?: string;
+ onChange?: (value: string) => void;
+ allowClear?: boolean;
+}
diff --git a/datahub-web-react/src/alchemy-components/index.ts b/datahub-web-react/src/alchemy-components/index.ts
index 8ef4f73f4408ff..7e40d343e884dc 100644
--- a/datahub-web-react/src/alchemy-components/index.ts
+++ b/datahub-web-react/src/alchemy-components/index.ts
@@ -15,6 +15,7 @@ export * from './components/LineChart';
export * from './components/PageTitle';
export * from './components/Pills';
export * from './components/Popover';
+export * from './components/SearchBar';
export * from './components/Select';
export * from './components/Switch';
export * from './components/Table';
diff --git a/datahub-web-react/src/app/SearchRoutes.tsx b/datahub-web-react/src/app/SearchRoutes.tsx
index 3343260c72bcf6..024b6e0add15a3 100644
--- a/datahub-web-react/src/app/SearchRoutes.tsx
+++ b/datahub-web-react/src/app/SearchRoutes.tsx
@@ -11,23 +11,36 @@ import { AnalyticsPage } from './analyticsDashboard/components/AnalyticsPage';
import { ManageIngestionPage } from './ingest/ManageIngestionPage';
import GlossaryRoutes from './glossary/GlossaryRoutes';
import { SettingsPage } from './settings/SettingsPage';
+import { useUserContext } from './context/useUserContext';
import DomainRoutes from './domain/DomainRoutes';
-import { useBusinessAttributesFlag, useIsAppConfigContextLoaded, useIsNestedDomainsEnabled } from './useAppConfig';
+import {
+ useAppConfig,
+ useBusinessAttributesFlag,
+ useIsAppConfigContextLoaded,
+ useIsNestedDomainsEnabled,
+} from './useAppConfig';
import { ManageDomainsPage } from './domain/ManageDomainsPage';
import { BusinessAttributes } from './businessAttribute/BusinessAttributes';
+import StructuredProperties from './govern/structuredProperties/StructuredProperties';
/**
* Container for all searchable page routes
*/
export const SearchRoutes = (): JSX.Element => {
const entityRegistry = useEntityRegistry();
+ const me = useUserContext();
const isNestedDomainsEnabled = useIsNestedDomainsEnabled();
const entities = isNestedDomainsEnabled
? entityRegistry.getEntitiesForSearchRoutes()
: entityRegistry.getNonGlossaryEntities();
+ const { config } = useAppConfig();
const businessAttributesFlag = useBusinessAttributesFlag();
const appConfigContextLoaded = useIsAppConfigContextLoaded();
+ const showStructuredProperties =
+ config?.featureFlags?.showManageStructuredProperties &&
+ (me.platformPrivileges?.manageStructuredProperties || me.platformPrivileges?.viewStructuredPropertiesPage);
+
return (
@@ -53,6 +66,9 @@ export const SearchRoutes = (): JSX.Element => {
} />
} />
} />
+ {showStructuredProperties && (
+ } />
+ )}
{
diff --git a/datahub-web-react/src/app/analytics/event.ts b/datahub-web-react/src/app/analytics/event.ts
index c3a57830b8c504..9152f2fb8eedb8 100644
--- a/datahub-web-react/src/app/analytics/event.ts
+++ b/datahub-web-react/src/app/analytics/event.ts
@@ -1,4 +1,12 @@
-import { DataHubViewType, EntityType, RecommendationRenderType, ScenarioType } from '../../types.generated';
+import {
+ AllowedValue,
+ DataHubViewType,
+ EntityType,
+ PropertyCardinality,
+ PropertyValueInput,
+ RecommendationRenderType,
+ ScenarioType,
+} from '../../types.generated';
import { EmbedLookupNotFoundReason } from '../embed/lookup/constants';
import { Direction } from '../lineage/types';
import { FilterMode } from '../search/utils/constants';
@@ -82,6 +90,14 @@ export enum EventType {
EmbedProfileViewInDataHubEvent,
EmbedLookupNotFoundEvent,
CreateBusinessAttributeEvent,
+ CreateStructuredPropertyClickEvent,
+ CreateStructuredPropertyEvent,
+ EditStructuredPropertyEvent,
+ DeleteStructuredPropertyEvent,
+ ViewStructuredPropertyEvent,
+ ApplyStructuredPropertyEvent,
+ UpdateStructuredPropertyOnAssetEvent,
+ RemoveStructuredPropertyEvent,
}
/**
@@ -640,6 +656,64 @@ export interface CreateBusinessAttributeEvent extends BaseEvent {
name: string;
}
+export interface CreateStructuredPropertyClickEvent extends BaseEvent {
+ type: EventType.CreateStructuredPropertyClickEvent;
+}
+
+interface StructuredPropertyEvent extends BaseEvent {
+ propertyType: string;
+ appliesTo: string[];
+ qualifiedName?: string;
+ allowedAssetTypes?: string[];
+ allowedValues?: AllowedValue[];
+ cardinality?: PropertyCardinality;
+ showInFilters?: boolean;
+ isHidden: boolean;
+ showInSearchFilters: boolean;
+ showAsAssetBadge: boolean;
+ showInAssetSummary: boolean;
+ showInColumnsTable: boolean;
+}
+
+export interface CreateStructuredPropertyEvent extends StructuredPropertyEvent {
+ type: EventType.CreateStructuredPropertyEvent;
+}
+
+export interface EditStructuredPropertyEvent extends StructuredPropertyEvent {
+ type: EventType.EditStructuredPropertyEvent;
+ propertyUrn: string;
+}
+
+export interface DeleteStructuredPropertyEvent extends StructuredPropertyEvent {
+ type: EventType.DeleteStructuredPropertyEvent;
+ propertyUrn: string;
+}
+
+export interface ViewStructuredPropertyEvent extends BaseEvent {
+ type: EventType.ViewStructuredPropertyEvent;
+ propertyUrn: string;
+}
+
+interface StructuredPropertyOnAssetEvent extends BaseEvent {
+ propertyUrn: string;
+ propertyType: string;
+ assetUrn: string;
+ assetType: EntityType;
+}
+export interface ApplyStructuredPropertyEvent extends StructuredPropertyOnAssetEvent {
+ type: EventType.ApplyStructuredPropertyEvent;
+ values: PropertyValueInput[];
+}
+
+export interface UpdateStructuredPropertyOnAssetEvent extends StructuredPropertyOnAssetEvent {
+ type: EventType.UpdateStructuredPropertyOnAssetEvent;
+ values: PropertyValueInput[];
+}
+
+export interface RemoveStructuredPropertyEvent extends StructuredPropertyOnAssetEvent {
+ type: EventType.RemoveStructuredPropertyEvent;
+}
+
/**
* Event consisting of a union of specific event types.
*/
@@ -718,4 +792,12 @@ export type Event =
| EmbedProfileViewEvent
| EmbedProfileViewInDataHubEvent
| EmbedLookupNotFoundEvent
- | CreateBusinessAttributeEvent;
+ | CreateBusinessAttributeEvent
+ | CreateStructuredPropertyClickEvent
+ | CreateStructuredPropertyEvent
+ | EditStructuredPropertyEvent
+ | DeleteStructuredPropertyEvent
+ | ViewStructuredPropertyEvent
+ | ApplyStructuredPropertyEvent
+ | UpdateStructuredPropertyOnAssetEvent
+ | RemoveStructuredPropertyEvent;
diff --git a/datahub-web-react/src/app/buildEntityRegistry.ts b/datahub-web-react/src/app/buildEntityRegistry.ts
index 0b70986672be51..181ec7d328a587 100644
--- a/datahub-web-react/src/app/buildEntityRegistry.ts
+++ b/datahub-web-react/src/app/buildEntityRegistry.ts
@@ -24,6 +24,7 @@ import { RoleEntity } from './entity/Access/RoleEntity';
import { RestrictedEntity } from './entity/restricted/RestrictedEntity';
import { BusinessAttributeEntity } from './entity/businessAttribute/BusinessAttributeEntity';
import { SchemaFieldPropertiesEntity } from './entity/schemaField/SchemaFieldPropertiesEntity';
+import { StructuredPropertyEntity } from './entity/structuredProperty/StructuredPropertyEntity';
export default function buildEntityRegistry() {
const registry = new EntityRegistry();
@@ -52,5 +53,6 @@ export default function buildEntityRegistry() {
registry.register(new RestrictedEntity());
registry.register(new BusinessAttributeEntity());
registry.register(new SchemaFieldPropertiesEntity());
+ registry.register(new StructuredPropertyEntity());
return registry;
}
diff --git a/datahub-web-react/src/app/entity/Access/RoleEntity.tsx b/datahub-web-react/src/app/entity/Access/RoleEntity.tsx
index ab609b04f104ac..58a1ba8dd793b8 100644
--- a/datahub-web-react/src/app/entity/Access/RoleEntity.tsx
+++ b/datahub-web-react/src/app/entity/Access/RoleEntity.tsx
@@ -88,4 +88,8 @@ export class RoleEntity implements Entity {
supportedCapabilities = () => {
return new Set([EntityCapabilityType.OWNERS]);
};
+
+ getGraphName = () => {
+ return 'roleEntity';
+ };
}
diff --git a/datahub-web-react/src/app/entity/Entity.tsx b/datahub-web-react/src/app/entity/Entity.tsx
index 490f23330c5945..c56c97454a1d57 100644
--- a/datahub-web-react/src/app/entity/Entity.tsx
+++ b/datahub-web-react/src/app/entity/Entity.tsx
@@ -172,6 +172,11 @@ export interface Entity {
*/
getGenericEntityProperties: (data: T) => GenericEntityProperties | null;
+ /**
+ * Returns the graph name of the entity, as it appears in the GMS entity registry
+ */
+ getGraphName: () => string;
+
/**
* Returns the supported features for the entity
*/
diff --git a/datahub-web-react/src/app/entity/EntityRegistry.tsx b/datahub-web-react/src/app/entity/EntityRegistry.tsx
index 00e7385ff5784b..0f65390f959df2 100644
--- a/datahub-web-react/src/app/entity/EntityRegistry.tsx
+++ b/datahub-web-react/src/app/entity/EntityRegistry.tsx
@@ -241,4 +241,8 @@ export default class EntityRegistry {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.getCustomCardUrlPath?.();
}
+
+ getGraphNameFromType(type: EntityType): string {
+ return validatedGet(type, this.entityTypeToEntity).getGraphName();
+ }
}
diff --git a/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx b/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx
index b827a3c37d6a5c..442aaf735575ab 100644
--- a/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx
+++ b/datahub-web-react/src/app/entity/businessAttribute/BusinessAttributeEntity.tsx
@@ -59,6 +59,8 @@ export class BusinessAttributeEntity implements Entity {
getCollectionName = () => 'Business Attributes';
+ getGraphName = () => 'businessAttribute';
+
getCustomCardUrlPath = () => PageRoutes.BUSINESS_ATTRIBUTE;
isBrowseEnabled = () => false;
diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
index 913d502972fe14..8a62a9018661e1 100644
--- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
+++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
@@ -69,6 +69,8 @@ export class ChartEntity implements Entity {
getAutoCompleteFieldName = () => 'title';
+ getGraphName = () => 'chart';
+
getPathName = () => 'chart';
getEntityName = () => 'Chart';
diff --git a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
index 89f9122c6287f8..941e7fc3f552da 100644
--- a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
+++ b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
@@ -59,6 +59,8 @@ export class ContainerEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'container';
+
getPathName = () => 'container';
getEntityName = () => 'Container';
diff --git a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
index 9564cbc18198e4..95d4431d591790 100644
--- a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
+++ b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
@@ -291,6 +291,8 @@ export class DashboardEntity implements Entity {
]);
};
+ getGraphName = () => this.getPathName();
+
renderEmbeddedProfile = (urn: string) => (
{
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'dataFlow';
+
getPathName = () => 'pipelines';
getEntityName = () => 'Pipeline';
diff --git a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
index fe1a906371e9d0..6bf95482269190 100644
--- a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
@@ -64,6 +64,8 @@ export class DataJobEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'dataJob';
+
getPathName = () => 'tasks';
getEntityName = () => 'Task';
diff --git a/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx b/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx
index 6687ec9f914c1b..89cbaf3cbeaa12 100644
--- a/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataPlatform/DataPlatformEntity.tsx
@@ -71,4 +71,8 @@ export class DataPlatformEntity implements Entity {
supportedCapabilities = () => {
return new Set([]);
};
+
+ getGraphName = () => {
+ return 'dataPlatform';
+ };
}
diff --git a/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx b/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx
index a542e1b52f510d..d0db687ffed929 100644
--- a/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataPlatformInstance/DataPlatformInstanceEntity.tsx
@@ -58,4 +58,8 @@ export class DataPlatformInstanceEntity implements Entity
supportedCapabilities = () => {
return new Set([]);
};
+
+ getGraphName = () => {
+ return 'dataPlatformInstance';
+ };
}
diff --git a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
index 6b31de84f85bb1..90c1127d9a5fcd 100644
--- a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
@@ -191,4 +191,8 @@ export class DataProductEntity implements Entity {
EntityCapabilityType.DOMAINS,
]);
};
+
+ getGraphName = () => {
+ return 'dataProduct';
+ };
}
diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
index 21ae085832cb3f..07ab27a38f8893 100644
--- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
@@ -85,6 +85,8 @@ export class DatasetEntity implements Entity {
getPathName = () => 'dataset';
+ getGraphName = () => 'dataset';
+
getEntityName = () => 'Dataset';
getCollectionName = () => 'Datasets';
diff --git a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
index 2b67c88a6ff235..81d245c230843f 100644
--- a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
+++ b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
@@ -60,6 +60,8 @@ export class DomainEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'domain';
+
getPathName = () => 'domain';
getEntityName = () => 'Domain';
diff --git a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx
index aece3db1312afb..3eb950cb0e7ac5 100644
--- a/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx
+++ b/datahub-web-react/src/app/entity/ermodelrelationships/ERModelRelationshipEntity.tsx
@@ -58,6 +58,8 @@ export class ERModelRelationshipEntity implements Entity {
getEntityName = () => 'ER-Model-Relationship';
+ getGraphName = () => 'erModelRelationship';
+
renderProfile = (urn: string) => (
{
EntityCapabilityType.SOFT_DELETE,
]);
};
+
+ getGraphName = () => this.getPathName();
}
export default GlossaryNodeEntity;
diff --git a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
index 8bbc0a693b2231..73c5a8e12122d3 100644
--- a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
+++ b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
@@ -179,4 +179,6 @@ export class GlossaryTermEntity implements Entity {
EntityCapabilityType.SOFT_DELETE,
]);
};
+
+ getGraphName = () => this.getPathName();
}
diff --git a/datahub-web-react/src/app/entity/group/Group.tsx b/datahub-web-react/src/app/entity/group/Group.tsx
index cd9cf1ca6eec44..763db856f33ac6 100644
--- a/datahub-web-react/src/app/entity/group/Group.tsx
+++ b/datahub-web-react/src/app/entity/group/Group.tsx
@@ -40,6 +40,8 @@ export class GroupEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName: () => string = () => 'corpGroup';
+
getPathName: () => string = () => 'group';
getEntityName = () => 'Group';
diff --git a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
index 2f2786b1c0d960..eecffdb2f38430 100644
--- a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
@@ -52,6 +52,8 @@ export class MLFeatureEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'mlFeature';
+
getPathName = () => 'features';
getEntityName = () => 'Feature';
diff --git a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
index 595c73fbc3cb67..8aa0c056b716fc 100644
--- a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
@@ -53,6 +53,8 @@ export class MLFeatureTableEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'mlFeatureTable';
+
getPathName = () => 'featureTables';
getEntityName = () => 'Feature Table';
diff --git a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
index d4d0b37da9ec96..92f03aaef7a175 100644
--- a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
@@ -52,6 +52,8 @@ export class MLModelEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'mlModel';
+
getPathName = () => 'mlModels';
getEntityName = () => 'ML Model';
diff --git a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
index 5896c1864cc435..b5d32275f97bfd 100644
--- a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
@@ -50,6 +50,8 @@ export class MLModelGroupEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'mlModelGroup';
+
getPathName = () => 'mlModelGroup';
getEntityName = () => 'ML Group';
diff --git a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
index 60c7531a4f57cc..119a566b04f135 100644
--- a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
@@ -51,6 +51,8 @@ export class MLPrimaryKeyEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'mlPrimaryKey';
+
getPathName = () => 'mlPrimaryKeys';
getEntityName = () => 'ML Primary Key';
diff --git a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx
index 88743012ddbc8a..2c59c476195d0b 100644
--- a/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx
+++ b/datahub-web-react/src/app/entity/schemaField/SchemaFieldPropertiesEntity.tsx
@@ -44,6 +44,8 @@ export class SchemaFieldPropertiesEntity implements Entity {
// Currently unused.
renderProfile = (_: string) => <>>;
+ getGraphName = () => 'schemaField';
+
renderPreview = (previewType: PreviewType, data: SchemaFieldEntity) => {
const parent = data.parent as Dataset;
return (
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
index 86365b8232905c..4635486c24d1d6 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx
@@ -23,6 +23,25 @@ export function mapStructuredPropertyValues(structuredPropertiesEntry: Structure
}));
}
+export function mapStructuredPropertyToPropertyRow(structuredPropertiesEntry: StructuredPropertiesEntry) {
+ const { displayName, qualifiedName } = structuredPropertiesEntry.structuredProperty.definition;
+ return {
+ 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,
+ associatedUrn: structuredPropertiesEntry.associatedUrn,
+ };
+}
+
// map the properties map into a list of PropertyRow objects to render in a table
function getStructuredPropertyRows(entityData?: GenericEntityProperties | null) {
const structuredPropertyRows: PropertyRow[] = [];
diff --git a/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx b/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx
new file mode 100644
index 00000000000000..2bdfd550652fa5
--- /dev/null
+++ b/datahub-web-react/src/app/entity/structuredProperty/StructuredPropertyEntity.tsx
@@ -0,0 +1,86 @@
+import * as React from 'react';
+import styled from 'styled-components';
+import TableIcon from '@src/images/table-icon.svg?react';
+import { TYPE_ICON_CLASS_NAME } from '@src/app/shared/constants';
+import DefaultPreviewCard from '@src/app/preview/DefaultPreviewCard';
+import { EntityType, SearchResult, StructuredPropertyEntity as StructuredProperty } from '../../../types.generated';
+import { Entity, IconStyleType, PreviewType } from '../Entity';
+import { getDataForEntityType } from '../shared/containers/profile/utils';
+import { urlEncodeUrn } from '../shared/utils';
+
+const PreviewPropIcon = styled(TableIcon)`
+ font-size: 20px;
+`;
+
+/**
+ * Definition of the DataHub Structured Property entity.
+ */
+export class StructuredPropertyEntity implements Entity {
+ type: EntityType = EntityType.StructuredProperty;
+
+ icon = (fontSize?: number, styleType?: IconStyleType, color?: string) => {
+ if (styleType === IconStyleType.TAB_VIEW) {
+ return ;
+ }
+
+ if (styleType === IconStyleType.HIGHLIGHT) {
+ return ;
+ }
+
+ return (
+
+ );
+ };
+
+ isSearchEnabled = () => false;
+
+ isBrowseEnabled = () => false;
+
+ isLineageEnabled = () => false;
+
+ getAutoCompleteFieldName = () => 'name';
+
+ getGraphName = () => 'structuredProperty';
+
+ getPathName: () => string = () => this.getGraphName();
+
+ getCollectionName: () => string = () => 'Structured Properties';
+
+ getEntityName: () => string = () => 'Structured Property';
+
+ renderProfile: (urn: string) => JSX.Element = (_urn) =>
; // not used right now
+
+ renderPreview = (previewType: PreviewType, data: StructuredProperty) => (
+ }
+ typeIcon={this.icon(14, IconStyleType.ACCENT)}
+ previewType={previewType}
+ />
+ );
+
+ renderSearch = (result: SearchResult) => {
+ return this.renderPreview(PreviewType.SEARCH, result.entity as StructuredProperty);
+ };
+
+ displayName = (data: StructuredProperty) => {
+ return data.definition?.displayName || data.definition?.qualifiedName || data.urn;
+ };
+
+ getGenericEntityProperties = (entity: StructuredProperty) => {
+ return getDataForEntityType({ data: entity, entityType: this.type, getOverrideProperties: (data) => data });
+ };
+
+ supportedCapabilities = () => {
+ return new Set([]);
+ };
+}
diff --git a/datahub-web-react/src/app/entity/tag/Tag.tsx b/datahub-web-react/src/app/entity/tag/Tag.tsx
index 6f0839e5f812be..d3c5b079660993 100644
--- a/datahub-web-react/src/app/entity/tag/Tag.tsx
+++ b/datahub-web-react/src/app/entity/tag/Tag.tsx
@@ -46,6 +46,8 @@ export class TagEntity implements Entity {
getAutoCompleteFieldName = () => 'name';
+ getGraphName = () => 'tag';
+
getPathName: () => string = () => 'tag';
getCollectionName: () => string = () => 'Tags';
diff --git a/datahub-web-react/src/app/entity/user/User.tsx b/datahub-web-react/src/app/entity/user/User.tsx
index ec1c5fbdc86980..058349f83eaecb 100644
--- a/datahub-web-react/src/app/entity/user/User.tsx
+++ b/datahub-web-react/src/app/entity/user/User.tsx
@@ -39,6 +39,8 @@ export class UserEntity implements Entity {
getAutoCompleteFieldName = () => 'username';
+ getGraphName: () => string = () => 'corpuser';
+
getPathName: () => string = () => 'user';
getEntityName = () => 'Person';
diff --git a/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx b/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx
new file mode 100644
index 00000000000000..620143258ef5f5
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/AdvancedOptions.tsx
@@ -0,0 +1,64 @@
+import { Icon, Input, Text, Tooltip } from '@components';
+import { Collapse, Form } from 'antd';
+import React from 'react';
+import { CollapseHeader, FlexContainer, InputLabel, StyledCollapse } from './styledComponents';
+
+interface Props {
+ isEditMode: boolean;
+}
+
+const AdvancedOptions = ({ isEditMode }: Props) => {
+ return (
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[]}
+ >
+
+
+ Advanced Options
+
+
+ }
+ forceRender
+ >
+
+
+ Qualified Name
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AdvancedOptions;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx
new file mode 100644
index 00000000000000..f1dccb6db0c22c
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesDrawer.tsx
@@ -0,0 +1,142 @@
+import { Button, Icon, Input, Text, TextArea } from '@src/alchemy-components';
+import { AllowedValue } from '@src/types.generated';
+import { Form, FormInstance } from 'antd';
+import { Tooltip } from '@components';
+import React, { useEffect, useRef } from 'react';
+import {
+ AddButtonContainer,
+ DeleteIconContainer,
+ FieldGroupContainer,
+ FormContainer,
+ InputLabel,
+ StyledDivider,
+ ValuesContainer,
+} from './styledComponents';
+import { PropValueField } from './utils';
+
+interface Props {
+ showAllowedValuesDrawer: boolean;
+ propType: PropValueField;
+ allowedValues: AllowedValue[] | undefined;
+ isEditMode: boolean;
+ noOfExistingValues: number;
+ form: FormInstance;
+}
+
+const AllowedValuesDrawer = ({
+ showAllowedValuesDrawer,
+ propType,
+ allowedValues,
+ isEditMode,
+ noOfExistingValues,
+ form,
+}: Props) => {
+ useEffect(() => {
+ form.setFieldsValue({ allowedValues: allowedValues || [{}] });
+ }, [form, showAllowedValuesDrawer, allowedValues]);
+
+ const containerRef = useRef(null);
+
+ // Scroll to the bottom to show the newly added fields
+ const scrollToBottom = () => {
+ if (containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ };
+
+ return (
+
+ {(fields, { add, remove }) => (
+
+ {fields.length > 0 && (
+
+ {fields.map((field, index) => {
+ const isExisting = isEditMode && index < noOfExistingValues;
+
+ return (
+
+
+ Value
+
+ *
+
+
+
+
+
+
+
+
+
+
+ {!isExisting && (
+
+
+ remove(field.name)}
+ color="gray"
+ size="xl"
+ />
+
+
+ )}
+ {index < fields.length - 1 && }
+
+ );
+ })}
+
+ )}
+
+
+
+ {
+ add();
+ setTimeout(() => scrollToBottom(), 0);
+ }}
+ color="violet"
+ >
+ Add
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default AllowedValuesDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx
new file mode 100644
index 00000000000000..9a71d309c101f8
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/AllowedValuesField.tsx
@@ -0,0 +1,77 @@
+import { Icon, Text, Tooltip } from '@components';
+import { AllowedValue } from '@src/types.generated';
+import React from 'react';
+import {
+ FieldLabel,
+ FlexContainer,
+ ItemsContainer,
+ RowContainer,
+ StyledIcon,
+ ValueListContainer,
+ ValuesList,
+ ValueType,
+ VerticalDivider,
+} from './styledComponents';
+import { isStringOrNumberTypeSelected, PropValueField } from './utils';
+
+interface Props {
+ selectedValueType: string;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+}
+
+const AllowedValuesField = ({ selectedValueType, allowedValues, valueField, setShowAllowedValuesDrawer }: Props) => {
+ return (
+ <>
+ {isStringOrNumberTypeSelected(selectedValueType) && (
+
+
+
+ Allowed Values
+
+
+
+
+
+
+ {allowedValues && allowedValues.length > 0 ? (
+
+
+ {allowedValues.map((val, index) => {
+ return (
+ <>
+ {val[valueField]}
+ {index < allowedValues.length - 1 && }
+ >
+ );
+ })}
+
+
+ setShowAllowedValuesDrawer(true)}
+ />
+
+
+ ) : (
+
+ Any
+ {valueField === 'stringValue' ? 'text' : 'number'}
+ value will be allowed
+
+ setShowAllowedValuesDrawer(true)} />
+
+
+ )}
+
+ )}
+ >
+ );
+};
+
+export default AllowedValuesField;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx b/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx
new file mode 100644
index 00000000000000..260c91ef93207c
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/DisplayPreferences.tsx
@@ -0,0 +1,165 @@
+import { Icon, Pill, Switch, Text } from '@src/alchemy-components';
+import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal';
+import { AllowedValue, StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React, { useState } from 'react';
+import { useUpdateStructuredPropertyMutation } from '@src/graphql/structuredProperties.generated';
+import { CollapseHeader, StyledCollapse, StyledFormItem, TogglesContainer } from './styledComponents';
+import { getDisplayName, canBeAssetBadge, StructuredProp } from './utils';
+
+const SCHEMA_FIELD_URN = 'urn:li:entityType:datahub.schemaField';
+
+interface Props {
+ formValues: StructuredProp | undefined;
+ handleDisplaySettingChange: (settingField: string, value: boolean) => void;
+ selectedValueType: string;
+ refetchProperties: () => void;
+ allowedValues?: AllowedValue[];
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const DisplayPreferences = ({
+ formValues,
+ handleDisplaySettingChange,
+ selectedValueType,
+ refetchProperties,
+ allowedValues,
+ badgeProperty,
+}: Props) => {
+ const [updateProperty] = useUpdateStructuredPropertyMutation();
+ const [showReplaceBadge, setShowReplaceBadge] = useState(false);
+
+ const handleReplaceClose = () => {
+ setShowReplaceBadge(false);
+ };
+
+ function updateBadgePropertyToOff() {
+ if (badgeProperty) {
+ updateProperty({
+ variables: { input: { urn: badgeProperty.urn, settings: { showAsAssetBadge: false } } },
+ }).then(() => refetchProperties());
+ }
+ }
+
+ const hasAssetBadgeEnabled = formValues?.settings?.showAsAssetBadge;
+ const showInColumnsTable = formValues?.settings?.showInColumnsTable;
+ const hasColumnEntityType = formValues?.entityTypes?.includes(SCHEMA_FIELD_URN);
+
+ return (
+ <>
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[1]}
+ >
+
+
+ Display Preferences
+
+
+ }
+ forceRender
+ >
+
+
+ handleDisplaySettingChange('isHidden', e.target.checked)}
+ labelHoverText="If enabled, this property will be hidden everywhere"
+ data-testid="structured-props-hide-switch"
+ />
+
+
+ handleDisplaySettingChange('showInSearchFilters', e.target.checked)}
+ isDisabled={formValues?.settings?.isHidden}
+ labelHoverText="If enabled, this property will appear in search filters"
+ />
+
+
+ handleDisplaySettingChange('showInAssetSummary', e.target.checked)}
+ isDisabled={formValues?.settings?.isHidden}
+ labelHoverText="If enabled, this property will appear in asset sidebar"
+ />
+
+
+ {
+ if (badgeProperty && e.target.checked) setShowReplaceBadge(true);
+ else handleDisplaySettingChange('showAsAssetBadge', e.target.checked);
+ }}
+ isDisabled={
+ !hasAssetBadgeEnabled &&
+ (formValues?.settings?.isHidden ||
+ !canBeAssetBadge(selectedValueType, allowedValues))
+ }
+ labelHoverText="If enabled, this property will appear as asset badge"
+ disabledHoverText="Only Text or Number property types with allowed values defined can appear as an asset badge."
+ />
+
+
+ handleDisplaySettingChange('showInColumnsTable', e.target.checked)}
+ isDisabled={
+ !showInColumnsTable && (formValues?.settings?.isHidden || !hasColumnEntityType)
+ }
+ labelHoverText="If enabled, this property will appear as a column in the Columns table for Datasets"
+ disabledHoverText="Property must apply to Columns in order to show in columns table."
+ data-testid="structured-props-show-in-columns-table-switch"
+ />
+
+
+
+
+ {badgeProperty && (
+ {
+ handleDisplaySettingChange('showAsAssetBadge', true);
+ setShowReplaceBadge(false);
+ updateBadgePropertyToOff();
+ }}
+ confirmButtonText="Update"
+ modalTitle="Update Property"
+ modalText={
+
+ Another property
+
+ is already being shown on asset previews, but only one property is allowed at a time.
+ Do you want to replace the current property? This will hide PropVal on all asset previews.
+
+ }
+ />
+ )}
+ >
+ );
+};
+
+export default DisplayPreferences;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx b/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx
new file mode 100644
index 00000000000000..5f7f77b0582fdb
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/EmptyStructuredProperties.tsx
@@ -0,0 +1,29 @@
+import { Text } from '@components';
+import EmptyFormsImage from '@src/images/empty-forms.svg?react';
+import React from 'react';
+import { EmptyContainer } from './styledComponents';
+
+interface Props {
+ isEmptySearch?: boolean;
+}
+
+const EmptyStructuredProperties = ({ isEmptySearch }: Props) => {
+ return (
+
+ {isEmptySearch ? (
+
+ No search results!
+
+ ) : (
+ <>
+
+
+ No properties yet!
+
+ >
+ )}
+
+ );
+};
+
+export default EmptyStructuredProperties;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx b/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx
new file mode 100644
index 00000000000000..5842c363b3b317
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/RequiredAsterisk.tsx
@@ -0,0 +1,12 @@
+import { Text } from '@components';
+import React from 'react';
+
+const RequiredAsterisk = () => {
+ return (
+
+ *
+
+ );
+};
+
+export default RequiredAsterisk;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx
new file mode 100644
index 00000000000000..304bb77f37ed41
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredProperties.tsx
@@ -0,0 +1,115 @@
+import { Button, PageTitle, SearchBar, Tooltip } from '@components';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { EntityType, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import React, { useState } from 'react';
+import StructuredPropsDrawer from './StructuredPropsDrawer';
+import StructuredPropsTable from './StructuredPropsTable';
+import { ButtonContainer, HeaderContainer, HeaderContent, PageContainer, TableContainer } from './styledComponents';
+import ViewStructuredPropsDrawer from './ViewStructuredPropsDrawer';
+
+const StructuredProperties = () => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [isViewDrawerOpen, setIsViewDrawerOpen] = useState(false);
+ const [selectedProperty, setSelectedProperty] = useState();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const handleSearch = (value) => {
+ setSearchQuery(value);
+ };
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 500,
+ searchFlags: { skipCache: true },
+ };
+
+ // Execute search
+ const { data, loading, refetch } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ fetchPolicy: 'cache-first',
+ });
+
+ const searchAcrossEntities = data?.searchAcrossEntities;
+ const noOfProperties = searchAcrossEntities?.searchResults?.length;
+ const badgeProperty = searchAcrossEntities?.searchResults.find(
+ (prop) => (prop.entity as StructuredPropertyEntity).settings?.showAsAssetBadge,
+ )?.entity;
+
+ return (
+
+
+
+
+
+
+
+ {
+ setIsDrawerOpen(true);
+ analytics.event({ type: EventType.CreateStructuredPropertyClickEvent });
+ }}
+ >
+ Create
+
+
+
+
+ handleSearch(value)} />
+
+
+
+
+ {selectedProperty && (
+
+ )}
+
+ );
+};
+
+export default StructuredProperties;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx
new file mode 100644
index 00000000000000..4b2bbaaf96826b
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsDrawer.tsx
@@ -0,0 +1,396 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { useApolloClient } from '@apollo/client';
+import { Button, Text } from '@src/alchemy-components';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils';
+import {
+ useCreateStructuredPropertyMutation,
+ useUpdateStructuredPropertyMutation,
+} from '@src/graphql/structuredProperties.generated';
+import {
+ AllowedValue,
+ PropertyCardinality,
+ SearchAcrossEntitiesInput,
+ SearchResult,
+ SearchResults,
+ StructuredPropertyEntity,
+ UpdateStructuredPropertyInput,
+} from '@src/types.generated';
+import { Form } from 'antd';
+import { Tooltip } from '@components';
+import React, { useEffect, useState } from 'react';
+import AllowedValuesDrawer from './AllowedValuesDrawer';
+import { updatePropertiesList } from './cacheUtils';
+import StructuredPropsForm from './StructuredPropsForm';
+import {
+ DrawerHeader,
+ FooterContainer,
+ StyledDrawer,
+ StyledIcon,
+ StyledSpin,
+ TitleContainer,
+} from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import {
+ getDisplayName,
+ getNewAllowedTypes,
+ getNewAllowedValues,
+ getNewEntityTypes,
+ getStringOrNumberValueField,
+ getValueType,
+ PropValueField,
+ StructuredProp,
+ valueTypes,
+} from './utils';
+
+interface Props {
+ isDrawerOpen: boolean;
+ setIsDrawerOpen: React.Dispatch>;
+ selectedProperty?: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+ refetch: () => void;
+ inputs: SearchAcrossEntitiesInput;
+ searchAcrossEntities?: SearchResults | null;
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const StructuredPropsDrawer = ({
+ isDrawerOpen,
+ setIsDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+ refetch,
+ inputs,
+ searchAcrossEntities,
+ badgeProperty,
+}: Props) => {
+ const [form] = Form.useForm();
+ const [valuesForm] = Form.useForm();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const [createStructuredProperty] = useCreateStructuredPropertyMutation();
+ const [updateStructuredProperty] = useUpdateStructuredPropertyMutation();
+ const client = useApolloClient();
+
+ const [cardinality, setCardinality] = useState(PropertyCardinality.Single);
+ const [formValues, setFormValues] = useState();
+ const [selectedValueType, setSelectedValueType] = useState('');
+ const [allowedValues, setAllowedValues] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [valueField, setValueField] = useState('stringValue');
+
+ const [showAllowedValuesDrawer, setShowAllowedValuesDrawer] = useState(false);
+
+ const { handleTypeUpdate } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ const isEditMode = !!selectedProperty;
+
+ const clearValues = () => {
+ form.resetFields();
+ setIsDrawerOpen(false);
+ setSelectedProperty(undefined);
+ setFormValues(undefined);
+ setSelectedValueType('');
+ setAllowedValues(undefined);
+ setCardinality(PropertyCardinality.Single);
+ setShowAllowedValuesDrawer(false);
+ valuesForm.resetFields();
+ };
+
+ const handleClose = () => {
+ clearValues();
+ };
+
+ const showErrorMessage = () => {
+ showToastMessage(ToastType.ERROR, `Failed to ${isEditMode ? 'update' : 'create'} structured property.`, 3);
+ };
+
+ const showSuccessMessage = () => {
+ showToastMessage(ToastType.SUCCESS, `Structured property ${isEditMode ? 'updated' : 'created'}!`, 3);
+ };
+
+ const handleSubmit = () => {
+ if (isEditMode) {
+ form.validateFields().then(() => {
+ const updateValues = {
+ ...form.getFieldsValue(),
+ allowedValues,
+ };
+
+ const editInput: UpdateStructuredPropertyInput = {
+ urn: selectedProperty.entity.urn,
+ displayName: updateValues.displayName,
+ description: updateValues.description,
+ typeQualifier: {
+ newAllowedTypes: getNewAllowedTypes(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ },
+ newEntityTypes: getNewEntityTypes(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ newAllowedValues: getNewAllowedValues(
+ selectedProperty.entity as StructuredPropertyEntity,
+ updateValues,
+ ),
+ setCardinalityAsMultiple: cardinality === PropertyCardinality.Multiple,
+ settings: {
+ isHidden: updateValues.settings?.isHidden ?? false,
+ showInSearchFilters: updateValues.settings?.showInSearchFilters ?? false,
+ showAsAssetBadge: updateValues.settings?.showAsAssetBadge ?? false,
+ showInAssetSummary: updateValues.settings?.showInAssetSummary ?? false,
+ showInColumnsTable: updateValues.settings?.showInColumnsTable ?? false,
+ },
+ };
+
+ setIsLoading(true);
+ updateStructuredProperty({
+ variables: {
+ input: editInput,
+ },
+ })
+ .then(() => {
+ analytics.event({
+ type: EventType.EditStructuredPropertyEvent,
+ propertyUrn: selectedProperty.entity.urn,
+ propertyType:
+ valueTypes.find((valType) => valType.value === form.getFieldValue('valueType'))?.urn ||
+ '',
+ appliesTo: form.getFieldValue('entityTypes'),
+ qualifiedName: form.getFieldValue('qualifiedName'),
+ allowedAssetTypes: form.getFieldValue(['typeQualifier', 'allowedTypes']),
+ allowedValues: form.getFieldValue('allowedValues'),
+ cardinality,
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ });
+ refetch();
+ showSuccessMessage();
+ })
+ .catch(() => {
+ showErrorMessage();
+ })
+ .finally(() => {
+ setIsLoading(false);
+ clearValues();
+ });
+ });
+ } else {
+ form.validateFields().then(() => {
+ const createInput = {
+ ...form.getFieldsValue(),
+ valueType: valueTypes.find((type) => type.value === form.getFieldValue('valueType'))?.urn,
+ allowedValues,
+ cardinality,
+ settings: {
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ },
+ };
+
+ setIsLoading(true);
+ createStructuredProperty({
+ variables: {
+ input: createInput,
+ },
+ })
+ .then((res) => {
+ analytics.event({
+ type: EventType.CreateStructuredPropertyEvent,
+ propertyType:
+ valueTypes.find((valType) => valType.value === form.getFieldValue('valueType'))?.urn ||
+ '',
+ appliesTo: form.getFieldValue('entityTypes'),
+ qualifiedName: form.getFieldValue('qualifiedName'),
+ allowedAssetTypes: form.getFieldValue(['typeQualifier', 'allowedTypes']),
+ allowedValues: form.getFieldValue('allowedValues'),
+ cardinality,
+ isHidden: form.getFieldValue(['settings', 'isHidden']) ?? false,
+ showInSearchFilters: form.getFieldValue(['settings', 'showInSearchFilters']) ?? false,
+ showAsAssetBadge: form.getFieldValue(['settings', 'showAsAssetBadge']) ?? false,
+ showInAssetSummary: form.getFieldValue(['settings', 'showInAssetSummary']) ?? false,
+ showInColumnsTable: form.getFieldValue(['settings', 'showInColumnsTable']) ?? false,
+ });
+
+ showSuccessMessage();
+ updatePropertiesList(client, inputs, res.data?.createStructuredProperty, searchAcrossEntities);
+ })
+ .catch(() => {
+ showErrorMessage();
+ })
+ .finally(() => {
+ setIsLoading(false);
+ clearValues();
+ });
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (selectedProperty) {
+ const entity = selectedProperty.entity as StructuredPropertyEntity;
+ const typeValue = getValueType(
+ entity.definition.valueType.urn,
+ entity.definition.cardinality || PropertyCardinality.Single,
+ );
+
+ const values: StructuredProp = {
+ displayName: getDisplayName(entity),
+ description: entity.definition.description,
+ qualifiedName: entity.definition.qualifiedName,
+ valueType: typeValue,
+ entityTypes: entity.definition.entityTypes.map((entityType) => entityType.urn),
+ typeQualifier: {
+ allowedTypes: entity.definition.typeQualifier?.allowedTypes?.map((entityType) => entityType.urn),
+ },
+ immutable: entity.definition.immutable,
+ settings: entity.settings,
+ };
+
+ setFormValues(values);
+ if (typeValue) handleTypeUpdate(typeValue);
+ form.setFieldsValue(values);
+ } else {
+ setFormValues(undefined);
+ form.resetFields();
+ setSelectedValueType('');
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedProperty, form]);
+
+ useEffect(() => {
+ const entity = selectedProperty?.entity as StructuredPropertyEntity;
+ const field = getStringOrNumberValueField(selectedValueType);
+ setValueField(field);
+ const allowedList = entity?.definition?.allowedValues?.map((item) => {
+ return {
+ [field]: item.value[field],
+ description: item.description,
+ } as AllowedValue;
+ });
+ setAllowedValues(allowedList);
+ }, [selectedProperty, selectedValueType, setAllowedValues]);
+
+ const handleUpdateAllowedValues = () => {
+ valuesForm.validateFields().then(() => {
+ setAllowedValues(valuesForm.getFieldValue('allowedValues'));
+ form.setFieldValue('allowedValues', valuesForm.getFieldValue('allowedValues'));
+ setShowAllowedValuesDrawer(false);
+ });
+ };
+
+ return (
+
+ {showAllowedValuesDrawer ? (
+
+ setShowAllowedValuesDrawer(false)}
+ />
+
+ Allowed Values
+
+
+ ) : (
+
+ {`${isEditMode ? 'Edit' : 'Create'} Structured Property`}
+
+ )}
+
+
+ }
+ footer={
+
+
+ {showAllowedValuesDrawer ? (
+
+ Update Allowed Values
+
+ ) : (
+
+ {isEditMode ? 'Update' : 'Create'}
+
+ )}
+
+
+ }
+ destroyOnClose
+ >
+ }>
+ {showAllowedValuesDrawer ? (
+ <>
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default StructuredPropsDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx
new file mode 100644
index 00000000000000..51599367228840
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsForm.tsx
@@ -0,0 +1,139 @@
+import { Icon, Input, SimpleSelect, TextArea } from '@src/alchemy-components';
+import { AllowedValue, PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import { Form, FormInstance } from 'antd';
+import { Tooltip } from '@components';
+import React from 'react';
+import AdvancedOptions from './AdvancedOptions';
+import RequiredAsterisk from './RequiredAsterisk';
+import DisplayPreferences from './DisplayPreferences';
+import StructuredPropsFormSection from './StructuredPropsFormSection';
+import { FieldLabel, FlexContainer, GridFormItem, RowContainer } from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import { PropValueField, StructuredProp, valueTypes } from './utils';
+
+interface Props {
+ selectedProperty: SearchResult | undefined;
+ form: FormInstance;
+ formValues: StructuredProp | undefined;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ isEditMode: boolean;
+ selectedValueType: string;
+ setSelectedValueType: React.Dispatch>;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+ refetchProperties: () => void;
+ badgeProperty?: StructuredPropertyEntity;
+}
+
+const StructuredPropsForm = ({
+ selectedProperty,
+ form,
+ formValues,
+ setFormValues,
+ isEditMode,
+ setCardinality,
+ selectedValueType,
+ setSelectedValueType,
+ allowedValues,
+ valueField,
+ setShowAllowedValuesDrawer,
+ refetchProperties,
+ badgeProperty,
+}: Props) => {
+ const { handleTypeUpdate, handleDisplaySettingChange } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Property Type
+
+
+
+
+
+
+
+
+
+ {
+ handleTypeUpdate(values[0]);
+ }}
+ placeholder="Select Property Type"
+ options={valueTypes}
+ values={formValues?.valueType ? [formValues?.valueType] : undefined}
+ isDisabled={isEditMode}
+ showDescriptions
+ data-testid="structured-props-select-input-type"
+ optionListTestId="structured-props-property-type-options-list"
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default StructuredPropsForm;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx
new file mode 100644
index 00000000000000..82875c8f58a4dd
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsFormSection.tsx
@@ -0,0 +1,176 @@
+import { Icon, SimpleSelect, Text, Tooltip } from '@src/alchemy-components';
+import { AllowedValue, PropertyCardinality, SearchResult } from '@src/types.generated';
+import { Form, FormInstance } from 'antd';
+import React from 'react';
+import AllowedValuesField from './AllowedValuesField';
+import RequiredAsterisk from './RequiredAsterisk';
+import { FieldLabel, FlexContainer, RowContainer, SubTextContainer } from './styledComponents';
+import useStructuredProp from './useStructuredProp';
+import {
+ APPLIES_TO_ENTITIES,
+ isEntityTypeSelected,
+ PropValueField,
+ SEARCHABLE_ENTITY_TYPES,
+ StructuredProp,
+} from './utils';
+
+interface Props {
+ selectedProperty: SearchResult | undefined;
+ form: FormInstance;
+ formValues: StructuredProp | undefined;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ isEditMode: boolean;
+ selectedValueType: string;
+ setSelectedValueType: React.Dispatch>;
+ allowedValues: AllowedValue[] | undefined;
+ valueField: PropValueField;
+ setShowAllowedValuesDrawer: React.Dispatch>;
+}
+
+const StructuredPropsFormSection = ({
+ selectedProperty,
+ form,
+ formValues,
+ setFormValues,
+ isEditMode,
+ setCardinality,
+ selectedValueType,
+ setSelectedValueType,
+ allowedValues,
+ valueField,
+ setShowAllowedValuesDrawer,
+}: Props) => {
+ const {
+ handleSelectChange,
+ handleSelectUpdateChange,
+ getEntitiesListOptions,
+ disabledEntityTypeValues,
+ disabledTypeQualifierValues,
+ } = useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+ });
+
+ return (
+ <>
+ {!(isEditMode && !allowedValues) && (
+
+ )}
+ {isEntityTypeSelected(selectedValueType) && (
+
+
+
+ Allowed Entity Types
+
+
+
+
+ {isEditMode && (
+
+
+
+ (Add-only)
+
+
+
+ )}
+
+
+
+
+ isEditMode
+ ? handleSelectUpdateChange(['typeQualifier', 'allowedTypes'], values)
+ : handleSelectChange(['typeQualifier', 'allowedTypes'], values)
+ }
+ placeholder="Any"
+ isMultiSelect
+ values={formValues?.typeQualifier?.allowedTypes}
+ disabledValues={disabledTypeQualifierValues}
+ width="full"
+ />
+
+
+
+ )}
+
+
+
+ Applies to
+
+
+
+
+
+ {isEditMode && (
+
+
+
+ (Add-only)
+
+
+
+ )}
+
+
+
+
+ isEditMode
+ ? handleSelectUpdateChange('entityTypes', values)
+ : handleSelectChange('entityTypes', values)
+ }
+ placeholder="Select Entity Types"
+ isMultiSelect
+ values={formValues?.entityTypes ? formValues?.entityTypes : undefined}
+ disabledValues={disabledEntityTypeValues}
+ width="full"
+ showSelectAll
+ selectAllLabel="All Asset Types"
+ data-testid="structured-props-select-input-applies-to"
+ optionListTestId="applies-to-options-list"
+ />
+
+
+ >
+ );
+};
+
+export default StructuredPropsFormSection;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx
new file mode 100644
index 00000000000000..10c42c0925eeac
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/StructuredPropsTable.tsx
@@ -0,0 +1,376 @@
+import { useApolloClient } from '@apollo/client';
+import { Icon, Pill, Table, Text, Tooltip } from '@components';
+import { AlignmentOptions } from '@src/alchemy-components/theme/config';
+import analytics, { EventType } from '@src/app/analytics';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { HoverEntityTooltip } from '@src/app/recommendations/renderer/component/HoverEntityTooltip';
+import { CustomAvatar } from '@src/app/shared/avatar';
+import { toLocalDateString, toRelativeTimeString } from '@src/app/shared/time/timeUtils';
+import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal';
+import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { useDeleteStructuredPropertyMutation } from '@src/graphql/structuredProperties.generated';
+import TableIcon from '@src/images/table-icon.svg?react';
+import {
+ Entity,
+ EntityType,
+ SearchAcrossEntitiesInput,
+ SearchResult,
+ SearchResults,
+ StructuredPropertyEntity,
+} from '@src/types.generated';
+import { Dropdown } from 'antd';
+import React, { useState } from 'react';
+import Highlight from 'react-highlighter';
+import { Link } from 'react-router-dom';
+import { removeFromPropertiesList } from './cacheUtils';
+import EmptyStructuredProperties from './EmptyStructuredProperties';
+import {
+ CardIcons,
+ CreatedByContainer,
+ DataContainer,
+ IconContainer,
+ MenuItem,
+ NameColumn,
+ PillContainer,
+ PillsContainer,
+ PropDescription,
+ PropName,
+} from './styledComponents';
+import { getDisplayName } from './utils';
+
+interface Props {
+ searchQuery: string;
+ data: GetSearchResultsForMultipleQuery | undefined;
+ loading: boolean;
+ setIsDrawerOpen: React.Dispatch>;
+ setIsViewDrawerOpen: React.Dispatch>;
+ selectedProperty?: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+ inputs: SearchAcrossEntitiesInput;
+ searchAcrossEntities?: SearchResults | null;
+}
+
+const StructuredPropsTable = ({
+ searchQuery,
+ data,
+ loading,
+ setIsDrawerOpen,
+ setIsViewDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+ inputs,
+ searchAcrossEntities,
+}: Props) => {
+ const entityRegistry = useEntityRegistry();
+ const client = useApolloClient();
+ const me = useUserContext();
+ const canEditProps = me.platformPrivileges?.manageStructuredProperties;
+
+ const structuredProperties = data?.searchAcrossEntities?.searchResults || [];
+
+ // Filter the table data based on the search query
+ const filteredProperties = structuredProperties
+ .filter((prop: any) => prop.entity.definition?.displayName?.toLowerCase().includes(searchQuery.toLowerCase()))
+ .sort(
+ (propA, propB) =>
+ ((propB.entity as StructuredPropertyEntity).definition.created?.time || 0) -
+ ((propA.entity as StructuredPropertyEntity).definition.created?.time || 0),
+ );
+
+ const [deleteStructuredProperty] = useDeleteStructuredPropertyMutation();
+
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false);
+
+ const handleDeleteProperty = (property) => {
+ const deleteEntity = property.entity as StructuredPropertyEntity;
+ showToastMessage(ToastType.LOADING, 'Deleting structured property', 1);
+ deleteStructuredProperty({
+ variables: {
+ input: {
+ urn: deleteEntity.urn,
+ },
+ },
+ })
+ .then(() => {
+ analytics.event({
+ type: EventType.DeleteStructuredPropertyEvent,
+ propertyUrn: property.entity.urn,
+ propertyType: deleteEntity.definition.valueType.urn,
+ appliesTo: deleteEntity.definition.entityTypes.map((type) => type.urn),
+ qualifiedName: deleteEntity.definition.qualifiedName,
+ showInFilters: deleteEntity.settings?.showInSearchFilters,
+ allowedAssetTypes: deleteEntity.definition.typeQualifier?.allowedTypes?.map(
+ (allowedType) => allowedType.urn,
+ ),
+ allowedValues: deleteEntity.definition.allowedValues || undefined,
+ cardinality: deleteEntity.definition.cardinality || undefined,
+ isHidden: deleteEntity.settings?.isHidden ?? false,
+ showInSearchFilters: deleteEntity.settings?.showInSearchFilters ?? false,
+ showAsAssetBadge: deleteEntity.settings?.showAsAssetBadge ?? false,
+ showInAssetSummary: deleteEntity.settings?.showInAssetSummary ?? false,
+ showInColumnsTable: deleteEntity.settings?.showInColumnsTable ?? false,
+ });
+ showToastMessage(ToastType.SUCCESS, 'Structured property deleted successfully!', 3);
+ removeFromPropertiesList(client, inputs, property.entity.urn, searchAcrossEntities);
+ })
+ .catch(() => {
+ showToastMessage(ToastType.ERROR, 'Failed to delete structured property', 3);
+ });
+
+ setShowConfirmDelete(false);
+ setSelectedProperty(undefined);
+ };
+
+ const handleDeleteClose = () => {
+ setShowConfirmDelete(false);
+ setSelectedProperty(undefined);
+ };
+
+ if (!loading && !filteredProperties.length) {
+ return ;
+ }
+
+ const columns = [
+ {
+ title: 'Name',
+ key: 'name',
+ render: (record) => {
+ return (
+
+
+
+
+
+ {
+ if (canEditProps) setIsDrawerOpen(true);
+ else setIsViewDrawerOpen(true);
+
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }}
+ >
+ {getDisplayName(record.entity)}
+
+ {record.entity.definition.description}
+
+
+ );
+ },
+ width: '580px',
+ sorter: (sourceA, sourceB) => {
+ return getDisplayName(sourceA.entity).localeCompare(getDisplayName(sourceB.entity));
+ },
+ },
+ {
+ title: 'Entity Types',
+ key: 'entityTypes',
+ width: '270px',
+ render: (record) => {
+ const types = record.entity.definition.entityTypes;
+ const maxTypesToShow = 2;
+ const overflowCount = types.length - maxTypesToShow;
+
+ return (
+
+ {types.slice(0, maxTypesToShow).map((entityType) => {
+ const typeName = entityRegistry.getEntityName(entityType.info.type);
+ return (
+ {typeName && }
+ );
+ })}
+ {overflowCount > 0 && (
+ {
+ const name = entityRegistry.getEntityName(eType.info.type);
+ return name;
+ })
+ .join(', ')}
+ showArrow={false}
+ >
+ <>
+ {`+${overflowCount}`}
+ >
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: 'Creation Date',
+ key: 'creationDate',
+ render: (record) => {
+ const createdTime = record.entity.definition.created?.time;
+ return (
+
+ {createdTime ? toRelativeTimeString(createdTime) : '-'}
+
+ );
+ },
+ sorter: (sourceA, sourceB) => {
+ const timeA = sourceA.entity.definition.created?.time || Number.MAX_SAFE_INTEGER;
+ const timeB = sourceB.entity.definition.created?.time || Number.MAX_SAFE_INTEGER;
+
+ return timeA - timeB;
+ },
+ },
+
+ {
+ title: 'Created By',
+ key: 'createdBy',
+ render: (record) => {
+ const createdByUser = record.entity.definition?.created?.actor;
+ const name = createdByUser && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUser);
+ const avatarUrl = createdByUser?.editableProperties?.pictureLink || undefined;
+
+ return (
+ <>
+ {createdByUser && (
+
+
+
+
+
+ {name}
+
+
+
+
+ )}
+ >
+ );
+ },
+ sorter: (sourceA, sourceB) => {
+ const createdByUserA = sourceA.entity.definition?.created?.actor;
+ const nameA = createdByUserA && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUserA);
+ const createdByUserB = sourceB.entity.definition?.created?.actor;
+ const nameB = createdByUserB && entityRegistry.getDisplayName(EntityType.CorpUser, createdByUserB);
+
+ return nameA?.localeCompare(nameB);
+ },
+ },
+ {
+ title: '',
+ key: 'actions',
+ alignment: 'right' as AlignmentOptions,
+ render: (record) => {
+ const items = [
+ {
+ key: '0',
+ label: (
+ {
+ setIsViewDrawerOpen(true);
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }}
+ >
+ View
+
+ ),
+ },
+ {
+ key: '1',
+ disabled: !canEditProps,
+ label: (
+
+ {
+ if (canEditProps) {
+ setIsDrawerOpen(true);
+ setSelectedProperty(record);
+ analytics.event({
+ type: EventType.ViewStructuredPropertyEvent,
+ propertyUrn: record.entity.urn,
+ });
+ }
+ }}
+ >
+ Edit
+
+
+ ),
+ },
+ {
+ key: '2',
+ disabled: !canEditProps,
+ label: (
+
+ {
+ if (canEditProps) {
+ setSelectedProperty(record);
+ setShowConfirmDelete(true);
+ }
+ }}
+ >
+ Delete
+
+
+ ),
+ },
+ ];
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+ },
+ },
+ ];
+ return (
+ <>
+
+ handleDeleteProperty(selectedProperty)}
+ modalTitle="Confirm Delete"
+ modalText="Are you sure you want to delete? Deleting will remove this structured property from all assets it's currently on."
+ />
+ >
+ );
+};
+
+export default StructuredPropsTable;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx
new file mode 100644
index 00000000000000..1f08995e237ec5
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewAdvancedOptions.tsx
@@ -0,0 +1,43 @@
+import { Icon, Text } from '@components';
+import { StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React from 'react';
+import { CollapseHeader, RowContainer, StyledCollapse, StyledLabel } from './styledComponents';
+
+interface Props {
+ propEntity: StructuredPropertyEntity;
+}
+
+const ViewAdvancedOptions = ({ propEntity }: Props) => {
+ return (
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[]}
+ >
+
+
+ Advanced Options
+
+
+ }
+ forceRender
+ >
+ {propEntity && (
+
+ Qualified Name
+ {propEntity?.definition.qualifiedName}
+
+ )}
+
+
+ );
+};
+
+export default ViewAdvancedOptions;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx
new file mode 100644
index 00000000000000..a1baeaeb618f70
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewDisplayPreferences.tsx
@@ -0,0 +1,86 @@
+import { colors, Icon, Switch, Text } from '@src/alchemy-components';
+import { StructuredPropertyEntity } from '@src/types.generated';
+import { Collapse } from 'antd';
+import React from 'react';
+import { CollapseHeader, StyledCollapse, StyledFormItem, TogglesContainer } from './styledComponents';
+
+interface Props {
+ propEntity: StructuredPropertyEntity;
+}
+
+const ViewDisplayPreferences = ({ propEntity }: Props) => {
+ return (
+ <>
+ (
+
+ )}
+ expandIconPosition="end"
+ defaultActiveKey={[1]}
+ >
+
+
+ Display Preferences
+
+
+ }
+ forceRender
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ViewDisplayPreferences;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx b/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx
new file mode 100644
index 00000000000000..2fd36a29c8e760
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/ViewStructuredPropsDrawer.tsx
@@ -0,0 +1,142 @@
+import { Text } from '@components';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import React from 'react';
+import {
+ DescriptionContainer,
+ DrawerHeader,
+ ItemsList,
+ RowContainer,
+ StyledDrawer,
+ StyledIcon,
+ StyledLabel,
+ VerticalDivider,
+ ViewDivider,
+ ViewFieldsContainer,
+} from './styledComponents';
+import { getDisplayName, getValueTypeLabel } from './utils';
+import ViewAdvancedOptions from './ViewAdvancedOptions';
+import ViewDisplayPreferences from './ViewDisplayPreferences';
+
+interface Props {
+ isViewDrawerOpen: boolean;
+ setIsViewDrawerOpen: React.Dispatch>;
+ selectedProperty: SearchResult;
+ setSelectedProperty: React.Dispatch>;
+}
+
+const ViewStructuredPropsDrawer = ({
+ isViewDrawerOpen,
+ setIsViewDrawerOpen,
+ selectedProperty,
+ setSelectedProperty,
+}: Props) => {
+ const entityRegistry = useEntityRegistry();
+
+ const handleClose = () => {
+ setIsViewDrawerOpen(false);
+ setSelectedProperty(undefined);
+ };
+
+ const selectedPropEntity = selectedProperty && (selectedProperty?.entity as StructuredPropertyEntity);
+
+ const allowedValues = selectedPropEntity?.definition.allowedValues;
+
+ const allowedTypes = selectedPropEntity?.definition.typeQualifier?.allowedTypes;
+
+ const propType = getValueTypeLabel(
+ selectedPropEntity.definition.valueType.urn,
+ selectedPropEntity.definition.cardinality || PropertyCardinality.Single,
+ );
+
+ return (
+
+ {selectedProperty && (
+
+
+ {getDisplayName(selectedProperty?.entity as StructuredPropertyEntity)}
+
+
+
+
+ )}
+ >
+ }
+ footer={null}
+ destroyOnClose
+ >
+
+ {selectedPropEntity.definition.description && (
+
+
+ Description
+
+ {selectedPropEntity.definition.description}
+
+
+ )}
+
+ Property Type
+ {propType}
+
+ {allowedTypes && allowedTypes.length > 0 && (
+
+ Allowed Entity Types
+
+ {allowedTypes.map((type, index) => {
+ return (
+ <>
+ {entityRegistry.getEntityName(type.info.type)}
+ {index < allowedTypes.length - 1 && }
+ >
+ );
+ })}
+
+
+ )}
+ {allowedValues && allowedValues.length > 0 && (
+
+ Allowed Values
+
+ {allowedValues?.map((val, index) => {
+ return (
+ <>
+
+ {(val.value as any).stringValue || (val.value as any).numberValue}
+
+ {index < allowedValues?.length - 1 && }
+ >
+ );
+ })}
+
+
+ )}
+
+
+ Applies To
+
+ {selectedPropEntity.definition.entityTypes?.map((type, index) => {
+ return (
+ <>
+ {entityRegistry.getEntityName(type.info.type)}
+ {index < selectedPropEntity.definition.entityTypes?.length - 1 && (
+
+ )}
+ >
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+export default ViewStructuredPropsDrawer;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts b/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts
new file mode 100644
index 00000000000000..590189d06e6b16
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/cacheUtils.ts
@@ -0,0 +1,102 @@
+import { GetSearchResultsForMultipleDocument, GetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+
+const addToCache = (existingProperties, newProperty) => {
+ const propertyToWrite = {
+ entity: {
+ urn: newProperty.urn,
+ type: newProperty.type,
+ definition: {
+ displayName: newProperty.definition.displayName,
+ qualifiedName: newProperty.definition.qualifiedName,
+ description: newProperty.definition.description,
+ cardinality: newProperty.definition.cardinality,
+ immutable: newProperty.definition.immutable,
+ valueType: newProperty.definition.valueType,
+ entityTypes: newProperty.definition.entityTypes,
+ typeQualifier: newProperty.definition.typeQualifier,
+ allowedValues: newProperty.definition.allowedValues,
+ created: newProperty.definition.created,
+ lastModified: newProperty.definition.lastModified,
+ filterStatus: newProperty.definition.filterStatus,
+ },
+ settings: {
+ isHidden: newProperty.settings.isHidden,
+ showInSearchFilters: newProperty.settings.showInSearchFilters,
+ showAsAssetBadge: newProperty.settings.showAsAssetBadge,
+ showInAssetSummary: newProperty.settings.showInAssetSummary,
+ showInColumnsTable: newProperty.settings.showInColumnsTable,
+ },
+ __typename: 'StructuredPropertyEntity',
+ },
+ matchedFields: [],
+ insights: [],
+ extraProperties: [],
+ __typename: 'SearchResult',
+ };
+
+ return [propertyToWrite, ...existingProperties];
+};
+
+export const updatePropertiesList = (client, inputs, newProperty, searchAcrossEntities) => {
+ // Read the data from our cache for this query.
+ const currData: GetSearchResultsForMultipleQuery | null = client.readQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ });
+
+ if (currData === null) {
+ // If there's no cached data, the first load has not occurred. Let it occur naturally.
+ return;
+ }
+
+ const existingProperties = currData?.searchAcrossEntities?.searchResults || [];
+ const newProperties = addToCache(existingProperties, newProperty);
+
+ // Write our data back to the cache.
+ client.writeQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ data: {
+ searchAcrossEntities: {
+ ...searchAcrossEntities,
+ total: newProperties.length,
+ searchResults: newProperties,
+ },
+ },
+ });
+};
+
+export const removeFromPropertiesList = (client, inputs, deleteUrn, searchAcrossEntities) => {
+ const currData: GetSearchResultsForMultipleQuery | null = client.readQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ });
+
+ if (currData === null) {
+ return;
+ }
+
+ const existingProperties = currData?.searchAcrossEntities?.searchResults || [];
+
+ const newProperties = [...existingProperties.filter((prop) => prop.entity.urn !== deleteUrn)];
+
+ client.writeQuery({
+ query: GetSearchResultsForMultipleDocument,
+ variables: {
+ input: inputs,
+ },
+ data: {
+ searchAcrossEntities: {
+ ...searchAcrossEntities,
+ total: newProperties.length,
+ searchResults: newProperties,
+ },
+ },
+ });
+};
diff --git a/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts b/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts
new file mode 100644
index 00000000000000..211b4cdab6638c
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/styledComponents.ts
@@ -0,0 +1,473 @@
+import { colors, Icon, typography } from '@src/alchemy-components';
+import { Checkbox, Collapse, Divider, Drawer, Form, Modal, Select, Spin, Typography } from 'antd';
+import styled from 'styled-components';
+
+export const PageContainer = styled.div<{ $isShowNavBarRedesign?: boolean }>`
+ overflow: auto;
+ margin: ${(props) => (props.$isShowNavBarRedesign ? '0' : '0 12px 12px 0')};
+ padding: 16px 20px 20px 20px;
+ border-radius: ${(props) =>
+ props.$isShowNavBarRedesign ? props.theme.styles['border-radius-navbar-redesign'] : '8px'};
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 20px;
+ background-color: ${colors.white};
+ ${(props) => props.$isShowNavBarRedesign && 'max-height: calc(100vh - 88px);'};
+ ${(props) =>
+ props.$isShowNavBarRedesign &&
+ `
+ box-shadow: ${props.theme.styles['box-shadow-navbar-redesign']};
+ margin: 5px;
+ `}
+`;
+
+export const HeaderContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+export const TableContainer = styled.div`
+ display: flex;
+ overflow: auto;
+ flex: 1;
+`;
+
+export const HeaderContent = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+export const ButtonContainer = styled.div`
+ display: flex;
+ align-self: center;
+`;
+
+export const DataContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: calc(100% - 44px);
+`;
+
+export const PropName = styled(Typography.Text)`
+ font-size: 14px;
+ font-weight: 600;
+ color: ${colors.gray[600]};
+ line-height: normal;
+
+ :hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+`;
+
+export const PropDescription = styled(Typography.Text)`
+ font-size: 14px;
+ font-weight: 400;
+ color: ${colors.gray[1700]};
+ line-height: normal;
+`;
+
+export const NameColumn = styled.div`
+ display: flex;
+ gap: 12px;
+ align-items: center;
+`;
+
+export const IconContainer = styled.div`
+ height: 32px;
+ width: 32px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ border-radius: 200px;
+ background-color: ${colors.gray[1000]};
+`;
+
+export const PillsContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+export const PillContainer = styled.div`
+ display: flex;
+`;
+
+export const MenuItem = styled.div`
+ display: flex;
+ padding: 5px 100px 5px 5px;
+ font-size: 14px;
+ font-weight: 400;
+ color: ${colors.gray[600]};
+ font-family: ${typography.fonts.body};
+`;
+
+export const DrawerHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+export const TitleContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+`;
+
+export const StyledIcon = styled(Icon)`
+ &:hover {
+ cursor: pointer;
+ }
+`;
+
+export const FooterContainer = styled.div`
+ width: 100%;
+`;
+
+export const RowContainer = styled.div`
+ display: grid;
+ grid-template-columns: 180px 1fr;
+ align-items: center;
+`;
+
+export const ViewFieldsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+`;
+
+export const CheckboxWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 10px 0;
+ color: #374066;
+ p {
+ color: #374066;
+ font-weight: 500;
+ }
+`;
+
+export const StyledCheckbox = styled(Checkbox)`
+ .ant-checkbox-checked .ant-checkbox-inner {
+ background-color: ${colors.violet[500]};
+ border-color: ${colors.violet[500]} !important;
+ },
+`;
+
+export const StyledText = styled.div`
+ display: inline-flex;
+ margin-left: -4px;
+`;
+
+export const StyledFormItem = styled(Form.Item)`
+ margin: 0;
+`;
+
+export const GridFormItem = styled(Form.Item)`
+ display: grid;
+`;
+
+export const FieldLabel = styled.div`
+ font-size: 14px;
+ font-weight: 500;
+ color: ${colors.gray[600]};
+ margin-bottom: 24px;
+`;
+
+export const InputLabel = styled.div`
+ font-size: 14px;
+ font-weight: 500;
+ color: ${colors.gray[600]};
+ display: flex;
+ gap: 2px;
+`;
+
+export const StyledLabel = styled.div`
+ font-size: 12px;
+ font-weight: 700;
+ color: ${colors.gray[1700]};
+`;
+
+export const DescriptionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+export const FlexContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+`;
+
+export const StyledDrawer = styled(Drawer)`
+ .ant-drawer-body {
+ padding: 16px;
+ }
+
+ .ant-drawer-header {
+ padding: 16px;
+ }
+`;
+
+export const StyledCollapse = styled(Collapse)`
+ .ant-collapse-header {
+ padding: 0 !important;
+ }
+
+ .ant-collapse-content-box {
+ padding: 12px 0 !important;
+ }
+
+ .ant-collapse-arrow {
+ right: 0 !important;
+ }
+`;
+
+export const CollapseHeader = styled.div`
+ border-top: 1px solid ${colors.gray[1400]};
+ padding: 16px 0;
+ margin-left: -16px;
+ width: calc(100% + 32px);
+ padding: 16px;
+ margin-top: 12px;
+`;
+
+export const TogglesContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+`;
+
+export const StyledSpin = styled(Spin)`
+ max-height: 100% !important;
+ color: ${colors.violet[500]};
+`;
+
+export const CreatedByContainer = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 6px 3px 4px;
+ border-radius: 20px;
+ border: 1px solid ${colors.gray[1400]};
+
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+export const SubTextContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ p {
+ margin: 0;
+ }
+`;
+
+export const ValueListContainer = styled.div`
+ margin-bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ color: ${colors.gray[500]};
+
+ svg {
+ :hover {
+ cursor: pointer;
+ }
+ }
+`;
+
+export const ValueType = styled.div`
+ background-color: ${colors.gray[100]};
+ border-radius: 4px;
+ padding: 2px 4px;
+`;
+
+export const StyledModal = styled(Modal)`
+ font-family: Mulish;
+
+ &&& .ant-modal-content {
+ box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
+ border-radius: 12px;
+ width: 452px;
+ }
+
+ .ant-modal-header {
+ border-top-left-radius: 12px !important;
+ border-top-right-radius: 12px !important;
+ padding: 20px 20px 8px 20px;
+ }
+
+ .ant-modal-body {
+ padding: 0;
+ }
+`;
+
+export const FieldGroupContainer = styled.div`
+ display: grid;
+ margin-bottom: 8px;
+`;
+
+export const DeleteIconContainer = styled.div`
+ display: flex;
+ margin-top: -16px;
+ justify-self: end;
+
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+export const ValuesContainer = styled.div<{ height: number }>`
+ max-height: ${(props) => `calc(${props.height}px - 200px)`};
+ overflow: auto;
+ padding: 20px;
+`;
+
+export const ValuesList = styled.div`
+ font-size: 14px;
+ color: ${colors.gray[500]};
+ display: flex;
+ flex: 1;
+ align-items: center;
+ flex-wrap: wrap;
+
+ p {
+ line-height: 24px;
+ }
+`;
+
+export const ItemsList = styled.div`
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+`;
+
+export const ItemsContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ width: fit-content;
+ margin: 0 24px 24px 0;
+`;
+
+export const AddButtonContainer = styled.div`
+ display: flex;
+ margin: 10px 20px 0 0;
+ justify-self: end;
+`;
+
+export const FormContainer = styled.div`
+ display: grid;
+`;
+
+export const ModalFooter = styled.div`
+ display: flex;
+ gap: 16px;
+ justify-content: end;
+`;
+
+export const VerticalDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ height: 20px;
+ width: 2px;
+`;
+
+export const StyledSelect = styled(Select)`
+ font-family: ${typography.fonts.body};
+
+ .ant-select-selector {
+ height: 50px !important;
+ border-radius: 8px !important;
+ outline: 2px solid transparent !important;
+
+ &:hover,
+ &:focus-within {
+ border: 1px solid ${colors.violet[200]} !important;
+ outline: 2px solid ${colors.violet[200]} !important;
+ box-shadow: none !important;
+ }
+ }
+
+ .ant-select-selection-item {
+ p {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+
+ .ant-select-selection-placeholder {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ color: ${colors.gray[400]};
+ }
+`;
+
+export const CustomDropdown = styled.div`
+ .ant-select-item-option-content {
+ white-space: normal;
+ }
+`;
+
+export const SelectOptionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 4px 0;
+
+ p {
+ line-height: 20px;
+ }
+`;
+
+export const StyledDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ margin: 16px 0;
+`;
+
+export const ViewDivider = styled(Divider)`
+ color: ${colors.gray[1400]};
+ margin: 16px 0 0 -16px;
+ width: calc(100% + 32px);
+`;
+
+export const EmptyContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ gap: 16px;
+
+ svg {
+ width: 160px;
+ height: 160px;
+ }
+`;
+
+export const CardIcons = styled.div`
+ display: flex;
+ justify-content: end;
+ gap: 12px;
+
+ div {
+ border: 1px solid $E9EAEE;
+ border-radius: 20px;
+ width: 28px;
+ height: 28px;
+ padding: 4px;
+ color: #8088a3;
+ :hover {
+ cursor: pointer;
+ }
+ }
+`;
diff --git a/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts b/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts
new file mode 100644
index 00000000000000..6de195c1cd4241
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/useStructuredProp.ts
@@ -0,0 +1,135 @@
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { EntityType, PropertyCardinality, SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import { FormInstance } from 'antd';
+import { useMemo } from 'react';
+import { getEntityTypeUrn, StructuredProp, valueTypes } from './utils';
+
+interface Props {
+ selectedProperty?: SearchResult;
+ form: FormInstance;
+ setFormValues: React.Dispatch>;
+ setCardinality: React.Dispatch>;
+ setSelectedValueType: React.Dispatch>;
+}
+
+export default function useStructuredProp({
+ selectedProperty,
+ form,
+ setFormValues,
+ setCardinality,
+ setSelectedValueType,
+}: Props) {
+ const entityRegistry = useEntityRegistry();
+
+ const getEntitiesListOptions = (entitiesList: EntityType[]) => {
+ const listOptions: { label: string; value: string }[] = [];
+ entitiesList.forEach((type) => {
+ const entity = {
+ label: entityRegistry.getEntityName(type) || '',
+ value: getEntityTypeUrn(entityRegistry, type),
+ };
+ listOptions.push(entity);
+ });
+ return listOptions;
+ };
+
+ const updateFormValues = (field, values) => {
+ if (field.includes('typeQualifier')) {
+ setFormValues((prev) => ({
+ ...prev,
+ typeQualifier: {
+ allowedTypes: values,
+ },
+ }));
+ } else
+ setFormValues((prev) => ({
+ ...prev,
+ [field]: values,
+ }));
+ };
+
+ const handleSelectChange = (field, values) => {
+ form.setFieldValue(field, values);
+ updateFormValues(field, values);
+ };
+
+ const handleSelectUpdateChange = (field, values) => {
+ const entity = selectedProperty?.entity as StructuredPropertyEntity;
+ let initialValues: string[] = [];
+
+ if (field === 'entityTypes') initialValues = entity.definition.entityTypes.map((type) => type.urn);
+
+ if (field.includes('typeQualifier'))
+ initialValues = entity.definition.typeQualifier?.allowedTypes?.map((type) => type.urn) || [];
+
+ const updatedValues = [...initialValues, ...values.filter((value) => !initialValues.includes(value))];
+
+ form.setFieldValue(field, updatedValues);
+ updateFormValues(field, updatedValues);
+ };
+
+ // Handle change in the property type dropdown
+ const handleTypeUpdate = (value: string) => {
+ const typeOption = valueTypes.find((type) => type.value === value);
+ setSelectedValueType(value);
+ handleSelectChange('valueType', value);
+ setFormValues((prev) => ({
+ ...prev,
+ valueType: value,
+ }));
+
+ const isList = typeOption?.cardinality === PropertyCardinality.Multiple;
+ if (isList) setCardinality(PropertyCardinality.Multiple);
+ else setCardinality(PropertyCardinality.Single);
+ };
+
+ const settingsDefault = {
+ isHidden: false,
+ showInSearchFilters: false,
+ showAsAssetBadge: false,
+ showInAssetSummary: false,
+ showInColumnsTable: false,
+ };
+
+ const handleDisplaySettingChange = (settingField: string, value: boolean) => {
+ if (settingField === 'isHidden' && value) {
+ Object.keys(settingsDefault).forEach((settingKey) => form.setFieldValue(['settings', settingKey], false));
+ setFormValues((prev) => ({
+ ...prev,
+ settings: {
+ ...settingsDefault,
+ [settingField]: value,
+ },
+ }));
+ } else {
+ setFormValues((prev) => ({
+ ...prev,
+ settings: {
+ ...(prev?.settings || settingsDefault),
+ [settingField]: value,
+ },
+ }));
+ }
+ form.setFieldValue(['settings', settingField], value);
+ };
+
+ const disabledEntityTypeValues = useMemo(() => {
+ return (selectedProperty?.entity as StructuredPropertyEntity)?.definition?.entityTypes?.map((type) => type.urn);
+ }, [selectedProperty]);
+
+ const disabledTypeQualifierValues = useMemo(() => {
+ return (selectedProperty?.entity as StructuredPropertyEntity)?.definition?.typeQualifier?.allowedTypes?.map(
+ (type) => type.urn,
+ );
+ }, [selectedProperty]);
+
+ return {
+ handleSelectChange,
+ handleSelectUpdateChange,
+ handleTypeUpdate,
+ getEntitiesListOptions,
+ disabledEntityTypeValues,
+ disabledTypeQualifierValues,
+ handleDisplaySettingChange,
+ };
+}
diff --git a/datahub-web-react/src/app/govern/structuredProperties/utils.ts b/datahub-web-react/src/app/govern/structuredProperties/utils.ts
new file mode 100644
index 00000000000000..744ce20cf45732
--- /dev/null
+++ b/datahub-web-react/src/app/govern/structuredProperties/utils.ts
@@ -0,0 +1,249 @@
+import EntityRegistry from '@src/app/entity/EntityRegistry';
+import { mapStructuredPropertyToPropertyRow } from '@src/app/entity/shared/tabs/Properties/useStructuredProperties';
+import {
+ ENTITY_TYPES_FILTER_NAME,
+ IS_HIDDEN_PROPERTY_FILTER_NAME,
+ SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+} from '@src/app/search/utils/constants';
+import {
+ AllowedValue,
+ EntityType,
+ FacetFilterInput,
+ Maybe,
+ PropertyCardinality,
+ SearchResult,
+ StructuredProperties,
+ StructuredPropertyEntity,
+ StructuredPropertySettings,
+} from '@src/types.generated';
+
+export type StructuredProp = {
+ displayName?: string;
+ qualifiedName?: string;
+ cardinality?: PropertyCardinality;
+ description?: string | null;
+ valueType?: string;
+ entityTypes?: string[];
+ typeQualifier?: {
+ allowedTypes?: string[];
+ };
+ immutable?: boolean;
+ allowedValues?: AllowedValue[];
+ settings?: StructuredPropertySettings | null;
+};
+
+export const valueTypes = [
+ {
+ urn: 'urn:li:dataType:datahub.string',
+ label: 'Text',
+ value: 'string',
+ cardinality: PropertyCardinality.Single,
+ description: 'A string value',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.string',
+ label: 'Text - List',
+ value: 'stringList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A list of string values',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.number',
+ label: 'Number',
+ value: 'number',
+ cardinality: PropertyCardinality.Single,
+ description: 'An integer or decimal',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.number',
+ label: 'Number - List',
+ value: 'numberList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A list of integers or decimals',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.urn',
+ label: 'Entity',
+ value: 'entity',
+ cardinality: PropertyCardinality.Single,
+ description: 'A reference to a DataHub asset',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.urn',
+ label: 'Entity - List',
+ value: 'entityList',
+ cardinality: PropertyCardinality.Multiple,
+ description: 'A reference to a list of DataHub assets',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.rich_text',
+ label: 'Rich Text',
+ value: 'richText',
+ cardinality: PropertyCardinality.Single,
+ description: 'A freeform string of markdown text ',
+ },
+ {
+ urn: 'urn:li:dataType:datahub.date',
+ label: 'Date',
+ value: 'date',
+ cardinality: PropertyCardinality.Single,
+ description: 'A specific date',
+ },
+];
+
+export const SEARCHABLE_ENTITY_TYPES = [
+ EntityType.Dataset,
+ EntityType.DataJob,
+ EntityType.DataFlow,
+ EntityType.Chart,
+ EntityType.Dashboard,
+ EntityType.Domain,
+ EntityType.Container,
+ EntityType.GlossaryTerm,
+ EntityType.GlossaryNode,
+ EntityType.Mlmodel,
+ EntityType.MlmodelGroup,
+ EntityType.Mlfeature,
+ EntityType.MlfeatureTable,
+ EntityType.MlprimaryKey,
+ EntityType.DataProduct,
+ EntityType.CorpUser,
+ EntityType.CorpGroup,
+ EntityType.Tag,
+ EntityType.Role,
+];
+
+export const APPLIES_TO_ENTITIES = [
+ EntityType.Dataset,
+ EntityType.DataJob,
+ EntityType.DataFlow,
+ EntityType.Chart,
+ EntityType.Dashboard,
+ EntityType.Domain,
+ EntityType.Container,
+ EntityType.GlossaryTerm,
+ EntityType.GlossaryNode,
+ EntityType.Mlmodel,
+ EntityType.MlmodelGroup,
+ EntityType.Mlfeature,
+ EntityType.MlfeatureTable,
+ EntityType.MlprimaryKey,
+ EntityType.DataProduct,
+ EntityType.SchemaField,
+];
+
+export const getEntityTypeUrn = (entityRegistry: EntityRegistry, entityType: EntityType) => {
+ return `urn:li:entityType:datahub.${entityRegistry.getGraphNameFromType(entityType)}`;
+};
+
+export function getDisplayName(structuredProperty: StructuredPropertyEntity) {
+ return structuredProperty.definition.displayName || structuredProperty.definition.qualifiedName;
+}
+
+export const getValueType = (valueUrn: string, cardinality: PropertyCardinality) => {
+ return valueTypes.find((valueType) => valueType.urn === valueUrn && valueType.cardinality === cardinality)?.value;
+};
+
+export const getValueTypeLabel = (valueUrn: string, cardinality: PropertyCardinality) => {
+ return valueTypes.find((valueType) => valueType.urn === valueUrn && valueType.cardinality === cardinality)?.label;
+};
+
+export const getNewAllowedTypes = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentTypeUrns = entity.definition.typeQualifier?.allowedTypes?.map((type) => type.urn);
+ return values.typeQualifier?.allowedTypes?.filter((type) => !currentTypeUrns?.includes(type));
+};
+
+export const getNewEntityTypes = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentTypeUrns = entity.definition.entityTypes?.map((type) => type.urn);
+ return values.entityTypes?.filter((type) => !currentTypeUrns.includes(type));
+};
+
+export const getNewAllowedValues = (entity: StructuredPropertyEntity, values: StructuredProp) => {
+ const currentAllowedValues = entity.definition.allowedValues?.map(
+ (val: any) => val.value.numberValue || val.value.stringValue,
+ );
+ return values.allowedValues?.filter(
+ (val: any) =>
+ !(currentAllowedValues?.includes(val.stringValue) || currentAllowedValues?.includes(val.numberValue)),
+ );
+};
+
+export const isEntityTypeSelected = (selectedType: string) => {
+ if (selectedType === 'entity' || selectedType === 'entityList') return true;
+ return false;
+};
+
+export const isStringOrNumberTypeSelected = (selectedType: string) => {
+ if (
+ selectedType === 'string' ||
+ selectedType === 'stringList' ||
+ selectedType === 'number' ||
+ selectedType === 'numberList'
+ )
+ return true;
+ return false;
+};
+
+export const canBeAssetBadge = (selectedType: string, allowedValues?: AllowedValue[]) => {
+ if (selectedType === 'string' || selectedType === 'number') {
+ return !!allowedValues?.length;
+ }
+ return false;
+};
+
+export type PropValueField = 'stringValue' | 'numberValue';
+
+export const getStringOrNumberValueField = (selectedType: string) => {
+ if (selectedType === 'number' || selectedType === 'numberList') return 'numberValue' as PropValueField;
+ return 'stringValue' as PropValueField;
+};
+
+export const getPropertyRowFromSearchResult = (
+ property: SearchResult,
+ structuredProperties: Maybe | undefined,
+) => {
+ const entityProp = structuredProperties?.properties?.find(
+ (prop) => prop.structuredProperty.urn === property.entity.urn,
+ );
+ return entityProp ? mapStructuredPropertyToPropertyRow(entityProp) : undefined;
+};
+
+export const getNotHiddenPropertyFilter = () => {
+ const isHiddenFilter: FacetFilterInput = {
+ field: IS_HIDDEN_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ negated: true,
+ };
+ return isHiddenFilter;
+};
+
+export const getShowInColumnsTablePropertyFilter = () => {
+ const columnsTableFilter: FacetFilterInput = {
+ field: SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ };
+ return columnsTableFilter;
+};
+
+export const getShowInAssetSummaryPropertyFilter = () => {
+ const assetSummaryFilter: FacetFilterInput = {
+ field: SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ };
+ return assetSummaryFilter;
+};
+
+export const getEntityTypesPropertyFilter = (
+ entityRegistry: EntityRegistry,
+ isSchemaField: boolean,
+ entityType?: EntityType,
+) => {
+ const type = isSchemaField ? EntityType.SchemaField : entityType;
+
+ const entityTypesFilter: FacetFilterInput = {
+ field: ENTITY_TYPES_FILTER_NAME,
+ values: [getEntityTypeUrn(entityRegistry, type || EntityType.SchemaField)],
+ };
+ return entityTypesFilter;
+};
diff --git a/datahub-web-react/src/app/search/SearchPage.tsx b/datahub-web-react/src/app/search/SearchPage.tsx
index fa0643742e376b..aa83e739f2d778 100644
--- a/datahub-web-react/src/app/search/SearchPage.tsx
+++ b/datahub-web-react/src/app/search/SearchPage.tsx
@@ -59,7 +59,7 @@ export const SearchPage = () => {
orFilters,
viewUrn,
sortInput,
- searchFlags: { getSuggestions: true },
+ searchFlags: { getSuggestions: true, includeStructuredPropertyFacets: true },
},
},
fetchPolicy: 'cache-and-network',
diff --git a/datahub-web-react/src/app/search/filters/ActiveFilter.tsx b/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
index 29789f872e51d3..5882641cd2c9ca 100644
--- a/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
+++ b/datahub-web-react/src/app/search/filters/ActiveFilter.tsx
@@ -49,9 +49,18 @@ function ActiveFilter({
const filterEntity = getFilterEntity(filterFacet.field, filterValue, availableFilters);
const filterLabelOverride = useGetBrowseV2LabelOverride(filterFacet.field, filterValue, entityRegistry);
const filterRenderer = useFilterRendererRegistry();
+ const facetEntity = availableFilters?.find((f) => f.field === filterFacet.field)?.entity;
const { icon, label } = !filterRenderer.hasRenderer(filterFacet.field)
- ? getFilterIconAndLabel(filterFacet.field, filterValue, entityRegistry, filterEntity, 12, filterLabelOverride)
+ ? getFilterIconAndLabel(
+ filterFacet.field,
+ filterValue,
+ entityRegistry,
+ filterEntity,
+ 12,
+ filterLabelOverride,
+ facetEntity,
+ )
: {
icon: filterRenderer.getIcon(filterFacet.field),
label: filterRenderer.getValueLabel(filterFacet.field, filterValue),
diff --git a/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx b/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx
new file mode 100644
index 00000000000000..aace8dcb97f480
--- /dev/null
+++ b/datahub-web-react/src/app/search/filters/DateRangeMenu/DateRangeMenu.tsx
@@ -0,0 +1,88 @@
+import moment from 'moment';
+import { Text } from '@src/alchemy-components';
+import React, { useCallback, useRef, useState } from 'react';
+import { DatePicker } from 'antd';
+import styled from 'styled-components';
+import { FacetFilterInput, FacetMetadata, FilterOperator } from '@src/types.generated';
+import { useFilterDisplayName } from '../utils';
+import useDateRangeFilterValues, { Datetime } from './useDateRangeFilterValues';
+
+const { RangePicker } = DatePicker;
+
+const Container = styled.div`
+ padding: 16px;
+ background-color: #ffffff;
+ box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
+ border-radius: 8px;
+ min-width: 225px;
+`;
+
+interface Props {
+ field: FacetMetadata;
+ manuallyUpdateFilters: (newValues: FacetFilterInput[]) => void;
+}
+
+export default function DateRangeMenu({ field, manuallyUpdateFilters }: Props) {
+ const displayName = useFilterDisplayName(field);
+ moment.tz.setDefault('GMT');
+
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const ref = useRef(null);
+
+ useDateRangeFilterValues({ filterField: field.field, setStartDate, setEndDate });
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ setIsOpen(open);
+ if (!open) {
+ ref.current?.blur();
+ if (startDate && endDate) {
+ manuallyUpdateFilters([
+ {
+ field: field.field,
+ values: [startDate.valueOf().toString()],
+ condition: FilterOperator.GreaterThan,
+ },
+ {
+ field: field.field,
+ values: [endDate.valueOf().toString()],
+ condition: FilterOperator.LessThan,
+ },
+ ]);
+ }
+ }
+ },
+ [startDate, endDate, field.field, manuallyUpdateFilters],
+ );
+
+ const handleRangeChange = useCallback((dates: [Datetime, Datetime] | null) => {
+ const [start, end] = dates || [null, null];
+
+ start?.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
+ end?.set({ hour: 23, minute: 59, second: 59, millisecond: 999 });
+
+ setStartDate(start);
+ setEndDate(end);
+ }, []);
+
+ return (
+
+ Filter by {displayName}
+ handleOpenChange(true)}
+ style={{ paddingLeft: 0, paddingRight: 0 }}
+ />
+
+ );
+}
diff --git a/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts b/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts
new file mode 100644
index 00000000000000..ce1810c86002ff
--- /dev/null
+++ b/datahub-web-react/src/app/search/filters/DateRangeMenu/useDateRangeFilterValues.ts
@@ -0,0 +1,48 @@
+import { useEffect, useMemo } from 'react';
+import { FilterOperator } from '@src/types.generated';
+import moment from 'moment';
+import useGetSearchQueryInputs from '../../useGetSearchQueryInputs';
+
+export type Datetime = moment.Moment | null;
+
+interface Props {
+ filterField: string;
+ setStartDate: React.Dispatch>;
+ setEndDate: React.Dispatch>;
+}
+
+export default function useDateRangeFilterValues({ filterField, setStartDate, setEndDate }: Props) {
+ const { filters: activeFilters } = useGetSearchQueryInputs();
+ const matchingFilters = useMemo(
+ () => activeFilters.filter((f) => f.field === filterField),
+ [activeFilters, filterField],
+ );
+ const startDateFromFilter = useMemo(
+ () => matchingFilters.find((f) => f.condition === FilterOperator.GreaterThan)?.values?.[0],
+ [matchingFilters],
+ );
+ const endDateFromFilter = useMemo(
+ () => matchingFilters.find((f) => f.condition === FilterOperator.LessThan)?.values?.[0],
+ [matchingFilters],
+ );
+
+ useEffect(() => {
+ if (startDateFromFilter) {
+ setStartDate(
+ moment(parseInt(startDateFromFilter, 10)).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }),
+ );
+ } else {
+ setStartDate(null);
+ }
+ }, [startDateFromFilter, setStartDate]);
+
+ useEffect(() => {
+ if (endDateFromFilter) {
+ setEndDate(
+ moment(parseInt(endDateFromFilter, 10)).set({ hour: 23, minute: 59, second: 59, millisecond: 999 }),
+ );
+ } else {
+ setEndDate(null);
+ }
+ }, [endDateFromFilter, setEndDate]);
+}
diff --git a/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts b/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
index 38f8007985451e..87c85a4e39dc00 100644
--- a/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
+++ b/datahub-web-react/src/app/search/filters/EntityTypeFilter/entityTypeFilterUtils.ts
@@ -91,7 +91,7 @@ export function getDisplayedFilterOptions(
.filter(
(option) => option.value.includes(FILTER_DELIMITER) && option.value.includes(filterOption.value),
)
- .map((option) => ({ field: ENTITY_SUB_TYPE_FILTER_NAME, ...option }))
+ .map((option) => ({ field: ENTITY_SUB_TYPE_FILTER_NAME, ...option } as FilterOptionType))
.filter((o) => filterNestedOptions(o, entityRegistry, searchQuery));
return mapFilterOption({
filterOption,
diff --git a/datahub-web-react/src/app/search/filters/FilterOption.tsx b/datahub-web-react/src/app/search/filters/FilterOption.tsx
index 50b78c7f0685c9..15851eb915e739 100644
--- a/datahub-web-react/src/app/search/filters/FilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/FilterOption.tsx
@@ -123,9 +123,10 @@ export default function FilterOption({
addPadding,
}: Props) {
const [areChildrenVisible, setAreChildrenVisible] = useState(true);
- const { field, value, count, entity } = filterOption;
+ const { field, value, count, entity, displayName } = filterOption;
const entityRegistry = useEntityRegistry();
const { icon, label } = getFilterIconAndLabel(field, value, entityRegistry, entity || null, 14);
+ const finalLabel = displayName || label;
const shouldShowIcon = (field === PLATFORM_FILTER_NAME || field === CONTAINER_FILTER_NAME) && icon !== null;
const shouldShowTagColor = field === TAGS_FILTER_NAME && entity?.type === EntityType.Tag;
const isSubTypeFilter = field === TYPE_NAMES_FILTER_NAME;
@@ -156,7 +157,7 @@ export default function FilterOption({
nestedOptions?.map((o) => o.value),
)}
onClick={updateFilterValues}
- data-testid={`filter-option-${label}`}
+ data-testid={`filter-option-${finalLabel}`}
>
{parentEntities.length > 0 && (
@@ -170,8 +171,8 @@ export default function FilterOption({
)}
{(shouldShowIcon || shouldShowTagColor) && }
-
- {isSubTypeFilter ? capitalizeFirstLetterOnly(label as string) : label}
+
+ {isSubTypeFilter ? capitalizeFirstLetterOnly(finalLabel as string) : finalLabel}
{countText}
{nestedOptions && nestedOptions.length > 0 && (
diff --git a/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx b/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
index 5012f8c645d17f..41df6b86c01625 100644
--- a/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/MoreFilterOption.tsx
@@ -1,14 +1,13 @@
import { RightOutlined } from '@ant-design/icons';
import { Dropdown } from 'antd';
-import styled from 'styled-components';
import React, { useRef } from 'react';
+import styled from 'styled-components';
import { FacetFilterInput, FacetMetadata } from '../../../types.generated';
-import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import OptionsDropdownMenu from './OptionsDropdownMenu';
-import useSearchFilterDropdown from './useSearchFilterDropdown';
import { IconWrapper } from './SearchFilterView';
-import { getFilterDropdownIcon, useElementDimensions } from './utils';
import { MoreFilterOptionLabel } from './styledComponents';
+import useSearchFilterDropdown from './useSearchFilterDropdown';
+import { getFilterDropdownIcon, useElementDimensions, useFilterDisplayName } from './utils';
const IconNameWrapper = styled.span`
display: flex;
@@ -34,12 +33,14 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter
areFiltersLoading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
} = useSearchFilterDropdown({
filter,
activeFilters,
onChangeFilters,
});
const filterIcon = getFilterDropdownIcon(filter.field);
+ const displayName = useFilterDisplayName(filter);
return (
)}
>
@@ -64,11 +67,11 @@ export default function MoreFilterOption({ filter, activeFilters, onChangeFilter
onClick={() => updateIsMenuOpen(!isMenuOpen)}
isActive={!!numActiveFilters}
isOpen={isMenuOpen}
- data-testid={`more-filter-${capitalizeFirstLetterOnly(filter.displayName)}`}
+ data-testid={`more-filter-${displayName?.replace(/\s/g, '-')}`}
>
{filterIcon && {filterIcon} }
- {capitalizeFirstLetterOnly(filter.displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
+ {displayName} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
diff --git a/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx b/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
index 13196af5ef85bf..f1c806671a39b9 100644
--- a/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
+++ b/datahub-web-react/src/app/search/filters/OptionsDropdownMenu.tsx
@@ -2,9 +2,12 @@ import { LoadingOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { CSSProperties } from 'react';
import styled from 'styled-components/macro';
+import { FacetFilterInput, FacetMetadata } from '@src/types.generated';
import { useEntityRegistry } from '../../useEntityRegistry';
import { SearchBar } from '../SearchBar';
import { useEnterKeyListener } from '../../shared/useEnterKeyListener';
+import { getIsDateRangeFilter } from './utils';
+import DateRangeMenu from './DateRangeMenu/DateRangeMenu';
const StyledButton = styled(Button)`
width: 100%;
@@ -53,6 +56,8 @@ interface Props {
updateSearchQuery: (query: string) => void;
searchPlaceholder?: string;
style?: CSSProperties;
+ filter?: FacetMetadata;
+ manuallyUpdateFilters?: (newValues: FacetFilterInput[]) => void;
}
export default function OptionsDropdownMenu({
@@ -63,11 +68,21 @@ export default function OptionsDropdownMenu({
updateSearchQuery,
searchPlaceholder,
style,
+ filter,
+ manuallyUpdateFilters,
}: Props) {
const entityRegistry = useEntityRegistry();
useEnterKeyListener({ querySelectorToExecuteClick: '#updateFiltersButton' });
+ if (filter && manuallyUpdateFilters && getIsDateRangeFilter(filter)) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/datahub-web-react/src/app/search/filters/SearchFilter.tsx b/datahub-web-react/src/app/search/filters/SearchFilter.tsx
index 26b3a0d5ff5c23..9a69c4ec07716c 100644
--- a/datahub-web-react/src/app/search/filters/SearchFilter.tsx
+++ b/datahub-web-react/src/app/search/filters/SearchFilter.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { FacetFilterInput, FacetMetadata } from '../../../types.generated';
import useSearchFilterDropdown from './useSearchFilterDropdown';
-import { getFilterDropdownIcon } from './utils';
+import { getFilterDropdownIcon, useFilterDisplayName } from './utils';
import SearchFilterView from './SearchFilterView';
import { ENTITY_FILTER_NAME } from '../utils/constants';
import EntityTypeFilter from './EntityTypeFilter/EntityTypeFilter';
@@ -22,12 +22,14 @@ export default function SearchFilter({ filter, activeFilters, onChangeFilters }:
areFiltersLoading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
} = useSearchFilterDropdown({
filter,
activeFilters,
onChangeFilters,
});
const filterIcon = getFilterDropdownIcon(filter.field);
+ const displayName = useFilterDisplayName(filter);
if (filter.field === ENTITY_FILTER_NAME) {
return ;
@@ -39,12 +41,14 @@ export default function SearchFilter({ filter, activeFilters, onChangeFilters }:
isMenuOpen={isMenuOpen}
numActiveFilters={numActiveFilters}
filterIcon={filterIcon}
- displayName={filter.displayName || ''}
+ displayName={displayName || ''}
searchQuery={searchQuery}
loading={areFiltersLoading}
updateIsMenuOpen={updateIsMenuOpen}
setSearchQuery={updateSearchQuery}
updateFilters={updateFilters}
+ filter={filter}
+ manuallyUpdateFilters={manuallyUpdateFilters}
/>
);
}
diff --git a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
index ee448e6e1acf26..c8c629d3735b80 100644
--- a/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
+++ b/datahub-web-react/src/app/search/filters/SearchFilterView.tsx
@@ -2,8 +2,8 @@ import { CaretDownFilled } from '@ant-design/icons';
import { Dropdown } from 'antd';
import React from 'react';
import styled from 'styled-components';
+import { FacetFilterInput, FacetMetadata } from '@src/types.generated';
import OptionsDropdownMenu from './OptionsDropdownMenu';
-import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import { DisplayedFilterOption } from './mapFilterOption';
import { SearchFilterLabel } from './styledComponents';
@@ -27,6 +27,8 @@ interface Props {
updateIsMenuOpen: (isOpen: boolean) => void;
setSearchQuery: (query: string) => void;
updateFilters: () => void;
+ filter?: FacetMetadata;
+ manuallyUpdateFilters?: (newValues: FacetFilterInput[]) => void;
}
export default function SearchFilterView({
@@ -40,6 +42,8 @@ export default function SearchFilterView({
updateIsMenuOpen,
setSearchQuery,
updateFilters,
+ filter,
+ manuallyUpdateFilters,
}: Props) {
return (
)}
>
updateIsMenuOpen(!isMenuOpen)}
isActive={!!numActiveFilters}
- data-testid={`filter-dropdown-${capitalizeFirstLetterOnly(displayName)}`}
+ data-testid={`filter-dropdown-${displayName?.replace(/\s/g, '-')}`}
>
{filterIcon && {filterIcon} }
- {capitalizeFirstLetterOnly(displayName)} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
+ {displayName} {numActiveFilters ? `(${numActiveFilters}) ` : ''}
diff --git a/datahub-web-react/src/app/search/filters/mapFilterOption.tsx b/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
index 4f2b7bf21051c2..6abc3505fe3a47 100644
--- a/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
+++ b/datahub-web-react/src/app/search/filters/mapFilterOption.tsx
@@ -45,7 +45,7 @@ export function mapFilterOption({
/>
),
style: { padding: 0 },
- displayName: displayName as string,
+ displayName: filterOption.displayName || (displayName as string),
nestedOptions,
};
}
diff --git a/datahub-web-react/src/app/search/filters/types.ts b/datahub-web-react/src/app/search/filters/types.ts
index 3f4a705e827c41..6704c7fa01a48e 100644
--- a/datahub-web-react/src/app/search/filters/types.ts
+++ b/datahub-web-react/src/app/search/filters/types.ts
@@ -5,4 +5,5 @@ export interface FilterOptionType {
value: string;
count?: number;
entity?: Entity | null;
+ displayName?: string;
}
diff --git a/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx b/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
index 8467449ab6029f..a614ae8e7f35ee 100644
--- a/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
+++ b/datahub-web-react/src/app/search/filters/useSearchFilterDropdown.tsx
@@ -81,6 +81,15 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
setIsMenuOpen(false);
}
+ function manuallyUpdateFilters(newFilters: FacetFilterInput[]) {
+ // remove any filters that are in newFilters to overwrite them
+ const filtersNotInNewFilters = activeFilters.filter(
+ (f) => !newFilters.find((newFilter) => newFilter.field === f.field),
+ );
+ onChangeFilters([...filtersNotInNewFilters, ...newFilters]);
+ setIsMenuOpen(false);
+ }
+
function updateSearchQuery(newQuery: string) {
setSearchQuery(newQuery);
if (newQuery && FACETS_TO_ENTITY_TYPES[filter.field]) {
@@ -107,7 +116,13 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
);
// filter out any aggregations with a count of 0 unless it's already selected and in activeFilters
const finalAggregations = filterEmptyAggregations(combinedAggregations, activeFilters);
- const filterOptions = getFilterOptions(filter.field, finalAggregations, selectedFilterOptions, autoCompleteResults)
+ const filterOptions = getFilterOptions(
+ filter.field,
+ finalAggregations,
+ selectedFilterOptions,
+ autoCompleteResults,
+ filter.entity,
+ )
.map((filterOption) =>
mapFilterOption({ filterOption, entityRegistry, selectedFilterOptions, setSelectedFilterOptions }),
)
@@ -122,5 +137,6 @@ export default function useSearchFilterDropdown({ filter, activeFilters, onChang
areFiltersLoading: loading,
searchQuery,
updateSearchQuery,
+ manuallyUpdateFilters,
};
}
diff --git a/datahub-web-react/src/app/search/filters/utils.tsx b/datahub-web-react/src/app/search/filters/utils.tsx
index bd747777d11175..2bbad251533632 100644
--- a/datahub-web-react/src/app/search/filters/utils.tsx
+++ b/datahub-web-react/src/app/search/filters/utils.tsx
@@ -1,4 +1,7 @@
+import moment from 'moment-timezone';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
import Icon from '@ant-design/icons/lib/components/Icon';
+import TableIcon from '@src/images/table-icon.svg?react';
import {
BookOutlined,
DatabaseOutlined,
@@ -8,6 +11,8 @@ import {
TagOutlined,
UserOutlined,
} from '@ant-design/icons';
+import { removeMarkdown } from '@src/app/entity/shared/components/styled/StripMarkdownText';
+import { DATE_TYPE_URN } from '@src/app/shared/constants';
import React, { useLayoutEffect, useState } from 'react';
import styled from 'styled-components';
import {
@@ -21,6 +26,7 @@ import {
FacetMetadata,
GlossaryTerm,
Container,
+ StructuredPropertyEntity,
} from '../../../types.generated';
import { IconStyleType } from '../../entity/Entity';
import {
@@ -34,6 +40,7 @@ import {
LEGACY_ENTITY_FILTER_NAME,
OWNERS_FILTER_NAME,
PLATFORM_FILTER_NAME,
+ STRUCTURED_PROPERTIES_FILTER_NAME,
TAGS_FILTER_NAME,
TYPE_NAMES_FILTER_NAME,
UNIT_SEPARATOR,
@@ -164,6 +171,7 @@ export function getFilterIconAndLabel(
filterEntity: Entity | null,
size?: number,
filterLabelOverride?: string | null,
+ facetEntity?: Entity | null,
) {
let icon: React.ReactNode = null;
let label: React.ReactNode = null;
@@ -209,6 +217,9 @@ export function getFilterIconAndLabel(
icon = newIcon;
label = newLabel;
+ } else if (filterField.startsWith(STRUCTURED_PROPERTIES_FILTER_NAME)) {
+ label = getStructuredPropFilterDisplayName(filterField, filterValue, facetEntity);
+ icon = ;
} else {
label = filterValue;
}
@@ -268,6 +279,10 @@ export function sortFacets(facetA: FacetMetadata, facetB: FacetMetadata, sortedF
}
export function getFilterDropdownIcon(field: string) {
+ if (field.startsWith(STRUCTURED_PROPERTIES_FILTER_NAME)) {
+ return ;
+ }
+
switch (field) {
case PLATFORM_FILTER_NAME:
return ;
@@ -296,8 +311,13 @@ export function getFilterOptions(
aggregations: AggregationMetadata[],
selectedFilterOptions: FilterOptionType[],
autoCompleteResults?: GetAutoCompleteMultipleResultsQuery,
+ filterEntity?: Entity | null,
) {
- const aggregationFilterOptions = aggregations.map((agg) => ({ field: filterField, ...agg }));
+ const aggregationFilterOptions = aggregations.map((agg) => ({
+ field: filterField,
+ displayName: getStructuredPropFilterDisplayName(filterField, agg.value, filterEntity),
+ ...agg,
+ }));
const searchResults = autoCompleteResults?.autoCompleteForMultiple?.suggestions.find((suggestion) =>
FACETS_TO_ENTITY_TYPES[filterField]?.includes(suggestion.type),
@@ -384,3 +404,43 @@ export function useElementDimensions(ref) {
return dimensions;
}
+
+export function getStructuredPropFilterDisplayName(field: string, value: string, entity?: Entity | null) {
+ const isStructuredPropertyValue = field.startsWith('structuredProperties.');
+ if (!isStructuredPropertyValue) return undefined;
+
+ // check for structured prop entity values
+ if (value.startsWith('urn:li:')) {
+ // this value is an urn, handle entity display names elsewhere
+ return undefined;
+ }
+
+ // check for structured prop date values
+ if (entity && (entity as StructuredPropertyEntity).definition?.valueType?.urn === DATE_TYPE_URN) {
+ return moment(parseInt(value, 10)).tz('GMT').format('MM/DD/YYYY').toString();
+ }
+
+ // check for structured prop number values
+ if (!Number.isNaN(parseFloat(value))) {
+ return parseFloat(value).toString();
+ }
+
+ return removeMarkdown(value);
+}
+
+export function useFilterDisplayName(filter: FacetMetadata, predicateDisplayName?: string) {
+ const entityRegistry = useEntityRegistry();
+
+ if (filter.entity) {
+ return entityRegistry.getDisplayName(filter.entity.type, filter.entity);
+ }
+
+ return predicateDisplayName || filter.displayName || filter.field;
+}
+
+export function getIsDateRangeFilter(field: FacetMetadata) {
+ if (field.entity && field.entity.type === EntityType.StructuredProperty) {
+ return (field.entity as StructuredPropertyEntity).definition?.valueType?.urn === DATE_TYPE_URN;
+ }
+ return false;
+}
diff --git a/datahub-web-react/src/app/search/utils/constants.ts b/datahub-web-react/src/app/search/utils/constants.ts
index 8616f67ac2b6a3..7aa416e12c954f 100644
--- a/datahub-web-react/src/app/search/utils/constants.ts
+++ b/datahub-web-react/src/app/search/utils/constants.ts
@@ -25,6 +25,11 @@ export const REMOVED_FILTER_NAME = 'removed';
export const ORIGIN_FILTER_NAME = 'origin';
export const DEGREE_FILTER_NAME = 'degree';
export const BROWSE_PATH_V2_FILTER_NAME = 'browsePathV2';
+export const STRUCTURED_PROPERTIES_FILTER_NAME = 'structuredProperties.';
+export const ENTITY_TYPES_FILTER_NAME = 'entityTypes';
+export const IS_HIDDEN_PROPERTY_FILTER_NAME = 'isHidden';
+export const SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME = 'showInColumnsTable';
+export const SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME = 'showInAssetSummary';
export const LEGACY_ENTITY_FILTER_FIELDS = [ENTITY_FILTER_NAME, LEGACY_ENTITY_FILTER_NAME];
diff --git a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
index 8936a935735c57..2e8556a20b0865 100644
--- a/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
+++ b/datahub-web-react/src/app/shared/admin/HeaderLinks.tsx
@@ -8,6 +8,7 @@ import {
SolutionOutlined,
DownOutlined,
GlobalOutlined,
+ UnorderedListOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { Button, Dropdown, Tooltip } from 'antd';
@@ -15,6 +16,7 @@ import { useAppConfig, useBusinessAttributesFlag } from '../../useAppConfig';
import { ANTD_GRAY } from '../../entity/shared/constants';
import { HOME_PAGE_INGESTION_ID } from '../../onboarding/config/HomePageOnboardingConfig';
import { useToggleEducationStepIdsAllowList } from '../../onboarding/useToggleEducationStepIdsAllowList';
+import { PageRoutes } from '../../../conf/Global';
import { useUserContext } from '../../context/useUserContext';
import DomainIcon from '../../domain/DomainIcon';
@@ -42,6 +44,10 @@ const NavTitleContainer = styled.span`
padding: 2px;
`;
+const NavSubItemTitleContainer = styled(NavTitleContainer)`
+ line-height: 20px;
+`;
+
const NavTitleText = styled.span`
margin-left: 6px;
font-weight: bold;
@@ -71,6 +77,9 @@ export function HeaderLinks(props: Props) {
const showSettings = true;
const showIngestion =
isIngestionEnabled && me && (me.platformPrivileges?.manageIngestion || me.platformPrivileges?.manageSecrets);
+ const showStructuredProperties =
+ config?.featureFlags?.showManageStructuredProperties &&
+ (me.platformPrivileges?.manageStructuredProperties || me.platformPrivileges?.viewStructuredPropertiesPage);
useToggleEducationStepIdsAllowList(!!showIngestion, HOME_PAGE_INGESTION_ID);
@@ -125,6 +134,22 @@ export function HeaderLinks(props: Props) {
},
]
: []),
+ ...(showStructuredProperties
+ ? [
+ {
+ key: 5,
+ label: (
+
+
+
+ Structured Properties
+
+ Manage custom properties for your data assets
+
+ ),
+ },
+ ]
+ : []),
];
return (
diff --git a/datahub-web-react/src/app/shared/constants.ts b/datahub-web-react/src/app/shared/constants.ts
index 1cd9077ab8cdf8..5ccc6e5698faa2 100644
--- a/datahub-web-react/src/app/shared/constants.ts
+++ b/datahub-web-react/src/app/shared/constants.ts
@@ -1,5 +1,8 @@
export const ENTER_KEY_CODE = 13;
+export const DATE_TYPE_URN = 'urn:li:dataType:datahub.date';
+export const TYPE_ICON_CLASS_NAME = 'typeIcon';
+
export enum ErrorCodes {
BadRequest = 400,
Unauthorized = 401,
diff --git a/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx b/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx
new file mode 100644
index 00000000000000..34c317d27a0a4f
--- /dev/null
+++ b/datahub-web-react/src/app/sharedV2/modals/ConfirmationModal.tsx
@@ -0,0 +1,76 @@
+import { Button, Heading, Text, typography } from '@components';
+import { Modal } from 'antd';
+import React from 'react';
+import styled from 'styled-components';
+
+const ButtonsContainer = styled.div`
+ display: flex;
+ gap: 16px;
+ justify-content: end;
+`;
+
+export const StyledModal = styled(Modal)`
+ font-family: ${typography.fonts.body};
+
+ &&& .ant-modal-content {
+ box-shadow: 0px 4px 12px 0px rgba(9, 1, 61, 0.12);
+ border-radius: 12px;
+ }
+
+ .ant-modal-header {
+ border-bottom: 0;
+ padding-bottom: 0;
+ border-radius: 12px !important;
+ }
+
+ .ant-modal-body {
+ padding: 12px 24px;
+ }
+`;
+
+interface Props {
+ isOpen: boolean;
+ handleConfirm: (e: any) => void;
+ handleClose: () => void;
+ modalTitle?: string;
+ modalText?: string | React.ReactNode;
+ closeButtonText?: string;
+ confirmButtonText?: string;
+}
+
+export const ConfirmationModal = ({
+ isOpen,
+ handleClose,
+ handleConfirm,
+ modalTitle,
+ modalText,
+ closeButtonText,
+ confirmButtonText,
+}: Props) => {
+ return (
+
+
+ {closeButtonText || 'Cancel'}
+
+
+ {confirmButtonText || 'Yes'}
+
+
+ }
+ title={
+
+ {modalTitle || 'Confirm'}
+
+ }
+ >
+
+ {modalText || 'Are you sure?'}
+
+
+ );
+};
diff --git a/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts b/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts
new file mode 100644
index 00000000000000..b714d668e05fc2
--- /dev/null
+++ b/datahub-web-react/src/app/sharedV2/toastMessageUtils.ts
@@ -0,0 +1,18 @@
+import { message } from 'antd';
+import { ReactNode } from 'react';
+
+export enum ToastType {
+ INFO = 'info',
+ ERROR = 'error',
+ SUCCESS = 'success',
+ WARNING = 'warning',
+ LOADING = 'loading',
+}
+
+export const showToastMessage = (type: ToastType, content: string | ReactNode, duration: number) => {
+ message.open({
+ type,
+ content,
+ duration,
+ });
+};
diff --git a/datahub-web-react/src/appConfigContext.tsx b/datahub-web-react/src/appConfigContext.tsx
index f119f8bc7d53af..d7fef85db4b625 100644
--- a/datahub-web-react/src/appConfigContext.tsx
+++ b/datahub-web-react/src/appConfigContext.tsx
@@ -56,6 +56,7 @@ export const DEFAULT_APP_CONFIG = {
dataContractsEnabled: false,
editableDatasetNameEnabled: false,
showSeparateSiblings: false,
+ showManageStructuredProperties: false,
},
};
diff --git a/datahub-web-react/src/conf/Global.ts b/datahub-web-react/src/conf/Global.ts
index e0172d3f07e41e..7b3925f8c6ba1c 100644
--- a/datahub-web-react/src/conf/Global.ts
+++ b/datahub-web-react/src/conf/Global.ts
@@ -25,6 +25,7 @@ export enum PageRoutes {
DOMAINS = '/domains',
DOMAIN = '/domain',
GLOSSARY = '/glossary',
+ STRUCTURED_PROPERTIES = '/structured-properties',
SETTINGS_VIEWS = '/settings/views',
EMBED = '/embed',
EMBED_LOOKUP = '/embed/lookup/:url',
diff --git a/datahub-web-react/src/graphql/app.graphql b/datahub-web-react/src/graphql/app.graphql
index 2a0baf238761f3..0d1999f82f77cd 100644
--- a/datahub-web-react/src/graphql/app.graphql
+++ b/datahub-web-react/src/graphql/app.graphql
@@ -71,6 +71,7 @@ query appConfig {
dataContractsEnabled
editableDatasetNameEnabled
showSeparateSiblings
+ showManageStructuredProperties
}
}
}
diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql
index ade63f151d1a09..788c68349b4268 100644
--- a/datahub-web-react/src/graphql/fragments.graphql
+++ b/datahub-web-react/src/graphql/fragments.graphql
@@ -589,6 +589,11 @@ fragment nonRecursiveMLFeature on MLFeature {
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment nonRecursiveMLPrimaryKey on MLPrimaryKey {
@@ -659,6 +664,11 @@ fragment nonRecursiveMLPrimaryKey on MLPrimaryKey {
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment nonRecursiveMLFeatureTable on MLFeatureTable {
@@ -715,6 +725,11 @@ fragment nonRecursiveMLFeatureTable on MLFeatureTable {
browsePathV2 {
...browsePathV2Fields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
fragment schemaFieldFields on SchemaField {
@@ -1225,6 +1240,12 @@ fragment entityDisplayNameFields on Entity {
... on DataPlatformInstance {
instanceId
}
+ ... on StructuredPropertyEntity {
+ definition {
+ displayName
+ qualifiedName
+ }
+ }
}
fragment ermodelrelationPropertiesFields on ERModelRelationshipProperties {
@@ -1278,12 +1299,16 @@ fragment structuredPropertyFields on StructuredPropertyEntity {
cardinality
immutable
valueType {
+ urn
+ type
info {
type
displayName
}
}
entityTypes {
+ urn
+ type
info {
type
}
@@ -1310,6 +1335,35 @@ fragment structuredPropertyFields on StructuredPropertyEntity {
}
description
}
+ created {
+ time
+ actor {
+ urn
+ editableProperties {
+ displayName
+ pictureLink
+ }
+ ...entityDisplayNameFields
+ }
+ }
+ lastModified {
+ time
+ actor {
+ urn
+ editableProperties {
+ displayName
+ pictureLink
+ }
+ ...entityDisplayNameFields
+ }
+ }
+ }
+ settings {
+ isHidden
+ showInSearchFilters
+ showAsAssetBadge
+ showInAssetSummary
+ showInColumnsTable
}
}
@@ -1331,6 +1385,7 @@ fragment structuredPropertiesFields on StructuredPropertiesEntry {
type
...entityDisplayNameFields
}
+ associatedUrn
}
fragment autoRenderAspectFields on RawAspect {
diff --git a/datahub-web-react/src/graphql/lineage.graphql b/datahub-web-react/src/graphql/lineage.graphql
index c4936fc3c8abec..ee05811cbb72de 100644
--- a/datahub-web-react/src/graphql/lineage.graphql
+++ b/datahub-web-react/src/graphql/lineage.graphql
@@ -62,6 +62,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataFlow {
orchestrator
@@ -97,6 +102,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Dashboard {
urn
@@ -155,6 +165,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Chart {
tool
@@ -188,6 +203,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Dataset {
name
@@ -227,6 +247,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModelGroup {
urn
@@ -243,6 +268,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
status {
removed
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModel {
urn
@@ -259,6 +289,11 @@ fragment lineageNodeProperties on EntityWithRelationships {
status {
removed
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeatureTable {
...nonRecursiveMLFeatureTable
diff --git a/datahub-web-react/src/graphql/me.graphql b/datahub-web-react/src/graphql/me.graphql
index 549aae1ad17de5..e02a480fffe765 100644
--- a/datahub-web-react/src/graphql/me.graphql
+++ b/datahub-web-react/src/graphql/me.graphql
@@ -51,6 +51,8 @@ query getMe {
manageGlobalAnnouncements
createBusinessAttributes
manageBusinessAttributes
+ manageStructuredProperties
+ viewStructuredPropertiesPage
}
}
}
diff --git a/datahub-web-react/src/graphql/search.graphql b/datahub-web-react/src/graphql/search.graphql
index 3e26dd7121b72c..ce0fde27f4c425 100644
--- a/datahub-web-react/src/graphql/search.graphql
+++ b/datahub-web-react/src/graphql/search.graphql
@@ -396,9 +396,19 @@ fragment searchResultsWithoutSchemaField on Entity {
type
... on Dataset {
...nonSiblingsDatasetSearchFields
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
}
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Role {
id
@@ -509,6 +519,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Chart {
chartId
@@ -578,6 +593,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataFlow {
flowId
@@ -619,6 +639,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on DataJob {
dataFlow {
@@ -671,6 +696,11 @@ fragment searchResultsWithoutSchemaField on Entity {
health {
...entityHealth
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on GlossaryTerm {
name
@@ -696,12 +726,22 @@ fragment searchResultsWithoutSchemaField on Entity {
domain {
...entityDomain
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on GlossaryNode {
...glossaryNode
parentNodes {
...parentNodesFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Domain {
properties {
@@ -715,6 +755,11 @@ fragment searchResultsWithoutSchemaField on Entity {
...parentDomainsFields
}
...domainEntitiesFields
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Container {
properties {
@@ -752,6 +797,11 @@ fragment searchResultsWithoutSchemaField on Entity {
parentContainers {
...parentContainersFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeatureTable {
name
@@ -777,12 +827,27 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLFeature {
...nonRecursiveMLFeature
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLPrimaryKey {
...nonRecursiveMLPrimaryKey
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModel {
name
@@ -800,6 +865,11 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on MLModelGroup {
name
@@ -817,6 +887,11 @@ fragment searchResultsWithoutSchemaField on Entity {
dataPlatformInstance {
...dataPlatformInstanceFields
}
+ structuredProperties {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
}
... on Tag {
name
@@ -855,6 +930,9 @@ fragment searchResultsWithoutSchemaField on Entity {
... on BusinessAttribute {
...businessAttributeFields
}
+ ... on StructuredPropertyEntity {
+ ...structuredPropertyFields
+ }
}
fragment searchResultFields on Entity {
@@ -867,6 +945,18 @@ fragment searchResultFields on Entity {
fragment facetFields on FacetMetadata {
field
displayName
+ entity {
+ urn
+ type
+ ...entityDisplayNameFields
+ ... on StructuredPropertyEntity {
+ definition {
+ valueType {
+ urn
+ }
+ }
+ }
+ }
aggregations {
value
count
diff --git a/datahub-web-react/src/graphql/structuredProperties.graphql b/datahub-web-react/src/graphql/structuredProperties.graphql
index 60079db3b1cd4b..8a59b6e5fe0a0f 100644
--- a/datahub-web-react/src/graphql/structuredProperties.graphql
+++ b/datahub-web-react/src/graphql/structuredProperties.graphql
@@ -1,3 +1,116 @@
+query getStructuredProperty($urn: String!) {
+ entity(urn: $urn) {
+ urn
+ type
+ ... on StructuredPropertyEntity {
+ urn
+ type
+ definition {
+ qualifiedName
+ displayName
+ description
+ cardinality
+ entityTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ valueType {
+ urn
+ type
+ info {
+ type
+ qualifiedName
+ displayName
+ description
+ }
+ }
+ allowedValues {
+ value {
+ ... on StringValue {
+ stringValue
+ }
+ ... on NumberValue {
+ numberValue
+ }
+ }
+ description
+ }
+ typeQualifier {
+ allowedTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+query searchStructuredProperties($query: String!, $start: Int!, $count: Int!) {
+ searchAcrossEntities(input: { query: $query, types: [STRUCTURED_PROPERTY], start: $start, count: $count }) {
+ start
+ count
+ total
+ searchResults {
+ entity {
+ ... on StructuredPropertyEntity {
+ urn
+ type
+ definition {
+ qualifiedName
+ displayName
+ description
+ cardinality
+ entityTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ valueType {
+ urn
+ type
+ info {
+ type
+ qualifiedName
+ displayName
+ description
+ }
+ }
+ allowedValues {
+ value {
+ ... on StringValue {
+ stringValue
+ }
+ ... on NumberValue {
+ numberValue
+ }
+ }
+ description
+ }
+ typeQualifier {
+ allowedTypes {
+ urn
+ type
+ info {
+ type
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
mutation upsertStructuredProperties($input: UpsertStructuredPropertiesInput!) {
upsertStructuredProperties(input: $input) {
properties {
@@ -5,3 +118,27 @@ mutation upsertStructuredProperties($input: UpsertStructuredPropertiesInput!) {
}
}
}
+
+mutation removeStructuredProperties($input: RemoveStructuredPropertiesInput!) {
+ removeStructuredProperties(input: $input) {
+ properties {
+ ...structuredPropertiesFields
+ }
+ }
+}
+
+mutation createStructuredProperty($input: CreateStructuredPropertyInput!) {
+ createStructuredProperty(input: $input) {
+ ...structuredPropertyFields
+ }
+}
+
+mutation updateStructuredProperty($input: UpdateStructuredPropertyInput!) {
+ updateStructuredProperty(input: $input) {
+ ...structuredPropertyFields
+ }
+}
+
+mutation deleteStructuredProperty($input: DeleteStructuredPropertyInput!) {
+ deleteStructuredProperty(input: $input)
+}
diff --git a/datahub-web-react/src/images/empty-forms.svg b/datahub-web-react/src/images/empty-forms.svg
new file mode 100644
index 00000000000000..e803bd4ccee2be
--- /dev/null
+++ b/datahub-web-react/src/images/empty-forms.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/datahub-web-react/src/images/table-icon.svg b/datahub-web-react/src/images/table-icon.svg
new file mode 100644
index 00000000000000..e9535061ab87ef
--- /dev/null
+++ b/datahub-web-react/src/images/table-icon.svg
@@ -0,0 +1,3 @@
+
+
+
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 60ca7649331a00..8b83439a3008c1 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
@@ -5,6 +5,7 @@
import static com.linkedin.metadata.search.utils.ESUtils.toParentField;
import static com.linkedin.metadata.utils.SearchUtil.*;
+import com.linkedin.common.urn.UrnUtils;
import com.linkedin.data.template.LongMap;
import com.linkedin.metadata.aspect.AspectRetriever;
import com.linkedin.metadata.config.search.SearchConfiguration;
@@ -22,10 +23,13 @@
import com.linkedin.util.Pair;
import io.datahubproject.metadata.context.OperationContext;
import io.opentelemetry.extension.annotations.WithSpan;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -48,6 +52,7 @@
@Slf4j
public class AggregationQueryBuilder {
private static final String URN_FILTER = "urn";
+ private static final String STRUCTURED_PROPERTIES_PREFIX = "structuredProperties.";
private final SearchConfiguration configs;
private final Set defaultFacetFields;
private final Set allFacetFields;
@@ -80,12 +85,13 @@ public List getAggregations(@Nonnull OperationContext opCont
*/
public List getAggregations(
@Nonnull OperationContext opContext, @Nullable List facets) {
- final Set facetsToAggregate;
+ final Set facetsToAggregate = new HashSet<>();
+ if (Boolean.TRUE.equals(
+ opContext.getSearchContext().getSearchFlags().isIncludeDefaultFacets())) {
+ facetsToAggregate.addAll(defaultFacetFields);
+ }
if (facets != null) {
- facetsToAggregate =
- facets.stream().filter(this::isValidAggregate).collect(Collectors.toSet());
- } else {
- facetsToAggregate = defaultFacetFields;
+ facets.stream().filter(this::isValidAggregate).forEach(facetsToAggregate::add);
}
return facetsToAggregate.stream()
.map(f -> facetToAggregationBuilder(opContext, f))
@@ -247,7 +253,7 @@ List extractAggregationMetadata(
return addFiltersToAggregationMetadata(aggregationMetadataList, filter, aspectRetriever);
}
- private void processTermAggregations(
+ public void processTermAggregations(
final Map.Entry entry,
final List aggregationMetadataList) {
final Map oneTermAggResult =
@@ -264,6 +270,7 @@ private void processTermAggregations(
.setFilterValues(
new FilterValueArray(
SearchUtil.convertToFilters(oneTermAggResult, Collections.emptySet())));
+ updateAggregationEntity(aggregationMetadata);
aggregationMetadataList.add(aggregationMetadata);
}
@@ -300,7 +307,15 @@ private static void recurseTermsAgg(
private static void processTermBucket(
Terms.Bucket bucket, Map aggResult, boolean includeZeroes) {
- String key = bucket.getKeyAsString();
+ final String key = bucket.getKeyAsString();
+ String finalKey = key;
+ try {
+ // if the value is a date string, convert to milliseconds since epoch
+ OffsetDateTime time = OffsetDateTime.parse(key);
+ finalKey = String.valueOf(time.toEpochSecond() * 1000);
+ } catch (DateTimeParseException e) {
+ // do nothing, this is expected if the value is not a date
+ }
// Gets filtered sub aggregation doc count if exist
Map subAggs = recursivelyAddNestedSubAggs(bucket.getAggregations());
subAggs.forEach(
@@ -309,7 +324,7 @@ private static void processTermBucket(
String.format("%s%s%s", key, AGGREGATION_SEPARATOR_CHAR, entryKey), entryValue));
long docCount = bucket.getDocCount();
if (includeZeroes || docCount > 0) {
- aggResult.put(key, docCount);
+ aggResult.put(finalKey, docCount);
}
}
@@ -474,11 +489,24 @@ private AggregationMetadata buildAggregationMetadata(
@Nonnull final String displayName,
@Nonnull final LongMap aggValues,
@Nonnull final FilterValueArray filterValues) {
- return new AggregationMetadata()
- .setName(facetField)
- .setDisplayName(displayName)
- .setAggregations(aggValues)
- .setFilterValues(filterValues);
+ AggregationMetadata aggregationMetadata =
+ new AggregationMetadata()
+ .setName(facetField)
+ .setDisplayName(displayName)
+ .setAggregations(aggValues)
+ .setFilterValues(filterValues);
+ updateAggregationEntity(aggregationMetadata);
+ return aggregationMetadata;
+ }
+
+ public void updateAggregationEntity(@Nonnull final AggregationMetadata aggregationMetadata) {
+ if (aggregationMetadata.getName().startsWith(STRUCTURED_PROPERTIES_PREFIX)) {
+ aggregationMetadata.setEntity(
+ UrnUtils.getUrn(
+ String.format(
+ "urn:li:structuredProperty:%s",
+ aggregationMetadata.getName().replaceFirst(STRUCTURED_PROPERTIES_PREFIX, ""))));
+ }
}
private List>> getFacetFieldDisplayNameFromAnnotation(
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 fd663de40e0050..476f0114817be1 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
@@ -1124,9 +1124,10 @@ public void testFacets() {
@Test
public void testNestedAggregation() {
Set expectedFacets = Set.of("platform");
+ OperationContext context =
+ getOperationContext().withSearchFlags(flags -> flags.setIncludeDefaultFacets(false));
SearchResult testResult =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult.getMetadata().getAggregations().size(), 1);
expectedFacets.forEach(
facet -> {
@@ -1143,8 +1144,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", "typeNames", "_entityType", "entity");
SearchResult testResult2 =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult2.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
@@ -1191,8 +1191,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", "typeNames", "entity");
SearchResult testResult3 =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResult3.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
@@ -1222,8 +1221,7 @@ public void testNestedAggregation() {
String singleNestedFacet = String.format("_entityType%sowners", AGGREGATION_SEPARATOR_CHAR);
expectedFacets = Set.of(singleNestedFacet);
SearchResult testResultSingleNested =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResultSingleNested.getMetadata().getAggregations().size(), 1);
Map expectedNestedFacetCounts = new HashMap<>();
expectedNestedFacetCounts.put("datajob␞urn:li:corpuser:datahub", 2L);
@@ -1245,8 +1243,7 @@ public void testNestedAggregation() {
expectedFacets = Set.of("platform", singleNestedFacet, "typeNames", "origin");
SearchResult testResultNested =
- facetAcrossEntities(
- getOperationContext(), getSearchService(), "cypress", List.copyOf(expectedFacets));
+ facetAcrossEntities(context, getSearchService(), "cypress", List.copyOf(expectedFacets));
assertEquals(testResultNested.getMetadata().getAggregations().size(), 4);
expectedFacets.forEach(
facet -> {
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 3969223981ec3f..c68997e25bcff7 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
@@ -3,9 +3,7 @@
import static com.linkedin.metadata.Constants.DATA_TYPE_URN_PREFIX;
import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME;
import static com.linkedin.metadata.utils.SearchUtil.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anySet;
-import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -53,6 +51,7 @@ public class AggregationQueryBuilderTest {
private static AspectRetriever aspectRetriever;
private static AspectRetriever aspectRetrieverV1;
+ private static String DEFAULT_FILTER = "_index";
@BeforeClass
public void setup() throws RemoteInvocationException, URISyntaxException {
@@ -267,16 +266,17 @@ public void testGetSpecificAggregationsHasFields() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(),
ImmutableList.of("test1", "test2", "hasTest1"));
- Assert.assertEquals(aggs.size(), 3);
+ Assert.assertEquals(aggs.size(), 4);
Set facets = aggs.stream().map(AggregationBuilder::getName).collect(Collectors.toSet());
- Assert.assertEquals(ImmutableSet.of("test1", "test2", "hasTest1"), facets);
+ Assert.assertEquals(ImmutableSet.of("test1", "test2", "hasTest1", "_entityType"), facets);
// Case 2: Ask for fields that should NOT exist.
aggs =
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(),
ImmutableList.of("hasTest2"));
- Assert.assertEquals(aggs.size(), 0);
+ Assert.assertEquals(
+ aggs.size(), 1); // default has one field already, hasTest2 will not be in there
}
@Test
@@ -292,7 +292,7 @@ public void testAggregateOverStructuredProperty() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.ab.fgh.ten"));
- Assert.assertEquals(aggs.size(), 1);
+ Assert.assertEquals(aggs.size(), 2);
AggregationBuilder aggBuilder = aggs.get(0);
Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
@@ -307,12 +307,16 @@ public void testAggregateOverStructuredProperty() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.ab.fgh.ten", "structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(
+ aggs.size(), 3); // has one default filter (_entityType) both get mapped to _index
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
- Set.of("structuredProperties.ab_fgh_ten.keyword", "structuredProperties.hello.keyword"));
+ Set.of(
+ "structuredProperties.ab_fgh_ten.keyword",
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -328,16 +332,12 @@ public void testAggregateOverStructuredPropertyNamespaced() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetriever),
List.of("structuredProperties.under.scores.and.dots_make_a_mess"));
- 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(aggs.size(), 2);
Assert.assertEquals(
- agg.field(),
- "structuredProperties.under_scores_and_dots_make_a_mess.keyword",
- "Terms aggregate must be on a keyword or subfield keyword");
-
+ aggs.stream()
+ .map(aggr -> ((TermsAggregationBuilder) aggr).field())
+ .collect(Collectors.toSet()),
+ Set.of("structuredProperties.under_scores_and_dots_make_a_mess.keyword", DEFAULT_FILTER));
// Two structured properties
aggs =
builder.getAggregations(
@@ -345,14 +345,15 @@ public void testAggregateOverStructuredPropertyNamespaced() {
List.of(
"structuredProperties.under.scores.and.dots_make_a_mess",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(aggs.size(), 3);
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties.under_scores_and_dots_make_a_mess.keyword",
- "structuredProperties.hello.keyword"));
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -368,7 +369,7 @@ public void testAggregateOverStructuredPropertyV1() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetrieverV1),
List.of("structuredProperties.ab.fgh.ten"));
- Assert.assertEquals(aggs.size(), 1);
+ Assert.assertEquals(aggs.size(), 2);
AggregationBuilder aggBuilder = aggs.get(0);
Assert.assertTrue(aggBuilder instanceof TermsAggregationBuilder);
TermsAggregationBuilder agg = (TermsAggregationBuilder) aggBuilder;
@@ -385,14 +386,16 @@ public void testAggregateOverStructuredPropertyV1() {
List.of(
"structuredProperties.ab.fgh.ten",
"structuredProperties._versioned.hello.00000000000001.string"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(
+ aggs.size(), 3); // has two one filter (_entityType) both get mapped to _index
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties._versioned.ab_fgh_ten.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -408,15 +411,14 @@ public void testAggregateOverStructuredPropertyNamespacedV1() {
builder.getAggregations(
TestOperationContexts.systemContextNoSearchAuthorization(aspectRetrieverV1),
List.of("structuredProperties.under.scores.and.dots_make_a_mess"));
- 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(aggs.size(), 2);
Assert.assertEquals(
- agg.field(),
- "structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
- "Terms aggregation must be on a keyword field or subfield.");
+ aggs.stream()
+ .map(aggr -> ((TermsAggregationBuilder) aggr).field())
+ .collect(Collectors.toSet()),
+ Set.of(
+ "structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
+ DEFAULT_FILTER));
// Two structured properties
aggs =
@@ -425,14 +427,15 @@ public void testAggregateOverStructuredPropertyNamespacedV1() {
List.of(
"structuredProperties.under.scores.and.dots_make_a_mess",
"structuredProperties._versioned.hello.00000000000001.string"));
- Assert.assertEquals(aggs.size(), 2);
+ Assert.assertEquals(aggs.size(), 3);
Assert.assertEquals(
aggs.stream()
.map(aggr -> ((TermsAggregationBuilder) aggr).field())
.collect(Collectors.toSet()),
Set.of(
"structuredProperties._versioned.under_scores_and_dots_make_a_mess.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -489,7 +492,7 @@ public void testAggregateOverFieldsAndStructProp() {
"hasTest1",
"structuredProperties.ab.fgh.ten",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 5);
+ Assert.assertEquals(aggs.size(), 6);
Set facets =
aggs.stream()
.map(aggB -> ((TermsAggregationBuilder) aggB).field())
@@ -501,7 +504,8 @@ public void testAggregateOverFieldsAndStructProp() {
"test2.keyword",
"hasTest1",
"structuredProperties.ab_fgh_ten.keyword",
- "structuredProperties.hello.keyword"));
+ "structuredProperties.hello.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -558,7 +562,8 @@ public void testAggregateOverFieldsAndStructPropV1() {
"hasTest1",
"structuredProperties.ab.fgh.ten",
"structuredProperties.hello"));
- Assert.assertEquals(aggs.size(), 5);
+ Assert.assertEquals(
+ aggs.size(), 6); // has one default filter (_entityType) both get mapped to _index
Set facets =
aggs.stream()
.map(aggB -> ((TermsAggregationBuilder) aggB).field())
@@ -570,7 +575,8 @@ public void testAggregateOverFieldsAndStructPropV1() {
"test2.keyword",
"hasTest1",
"structuredProperties._versioned.ab_fgh_ten.00000000000001.string.keyword",
- "structuredProperties._versioned.hello.00000000000001.string.keyword"));
+ "structuredProperties._versioned.hello.00000000000001.string.keyword",
+ DEFAULT_FILTER));
}
@Test
@@ -613,6 +619,39 @@ public void testMissingAggregation() {
MISSING_SPECIAL_TYPE + AGGREGATION_SPECIAL_TYPE_DELIMITER + "test")));
}
+ @Test
+ public void testUpdateAggregationEntityWithStructuredProp() {
+ final AggregationMetadata aggregationMetadata = new AggregationMetadata();
+ aggregationMetadata.setName("structuredProperties.test_me.one");
+
+ SearchConfiguration config = new SearchConfiguration();
+ config.setMaxTermBucketSize(25);
+
+ AggregationQueryBuilder builder =
+ new AggregationQueryBuilder(
+ config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of()));
+
+ builder.updateAggregationEntity(aggregationMetadata);
+ Assert.assertEquals(
+ aggregationMetadata.getEntity(), UrnUtils.getUrn("urn:li:structuredProperty:test_me.one"));
+ }
+
+ @Test
+ public void testUpdateAggregationEntityWithRegularFilter() {
+ final AggregationMetadata aggregationMetadata = new AggregationMetadata();
+ aggregationMetadata.setName("domains");
+
+ SearchConfiguration config = new SearchConfiguration();
+ config.setMaxTermBucketSize(25);
+
+ AggregationQueryBuilder builder =
+ new AggregationQueryBuilder(
+ config, ImmutableMap.of(mock(EntitySpec.class), ImmutableList.of()));
+
+ builder.updateAggregationEntity(aggregationMetadata);
+ Assert.assertNull(aggregationMetadata.getEntity());
+ }
+
@Test
public void testAddFiltersToMetadataWithStructuredPropsNoResults() {
final Urn propertyUrn = UrnUtils.getUrn("urn:li:structuredProperty:test_me.one");
@@ -638,7 +677,7 @@ public void testAddFiltersToMetadataWithStructuredPropsNoResults() {
// ensure we add the correct structured prop aggregation here
Assert.assertEquals(aggregationMetadataList.size(), 1);
- // Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
+ Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
Assert.assertEquals(
aggregationMetadataList.get(0).getName(), "structuredProperties.test_me.one");
Assert.assertEquals(aggregationMetadataList.get(0).getAggregations().size(), 1);
@@ -651,6 +690,7 @@ public void testAddFiltersToMetadataWithStructuredPropsWithAggregations() {
final AggregationMetadata aggregationMetadata = new AggregationMetadata();
aggregationMetadata.setName("structuredProperties.test_me.one");
+ aggregationMetadata.setEntity(propertyUrn);
FilterValue filterValue =
new FilterValue().setValue("test123").setFiltered(false).setFacetCount(1);
aggregationMetadata.setFilterValues(new FilterValueArray(filterValue));
@@ -679,6 +719,7 @@ public void testAddFiltersToMetadataWithStructuredPropsWithAggregations() {
criterion, aggregationMetadataList, mockAspectRetriever);
Assert.assertEquals(aggregationMetadataList.size(), 1);
+ Assert.assertEquals(aggregationMetadataList.get(0).getEntity(), propertyUrn);
Assert.assertEquals(
aggregationMetadataList.get(0).getName(), "structuredProperties.test_me.one");
Assert.assertEquals(aggregationMetadataList.get(0).getAggregations().size(), 1);
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 718a00d067ce5a..393ca3ca5d4a64 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
@@ -298,7 +298,8 @@ public void testAggregationsInSearch() {
String.format("_entityType%stextFieldOverride", AGGREGATION_SEPARATOR_CHAR);
SearchRequest searchRequest =
requestHandler.getSearchRequest(
- operationContext.withSearchFlags(flags -> flags.setFulltext(true)),
+ operationContext.withSearchFlags(
+ flags -> flags.setFulltext(true).setIncludeDefaultFacets(false)),
"*",
null,
null,
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 a3d2067ae5db25..a3a7a8cda58a8d 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
@@ -58,4 +58,10 @@ record SearchFlags {
* invoke query rewrite chain for filters based on configured rewriters
*/
rewriteQuery: optional boolean = true
+
+ /**
+ * Include default facets when getting facets to aggregate on in search requests.
+ * By default we include these, but custom aggregation requests don't need them.
+ */
+ includeDefaultFacets: optional boolean = true
}
diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
index 0d3fa1e2a4ecf0..d3d25083ff4677 100644
--- a/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
+++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/search/AggregationMetadata.pdl
@@ -13,6 +13,11 @@ record AggregationMetadata {
*/
displayName: optional string
+ /**
+ * Entity associated with this facet
+ */
+ entity: optional Urn
+
/**
* List of aggregations showing the number of documents falling into each bucket. e.g, for platform aggregation, the bucket can be hive, kafka, etc
*/
diff --git a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
index 0c62bdc1963261..28abb26be1f524 100644
--- a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
+++ b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java
@@ -24,4 +24,5 @@ public class FeatureFlags {
private boolean editableDatasetNameEnabled = false;
private boolean showSeparateSiblings = false;
private boolean alternateMCPValidation = false;
+ private boolean showManageStructuredProperties = false;
}
diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml
index 15cd126408a7cc..e97120ec751185 100644
--- a/metadata-service/configuration/src/main/resources/application.yaml
+++ b/metadata-service/configuration/src/main/resources/application.yaml
@@ -455,6 +455,7 @@ featureFlags:
alternateMCPValidation: ${ALTERNATE_MCP_VALIDATION:false} # Enables alternate MCP validation flow
showSeparateSiblings: ${SHOW_SEPARATE_SIBLINGS:false} # If turned on, all siblings will be separated with no way to get to a "combined" sibling view
editableDatasetNameEnabled: ${EDITABLE_DATASET_NAME_ENABLED:false} # Enables the ability to edit the dataset name in the UI
+ showManageStructuredProperties: ${SHOW_MANAGE_STRUCTURED_PROPERTIES:true} # If turned on, show the manage structured properties button on the govern dropdown
entityChangeEvents:
enabled: ${ENABLE_ENTITY_CHANGE_EVENTS_HOOK:true}
diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
index 061feafac1b9b9..92abc50abbc4df 100644
--- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
+++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.analytics.analytics.snapshot.json
@@ -63,10 +63,10 @@
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
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 e9e2778a479d32..827789130d8bbb 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
@@ -169,10 +169,10 @@
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
@@ -941,15 +941,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1068,15 +1071,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2570,7 +2576,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2977,7 +2983,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3151,15 +3157,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3181,15 +3190,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3362,11 +3374,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3392,11 +3406,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
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 959cb5381fd9b8..b549cef0af84b2 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
@@ -932,15 +932,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1059,15 +1062,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2783,7 +2789,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -3365,7 +3371,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3539,15 +3545,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3569,15 +3578,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3750,11 +3762,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3780,11 +3794,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
@@ -5204,9 +5220,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "isRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -5225,9 +5241,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "hasRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -6068,23 +6084,29 @@
"doc" : "invoke query rewrite chain for filters based on configured rewriters",
"default" : true,
"optional" : true
+ }, {
+ "name" : "includeDefaultFacets",
+ "type" : "boolean",
+ "doc" : "Include default facets when getting facets to aggregate on in search requests.\nBy default we include these, but custom aggregation requests don't need them.",
+ "default" : true,
+ "optional" : true
} ]
}, {
"type" : "enum",
"name" : "Condition",
"namespace" : "com.linkedin.metadata.query.filter",
"doc" : "The matching condition in a filter criterion",
- "symbols" : [ "CONTAIN", "END_WITH", "EQUAL","IEQUAL", "IS_NULL", "EXISTS", "GREATER_THAN", "GREATER_THAN_OR_EQUAL_TO", "IN", "LESS_THAN", "LESS_THAN_OR_EQUAL_TO", "START_WITH", "DESCENDANTS_INCL", "ANCESTORS_INCL", "RELATED_INCL" ],
+ "symbols" : [ "CONTAIN", "END_WITH", "EQUAL", "IEQUAL", "IS_NULL", "EXISTS", "GREATER_THAN", "GREATER_THAN_OR_EQUAL_TO", "IN", "LESS_THAN", "LESS_THAN_OR_EQUAL_TO", "START_WITH", "DESCENDANTS_INCL", "ANCESTORS_INCL", "RELATED_INCL" ],
"symbolDocs" : {
"ANCESTORS_INCL" : "Represent the relation: URN field matches any nested parent in addition to the given URN",
"CONTAIN" : "Represent the relation: String field contains value, e.g. name contains Profile",
"DESCENDANTS_INCL" : "Represent the relation: URN field any nested children in addition to the given URN",
"END_WITH" : "Represent the relation: String field ends with value, e.g. name ends with Event",
"EQUAL" : "Represent the relation: field = value, e.g. platform = hdfs",
- "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"EXISTS" : "Represents the relation: field exists and is non-empty, e.g. owners is not null and != [] (empty)",
"GREATER_THAN" : "Represent the relation greater than, e.g. ownerCount > 5",
"GREATER_THAN_OR_EQUAL_TO" : "Represent the relation greater than or equal to, e.g. ownerCount >= 5",
+ "IEQUAL" : "Represent the relation: field = value and support case insensitive values, e.g. platform = hdfs",
"IN" : "Represent the relation: String field is one of the array values to, e.g. name in [\"Profile\", \"Event\"]",
"IS_NULL" : "Represent the relation: field is null, e.g. platform is null",
"LESS_THAN" : "Represent the relation less than, e.g. ownerCount < 3",
@@ -6314,6 +6336,11 @@
"type" : "string",
"doc" : "Name of the filter to be displayed in the UI",
"optional" : true
+ }, {
+ "name" : "entity",
+ "type" : "com.linkedin.common.Urn",
+ "doc" : "Entity associated with this facet",
+ "optional" : true
}, {
"name" : "aggregations",
"type" : {
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 3e0cd46aba0c05..c8be9d063eaea9 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
@@ -674,15 +674,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -801,15 +804,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2294,7 +2300,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2701,7 +2707,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -2875,15 +2881,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -2905,15 +2914,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3086,11 +3098,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3116,11 +3130,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
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 7f651a10139e2e..8c7595c5e505d8 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
@@ -674,15 +674,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -801,15 +804,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2288,7 +2294,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -2695,7 +2701,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -2869,15 +2875,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -2899,15 +2908,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3080,11 +3092,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3110,11 +3124,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
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 c3e04add825c94..75e5c9a559076b 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
@@ -932,15 +932,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "tagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "tagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "tagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -1059,15 +1062,18 @@
"Searchable" : {
"/actor" : {
"fieldName" : "termAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/source" : {
"fieldName" : "termAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/time" : {
"fieldName" : "termAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
}
}
} ]
@@ -2777,7 +2783,7 @@
"Searchable" : {
"fieldName" : "managerLdap",
"fieldType" : "URN",
- "queryByDefault" : true
+ "queryByDefault" : false
}
}, {
"name" : "departmentId",
@@ -3359,7 +3365,7 @@
"type" : "com.linkedin.dataset.SchemaFieldPath",
"doc" : "Flattened name of the field. Field is computed from jsonPath field.",
"Searchable" : {
- "boostScore" : 5.0,
+ "boostScore" : 1.0,
"fieldName" : "fieldPaths",
"fieldType" : "TEXT",
"queryByDefault" : "true"
@@ -3533,15 +3539,18 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "fieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "fieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "fieldTagAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/tags/*/tag" : {
"boostScore" : 0.5,
@@ -3563,15 +3572,18 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "fieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "fieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "fieldTermAttributionDates",
- "fieldType" : "DATETIME"
+ "fieldType" : "DATETIME",
+ "queryByDefault" : false
},
"/terms/*/urn" : {
"boostScore" : 0.5,
@@ -3744,11 +3756,13 @@
"Searchable" : {
"/tags/*/attribution/actor" : {
"fieldName" : "editedFieldTagAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/source" : {
"fieldName" : "editedFieldTagAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/tags/*/attribution/time" : {
"fieldName" : "editedFieldTagAttributionDates",
@@ -3774,11 +3788,13 @@
"Searchable" : {
"/terms/*/attribution/actor" : {
"fieldName" : "editedFieldTermAttributionActors",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/source" : {
"fieldName" : "editedFieldTermAttributionSources",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
},
"/terms/*/attribution/time" : {
"fieldName" : "editedFieldTermAttributionDates",
@@ -5198,9 +5214,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "isRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
@@ -5219,9 +5235,9 @@
},
"Searchable" : {
"/*" : {
- "boostScore" : 2.0,
"fieldName" : "hasRelatedTerms",
- "fieldType" : "URN"
+ "fieldType" : "URN",
+ "queryByDefault" : false
}
}
}, {
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 dbe1f45fae29e5..43d5e61c47bacb 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
@@ -78,7 +78,15 @@ public enum DataHubUsageEventType {
EMBED_PROFILE_VIEW_EVENT("EmbedProfileViewEvent"),
EMBED_PROFILE_VIEW_IN_DATAHUB_EVENT("EmbedProfileViewInDataHubEvent"),
EMBED_LOOKUP_NOT_FOUND_EVENT("EmbedLookupNotFoundEvent"),
- CREATE_BUSINESS_ATTRIBUTE("CreateBusinessAttributeEvent");
+ CREATE_BUSINESS_ATTRIBUTE("CreateBusinessAttributeEvent"),
+ CREATE_STRUCTURED_PROPERTY_CLICK_EVENT("CreateStructuredPropertyClickEvent"),
+ CREATE_STRUCTURED_PROPERTY_EVENT("CreateStructuredPropertyEvent"),
+ EDIT_STRUCTURED_PROPERTY_EVENT("EditStructuredPropertyEvent"),
+ DELETE_STRUCTURED_PROPERTY_EVENT("DeleteStructuredPropertyEvent"),
+ VIEW_STRUCTURED_PROPERTY_EVENT("ViewStructuredPropertyEvent"),
+ APPLY_STRUCTURED_PROPERTY_EVENT("ApplyStructuredPropertyEvent"),
+ UPDATE_STRUCTURED_PROPERTY_ON_ASSET_EVENT("UpdateStructuredPropertyOnAssetEvent"),
+ REMOVE_STRUCTURED_PROPERTY_EVENT("RemoveStructuredPropertyEvent");
private final String type;
diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json
index e0f26b908c4991..e01b4f6f47eaaf 100644
--- a/metadata-service/war/src/main/resources/boot/policies.json
+++ b/metadata-service/war/src/main/resources/boot/policies.json
@@ -36,6 +36,7 @@
"CREATE_BUSINESS_ATTRIBUTE",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES",
"MANAGE_SYSTEM_OPERATIONS"
@@ -185,6 +186,7 @@
"CREATE_BUSINESS_ATTRIBUTE",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES"
],
@@ -274,6 +276,7 @@
"MANAGE_TAGS",
"MANAGE_BUSINESS_ATTRIBUTE",
"MANAGE_STRUCTURED_PROPERTIES",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
"MANAGE_DOCUMENTATION_FORMS",
"MANAGE_FEATURES"
],
@@ -427,7 +430,8 @@
"GET_TIMESERIES_ASPECT_PRIVILEGE",
"GET_ENTITY_PRIVILEGE",
"GET_TIMELINE_PRIVILEGE",
- "ES_EXPLAIN_QUERY_PRIVILEGE"
+ "ES_EXPLAIN_QUERY_PRIVILEGE",
+ "VIEW_STRUCTURED_PROPERTIES_PAGE"
],
"displayName": "Readers - Metadata Policy",
"description": "Readers can view all assets.",
diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
index bc676e94ecd4fa..d701c8fc8be035 100644
--- a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
+++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java
@@ -169,6 +169,12 @@ public class PoliciesConfig {
"Manage Structured Properties",
"Manage structured properties in your instance.");
+ public static final Privilege VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE =
+ Privilege.of(
+ "VIEW_STRUCTURED_PROPERTIES_PAGE",
+ "View Structured Properties",
+ "View structured properties in your instance.");
+
public static final Privilege MANAGE_DOCUMENTATION_FORMS_PRIVILEGE =
Privilege.of(
"MANAGE_DOCUMENTATION_FORMS",
From dcc8ad9d617760d9d72207dd1a12642769dd5a95 Mon Sep 17 00:00:00 2001
From: Chris Collins
Date: Wed, 11 Dec 2024 20:12:24 -0500
Subject: [PATCH 06/16] feat(ui) Add full support for structured properties on
assets (#12100)
---
.../src/app/entity/EntityRegistry.tsx | 19 +-
.../src/app/entity/chart/ChartEntity.tsx | 4 +
.../app/entity/container/ContainerEntity.tsx | 4 +
.../app/entity/dashboard/DashboardEntity.tsx | 4 +
.../app/entity/dataFlow/DataFlowEntity.tsx | 4 +
.../src/app/entity/dataJob/DataJobEntity.tsx | 4 +
.../entity/dataProduct/DataProductEntity.tsx | 4 +
.../src/app/entity/dataset/DatasetEntity.tsx | 7 +-
.../components/StructuredPropValues.tsx | 69 ++++++
.../src/app/entity/domain/DomainEntity.tsx | 4 +
.../glossaryNode/GlossaryNodeEntity.tsx | 4 +
.../glossaryTerm/GlossaryTermEntity.tsx | 4 +
.../app/entity/mlFeature/MLFeatureEntity.tsx | 4 +
.../mlFeatureTable/MLFeatureTableEntity.tsx | 4 +
.../src/app/entity/mlModel/MLModelEntity.tsx | 4 +
.../mlModelGroup/MLModelGroupEntity.tsx | 4 +
.../mlPrimaryKey/MLPrimaryKeyEntity.tsx | 4 +
.../src/app/entity/shared/PreviewContext.tsx | 9 +
.../profile/header/EntityHeader.tsx | 2 +
.../header/StructuredPropertyBadge.tsx | 92 +++++++
.../shared/containers/profile/header/utils.ts | 29 +++
.../DataProduct/DataProductSection.tsx | 4 +-
.../profile/sidebar/SidebarHeader.tsx | 5 +-
.../SidebarStructuredPropsSection.tsx | 146 +++++++++++
.../tabs/Dataset/Schema/SchemaTable.tsx | 9 +
.../Schema/components/PropertiesColumn.tsx | 4 +-
.../SchemaFieldDrawer/FieldProperties.tsx | 67 +++--
.../SchemaFieldDrawer/SchemaFieldDrawer.tsx | 11 +-
.../useGetSchemaColumnProperties.ts | 39 +++
.../Schema/useGetStructuredPropColumns.tsx | 22 ++
.../Schema/useGetTableColumnProperties.ts | 39 +++
.../components/CompactMarkdownViewer.tsx | 161 +++++++++++++
.../tabs/Properties/AddPropertyButton.tsx | 228 ++++++++++++++++++
.../tabs/Properties/Edit/EditColumn.tsx | 121 +++++++++-
.../Edit/EditStructuredPropertyModal.tsx | 50 ++--
.../shared/tabs/Properties/PropertiesTab.tsx | 6 +-
.../Properties/StructuredPropertyValue.tsx | 86 ++++---
.../shared/tabs/Properties/TabHeader.tsx | 10 +-
.../entity/shared/tabs/Properties/types.ts | 1 +
.../Properties/useStructuredProperties.tsx | 1 +
.../src/app/entity/shared/types.ts | 1 +
.../src/app/lineage/LineageEntityNode.tsx | 40 +++
datahub-web-react/src/app/lineage/types.ts | 4 +
.../app/lineage/utils/constructFetchedNode.ts | 1 +
.../src/app/lineage/utils/constructTree.ts | 4 +
.../src/app/preview/DefaultPreviewCard.tsx | 4 +
.../app/shared/sidebar/EmptySectionText.tsx | 20 ++
.../utils/test-utils/TestPageContainer.tsx | 2 +
.../models/StructuredPropertyUtils.java | 3 +-
49 files changed, 1290 insertions(+), 82 deletions(-)
create mode 100644 datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/PreviewContext.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/header/StructuredPropertyBadge.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts
create mode 100644 datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts
create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts
create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx
create mode 100644 datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx
create mode 100644 datahub-web-react/src/app/shared/sidebar/EmptySectionText.tsx
diff --git a/datahub-web-react/src/app/entity/EntityRegistry.tsx b/datahub-web-react/src/app/entity/EntityRegistry.tsx
index 0f65390f959df2..827f0e6692442e 100644
--- a/datahub-web-react/src/app/entity/EntityRegistry.tsx
+++ b/datahub-web-react/src/app/entity/EntityRegistry.tsx
@@ -7,6 +7,7 @@ import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from './Enti
import { GLOSSARY_ENTITY_TYPES } from './shared/constants';
import { EntitySidebarSection, GenericEntityProperties } from './shared/types';
import { dictToQueryStringParams, getFineGrainedLineageWithSiblings, urlEncodeUrn } from './shared/utils';
+import PreviewContext from './shared/PreviewContext';
function validatedGet(key: K, map: Map): V {
if (map.has(key)) {
@@ -142,13 +143,24 @@ export default class EntityRegistry {
renderPreview(entityType: EntityType, type: PreviewType, data: T): JSX.Element {
const entity = validatedGet(entityType, this.entityTypeToEntity);
- return entity.renderPreview(type, data);
+ const genericEntityData = entity.getGenericEntityProperties(data);
+ return (
+
+ {entity.renderPreview(type, data)}
+
+ );
}
renderSearchResult(type: EntityType, searchResult: SearchResult): JSX.Element {
const entity = validatedGet(type, this.entityTypeToEntity);
+ const genericEntityData = entity.getGenericEntityProperties(searchResult.entity);
+
return (
- {entity.renderSearch(searchResult)}
+
+
+ {entity.renderSearch(searchResult)}
+
+
);
}
@@ -205,6 +217,7 @@ export default class EntityRegistry {
schemaMetadata: genericEntityProperties?.schemaMetadata,
inputFields: genericEntityProperties?.inputFields,
canEditLineage: genericEntityProperties?.privileges?.canEditLineage,
+ structuredProperties: genericEntityProperties?.structuredProperties,
} as FetchedEntity) || undefined
);
}
@@ -239,7 +252,7 @@ export default class EntityRegistry {
getCustomCardUrlPath(type: EntityType): string | undefined {
const entity = validatedGet(type, this.entityTypeToEntity);
- return entity.getCustomCardUrlPath?.();
+ return entity.getCustomCardUrlPath?.() as string | undefined;
}
getGraphNameFromType(type: EntityType): string {
diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
index 8a62a9018661e1..70fe8a5e7c7c23 100644
--- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
+++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx
@@ -29,6 +29,7 @@ import { MatchedFieldList } from '../../search/matches/MatchedFieldList';
import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer';
import { IncidentTab } from '../shared/tabs/Incident/IncidentTab';
import { ChartQueryTab } from './ChartQueryTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Chart entity.
@@ -99,6 +100,9 @@ export class ChartEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderProfile = (urn: string) => (
diff --git a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
index 941e7fc3f552da..557f52146e77a6 100644
--- a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
+++ b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx
@@ -19,6 +19,7 @@ import { getDataProduct } from '../shared/utils';
import EmbeddedProfile from '../shared/embed/EmbeddedProfile';
import AccessManagement from '../shared/tabs/Dataset/AccessManagement/AccessManagement';
import { useAppConfig } from '../../useAppConfig';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Container entity.
@@ -133,6 +134,9 @@ export class ContainerEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
// TODO: Add back once entity-level recommendations are complete.
// {
// component: SidebarRecommendationsSection,
diff --git a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
index 95d4431d591790..7d0275f60435a9 100644
--- a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
+++ b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx
@@ -32,6 +32,7 @@ import { LOOKER_URN } from '../../ingest/source/builder/constants';
import { MatchedFieldList } from '../../search/matches/MatchedFieldList';
import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer';
import { IncidentTab } from '../shared/tabs/Incident/IncidentTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Dashboard entity.
@@ -103,6 +104,9 @@ export class DashboardEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderProfile = (urn: string) => (
diff --git a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx
index dd4ae833e76f1d..42555a0dd3f37d 100644
--- a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx
@@ -19,6 +19,7 @@ import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
import { IncidentTab } from '../shared/tabs/Incident/IncidentTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub DataFlow entity.
@@ -123,6 +124,9 @@ export class DataFlowEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
getOverridePropertiesFromEntity = (dataFlow?: DataFlow | null): GenericEntityProperties => {
diff --git a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
index 6bf95482269190..503acf7652dfa6 100644
--- a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx
@@ -22,6 +22,7 @@ import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
import { IncidentTab } from '../shared/tabs/Incident/IncidentTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
const getDataJobPlatformName = (data?: DataJob): string => {
return (
@@ -143,6 +144,9 @@ export class DataJobEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
getOverridePropertiesFromEntity = (dataJob?: DataJob | null): GenericEntityProperties => {
diff --git a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
index 90c1127d9a5fcd..b7912268eb2e35 100644
--- a/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataProduct/DataProductEntity.tsx
@@ -17,6 +17,7 @@ import { DataProductEntitiesTab } from './DataProductEntitiesTab';
import { EntityActionItem } from '../shared/entity/EntityActions';
import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Data Product entity.
@@ -123,6 +124,9 @@ export class DataProductEntity implements Entity {
updateOnly: true,
},
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderPreview = (_: PreviewType, data: DataProduct) => {
diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
index 07ab27a38f8893..35ed3ffcc4c532 100644
--- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
+++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx
@@ -37,6 +37,7 @@ import { matchedFieldPathsRenderer } from '../../search/matches/matchedFieldPath
import { getLastUpdatedMs } from './shared/utils';
import { IncidentTab } from '../shared/tabs/Incident/IncidentTab';
import { GovernanceTab } from '../shared/tabs/Dataset/Governance/GovernanceTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
const SUBTYPES = {
VIEW: 'view',
@@ -260,7 +261,11 @@ export class DatasetEntity implements Entity {
},
{
component: DataProductSection,
- }, // TODO: Add back once entity-level recommendations are complete.
+ },
+ {
+ component: SidebarStructuredPropsSection,
+ },
+ // TODO: Add back once entity-level recommendations are complete.
// {
// component: SidebarRecommendationsSection,
// },
diff --git a/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx b/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx
new file mode 100644
index 00000000000000..4cba36b9375dbc
--- /dev/null
+++ b/datahub-web-react/src/app/entity/dataset/profile/schema/components/StructuredPropValues.tsx
@@ -0,0 +1,69 @@
+import StructuredPropertyValue from '@src/app/entity/shared/tabs/Properties/StructuredPropertyValue';
+import { mapStructuredPropertyToPropertyRow } from '@src/app/entity/shared/tabs/Properties/useStructuredProperties';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { SchemaFieldEntity, SearchResult, StdDataType } from '@src/types.generated';
+import { Tooltip } from 'antd';
+import React from 'react';
+import styled from 'styled-components';
+
+const ValuesContainer = styled.span`
+ max-width: 120px;
+ display: flex;
+`;
+
+const MoreIndicator = styled.span`
+ float: right;
+`;
+
+interface Props {
+ schemaFieldEntity: SchemaFieldEntity;
+ propColumn: SearchResult | undefined;
+}
+
+const StructuredPropValues = ({ schemaFieldEntity, propColumn }: Props) => {
+ const entityRegistry = useEntityRegistry();
+
+ const property = schemaFieldEntity.structuredProperties?.properties?.find(
+ (prop) => prop.structuredProperty.urn === propColumn?.entity.urn,
+ );
+ const propRow = property ? mapStructuredPropertyToPropertyRow(property) : undefined;
+ const values = propRow?.values;
+ const isRichText = propRow?.dataType?.info.type === StdDataType.RichText;
+
+ const hasMoreValues = values && values.length > 2;
+ const displayedValues = hasMoreValues ? values.slice(0, 1) : values;
+ const tooltipContent = values?.map((value) => {
+ const title = value.entity
+ ? entityRegistry.getDisplayName(value.entity.type, value.entity)
+ : value.value?.toString();
+ return {title}
;
+ });
+
+ return (
+ <>
+ {values && (
+ <>
+ {displayedValues?.map((val) => {
+ return (
+
+
+
+ );
+ })}
+ {hasMoreValues && (
+
+ ...
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export default StructuredPropValues;
diff --git a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
index 81d245c230843f..0f25f3ce565f0f 100644
--- a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
+++ b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx
@@ -15,6 +15,7 @@ import DataProductsTab from './DataProductsTab/DataProductsTab';
import { EntityProfileTab } from '../shared/constants';
import DomainIcon from '../../domain/DomainIcon';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Domain entity.
@@ -112,6 +113,9 @@ export class DomainEntity implements Entity {
{
component: SidebarOwnerSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderPreview = (_: PreviewType, data: Domain) => {
diff --git a/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx b/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx
index b214412dd29afa..861e96811b3405 100644
--- a/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx
+++ b/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx
@@ -12,6 +12,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'
import ChildrenTab from './ChildrenTab';
import { Preview } from './preview/Preview';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
class GlossaryNodeEntity implements Entity {
type: EntityType = EntityType.GlossaryNode;
@@ -100,6 +101,9 @@ class GlossaryNodeEntity implements Entity {
{
component: SidebarOwnerSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
displayName = (data: GlossaryNode) => {
diff --git a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
index 73c5a8e12122d3..439cba2ea69231 100644
--- a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
+++ b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx
@@ -18,6 +18,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import { EntityActionItem } from '../shared/entity/EntityActions';
import { SidebarDomainSection } from '../shared/containers/profile/sidebar/Domain/SidebarDomainSection';
import { PageRoutes } from '../../../conf/Global';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub Dataset entity.
@@ -129,6 +130,9 @@ export class GlossaryTermEntity implements Entity {
hideOwnerType: true,
},
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
getOverridePropertiesFromEntity = (glossaryTerm?: GlossaryTerm | null): GenericEntityProperties => {
diff --git a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
index eecffdb2f38430..51b66c8c2a41de 100644
--- a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx
@@ -18,6 +18,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub MLFeature entity.
@@ -122,6 +123,9 @@ export class MLFeatureEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderPreview = (_: PreviewType, data: MlFeature) => {
diff --git a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
index 8aa0c056b716fc..56d4622311fb17 100644
--- a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx
@@ -19,6 +19,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub MLFeatureTable entity.
@@ -90,6 +91,9 @@ export class MLFeatureTableEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderProfile = (urn: string) => (
diff --git a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
index 92f03aaef7a175..b77f6a19436a51 100644
--- a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx
@@ -18,6 +18,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'
import MlModelFeaturesTab from './profile/MlModelFeaturesTab';
import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub MlModel entity.
@@ -91,6 +92,9 @@ export class MLModelEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderProfile = (urn: string) => (
diff --git a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
index b5d32275f97bfd..5c820007fd1e28 100644
--- a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx
@@ -16,6 +16,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'
import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub MlModelGroup entity.
@@ -87,6 +88,9 @@ export class MLModelGroupEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderProfile = (urn: string) => (
diff --git a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
index 119a566b04f135..d72fabc17ecf6e 100644
--- a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
+++ b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx
@@ -17,6 +17,7 @@ import { LineageTab } from '../shared/tabs/Lineage/LineageTab';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
import { PropertiesTab } from '../shared/tabs/Properties/PropertiesTab';
+import SidebarStructuredPropsSection from '../shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
/**
* Definition of the DataHub MLPrimaryKey entity.
@@ -120,6 +121,9 @@ export class MLPrimaryKeyEntity implements Entity {
{
component: DataProductSection,
},
+ {
+ component: SidebarStructuredPropsSection,
+ },
];
renderPreview = (_: PreviewType, data: MlPrimaryKey) => {
diff --git a/datahub-web-react/src/app/entity/shared/PreviewContext.tsx b/datahub-web-react/src/app/entity/shared/PreviewContext.tsx
new file mode 100644
index 00000000000000..889a6726f3c042
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/PreviewContext.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { GenericEntityProperties } from './types';
+
+const PreviewContext = React.createContext(null);
+export default PreviewContext;
+
+export function usePreviewData() {
+ return React.useContext(PreviewContext);
+}
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx
index 12fa9131f33c73..9e8dc83c32302d 100644
--- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx
+++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHeader.tsx
@@ -18,6 +18,7 @@ import { useUserContext } from '../../../../../context/useUserContext';
import { useEntityRegistry } from '../../../../../useEntityRegistry';
import EntityHeaderLoadingSection from './EntityHeaderLoadingSection';
import { useIsEditableDatasetNameEnabled } from '../../../../../useAppConfig';
+import StructuredPropertyBadge from './StructuredPropertyBadge';
const TitleWrapper = styled.div`
display: flex;
@@ -132,6 +133,7 @@ export const EntityHeader = ({ headerDropdownItems, headerActionItems, isNameEdi
baseUrl={entityRegistry.getEntityUrl(entityType, urn)}
/>
)}
+
{
+ const badgeStructuredProperty = structuredProperties?.properties?.find(filterForAssetBadge);
+
+ const propRow = badgeStructuredProperty ? mapStructuredPropertyToPropertyRow(badgeStructuredProperty) : undefined;
+
+ if (!badgeStructuredProperty) return null;
+
+ const propertyValue = propRow?.values[0].value;
+ const relatedDescription = propRow?.structuredProperty.definition.allowedValues?.find(
+ (v) => getStructuredPropertyValue(v.value) === propertyValue,
+ )?.description;
+
+ const BadgeTooltip = () => {
+ return (
+
+
+ {getDisplayName(badgeStructuredProperty.structuredProperty)}
+
+
+
+ Value
+
+ {propRow?.values[0].value}
+
+ {relatedDescription && (
+
+
+ Description
+
+ {relatedDescription}
+
+ )}
+
+ );
+ };
+
+ return (
+ }
+ color={colors.white}
+ overlayInnerStyle={{ width: 250, padding: 16 }}
+ >
+
+
+
+
+ );
+};
+
+export default StructuredPropertyBadge;
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts b/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts
new file mode 100644
index 00000000000000..6ab469725b51a6
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/utils.ts
@@ -0,0 +1,29 @@
+import EntityRegistry from '@src/app/entity/EntityRegistry';
+import { EntityType, StructuredPropertiesEntry } from '../../../../../../types.generated';
+import { capitalizeFirstLetterOnly } from '../../../../../shared/textUtil';
+import { GenericEntityProperties } from '../../../types';
+
+export function getDisplayedEntityType(
+ entityData: GenericEntityProperties | null,
+ entityRegistry: EntityRegistry,
+ entityType: EntityType,
+) {
+ return (
+ entityData?.entityTypeOverride ||
+ capitalizeFirstLetterOnly(entityData?.subTypes?.typeNames?.[0]) ||
+ entityRegistry.getEntityName(entityType) ||
+ ''
+ );
+}
+
+export function getEntityPlatforms(entityType: EntityType | null, entityData: GenericEntityProperties | null) {
+ const platform = entityType === EntityType.SchemaField ? entityData?.parent?.platform : entityData?.platform;
+ const platforms =
+ entityType === EntityType.SchemaField ? entityData?.parent?.siblingPlatforms : entityData?.siblingPlatforms;
+
+ return { platform, platforms };
+}
+
+export function filterForAssetBadge(prop: StructuredPropertiesEntry) {
+ return prop.structuredProperty.settings?.showAsAssetBadge && !prop.structuredProperty.settings?.isHidden;
+}
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx
index 1d489e88b50507..ec65b31968d83d 100644
--- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx
+++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/DataProduct/DataProductSection.tsx
@@ -67,7 +67,7 @@ export default function DataProductSection({ readOnly }: Props) {
};
return (
- <>
+
{dataProduct && (
)}
- >
+
);
}
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx
index 0ee3fcb90e5756..a5c7cce93a42a9 100644
--- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx
+++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/SidebarHeader.tsx
@@ -15,14 +15,15 @@ const HeaderContainer = styled.div`
type Props = {
title: string;
+ titleComponent?: React.ReactNode;
actions?: React.ReactNode;
children?: React.ReactNode;
};
-export const SidebarHeader = ({ title, actions, children }: Props) => {
+export const SidebarHeader = ({ title, titleComponent, actions, children }: Props) => {
return (
- {title}
+ {titleComponent || {title} }
{actions && {actions}
}
{children}
diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx
new file mode 100644
index 00000000000000..ea257ca2ade317
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection.tsx
@@ -0,0 +1,146 @@
+import { useUserContext } from '@src/app/context/useUserContext';
+import { useEntityData } from '@src/app/entity/shared/EntityContext';
+import { StyledList } from '@src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/FieldProperties';
+import { EditColumn } from '@src/app/entity/shared/tabs/Properties/Edit/EditColumn';
+import StructuredPropertyValue from '@src/app/entity/shared/tabs/Properties/StructuredPropertyValue';
+import { PropertyRow } from '@src/app/entity/shared/tabs/Properties/types';
+import EmptySectionText from '@src/app/shared/sidebar/EmptySectionText';
+import {
+ getDisplayName,
+ getEntityTypesPropertyFilter,
+ getNotHiddenPropertyFilter,
+ getPropertyRowFromSearchResult,
+} from '@src/app/govern/structuredProperties/utils';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { EntityType, SchemaField, SearchResult, StdDataType, StructuredPropertyEntity } from '@src/types.generated';
+import {
+ SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+} from '@src/app/search/utils/constants';
+import {
+ SectionHeader,
+ StyledDivider,
+} from '@src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/components';
+import { useGetEntityWithSchema } from '@src/app/entity/shared/tabs/Dataset/Schema/useGetEntitySchema';
+import React from 'react';
+import { SidebarHeader } from '../SidebarHeader';
+
+interface Props {
+ properties?: {
+ schemaField?: SchemaField;
+ schemaColumnProperties?: SearchResult[];
+ };
+}
+
+const SidebarStructuredPropsSection = ({ properties }: Props) => {
+ const schemaField = properties?.schemaField;
+ const schemaColumnProperties = properties?.schemaColumnProperties;
+ const { entityData, entityType } = useEntityData();
+ const me = useUserContext();
+ const entityRegistry = useEntityRegistry();
+ const { refetch: refetchSchema } = useGetEntityWithSchema(true);
+
+ const currentProperties = schemaField
+ ? schemaField?.schemaFieldEntity?.structuredProperties
+ : entityData?.structuredProperties;
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 50,
+ searchFlags: { skipCache: true },
+ orFilters: [
+ {
+ and: [
+ getEntityTypesPropertyFilter(entityRegistry, !!schemaField, entityType),
+ getNotHiddenPropertyFilter(),
+ {
+ field: schemaField
+ ? SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME
+ : SHOW_IN_ASSET_SUMMARY_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ },
+ ],
+ },
+ ],
+ };
+
+ // Execute search
+ const { data } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ skip: !!schemaColumnProperties,
+ fetchPolicy: 'cache-first',
+ });
+
+ const entityTypeProperties = schemaColumnProperties || data?.searchAcrossEntities?.searchResults;
+
+ const canEditProperties = me.platformPrivileges?.manageStructuredProperties;
+
+ return (
+ <>
+ {entityTypeProperties?.map((property) => {
+ const propertyRow: PropertyRow | undefined = getPropertyRowFromSearchResult(
+ property,
+ currentProperties,
+ );
+ const isRichText = propertyRow?.dataType?.info.type === StdDataType.RichText;
+ const values = propertyRow?.values;
+ const hasMultipleValues = values && values.length > 1;
+ const propertyName = getDisplayName(property.entity as StructuredPropertyEntity);
+
+ return (
+ <>
+
+ {propertyName}}
+ actions={
+ canEditProperties && (
+ <>
+ v.value) || []}
+ isAddMode={!values}
+ associatedUrn={schemaField?.schemaFieldEntity?.urn}
+ refetch={schemaField ? refetchSchema : undefined}
+ />
+ >
+ )
+ }
+ />
+
+ {values ? (
+ <>
+ {hasMultipleValues ? (
+
+ {values.map((value) => (
+
+
+
+ ))}
+
+ ) : (
+ <>
+ {values?.map((value) => (
+
+ ))}
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+ {schemaField && }
+ >
+ );
+ })}
+ >
+ );
+};
+
+export default SidebarStructuredPropsSection;
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 0bfd5255f3065f..83ebb8f6b78285 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
@@ -27,6 +27,8 @@ import PropertiesColumn from './components/PropertiesColumn';
import SchemaFieldDrawer from './components/SchemaFieldDrawer/SchemaFieldDrawer';
import useBusinessAttributeRenderer from './utils/useBusinessAttributeRenderer';
import { useBusinessAttributesFlag } from '../../../../../useAppConfig';
+import { useGetTableColumnProperties } from './useGetTableColumnProperties';
+import { useGetStructuredPropColumns } from './useGetStructuredPropColumns';
const TableContainer = styled.div`
overflow: inherit;
@@ -126,6 +128,9 @@ export default function SchemaTable({
const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText);
const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList);
+ const tableColumnStructuredProps = useGetTableColumnProperties();
+ const structuredPropColumns = useGetStructuredPropColumns(tableColumnStructuredProps);
+
const fieldColumn = {
width: '22%',
title: 'Field',
@@ -221,6 +226,10 @@ export default function SchemaTable({
allColumns = [...allColumns, propertiesColumn];
}
+ if (structuredPropColumns) {
+ allColumns = [...allColumns, ...structuredPropColumns];
+ }
+
if (hasUsageStats) {
allColumns = [...allColumns, usageColumn];
}
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
index b74de3e94e5544..74d14cb0db7536 100644
--- 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
@@ -17,7 +17,9 @@ interface Props {
export default function PropertiesColumn({ field }: Props) {
const { schemaFieldEntity } = field;
- const numProperties = schemaFieldEntity?.structuredProperties?.properties?.length;
+ const numProperties = schemaFieldEntity?.structuredProperties?.properties?.filter(
+ (prop) => !prop.structuredProperty.settings?.isHidden,
+ )?.length;
if (!schemaFieldEntity || !numProperties) return null;
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
index 9a0da20f22dfde..d6f2a837482512 100644
--- 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
@@ -1,47 +1,76 @@
+import { useEntityData } from '@src/app/entity/shared/EntityContext';
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';
+import { SchemaField, SearchResult, StdDataType } from '../../../../../../../../types.generated';
+import AddPropertyButton from '../../../../Properties/AddPropertyButton';
import { EditColumn } from '../../../../Properties/Edit/EditColumn';
+import StructuredPropertyValue from '../../../../Properties/StructuredPropertyValue';
+import { mapStructuredPropertyValues } from '../../../../Properties/useStructuredProperties';
import { useGetEntityWithSchema } from '../../useGetEntitySchema';
+import { StyledDivider } from './components';
-const PropertyTitle = styled.div`
+export const PropertyTitle = styled.div`
font-size: 14px;
font-weight: 700;
margin-bottom: 4px;
`;
-const PropertyWrapper = styled.div`
+export const PropertyWrapper = styled.div`
margin-bottom: 12px;
display: flex;
justify-content: space-between;
`;
-const PropertiesWrapper = styled.div`
+export const PropertiesWrapper = styled.div`
padding-left: 16px;
`;
-const StyledList = styled.ul`
+export const StyledList = styled.ul`
padding-left: 24px;
`;
+export const Header = styled.div`
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
interface Props {
expandedField: SchemaField;
+ schemaColumnProperties?: SearchResult[];
}
-export default function FieldProperties({ expandedField }: Props) {
+export default function FieldProperties({ expandedField, schemaColumnProperties }: Props) {
const { schemaFieldEntity } = expandedField;
const { refetch } = useGetEntityWithSchema(true);
+ const { entityData } = useEntityData();
const properties =
- schemaFieldEntity?.structuredProperties?.properties?.filter((prop) => prop.structuredProperty.exists) || [];
+ schemaFieldEntity?.structuredProperties?.properties?.filter(
+ (prop) =>
+ prop.structuredProperty.exists &&
+ !prop.structuredProperty.settings?.isHidden &&
+ !schemaColumnProperties?.find((p) => p.entity.urn === prop.structuredProperty.urn),
+ ) || [];
+
+ const canEditProperties =
+ entityData?.parent?.privileges?.canEditProperties || entityData?.privileges?.canEditProperties;
- if (!schemaFieldEntity || !properties.length) return null;
+ if (!schemaFieldEntity) return null;
return (
<>
- Properties
+
{properties.map((structuredProp) => {
const isRichText =
@@ -71,12 +100,14 @@ export default function FieldProperties({ expandedField }: Props) {
>
)}
- v.value) || []}
- refetch={refetch}
- />
+ {canEditProperties && (
+ v.value) || []}
+ refetch={refetch}
+ />
+ )}
);
})}
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
index 0d9f7a98f207c5..df0cd8b2dd762f 100644
--- 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
@@ -1,6 +1,7 @@
import { Drawer } from 'antd';
import React, { useMemo } from 'react';
import styled from 'styled-components';
+import SidebarStructuredPropsSection from '@src/app/entity/shared/containers/profile/sidebar/StructuredProperties/SidebarStructuredPropsSection';
import DrawerHeader from './DrawerHeader';
import FieldHeader from './FieldHeader';
import FieldDescription from './FieldDescription';
@@ -11,6 +12,7 @@ import FieldTags from './FieldTags';
import FieldTerms from './FieldTerms';
import FieldProperties from './FieldProperties';
import FieldAttribute from './FieldAttribute';
+import useGetSchemaColumnProperties from './useGetSchemaColumnProperties';
const StyledDrawer = styled(Drawer)`
position: absolute;
@@ -50,6 +52,7 @@ export default function SchemaFieldDrawer({
const editableFieldInfo = editableSchemaMetadata?.editableSchemaFieldInfo.find((candidateEditableFieldInfo) =>
pathMatchesNewPath(candidateEditableFieldInfo.fieldPath, expandedField?.fieldPath),
);
+ const schemaColumnProperties = useGetSchemaColumnProperties();
return (
-
+
+
>
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts
new file mode 100644
index 00000000000000..ed5af588fa0363
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaFieldDrawer/useGetSchemaColumnProperties.ts
@@ -0,0 +1,39 @@
+import { getEntityTypesPropertyFilter, getNotHiddenPropertyFilter } from '@src/app/govern/structuredProperties/utils';
+import { SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME } from '@src/app/search/utils/constants';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { EntityType, SearchResult } from '@src/types.generated';
+
+export default function useGetSchemaColumnProperties() {
+ const entityRegistry = useEntityRegistry();
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 50,
+ searchFlags: { skipCache: true },
+ orFilters: [
+ {
+ and: [
+ getEntityTypesPropertyFilter(entityRegistry, true, EntityType.SchemaField),
+ getNotHiddenPropertyFilter(),
+ {
+ field: SHOW_IN_COLUMNS_TABLE_PROPERTY_FILTER_NAME,
+ values: ['true'],
+ },
+ ],
+ },
+ ],
+ };
+
+ // Execute search
+ const { data } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ fetchPolicy: 'cache-first',
+ });
+
+ return data?.searchAcrossEntities?.searchResults || ([] as SearchResult[]);
+}
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx
new file mode 100644
index 00000000000000..eed3fd510724b8
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetStructuredPropColumns.tsx
@@ -0,0 +1,22 @@
+import StructuredPropValues from '@src/app/entity/dataset/profile/schema/components/StructuredPropValues';
+import { getDisplayName } from '@src/app/govern/structuredProperties/utils';
+import { SearchResult, StructuredPropertyEntity } from '@src/types.generated';
+import React, { useMemo } from 'react';
+
+export const useGetStructuredPropColumns = (properties: SearchResult[] | undefined) => {
+ const columns = useMemo(() => {
+ return properties?.map((prop) => {
+ const name = getDisplayName(prop.entity as StructuredPropertyEntity);
+ return {
+ width: 120,
+ title: name,
+ dataIndex: 'schemaFieldEntity',
+ key: prop.entity.urn,
+ render: (record) => ,
+ ellipsis: true,
+ };
+ });
+ }, [properties]);
+
+ return columns;
+};
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts
new file mode 100644
index 00000000000000..96ff90921b9372
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/useGetTableColumnProperties.ts
@@ -0,0 +1,39 @@
+import {
+ getEntityTypesPropertyFilter,
+ getNotHiddenPropertyFilter,
+ getShowInColumnsTablePropertyFilter,
+} from '@src/app/govern/structuredProperties/utils';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { EntityType } from '@src/types.generated';
+
+export const useGetTableColumnProperties = () => {
+ const entityRegistry = useEntityRegistry();
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 50,
+ searchFlags: { skipCache: true },
+ orFilters: [
+ {
+ and: [
+ getEntityTypesPropertyFilter(entityRegistry, true),
+ getNotHiddenPropertyFilter(),
+ getShowInColumnsTablePropertyFilter(),
+ ],
+ },
+ ],
+ };
+
+ // Execute search
+ const { data } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ fetchPolicy: 'cache-first',
+ });
+
+ return data?.searchAcrossEntities?.searchResults;
+};
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx
new file mode 100644
index 00000000000000..df93d51e721bba
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/tabs/Documentation/components/CompactMarkdownViewer.tsx
@@ -0,0 +1,161 @@
+import { Button } from 'antd';
+import React, { useCallback, useEffect, useState } from 'react';
+import styled from 'styled-components';
+import { Editor } from './editor/Editor';
+
+const LINE_HEIGHT = 1.5;
+
+const ShowMoreWrapper = styled.div`
+ align-items: start;
+ display: flex;
+ flex-direction: column;
+`;
+
+const MarkdownContainer = styled.div<{ lineLimit?: number | null }>`
+ max-width: 100%;
+ position: relative;
+ ${(props) =>
+ props.lineLimit &&
+ props.lineLimit <= 1 &&
+ `
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ ${ShowMoreWrapper}{
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+ }
+ `}
+`;
+
+const CustomButton = styled(Button)`
+ padding: 0;
+ color: #676b75;
+`;
+
+const MarkdownViewContainer = styled.div<{ scrollableY: boolean }>`
+ display: block;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ overflow-x: hidden;
+ overflow-y: ${(props) => (props.scrollableY ? 'auto' : 'hidden')};
+`;
+
+const CompactEditor = styled(Editor)<{ limit: number | null; customStyle?: React.CSSProperties }>`
+ .remirror-editor.ProseMirror {
+ ${({ limit }) => limit && `max-height: ${limit * LINE_HEIGHT}em;`}
+ h1 {
+ font-size: 1.4em;
+ }
+
+ h2 {
+ font-size: 1.3em;
+ }
+
+ h3 {
+ font-size: 1.2em;
+ }
+
+ h4 {
+ font-size: 1.1em;
+ }
+
+ h5,
+ h6 {
+ font-size: 1em;
+ }
+
+ p {
+ ${(props) => props?.customStyle?.fontSize && `font-size: ${props?.customStyle?.fontSize}`};
+ margin-bottom: 0;
+ }
+
+ padding: 0;
+ }
+`;
+
+const FixedLineHeightEditor = styled(CompactEditor)<{ customStyle?: React.CSSProperties }>`
+ .remirror-editor.ProseMirror {
+ * {
+ line-height: ${LINE_HEIGHT};
+ font-size: 1em !important;
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ p {
+ font-size: ${(props) => (props?.customStyle?.fontSize ? props?.customStyle?.fontSize : '1em')} !important;
+ }
+ }
+`;
+
+export type Props = {
+ content: string;
+ lineLimit?: number | null;
+ fixedLineHeight?: boolean;
+ isShowMoreEnabled?: boolean;
+ customStyle?: React.CSSProperties;
+ scrollableY?: boolean; // Whether the viewer is vertically scrollable.
+ handleShowMore?: () => void;
+ hideShowMore?: boolean;
+};
+
+export default function CompactMarkdownViewer({
+ content,
+ lineLimit = 4,
+ fixedLineHeight = false,
+ isShowMoreEnabled = false,
+ customStyle = {},
+ scrollableY = true,
+ handleShowMore,
+ hideShowMore,
+}: Props) {
+ const [isShowingMore, setIsShowingMore] = useState(false);
+ const [isTruncated, setIsTruncated] = useState(false);
+
+ useEffect(() => {
+ if (isShowMoreEnabled) {
+ setIsShowingMore(isShowMoreEnabled);
+ }
+ return () => {
+ setIsShowingMore(false);
+ };
+ }, [isShowMoreEnabled]);
+
+ const measuredRef = useCallback((node: HTMLDivElement | null) => {
+ if (node !== null) {
+ const resizeObserver = new ResizeObserver(() => {
+ setIsTruncated(node.scrollHeight > node.clientHeight + 1);
+ });
+ resizeObserver.observe(node);
+ }
+ }, []);
+
+ const StyledEditor = fixedLineHeight ? FixedLineHeightEditor : CompactEditor;
+
+ return (
+
+
+
+
+ {hideShowMore && <>...>}
+
+ {!hideShowMore &&
+ (isShowingMore || isTruncated) && ( // "show more" when isTruncated, "show less" when isShowingMore
+
+ (handleShowMore ? handleShowMore() : setIsShowingMore(!isShowingMore))}
+ >
+ {isShowingMore ? 'show less' : 'show more'}
+
+
+ )}
+
+ );
+}
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx
new file mode 100644
index 00000000000000..cac3e268c1df5e
--- /dev/null
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/AddPropertyButton.tsx
@@ -0,0 +1,228 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { colors, Icon, Input as InputComponent, Text } from '@src/alchemy-components';
+import { useUserContext } from '@src/app/context/useUserContext';
+import { getEntityTypesPropertyFilter, getNotHiddenPropertyFilter } from '@src/app/govern/structuredProperties/utils';
+import { useEntityRegistry } from '@src/app/useEntityRegistry';
+import { PageRoutes } from '@src/conf/Global';
+import { useGetSearchResultsForMultipleQuery } from '@src/graphql/search.generated';
+import { Dropdown } from 'antd';
+import { Tooltip } from '@components';
+import { EntityType, Maybe, StructuredProperties, StructuredPropertyEntity } from '@src/types.generated';
+import React, { useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import { useEntityData } from '../../EntityContext';
+import EditStructuredPropertyModal from './Edit/EditStructuredPropertyModal';
+
+const AddButton = styled.div<{ isV1Drawer?: boolean }>`
+ border-radius: 200px;
+ background-color: #5280e2;
+ width: ${(props) => (props.isV1Drawer ? '24px' : '32px')};
+ height: ${(props) => (props.isV1Drawer ? '24px' : '32px')};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+const DropdownContainer = styled.div`
+ border-radius: 12px;
+ box-shadow: 0px 0px 14px 0px rgba(0, 0, 0, 0.15);
+ background-color: ${colors.white};
+ padding-bottom: 8px;
+ width: 300px;
+`;
+
+const SearchContainer = styled.div`
+ padding: 8px;
+`;
+
+const OptionsContainer = styled.div`
+ max-height: 200px;
+ overflow-y: auto;
+ font-size: 14px;
+`;
+
+const Option = styled.div``;
+
+const LoadingContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ height: 100px;
+`;
+
+const EmptyContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ min-height: 50px;
+ padding: 16px;
+ text-align: center;
+`;
+
+interface Props {
+ fieldUrn?: string;
+ refetch?: () => void;
+ fieldProperties?: Maybe;
+ isV1Drawer?: boolean;
+}
+
+const AddPropertyButton = ({ fieldUrn, refetch, fieldProperties, isV1Drawer }: Props) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const { entityData, entityType } = useEntityData();
+ const me = useUserContext();
+ const entityRegistry = useEntityRegistry();
+ const [isEditModalVisible, setIsEditModalVisible] = useState(false);
+
+ const inputs = {
+ types: [EntityType.StructuredProperty],
+ query: '',
+ start: 0,
+ count: 100,
+ searchFlags: { skipCache: true },
+ orFilters: [
+ {
+ and: [
+ getEntityTypesPropertyFilter(entityRegistry, !!fieldUrn, entityType),
+ getNotHiddenPropertyFilter(),
+ ],
+ },
+ ],
+ };
+
+ // Execute search
+ const { data, loading } = useGetSearchResultsForMultipleQuery({
+ variables: {
+ input: inputs,
+ },
+ fetchPolicy: 'cache-first',
+ });
+
+ const [selectedProperty, setSelectedProperty] = useState(
+ data?.searchAcrossEntities?.searchResults?.[0]?.entity as StructuredPropertyEntity | undefined,
+ );
+
+ const handleOptionClick = (property: StructuredPropertyEntity) => {
+ setSelectedProperty(property);
+ setIsEditModalVisible(true);
+ };
+
+ const entityPropertiesUrns = entityData?.structuredProperties?.properties?.map(
+ (prop) => prop.structuredProperty.urn,
+ );
+ const fieldPropertiesUrns = fieldProperties?.properties?.map((prop) => prop.structuredProperty.urn);
+
+ // filter out the existing properties when displaying in the list of add button
+ const properties = useMemo(
+ () =>
+ data?.searchAcrossEntities?.searchResults
+ .filter((result) =>
+ fieldUrn
+ ? !fieldPropertiesUrns?.includes(result.entity.urn)
+ : !entityPropertiesUrns?.includes(result.entity.urn),
+ )
+ .map((prop) => {
+ const entity = prop.entity as StructuredPropertyEntity;
+ return {
+ label: (
+ handleOptionClick(entity)}>
+
+ {entity.definition?.displayName}
+
+
+ ),
+ key: entity.urn,
+ name: entity.definition?.displayName || entity.urn,
+ };
+ }),
+ [data, fieldUrn, fieldPropertiesUrns, entityPropertiesUrns],
+ );
+
+ const canEditProperties =
+ entityData?.parent?.privileges?.canEditProperties || entityData?.privileges?.canEditProperties;
+
+ if (!canEditProperties) return null;
+
+ // Filter items based on search query
+ const filteredItems = properties?.filter((prop) => prop.name?.toLowerCase().includes(searchQuery.toLowerCase()));
+
+ const noDataText =
+ properties?.length === 0 ? (
+ <>
+ It looks like there are no structured properties for this asset type.
+ {me.platformPrivileges?.manageStructuredProperties && (
+
+ {' '}
+ Manage custom properties
+
+ )}
+ >
+ ) : null;
+
+ return (
+ <>
+ (
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+ {loading ? (
+
+
+ Loading...
+
+ ) : (
+ <>
+ {filteredItems?.length === 0 && (
+
+
+ No results found
+
+
+ {noDataText}
+
+
+ )}
+ {menuNode}
+ >
+ )}
+
+ )}
+ >
+
+
+
+
+
+
+ {selectedProperty && (
+ setIsEditModalVisible(false)}
+ structuredProperty={selectedProperty}
+ associatedUrn={fieldUrn} // pass in fieldUrn to use otherwise we will use mutation urn for siblings
+ refetch={refetch}
+ isAddMode
+ />
+ )}
+ >
+ );
+};
+
+export default AddPropertyButton;
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx
index 6a0599c0cdb33d..a2d5c44b391e34 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditColumn.tsx
@@ -1,27 +1,128 @@
-import { Button } from 'antd';
+import { colors, Icon, Text } from '@src/alchemy-components';
+import analytics, { EventType } from '@src/app/analytics';
+import { MenuItem } from '@src/app/govern/structuredProperties/styledComponents';
+import { ConfirmationModal } from '@src/app/sharedV2/modals/ConfirmationModal';
+import { showToastMessage, ToastType } from '@src/app/sharedV2/toastMessageUtils';
+import { useRemoveStructuredPropertiesMutation } from '@src/graphql/structuredProperties.generated';
+import { EntityType, StructuredPropertyEntity } from '@src/types.generated';
+import { Dropdown } from 'antd';
import React, { useState } from 'react';
+import styled from 'styled-components';
+import { useEntityContext, useEntityData, useMutationUrn } from '../../../EntityContext';
import EditStructuredPropertyModal from './EditStructuredPropertyModal';
-import { StructuredPropertyEntity } from '../../../../../../types.generated';
+
+export const MoreOptionsContainer = styled.div`
+ display: flex;
+ gap: 12px;
+ justify-content: end;
+
+ div {
+ background-color: ${colors.gray[1500]};
+ border-radius: 20px;
+ width: 24px;
+ height: 24px;
+ padding: 3px;
+ color: ${colors.gray[1800]};
+ :hover {
+ cursor: pointer;
+ }
+ }
+`;
interface Props {
structuredProperty?: StructuredPropertyEntity;
associatedUrn?: string;
values?: (string | number | null)[];
refetch?: () => void;
+ isAddMode?: boolean;
}
-export function EditColumn({ structuredProperty, associatedUrn, values, refetch }: Props) {
+export function EditColumn({ structuredProperty, associatedUrn, values, refetch, isAddMode }: Props) {
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
+ const { refetch: entityRefetch } = useEntityContext();
+ const { entityType } = useEntityData();
+
+ const [removeStructuredProperty] = useRemoveStructuredPropertiesMutation();
+
+ const [showConfirmRemove, setShowConfirmRemove] = useState(false);
+ const mutationUrn = useMutationUrn();
if (!structuredProperty || structuredProperty?.definition.immutable) {
return null;
}
+ const handleRemoveProperty = () => {
+ showToastMessage(ToastType.LOADING, 'Removing structured property', 1);
+ removeStructuredProperty({
+ variables: {
+ input: {
+ assetUrn: associatedUrn || mutationUrn,
+ structuredPropertyUrns: [structuredProperty.urn],
+ },
+ },
+ })
+ .then(() => {
+ analytics.event({
+ type: EventType.RemoveStructuredPropertyEvent,
+ propertyUrn: structuredProperty.urn,
+ propertyType: structuredProperty.definition.valueType.urn,
+ assetUrn: associatedUrn || mutationUrn,
+ assetType: associatedUrn?.includes('urn:li:schemaField') ? EntityType.SchemaField : entityType,
+ });
+ showToastMessage(ToastType.SUCCESS, 'Structured property removed successfully!', 3);
+ if (refetch) {
+ refetch();
+ } else {
+ entityRefetch();
+ }
+ })
+ .catch(() => {
+ showToastMessage(ToastType.ERROR, 'Failed to remove structured property', 3);
+ });
+
+ setShowConfirmRemove(false);
+ };
+
+ const handleRemoveClose = () => {
+ setShowConfirmRemove(false);
+ };
+
+ const items = [
+ {
+ key: '0',
+ label: (
+ {
+ setIsEditModalVisible(true);
+ }}
+ >
+ {isAddMode ? 'Add' : 'Edit'}
+
+ ),
+ },
+ ];
+ if (values && values?.length > 0) {
+ items.push({
+ key: '1',
+ label: (
+ {
+ setShowConfirmRemove(true);
+ }}
+ >
+ Remove
+
+ ),
+ });
+ }
+
return (
<>
- setIsEditModalVisible(true)}>
- Edit
-
+
+
+
+
+
setIsEditModalVisible(false)}
refetch={refetch}
+ isAddMode={isAddMode}
+ />
+ handleRemoveProperty()}
+ modalTitle="Confirm Remove Structured Property"
+ modalText={`Are you sure you want to remove ${structuredProperty.definition.displayName} from this asset?`}
/>
>
);
diff --git a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx
index c8def8bef5e195..13aa0dfd42d1ee 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/Edit/EditStructuredPropertyModal.tsx
@@ -1,12 +1,13 @@
+import analytics, { EventType } from '@src/app/analytics';
import { Button, Modal, message } from 'antd';
import React, { useEffect, useMemo } from 'react';
import styled from 'styled-components';
-import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput';
-import { PropertyValueInput, StructuredPropertyEntity } from '../../../../../../types.generated';
import { useUpsertStructuredPropertiesMutation } from '../../../../../../graphql/structuredProperties.generated';
-import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty';
-import { useEntityContext, useMutationUrn } from '../../../EntityContext';
+import { EntityType, PropertyValueInput, StructuredPropertyEntity } from '../../../../../../types.generated';
import handleGraphQLError from '../../../../../shared/handleGraphQLError';
+import StructuredPropertyInput from '../../../components/styled/StructuredProperty/StructuredPropertyInput';
+import { useEditStructuredProperty } from '../../../components/styled/StructuredProperty/useEditStructuredProperty';
+import { useEntityContext, useEntityData, useMutationUrn } from '../../../EntityContext';
const Description = styled.div`
font-size: 14px;
@@ -21,6 +22,7 @@ interface Props {
values?: (string | number | null)[];
closeModal: () => void;
refetch?: () => void;
+ isAddMode?: boolean;
}
export default function EditStructuredPropertyModal({
@@ -30,9 +32,11 @@ export default function EditStructuredPropertyModal({
values,
closeModal,
refetch,
+ isAddMode,
}: Props) {
const { refetch: entityRefetch } = useEntityContext();
const mutationUrn = useMutationUrn();
+ const { entityType } = useEntityData();
const urn = associatedUrn || mutationUrn;
const initialValues = useMemo(() => values || [], [values]);
const { selectedValues, selectSingleValue, toggleSelectedValue, updateSelectedValues, setSelectedValues } =
@@ -44,7 +48,13 @@ export default function EditStructuredPropertyModal({
}, [isOpen, initialValues, setSelectedValues]);
function upsertProperties() {
- message.loading('Updating...');
+ message.loading(isAddMode ? 'Adding...' : 'Updating...');
+ const propValues = selectedValues.map((value) => {
+ if (typeof value === 'string') {
+ return { stringValue: value as string };
+ }
+ return { numberValue: value as number };
+ }) as PropertyValueInput[];
upsertStructuredProperties({
variables: {
input: {
@@ -52,25 +62,30 @@ export default function EditStructuredPropertyModal({
structuredPropertyInputParams: [
{
structuredPropertyUrn: structuredProperty.urn,
- values: selectedValues.map((value) => {
- if (typeof value === 'string') {
- return { stringValue: value as string };
- }
- return { numberValue: value as number };
- }) as PropertyValueInput[],
+ values: propValues,
},
],
},
},
})
.then(() => {
+ analytics.event({
+ type: isAddMode
+ ? EventType.ApplyStructuredPropertyEvent
+ : EventType.UpdateStructuredPropertyOnAssetEvent,
+ propertyUrn: structuredProperty.urn,
+ propertyType: structuredProperty.definition.valueType.urn,
+ assetUrn: urn,
+ assetType: associatedUrn?.includes('urn:li:schemaField') ? EntityType.SchemaField : entityType,
+ values: propValues,
+ });
if (refetch) {
refetch();
} else {
entityRefetch();
}
message.destroy();
- message.success('Successfully updated structured property!');
+ message.success(`Successfully ${isAddMode ? 'added' : 'updated'} structured property!`);
closeModal();
})
.catch((error) => {
@@ -84,7 +99,7 @@ export default function EditStructuredPropertyModal({
return (
Cancel
-
- Update
+
+ {isAddMode ? 'Add' : 'Update'}
>
}
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 eeff8fc2e27953..5fc209688c9573 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
@@ -44,6 +44,7 @@ export const PropertiesTab = () => {
render: (propertyRow: PropertyRow) => (
v.value) || []}
/>
),
@@ -51,9 +52,12 @@ export const PropertiesTab = () => {
}
const { structuredPropertyRows, expandedRowsFromFilter } = useStructuredProperties(entityRegistry, filterText);
+ const filteredStructuredPropertyRows = structuredPropertyRows.filter(
+ (row) => !row.structuredProperty?.settings?.isHidden,
+ );
const customProperties = getFilteredCustomProperties(filterText, entityData) || [];
const customPropertyRows = mapCustomPropertiesToPropertyRows(customProperties);
- const dataSource: PropertyRow[] = structuredPropertyRows.concat(customPropertyRows);
+ const dataSource: PropertyRow[] = filteredStructuredPropertyRows.concat(customPropertyRows);
const [expandedRows, setExpandedRows] = useState>(new Set());
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
index b1a01f2b69fe18..2ed4ab79a41eeb 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/StructuredPropertyValue.tsx
@@ -1,24 +1,26 @@
import Icon from '@ant-design/icons/lib/components/Icon';
-import React, { useState } from 'react';
+import React from 'react';
import Highlight from 'react-highlighter';
-import { Button, Typography } from 'antd';
+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 CompactMarkdownViewer from '../Documentation/components/CompactMarkdownViewer';
import EntityIcon from '../../components/styled/EntityIcon';
const ValueText = styled(Typography.Text)`
font-family: 'Manrope';
font-weight: 400;
- font-size: 14px;
+ font-size: 12px;
color: ${ANTD_GRAY[9]};
display: block;
+ width: 100%;
+ margin-bottom: 2px;
- ${MarkdownView} {
- font-size: 14px;
+ .remirror-editor.ProseMirror {
+ font-size: 12px;
}
`;
@@ -28,38 +30,56 @@ const StyledIcon = styled(Icon)`
const IconWrapper = styled.span`
margin-right: 4px;
+ display: flex;
`;
-const StyledButton = styled(Button)`
- margin-top: 2px;
+const EntityWrapper = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const EntityName = styled(Typography.Text)`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+const StyledHighlight = styled(Highlight)<{ truncateText?: boolean }>`
+ line-height: 1.5;
+ text-wrap: wrap;
+
+ ${(props) =>
+ props.truncateText &&
+ `
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ display: block;
+ `}
`;
interface Props {
value: ValueColumnData;
isRichText?: boolean;
filterText?: string;
+ truncateText?: boolean;
+ isFieldColumn?: boolean;
}
-const MAX_CHARACTERS = 200;
-
-export default function StructuredPropertyValue({ value, isRichText, filterText }: Props) {
+export default function StructuredPropertyValue({ value, isRichText, filterText, truncateText, isFieldColumn }: Props) {
const entityRegistry = useEntityRegistry();
- const [showMore, setShowMore] = useState(false);
-
- const toggleShowMore = () => {
- setShowMore(!showMore);
- };
-
- const valueAsString = value?.value?.toString() ?? '';
return (
{value.entity ? (
- <>
+
-
+
- {entityRegistry.getDisplayName(value.entity.type, value.entity)}
+
+ {entityRegistry.getDisplayName(value.entity.type, value.entity)}
+
- >
+
) : (
<>
{isRichText ? (
-
+
) : (
<>
-
- {showMore ? valueAsString : valueAsString?.substring(0, MAX_CHARACTERS)}
-
- {valueAsString?.length > MAX_CHARACTERS && (
-
- {showMore ? 'Show less' : 'Show more'}
-
+ {truncateText ? (
+
+ {value.value?.toString() ||
}
+
+ ) : (
+
+ {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
index 9e0b4992d9c786..192b840b50040e 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/TabHeader.tsx
@@ -1,8 +1,10 @@
import { SearchOutlined } from '@ant-design/icons';
+import { Maybe, StructuredProperties } from '@src/types.generated';
import { Input } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { ANTD_GRAY } from '../../constants';
+import AddPropertyButton from './AddPropertyButton';
const StyledInput = styled(Input)`
border-radius: 70px;
@@ -12,13 +14,18 @@ const StyledInput = styled(Input)`
const TableHeader = styled.div`
padding: 8px 16px;
border-bottom: 1px solid ${ANTD_GRAY[4.5]};
+ display: flex;
+ justify-content: space-between;
`;
interface Props {
setFilterText: (text: string) => void;
+ fieldUrn?: string;
+ fieldProperties?: Maybe;
+ refetch?: () => void;
}
-export default function TabHeader({ setFilterText }: Props) {
+export default function TabHeader({ setFilterText, fieldUrn, fieldProperties, refetch }: Props) {
return (
}
/>
+
);
}
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
index b93ba886d5a642..4adaafc3d98b60 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/types.ts
@@ -22,4 +22,5 @@ export interface PropertyRow {
dataType?: DataTypeEntity;
isParentRow?: boolean;
structuredProperty?: StructuredPropertyEntity;
+ associatedUrn?: string;
}
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
index 4635486c24d1d6..18ee6bb18da3d3 100644
--- a/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx
+++ b/datahub-web-react/src/app/entity/shared/tabs/Properties/useStructuredProperties.tsx
@@ -64,6 +64,7 @@ function getStructuredPropertyRows(entityData?: GenericEntityProperties | null)
typeNameToType[structuredPropertiesEntry.values[0].__typename].nativeDataType,
}
: undefined,
+ associatedUrn: structuredPropertiesEntry.associatedUrn,
});
});
diff --git a/datahub-web-react/src/app/entity/shared/types.ts b/datahub-web-react/src/app/entity/shared/types.ts
index 00e501740c2ad8..ceba5b4bf30eb0 100644
--- a/datahub-web-react/src/app/entity/shared/types.ts
+++ b/datahub-web-react/src/app/entity/shared/types.ts
@@ -122,6 +122,7 @@ export type GenericEntityProperties = {
browsePathV2?: Maybe;
inputOutput?: Maybe;
forms?: Maybe;
+ parent?: Maybe;
};
export type GenericEntityUpdate = {
diff --git a/datahub-web-react/src/app/lineage/LineageEntityNode.tsx b/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
index fcf2198e7e0047..994090059754cd 100644
--- a/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
+++ b/datahub-web-react/src/app/lineage/LineageEntityNode.tsx
@@ -19,6 +19,10 @@ import ManageLineageMenu from './manage/ManageLineageMenu';
import { useGetLineageTimeParams } from './utils/useGetLineageTimeParams';
import { EntityHealth } from '../entity/shared/containers/profile/header/EntityHealth';
import { EntityType } from '../../types.generated';
+import StructuredPropertyBadge, {
+ MAX_PROP_BADGE_WIDTH,
+} from '../entity/shared/containers/profile/header/StructuredPropertyBadge';
+import { filterForAssetBadge } from '../entity/shared/containers/profile/header/utils';
const CLICK_DELAY_THRESHOLD = 1000;
const DRAG_DISTANCE_THRESHOLD = 20;
@@ -38,6 +42,11 @@ const MultilineTitleText = styled.p`
word-break: break-all;
`;
+const PropertyBadgeWrapper = styled.div`
+ display: flex;
+ justify-content: flex-end;
+`;
+
export default function LineageEntityNode({
node,
isSelected,
@@ -150,6 +159,11 @@ export default function LineageEntityNode({
const baseUrl = node.data.type && node.data.urn && entityRegistry.getEntityUrl(node.data.type, node.data.urn);
const hasHealth = (health && baseUrl) || false;
+ const entityStructuredProps = node.data.structuredProperties;
+ const hasAssetBadge = entityStructuredProps?.properties?.find(filterForAssetBadge);
+ const siblingStructuredProps = node.data.siblingStructuredProperties;
+ const siblingHasAssetBadge = siblingStructuredProps?.properties?.find(filterForAssetBadge);
+
return (
{unexploredHiddenChildren && (isHovered || isSelected) ? (
@@ -340,6 +354,32 @@ export default function LineageEntityNode({
/>
)}
+ {hasAssetBadge && (
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
+ {!hasAssetBadge && siblingHasAssetBadge && (
+ e.stopPropagation()}
+ >
+
+
+
+
+ )}
;
+ structuredProperties?: Maybe;
};
export type NodeData = {
@@ -82,6 +84,8 @@ export type NodeData = {
upstreamRelationships?: Array;
downstreamRelationships?: Array;
health?: Maybe;
+ structuredProperties?: Maybe;
+ siblingStructuredProperties?: Maybe;
};
export type VizNode = {
diff --git a/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts b/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
index 12d4cca352bb3f..bb9f29522d0cbb 100644
--- a/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
+++ b/datahub-web-react/src/app/lineage/utils/constructFetchedNode.ts
@@ -68,6 +68,7 @@ export default function constructFetchedNode(
upstreamRelationships: fetchedNode?.upstreamRelationships || [],
downstreamRelationships: fetchedNode?.downstreamRelationships || [],
health: fetchedNode?.health,
+ structuredProperties: fetchedNode?.structuredProperties,
};
// eslint-disable-next-line no-param-reassign
diff --git a/datahub-web-react/src/app/lineage/utils/constructTree.ts b/datahub-web-react/src/app/lineage/utils/constructTree.ts
index 38a865ea9e093d..4e94ad2813674e 100644
--- a/datahub-web-react/src/app/lineage/utils/constructTree.ts
+++ b/datahub-web-react/src/app/lineage/utils/constructTree.ts
@@ -83,6 +83,8 @@ export default function constructTree(
});
const fetchedEntity = entityRegistry.getLineageVizConfig(entityAndType.type, entityAndType.entity);
+ const sibling = fetchedEntity?.siblings?.siblings?.[0];
+ const fetchedSiblingEntity = sibling ? entityRegistry.getLineageVizConfig(sibling.type, sibling) : null;
const root: NodeData = {
name: fetchedEntity?.name || '',
@@ -100,6 +102,8 @@ export default function constructTree(
upstreamRelationships: fetchedEntity?.upstreamRelationships || [],
downstreamRelationships: fetchedEntity?.downstreamRelationships || [],
health: fetchedEntity?.health,
+ structuredProperties: fetchedEntity?.structuredProperties,
+ siblingStructuredProperties: fetchedSiblingEntity?.structuredProperties,
};
const lineageConfig = entityRegistry.getLineageVizConfig(entityAndType.type, entityAndType.entity);
let updatedLineageConfig = { ...lineageConfig };
diff --git a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
index 0d5cfaaf42b9a3..4c8948a6664e07 100644
--- a/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
+++ b/datahub-web-react/src/app/preview/DefaultPreviewCard.tsx
@@ -37,6 +37,8 @@ import { DataProductLink } from '../shared/tags/DataProductLink';
import { EntityHealth } from '../entity/shared/containers/profile/header/EntityHealth';
import SearchTextHighlighter from '../search/matches/SearchTextHighlighter';
import { getUniqueOwners } from './utils';
+import StructuredPropertyBadge from '../entity/shared/containers/profile/header/StructuredPropertyBadge';
+import { usePreviewData } from '../entity/shared/PreviewContext';
const PreviewContainer = styled.div`
display: flex;
@@ -245,6 +247,7 @@ export default function DefaultPreviewCard({
// sometimes these lists will be rendered inside an entity container (for example, in the case of impact analysis)
// in those cases, we may want to enrich the preview w/ context about the container entity
const { entityData } = useEntityData();
+ const previewData = usePreviewData();
const insightViews: Array = [
...(insights?.map((insight) => (
<>
@@ -305,6 +308,7 @@ export default function DefaultPreviewCard({
)}
{health && health.length > 0 ? : null}
+
{externalUrl && (
{
+ return {message}. ;
+};
+
+export default EmptySectionText;
diff --git a/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx b/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
index eaca969bb524eb..f03fc3b43f320f 100644
--- a/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
+++ b/datahub-web-react/src/utils/test-utils/TestPageContainer.tsx
@@ -26,6 +26,7 @@ import { DataPlatformEntity } from '../../app/entity/dataPlatform/DataPlatformEn
import { ContainerEntity } from '../../app/entity/container/ContainerEntity';
import AppConfigProvider from '../../AppConfigProvider';
import { BusinessAttributeEntity } from '../../app/entity/businessAttribute/BusinessAttributeEntity';
+import { SchemaFieldPropertiesEntity } from '../../app/entity/schemaField/SchemaFieldPropertiesEntity';
type Props = {
children: React.ReactNode;
@@ -49,6 +50,7 @@ export function getTestEntityRegistry() {
entityRegistry.register(new DataPlatformEntity());
entityRegistry.register(new ContainerEntity());
entityRegistry.register(new BusinessAttributeEntity());
+ entityRegistry.register(new SchemaFieldPropertiesEntity());
return entityRegistry;
}
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
index 1b12f540badfb8..612f923d5d68b7 100644
--- a/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
+++ b/entity-registry/src/main/java/com/linkedin/metadata/models/StructuredPropertyUtils.java
@@ -395,7 +395,8 @@ public static boolean validatePropertySettings(
if (settings.isIsHidden()) {
if (settings.isShowInSearchFilters()
|| settings.isShowInAssetSummary()
- || settings.isShowAsAssetBadge()) {
+ || settings.isShowAsAssetBadge()
+ || settings.isShowInColumnsTable()) {
if (shouldThrow) {
throw new IllegalArgumentException(INVALID_SETTINGS_MESSAGE);
} else {
From a5ce2dba87ca1cc11fef6ffd6ddacbd51e9a05ab Mon Sep 17 00:00:00 2001
From: Maggie Hays
Date: Wed, 11 Dec 2024 20:13:33 -0600
Subject: [PATCH 07/16] docs(champions): Update directory of DH Champions
(#12089)
Co-authored-by: Hyejin Yoon <0327jane@gmail.com>
---
docs-website/src/pages/champions/index.js | 292 ++++------------------
1 file changed, 49 insertions(+), 243 deletions(-)
diff --git a/docs-website/src/pages/champions/index.js b/docs-website/src/pages/champions/index.js
index 79ecf915e174d0..6fd938344bbe1f 100644
--- a/docs-website/src/pages/champions/index.js
+++ b/docs-website/src/pages/champions/index.js
@@ -10,240 +10,108 @@ const championSections = [
{
people: [
{
- name: "Siladitya Chakraborty",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/siladitya_chakraborty.jpeg",
- position: "Data Engineer, Adevinta",
- bio: (
- <>
-
- Submiitted 6 pull requests including improvements on graphQL and search API.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/aditya-0bab9a84/",
- github: "https://github.com/siladitya2",
- },
- location: "Barcelona, Spain"
- },
- {
- name: "Sergio Gómez Villamor",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/sergio_gomez_villamor.jpg",
- position: "Tech Lead, Adevinta",
- bio: (
- <>
-
- Submitted 26 pull requests and raised 4 issues, also featured in "Humans of DataHub."
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/sgomezvillamor/",
- github: "https://github.com/sgomezvillamor/",
- },
- location: "Barcelona,Spain"
- },
- {
- name: "Amanda Ng",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/amanda-ng.png",
- position: "Lead Software Engineer, Grab",
+ name: "Patrick Braz",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/patrick-franco-braz.jpeg",
+ position: "Data Engineering Specialist, Grupo Boticário",
bio: (
- <>
-
- Submitted 9 pull requests and shared Grab's experience adopting and implementing DataHub during the October 2022 Town Hall.
-
- >
- ),
- social: {
- linkedin: "https://sg.linkedin.com/in/amandang19",
- github: "https://github.com/ngamanda",
- web: "https://ngamanda.com/",
- },
- location: "Singapore"
- },
- {
- name: "Patrick Braz",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/patrick-franco-braz.jpeg",
- position: "Data Engineering Specialist, Grupo Boticário",
- bio: (
<>
- Submitted 16 pull requests and 3 issues and regularly provided guidance to Community Members in Slack channels.
+ Submitted 16 pull requests and 3 issues and regularly provides guidance to Community Members in Slack channels.
>
- ),
- social: {
+ ),
+ social: {
linkedin: "https://www.linkedin.com/in/patrick-franco-braz/",
github: "https://github.com/PatrickfBraz",
- },
- location: "Rio de Janeiro, Brazil"
- },
- {
- name: "Steve Pham",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/cuong-pham.jpeg",
- bio: (
- <>
-
- Submitted 4 pull requests and reliably provided direction to Community Members across all support channels in Slack.
-
- >
- ),
- social: {
- },
- },
- {
- name: "Piotr Skrydalewicz",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/ps_white.jpg",
- position: "Data Engineer",
- bio: (
- <>
-
- Contributed 5 commits in 2022 to the main DataHub Project and Helm repositories, including Stateful Ingestion support for Presto-on-Hive.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/skrydal",
- },
- location: "Lodz, Poland"
- },
- {
- name: "Harvey Li",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/hl_black.jpg",
- position: "Lead Data Engineer, Grab",
- bio: (
- <>
-
- Shared Grab's experience adopting and implementing DataHub during the October 2022 Town Hall and featured in Humans of DataHub.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/li-haihui",
- github: "https://github.com/HarveyLeo",
- },
- location: "Singapore"
},
+ location: "Rio de Janeiro, Brazil"
+ },
{
- name: "Fredrik Sannholm",
- position: "Wolt",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/initials/fs_black.jpg",
+ name: "Mike Burke",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/mb.jpg",
position: "",
bio: (
<>
- Drove DataHub adoption at Wolt and featured in Humans of DataHub.
-
+ Regularly provides support to Community Members and amplifies DataHub across his network.
+
>
- ),
+ ),
social: {
},
- location: "Finland"
- },
- {
- name: "Scott Maciej",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/initials/sm.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/scottmaciej/",
- github: "https://github.com/sgm44",
- web: "https://www.badhabitbeer.com/",
- },
- location: "USA"
},
{
- name: "Tim Bossenmaier",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/tim_bossenmaier.jpeg",
- position: "Data & Software Engineer, Cloudflight",
+ name: "Siladitya Chakraborty",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/siladitya_chakraborty.jpeg",
+ position: "Data Engineer, Adevinta",
bio: (
<>
- Reliably provides direction to community members and submitted 5 pull request, including improvements to Athena ingestion (support for nested schemas) and the REST emitter.
+ Submitted 16 pull requests including improvements on graphQL and search API.
>
),
social: {
- linkedin: "https://www.linkedin.com/in/tim-bossenmaier/",
- github: "https://github.com/bossenti",
- },
- location: "Innsbruck, Austria"
+ linkedin: "https://www.linkedin.com/in/aditya-0bab9a84/",
+ github: "https://github.com/siladitya2",
},
+ location: "Barcelona, Spain"
+ },
{
- name: "Nikola Kasev",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/nikola_kasev.jpeg",
- position: "Data Engineer, KPN",
+ name: "Tim Drahn",
+ position: "Optum",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/td.jpg",
+ position: "Solution Architect, Optum Technologies",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack.
+ Submitted 2 pull requests and 1 issue while reliably providing direction to Community Members across all support channels in Slack.
>
),
social: {
- linkedin: "https://www.linkedin.com/in/nikolakasev",
- github: "https://github.com/nikolakasev",
+ linkedin: "https://www.linkedin.com/in/tim-drahn-a873532b/",
+ github: "https://github.com/tkdrahn",
},
- location: "Haarlem, Noord-holland"
- },
+ location: "MA, USA"
+ },
{
- name: "Nidhin Nandhakumar",
- position: "Coursera",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/nn.jpg",
+ name: "Steve Pham",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/data-practitioners-guild/cuong-pham.jpeg",
bio: (
<>
- Drove DataHub's adaptation and implementation on Coursera.
+ Submitted 4 pull requests and reliably provided direction to Community Members across all support channels in Slack.
>
),
social: {
},
- },
+ },
{
- name: "Wu Teng",
- position: "CashApp",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/wt.jpg",
+ name: "David Schmidt",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/refs/heads/main/imgs/datahub-champions/david_schmidt.jpeg",
+ position: "Data Engineer, inovex GmbH",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack.
-
+ Regularly provides support to Community Members in Slack, submitted 4 pull requests and 6 issies.
+
>
),
social: {
+ linkedin: "https://www.linkedin.com/in/david-schmidt-de/",
+ github: "https://github.com/DSchmidtDev",
},
- location: "Australia"
},
{
- name: "Felipe Gusmao",
- position: "Zynga",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/fg.jpg",
- bio: (
- <>
-
- Shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
-
- >
- ),
- social: {
- },
- location: "Toronto, Canada"
- },
- {
name: "Sudhakara ST",
image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/ss.jpg",
position: "Engineer, Zynga",
bio: (
<>
- Reliably provided direction to Community Members across all support channels in Slack and shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
+ Reliably provides direction to Community Members across all support channels in Slack and shared Zynga's experience adopting and implementing DataHub during the September 2023 Town Hall.
>
),
@@ -253,83 +121,21 @@ const championSections = [
location: "Bengaluru, India"
},
{
- name: "Bobbie-Jean Nowak",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/bn.jpg",
- position: "Technical Product Manager, Optum ",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/bobbie-jean-nowak-a0076b77/",
- },
- location: "Minnesota, USA"
- },
- {
- name: "Dima Korendovych",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/dk.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- },
- location: "USA"
- },
- {
- name: "Tim Drahn",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/td.jpg",
- position: "Solution Architect, Optum Technologies",
- bio: (
- <>
-
- Submitted 2 pull requests and 1 issue while reliably providing direction to Community Members across all support channels in Slack.
-
- >
- ),
- social: {
- linkedin: "https://www.linkedin.com/in/tim-drahn-a873532b/",
- github: "https://github.com/tkdrahn",
- },
- location: "MA, USA"
- },
- {
- name: "Kate Koy",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/kk.jpg",
- bio: (
- <>
-
- Drove DataHub's adaptation and implementation at Optum.
-
- >
- ),
- social: {
- },
- location: "USA"
- },
- {
- name: "Anjali Arora",
- position: "Optum",
- image: "https://raw.githubusercontent.com/datahub-project/static-assets/main/imgs/datahub-champions/initials/aa.jpg",
+ name: "Tim Bossenmaier",
+ image: "https://raw.githubusercontent.com/datahub-project/static-assets/champ-img/imgs/datahub-champions/tim_bossenmaier.jpeg",
+ position: "Data & Software Engineer, Cloudflight",
bio: (
<>
- Drove DataHub's adaptation and implementation at Optum.
+ Reliably provides direction to community members and submitted 9 pull request, including improvements to Athena ingestion (support for nested schemas) and the REST emitter.
>
),
social: {
+ linkedin: "https://www.linkedin.com/in/tim-bossenmaier/",
+ github: "https://github.com/bossenti",
},
- location: "USA"
+ location: "Innsbruck, Austria"
},
{
name: "Raj Tekal",
From 84de42d718ee52be17e77f04bb4ae0df6cd1d2f6 Mon Sep 17 00:00:00 2001
From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com>
Date: Thu, 12 Dec 2024 12:04:06 +0530
Subject: [PATCH 08/16] feat(ingest/snowflake): ingest secure, dynamic, hybrid
table metadata (#12094)
---
.../source/snowflake/snowflake_query.py | 8 ++++++--
.../source/snowflake/snowflake_report.py | 1 +
.../source/snowflake/snowflake_schema.py | 12 ++++++++++++
.../source/snowflake/snowflake_schema_gen.py | 19 +++++++++++++++++--
4 files changed, 36 insertions(+), 4 deletions(-)
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 bb5d0636f67123..99790de529ac3a 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_query.py
@@ -129,7 +129,9 @@ def tables_for_database(db_name: Optional[str]) -> str:
row_count AS "ROW_COUNT",
bytes AS "BYTES",
clustering_key AS "CLUSTERING_KEY",
- auto_clustering_on AS "AUTO_CLUSTERING_ON"
+ auto_clustering_on AS "AUTO_CLUSTERING_ON",
+ is_dynamic AS "IS_DYNAMIC",
+ is_iceberg AS "IS_ICEBERG"
FROM {db_clause}information_schema.tables t
WHERE table_schema != 'INFORMATION_SCHEMA'
and table_type in ( 'BASE TABLE', 'EXTERNAL TABLE', 'HYBRID TABLE')
@@ -149,7 +151,9 @@ def tables_for_schema(schema_name: str, db_name: Optional[str]) -> str:
row_count AS "ROW_COUNT",
bytes AS "BYTES",
clustering_key AS "CLUSTERING_KEY",
- auto_clustering_on AS "AUTO_CLUSTERING_ON"
+ auto_clustering_on AS "AUTO_CLUSTERING_ON",
+ is_dynamic AS "IS_DYNAMIC",
+ is_iceberg AS "IS_ICEBERG"
FROM {db_clause}information_schema.tables t
where table_schema='{schema_name}'
and table_type in ('BASE TABLE', 'EXTERNAL TABLE', 'HYBRID TABLE')
diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_report.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_report.py
index b5f56f99431f91..030b2d43be81f9 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_report.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_report.py
@@ -113,6 +113,7 @@ class SnowflakeV2Report(
external_lineage_queries_secs: float = -1
num_tables_with_known_upstreams: int = 0
num_upstream_lineage_edge_parsing_failed: int = 0
+ num_secure_views_missing_definition: int = 0
data_dictionary_cache: Optional["SnowflakeDataDictionary"] = None
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 600292c2c99429..5a69b4bb779d72 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema.py
@@ -90,6 +90,12 @@ class SnowflakeTable(BaseTable):
foreign_keys: List[SnowflakeFK] = field(default_factory=list)
tags: Optional[List[SnowflakeTag]] = None
column_tags: Dict[str, List[SnowflakeTag]] = field(default_factory=dict)
+ is_dynamic: bool = False
+ is_iceberg: bool = False
+
+ @property
+ def is_hybrid(self) -> bool:
+ return self.type is not None and self.type == "HYBRID TABLE"
@dataclass
@@ -98,6 +104,7 @@ class SnowflakeView(BaseView):
columns: List[SnowflakeColumn] = field(default_factory=list)
tags: Optional[List[SnowflakeTag]] = None
column_tags: Dict[str, List[SnowflakeTag]] = field(default_factory=dict)
+ is_secure: bool = False
@dataclass
@@ -289,6 +296,8 @@ def get_tables_for_database(
rows_count=table["ROW_COUNT"],
comment=table["COMMENT"],
clustering_key=table["CLUSTERING_KEY"],
+ is_dynamic=table.get("IS_DYNAMIC", "NO").upper() == "YES",
+ is_iceberg=table.get("IS_ICEBERG", "NO").upper() == "YES",
)
)
return tables
@@ -313,6 +322,8 @@ def get_tables_for_schema(
rows_count=table["ROW_COUNT"],
comment=table["COMMENT"],
clustering_key=table["CLUSTERING_KEY"],
+ is_dynamic=table.get("IS_DYNAMIC", "NO").upper() == "YES",
+ is_iceberg=table.get("IS_ICEBERG", "NO").upper() == "YES",
)
)
return tables
@@ -356,6 +367,7 @@ def get_views_for_database(self, db_name: str) -> Dict[str, List[SnowflakeView]]
materialized=(
view.get("is_materialized", "false").lower() == "true"
),
+ is_secure=(view.get("is_secure", "false").lower() == "true"),
)
)
diff --git a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema_gen.py b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema_gen.py
index 2bd8e8017f5492..4ceeb8560c1758 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema_gen.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_schema_gen.py
@@ -431,6 +431,8 @@ def _process_schema(
default_db=db_name,
default_schema=schema_name,
)
+ elif view.is_secure:
+ self.report.num_secure_views_missing_definition += 1
if self.config.include_technical_schema:
for view in views:
@@ -749,8 +751,21 @@ def get_dataset_properties(
) -> DatasetProperties:
custom_properties = {}
- if isinstance(table, SnowflakeTable) and table.clustering_key:
- custom_properties["CLUSTERING_KEY"] = table.clustering_key
+ if isinstance(table, SnowflakeTable):
+ if table.clustering_key:
+ custom_properties["CLUSTERING_KEY"] = table.clustering_key
+
+ if table.is_hybrid:
+ custom_properties["IS_HYBRID"] = "true"
+
+ if table.is_dynamic:
+ custom_properties["IS_DYNAMIC"] = "true"
+
+ if table.is_iceberg:
+ custom_properties["IS_ICEBERG"] = "true"
+
+ if isinstance(table, SnowflakeView) and table.is_secure:
+ custom_properties["IS_SECURE"] = "true"
return DatasetProperties(
name=table.name,
From 7339848f3e8890f05746112df77db59164efbed0 Mon Sep 17 00:00:00 2001
From: Jorrick Sleijster
Date: Thu, 12 Dec 2024 10:20:08 +0100
Subject: [PATCH 09/16] feat(spark):OpenLineage 1.25.0 (#12041)
---
build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.gradle b/build.gradle
index be4d7ee8a562b9..3df3ffe6abfbb8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -57,7 +57,7 @@ buildscript {
ext.hazelcastVersion = '5.3.6'
ext.ebeanVersion = '15.5.2'
ext.googleJavaFormatVersion = '1.18.1'
- ext.openLineageVersion = '1.24.2'
+ ext.openLineageVersion = '1.25.0'
ext.logbackClassicJava8 = '1.2.12'
ext.docker_registry = 'acryldata'
From 3ae82f6b49b15cebe8e6351270943d765a9cda60 Mon Sep 17 00:00:00 2001
From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com>
Date: Thu, 12 Dec 2024 15:24:35 +0530
Subject: [PATCH 10/16] fix(ingest): always resolve platform for browse path v2
(#12045)
---
metadata-ingestion/src/datahub/ingestion/api/source.py | 6 +++++-
.../ingestion/source/state/redundant_run_skip_handler.py | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/metadata-ingestion/src/datahub/ingestion/api/source.py b/metadata-ingestion/src/datahub/ingestion/api/source.py
index 586b1c610dc756..c80da04e481a9f 100644
--- a/metadata-ingestion/src/datahub/ingestion/api/source.py
+++ b/metadata-ingestion/src/datahub/ingestion/api/source.py
@@ -492,11 +492,15 @@ def close(self) -> None:
def _infer_platform(self) -> Optional[str]:
config = self.get_config()
- return (
+ platform = (
getattr(config, "platform_name", None)
or getattr(self, "platform", None)
or getattr(config, "platform", None)
)
+ if platform is None and hasattr(self, "get_platform_id"):
+ platform = type(self).get_platform_id()
+
+ return platform
def _get_browse_path_processor(self, dry_run: bool) -> MetadataWorkUnitProcessor:
config = self.get_config()
diff --git a/metadata-ingestion/src/datahub/ingestion/source/state/redundant_run_skip_handler.py b/metadata-ingestion/src/datahub/ingestion/source/state/redundant_run_skip_handler.py
index a2e078f233f1d4..8630a959d3f6a3 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/state/redundant_run_skip_handler.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/state/redundant_run_skip_handler.py
@@ -69,7 +69,7 @@ def _init_job_id(self) -> JobId:
platform: Optional[str] = None
source_class = type(self.source)
if hasattr(source_class, "get_platform_name"):
- platform = source_class.get_platform_name() # type: ignore
+ platform = source_class.get_platform_name()
# Default name for everything else
job_name_suffix = self.get_job_name_suffix()
From 9a7f98cd050eb5ee2f2a9ba58dbf8bb3f4a45c54 Mon Sep 17 00:00:00 2001
From: Aseem Bansal
Date: Thu, 12 Dec 2024 15:29:00 +0530
Subject: [PATCH 11/16] fix(ingest/sdk): report recipe correctly (#12101)
---
metadata-ingestion/sink_docs/metadata-file.md | 4 ++--
.../reporting/datahub_ingestion_run_summary_provider.py | 4 ++--
metadata-ingestion/src/datahub/ingestion/run/pipeline.py | 2 +-
.../src/datahub/ingestion/run/pipeline_config.py | 6 ++++++
4 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/metadata-ingestion/sink_docs/metadata-file.md b/metadata-ingestion/sink_docs/metadata-file.md
index 7cac8d55422438..49ca3c75397af4 100644
--- a/metadata-ingestion/sink_docs/metadata-file.md
+++ b/metadata-ingestion/sink_docs/metadata-file.md
@@ -25,7 +25,7 @@ source:
sink:
type: file
config:
- filename: ./path/to/mce/file.json
+ path: ./path/to/mce/file.json
```
## Config details
@@ -34,4 +34,4 @@ Note that a `.` is used to denote nested fields in the YAML recipe.
| Field | Required | Default | Description |
| -------- | -------- | ------- | ------------------------- |
-| filename | ✅ | | Path to file to write to. |
+| path | ✅ | | Path to file to write to. |
diff --git a/metadata-ingestion/src/datahub/ingestion/reporting/datahub_ingestion_run_summary_provider.py b/metadata-ingestion/src/datahub/ingestion/reporting/datahub_ingestion_run_summary_provider.py
index 5961a553a14943..28def68ccf3f55 100644
--- a/metadata-ingestion/src/datahub/ingestion/reporting/datahub_ingestion_run_summary_provider.py
+++ b/metadata-ingestion/src/datahub/ingestion/reporting/datahub_ingestion_run_summary_provider.py
@@ -148,10 +148,10 @@ def __init__(self, sink: Sink, report_recipe: bool, ctx: PipelineContext) -> Non
def _get_recipe_to_report(self, ctx: PipelineContext) -> str:
assert ctx.pipeline_config
- if not self.report_recipe or not ctx.pipeline_config._raw_dict:
+ if not self.report_recipe or not ctx.pipeline_config.get_raw_dict():
return ""
else:
- return json.dumps(redact_raw_config(ctx.pipeline_config._raw_dict))
+ return json.dumps(redact_raw_config(ctx.pipeline_config.get_raw_dict()))
def _emit_aspect(self, entity_urn: Urn, aspect_value: _Aspect) -> None:
self.sink.write_record_async(
diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py
index 667129ff83584a..ee1c1608cd48c6 100644
--- a/metadata-ingestion/src/datahub/ingestion/run/pipeline.py
+++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline.py
@@ -221,7 +221,7 @@ def __init__(
dry_run: bool = False,
preview_mode: bool = False,
preview_workunits: int = 10,
- report_to: Optional[str] = None,
+ report_to: Optional[str] = "datahub",
no_progress: bool = False,
):
self.config = config
diff --git a/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py b/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py
index 2b2f992249f1e8..7a4e7ec52a8e96 100644
--- a/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py
+++ b/metadata-ingestion/src/datahub/ingestion/run/pipeline_config.py
@@ -117,3 +117,9 @@ def from_dict(
config = cls.parse_obj(resolved_dict)
config._raw_dict = raw_dict
return config
+
+ def get_raw_dict(self) -> Dict:
+ result = self._raw_dict
+ if result is None:
+ result = self.dict()
+ return result
From 16a5db1f0c337cbce91cfe96255a41a8c113e6e9 Mon Sep 17 00:00:00 2001
From: Aseem Bansal
Date: Thu, 12 Dec 2024 15:29:40 +0530
Subject: [PATCH 12/16] feat(cli): add --workers arg in delete command (#12102)
---
.../src/datahub/cli/delete_cli.py | 86 ++++++++++++++-----
1 file changed, 66 insertions(+), 20 deletions(-)
diff --git a/metadata-ingestion/src/datahub/cli/delete_cli.py b/metadata-ingestion/src/datahub/cli/delete_cli.py
index a640f941b75276..1a75459a92c5cf 100644
--- a/metadata-ingestion/src/datahub/cli/delete_cli.py
+++ b/metadata-ingestion/src/datahub/cli/delete_cli.py
@@ -1,4 +1,5 @@
import logging
+from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from random import choices
@@ -345,6 +346,9 @@ def undo_by_filter(
default=False,
help="Only delete soft-deleted entities, for hard deletion",
)
+@click.option(
+ "--workers", type=int, default=1, help="Num of workers to use for deletion."
+)
@upgrade.check_upgrade
@telemetry.with_telemetry()
def by_filter(
@@ -362,6 +366,7 @@ def by_filter(
batch_size: int,
dry_run: bool,
only_soft_deleted: bool,
+ workers: int = 1,
) -> None:
"""Delete metadata from datahub using a single urn or a combination of filters."""
@@ -382,16 +387,19 @@ def by_filter(
# TODO: add some validation on entity_type
if not force and not soft and not dry_run:
+ message = (
+ "Hard deletion will permanently delete data from DataHub and can be slow. "
+ "We generally recommend using soft deletes instead. "
+ "Do you want to continue?"
+ )
if only_soft_deleted:
click.confirm(
- "This will permanently delete data from DataHub. Do you want to continue?",
+ message,
abort=True,
)
else:
click.confirm(
- "Hard deletion will permanently delete data from DataHub and can be slow. "
- "We generally recommend using soft deletes instead. "
- "Do you want to continue?",
+ message,
abort=True,
)
@@ -462,26 +470,64 @@ def by_filter(
abort=True,
)
- urns_iter = urns
- if not delete_by_urn and not dry_run:
- urns_iter = progressbar.progressbar(urns, redirect_stdout=True)
+ _delete_urns_parallel(
+ graph=graph,
+ urns=urns,
+ aspect_name=aspect,
+ soft=soft,
+ dry_run=dry_run,
+ delete_by_urn=delete_by_urn,
+ start_time=start_time,
+ end_time=end_time,
+ workers=workers,
+ )
+
- # Run the deletion.
+def _delete_urns_parallel(
+ graph: DataHubGraph,
+ urns: List[str],
+ delete_by_urn: bool,
+ start_time: Optional[datetime],
+ end_time: Optional[datetime],
+ aspect_name: Optional[str] = None,
+ soft: bool = True,
+ dry_run: bool = False,
+ workers: int = 1,
+) -> None:
deletion_result = DeletionResult()
- with PerfTimer() as timer:
- for urn in urns_iter:
- one_result = _delete_one_urn(
- graph=graph,
- urn=urn,
- aspect_name=aspect,
- soft=soft,
- dry_run=dry_run,
- start_time=start_time,
- end_time=end_time,
+
+ def process_urn(urn):
+ return _delete_one_urn(
+ graph=graph,
+ urn=urn,
+ aspect_name=aspect_name,
+ soft=soft,
+ dry_run=dry_run,
+ start_time=start_time,
+ end_time=end_time,
+ )
+
+ with PerfTimer() as timer, ThreadPoolExecutor(max_workers=workers) as executor:
+ future_to_urn = {executor.submit(process_urn, urn): urn for urn in urns}
+
+ completed_futures = as_completed(future_to_urn)
+ if not delete_by_urn and not dry_run:
+ futures_iter = progressbar.progressbar(
+ as_completed(future_to_urn),
+ max_value=len(future_to_urn),
+ redirect_stdout=True,
)
- deletion_result.merge(one_result)
+ else:
+ futures_iter = completed_futures
+
+ for future in futures_iter:
+ try:
+ one_result = future.result()
+ deletion_result.merge(one_result)
+ except Exception as e:
+ urn = future_to_urn[future]
+ click.secho(f"Error processing URN {urn}: {e}", fg="red")
- # Report out a summary of the deletion result.
click.echo(
deletion_result.format_message(
dry_run=dry_run, soft=soft, time_sec=timer.elapsed_seconds()
From 93c8ae2267a6df9d5b068b3f542198a0ca6b4351 Mon Sep 17 00:00:00 2001
From: Harshal Sheth
Date: Thu, 12 Dec 2024 05:01:32 -0500
Subject: [PATCH 13/16] fix(ingest/snowflake): handle dots in snowflake table
names (#12105)
---
.../source/snowflake/snowflake_utils.py | 50 +++++++++++++++++--
.../src/datahub/testing/doctest.py | 12 +++++
.../tests/integration/git/test_git_clone.py | 14 ++----
.../unit/sagemaker/test_sagemaker_source.py | 17 ++-----
.../unit/snowflake/test_snowflake_source.py | 6 +++
.../tests/unit/test_dbt_source.py | 4 +-
.../tests/unit/utilities/test_utilities.py | 14 ++----
7 files changed, 77 insertions(+), 40 deletions(-)
create mode 100644 metadata-ingestion/src/datahub/testing/doctest.py
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 5e79530d2391b8..d8c3075bd921b9 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/snowflake/snowflake_utils.py
@@ -1,6 +1,6 @@
import abc
from functools import cached_property
-from typing import ClassVar, Literal, Optional, Tuple
+from typing import ClassVar, List, Literal, Optional, Tuple
from datahub.configuration.pattern_utils import is_schema_allowed
from datahub.emitter.mce_builder import make_dataset_urn_with_platform_instance
@@ -184,6 +184,46 @@ def _is_sys_table(table_name: str) -> bool:
return table_name.lower().startswith("sys$")
+def _split_qualified_name(qualified_name: str) -> List[str]:
+ """
+ Split a qualified name into its constituent parts.
+
+ >>> _split_qualified_name("db.my_schema.my_table")
+ ['db', 'my_schema', 'my_table']
+ >>> _split_qualified_name('"db"."my_schema"."my_table"')
+ ['db', 'my_schema', 'my_table']
+ >>> _split_qualified_name('TEST_DB.TEST_SCHEMA."TABLE.WITH.DOTS"')
+ ['TEST_DB', 'TEST_SCHEMA', 'TABLE.WITH.DOTS']
+ >>> _split_qualified_name('TEST_DB."SCHEMA.WITH.DOTS".MY_TABLE')
+ ['TEST_DB', 'SCHEMA.WITH.DOTS', 'MY_TABLE']
+ """
+
+ # Fast path - no quotes.
+ if '"' not in qualified_name:
+ return qualified_name.split(".")
+
+ # First pass - split on dots that are not inside quotes.
+ in_quote = False
+ parts: List[List[str]] = [[]]
+ for char in qualified_name:
+ if char == '"':
+ in_quote = not in_quote
+ elif char == "." and not in_quote:
+ parts.append([])
+ else:
+ parts[-1].append(char)
+
+ # Second pass - remove outer pairs of quotes.
+ result = []
+ for part in parts:
+ if len(part) > 2 and part[0] == '"' and part[-1] == '"':
+ part = part[1:-1]
+
+ result.append("".join(part))
+
+ return result
+
+
# Qualified Object names from snowflake audit logs have quotes for for snowflake quoted identifiers,
# For example "test-database"."test-schema".test_table
# whereas we generate urns without quotes even for quoted identifiers for backward compatibility
@@ -192,7 +232,7 @@ def _is_sys_table(table_name: str) -> bool:
def _cleanup_qualified_name(
qualified_name: str, structured_reporter: SourceReport
) -> str:
- name_parts = qualified_name.split(".")
+ name_parts = _split_qualified_name(qualified_name)
if len(name_parts) != 3:
if not _is_sys_table(qualified_name):
structured_reporter.info(
@@ -203,9 +243,9 @@ def _cleanup_qualified_name(
)
return qualified_name.replace('"', "")
return _combine_identifier_parts(
- db_name=name_parts[0].strip('"'),
- schema_name=name_parts[1].strip('"'),
- table_name=name_parts[2].strip('"'),
+ db_name=name_parts[0],
+ schema_name=name_parts[1],
+ table_name=name_parts[2],
)
diff --git a/metadata-ingestion/src/datahub/testing/doctest.py b/metadata-ingestion/src/datahub/testing/doctest.py
new file mode 100644
index 00000000000000..b89df5c65c7e1b
--- /dev/null
+++ b/metadata-ingestion/src/datahub/testing/doctest.py
@@ -0,0 +1,12 @@
+import doctest
+from types import ModuleType
+
+
+def assert_doctest(module: ModuleType) -> None:
+ result = doctest.testmod(
+ module,
+ raise_on_error=True,
+ verbose=True,
+ )
+ if result.attempted == 0:
+ raise ValueError(f"No doctests found in {module.__name__}")
diff --git a/metadata-ingestion/tests/integration/git/test_git_clone.py b/metadata-ingestion/tests/integration/git/test_git_clone.py
index 773e84cbf7488b..60cf20fefcbdd1 100644
--- a/metadata-ingestion/tests/integration/git/test_git_clone.py
+++ b/metadata-ingestion/tests/integration/git/test_git_clone.py
@@ -1,12 +1,13 @@
-import doctest
import os
import pytest
from pydantic import SecretStr
+import datahub.ingestion.source.git.git_import
from datahub.configuration.common import ConfigurationWarning
from datahub.configuration.git import GitInfo, GitReference
from datahub.ingestion.source.git.git_import import GitClone
+from datahub.testing.doctest import assert_doctest
LOOKML_TEST_SSH_KEY = os.environ.get("DATAHUB_LOOKML_GIT_TEST_SSH_KEY")
@@ -82,15 +83,8 @@ def test_github_branch():
assert config.branch_for_clone == "main"
-def test_sanitize_repo_url():
- import datahub.ingestion.source.git.git_import
-
- assert (
- doctest.testmod(
- datahub.ingestion.source.git.git_import, raise_on_error=True
- ).attempted
- == 3
- )
+def test_sanitize_repo_url() -> None:
+ assert_doctest(datahub.ingestion.source.git.git_import)
def test_git_clone_public(tmp_path):
diff --git a/metadata-ingestion/tests/unit/sagemaker/test_sagemaker_source.py b/metadata-ingestion/tests/unit/sagemaker/test_sagemaker_source.py
index 138319feb3db67..c7a1fab068a838 100644
--- a/metadata-ingestion/tests/unit/sagemaker/test_sagemaker_source.py
+++ b/metadata-ingestion/tests/unit/sagemaker/test_sagemaker_source.py
@@ -3,6 +3,7 @@
from botocore.stub import Stubber
from freezegun import freeze_time
+import datahub.ingestion.source.aws.sagemaker_processors.models
from datahub.ingestion.api.common import PipelineContext
from datahub.ingestion.sink.file import write_metadata_file
from datahub.ingestion.source.aws.sagemaker import (
@@ -13,6 +14,7 @@
job_type_to_info,
job_types,
)
+from datahub.testing.doctest import assert_doctest
from tests.test_helpers import mce_helpers
from tests.unit.sagemaker.test_sagemaker_source_stubs import (
describe_endpoint_response_1,
@@ -243,16 +245,5 @@ def test_sagemaker_ingest(tmp_path, pytestconfig):
)
-def test_doc_test_run():
- import doctest
-
- import datahub.ingestion.source.aws.sagemaker_processors.models
-
- assert (
- doctest.testmod(
- datahub.ingestion.source.aws.sagemaker_processors.models,
- raise_on_error=True,
- verbose=True,
- ).attempted
- == 1
- )
+def test_doc_test_run() -> None:
+ assert_doctest(datahub.ingestion.source.aws.sagemaker_processors.models)
diff --git a/metadata-ingestion/tests/unit/snowflake/test_snowflake_source.py b/metadata-ingestion/tests/unit/snowflake/test_snowflake_source.py
index 3284baf103e5af..c735feb5396086 100644
--- a/metadata-ingestion/tests/unit/snowflake/test_snowflake_source.py
+++ b/metadata-ingestion/tests/unit/snowflake/test_snowflake_source.py
@@ -4,6 +4,7 @@
import pytest
from pydantic import ValidationError
+import datahub.ingestion.source.snowflake.snowflake_utils
from datahub.configuration.common import AllowDenyPattern
from datahub.configuration.pattern_utils import UUID_REGEX
from datahub.ingestion.api.source import SourceCapability
@@ -26,6 +27,7 @@
)
from datahub.ingestion.source.snowflake.snowflake_utils import SnowsightUrlBuilder
from datahub.ingestion.source.snowflake.snowflake_v2 import SnowflakeV2Source
+from datahub.testing.doctest import assert_doctest
from tests.test_helpers import test_connection_helpers
default_oauth_dict: Dict[str, Any] = {
@@ -658,3 +660,7 @@ def test_create_snowsight_base_url_ap_northeast_1():
).snowsight_base_url
assert result == "https://app.snowflake.com/ap-northeast-1.aws/account_locator/"
+
+
+def test_snowflake_utils() -> None:
+ assert_doctest(datahub.ingestion.source.snowflake.snowflake_utils)
diff --git a/metadata-ingestion/tests/unit/test_dbt_source.py b/metadata-ingestion/tests/unit/test_dbt_source.py
index f0d4c3408271f7..0a869297837014 100644
--- a/metadata-ingestion/tests/unit/test_dbt_source.py
+++ b/metadata-ingestion/tests/unit/test_dbt_source.py
@@ -1,4 +1,3 @@
-import doctest
from datetime import timedelta
from typing import Dict, List, Union
from unittest import mock
@@ -22,6 +21,7 @@
OwnershipSourceTypeClass,
OwnershipTypeClass,
)
+from datahub.testing.doctest import assert_doctest
def create_owners_list_from_urn_list(
@@ -442,7 +442,7 @@ def test_dbt_cloud_config_with_defined_metadata_endpoint():
def test_infer_metadata_endpoint() -> None:
- assert doctest.testmod(dbt_cloud, raise_on_error=True).attempted > 0
+ assert_doctest(dbt_cloud)
def test_dbt_time_parsing() -> None:
diff --git a/metadata-ingestion/tests/unit/utilities/test_utilities.py b/metadata-ingestion/tests/unit/utilities/test_utilities.py
index 91819bff41e629..c333ceb136fffc 100644
--- a/metadata-ingestion/tests/unit/utilities/test_utilities.py
+++ b/metadata-ingestion/tests/unit/utilities/test_utilities.py
@@ -1,9 +1,10 @@
-import doctest
import re
from typing import List
+import datahub.utilities.logging_manager
from datahub.sql_parsing.schema_resolver import SchemaResolver
from datahub.sql_parsing.sqlglot_lineage import sqlglot_lineage
+from datahub.testing.doctest import assert_doctest
from datahub.utilities.delayed_iter import delayed_iter
from datahub.utilities.is_pytest import is_pytest_running
from datahub.utilities.urns.dataset_urn import DatasetUrn
@@ -328,15 +329,8 @@ def test_sqllineage_sql_parser_tables_with_special_names():
assert sorted(SqlLineageSQLParser(sql_query).get_columns()) == expected_columns
-def test_logging_name_extraction():
- import datahub.utilities.logging_manager
-
- assert (
- doctest.testmod(
- datahub.utilities.logging_manager, raise_on_error=True
- ).attempted
- > 0
- )
+def test_logging_name_extraction() -> None:
+ assert_doctest(datahub.utilities.logging_manager)
def test_is_pytest_running() -> None:
From 47bd4464a35747caefa8e712111340b071e167d7 Mon Sep 17 00:00:00 2001
From: sid-acryl <155424659+sid-acryl@users.noreply.github.com>
Date: Thu, 12 Dec 2024 15:31:50 +0530
Subject: [PATCH 14/16] fix(ingest/tableau): apply `page_size` regardless of
object count (#12026)
---
.../src/datahub/ingestion/source/tableau/tableau_common.py | 1 -
.../tests/integration/tableau/test_tableau_ingest.py | 4 ++--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau_common.py b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau_common.py
index ac917c5f128ed2..c5d14e0afe15a5 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau_common.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/tableau/tableau_common.py
@@ -979,7 +979,6 @@ def get_filter_pages(query_filter: dict, page_size: int) -> List[dict]:
len(query_filter.keys()) == 1
and query_filter.get(c.ID_WITH_IN)
and isinstance(query_filter[c.ID_WITH_IN], list)
- and len(query_filter[c.ID_WITH_IN]) > 100 * page_size
):
ids = query_filter[c.ID_WITH_IN]
filter_pages = [
diff --git a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py
index 1665b1401a636a..5b557efdab0bb0 100644
--- a/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py
+++ b/metadata-ingestion/tests/integration/tableau/test_tableau_ingest.py
@@ -60,7 +60,7 @@
"site": "acryl",
"projects": ["default", "Project 2", "Samples"],
"extract_project_hierarchy": False,
- "page_size": 10,
+ "page_size": 1000,
"ingest_tags": True,
"ingest_owner": True,
"ingest_tables_external": True,
@@ -673,7 +673,7 @@ def test_tableau_ingest_with_platform_instance(
"site": "acryl",
"platform_instance": "acryl_site1",
"projects": ["default", "Project 2"],
- "page_size": 10,
+ "page_size": 1000,
"ingest_tags": True,
"ingest_owner": True,
"ingest_tables_external": True,
From c0b49a638fe2e4e3892f0be40c441f603ca510f9 Mon Sep 17 00:00:00 2001
From: Mayuri Nehate <33225191+mayurinehate@users.noreply.github.com>
Date: Thu, 12 Dec 2024 15:32:15 +0530
Subject: [PATCH 15/16] docs(ingest/snowflake): update permissions for dynamic
tables (#12074)
---
docs/quick-ingestion-guides/snowflake/setup.md | 5 +++++
metadata-ingestion/docs/sources/snowflake/snowflake_pre.md | 4 ++++
2 files changed, 9 insertions(+)
diff --git a/docs/quick-ingestion-guides/snowflake/setup.md b/docs/quick-ingestion-guides/snowflake/setup.md
index af5f15492376b5..aaa9c67014814f 100644
--- a/docs/quick-ingestion-guides/snowflake/setup.md
+++ b/docs/quick-ingestion-guides/snowflake/setup.md
@@ -43,6 +43,8 @@ In order to configure ingestion from Snowflake, you'll first have to ensure you
grant select on future external tables in database identifier($db_var) to role datahub_role;
grant select on all views in database identifier($db_var) to role datahub_role;
grant select on future views in database identifier($db_var) to role datahub_role;
+ grant select on all dynamic tables in database identifier($db_var) to role datahub_role;
+ grant select on future dynamic tables in database identifier($db_var) to role datahub_role;
-- Grant access to view tables and views
grant references on all tables in database identifier($db_var) to role datahub_role;
@@ -51,6 +53,9 @@ In order to configure ingestion from Snowflake, you'll first have to ensure you
grant references on future external tables in database identifier($db_var) to role datahub_role;
grant references on all views in database identifier($db_var) to role datahub_role;
grant references on future views in database identifier($db_var) to role datahub_role;
+ -- Grant access to dynamic tables
+ grant monitor on all dynamic tables in database identifier($db_var) to role datahub_role;
+ grant monitor on future dynamic tables in database identifier($db_var) to role datahub_role;
-- Assign privileges to extract lineage and usage statistics from Snowflake by executing the below query.
grant imported privileges on database snowflake to role datahub_role;
diff --git a/metadata-ingestion/docs/sources/snowflake/snowflake_pre.md b/metadata-ingestion/docs/sources/snowflake/snowflake_pre.md
index 75bd579417a48f..4cfbc470e8c239 100644
--- a/metadata-ingestion/docs/sources/snowflake/snowflake_pre.md
+++ b/metadata-ingestion/docs/sources/snowflake/snowflake_pre.md
@@ -23,12 +23,16 @@ grant references on all external tables in database "" to role da
grant references on future external tables in database "" to role datahub_role;
grant references on all views in database "" to role datahub_role;
grant references on future views in database "" to role datahub_role;
+grant monitor on all dynamic tables in database "" to role datahub_role;
+grant monitor on future dynamic tables in database "" to role datahub_role;
// If you ARE using Snowflake Profiling or Classification feature: Grant select privileges to your tables
grant select on all tables in database "" to role datahub_role;
grant select on future tables in database "" to role datahub_role;
grant select on all external tables in database "" to role datahub_role;
grant select on future external tables in database "" to role datahub_role;
+grant select on all dynamic tables in database "" to role datahub_role;
+grant select on future dynamic tables in database "" to role datahub_role;
// Create a new DataHub user and assign the DataHub role to it
create user datahub_user display_name = 'DataHub' password='' default_role = datahub_role default_warehouse = '';
From 2ec9cb053696daf07b1abce1b8e1c3012f58d88d Mon Sep 17 00:00:00 2001
From: sid-acryl <155424659+sid-acryl@users.noreply.github.com>
Date: Thu, 12 Dec 2024 15:32:56 +0530
Subject: [PATCH 16/16] fix(ingestion/lookml): resolve CLL issue caused by
column name casing. (#11876)
---
.../source/looker/lookml_concept_context.py | 3 +-
.../ingestion/source/looker/view_upstream.py | 95 +++--
.../datahub/sql_parsing/schema_resolver.py | 23 ++
.../datahub/sql_parsing/sqlglot_lineage.py | 61 ++-
.../gms_schema_resolution/data.model.lkml | 6 +
.../top_10_employee_income_source.view.lkml | 18 +
.../lookml/gms_schema_resolution_golden.json | 358 ++++++++++++++++++
.../tests/integration/lookml/test_lookml.py | 44 ++-
.../unit/sql_parsing/test_schemaresolver.py | 60 ++-
9 files changed, 614 insertions(+), 54 deletions(-)
create mode 100644 metadata-ingestion/tests/integration/lookml/gms_schema_resolution/data.model.lkml
create mode 100644 metadata-ingestion/tests/integration/lookml/gms_schema_resolution/top_10_employee_income_source.view.lkml
create mode 100644 metadata-ingestion/tests/integration/lookml/gms_schema_resolution_golden.json
diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_concept_context.py b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_concept_context.py
index 80be566cdcd468..103f4175a9ccff 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_concept_context.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/looker/lookml_concept_context.py
@@ -88,8 +88,7 @@ def column_name_in_sql_attribute(self) -> List[str]:
for upstream_field_match in re.finditer(r"\${TABLE}\.[\"]*([\.\w]+)", sql):
matched_field = upstream_field_match.group(1)
# Remove quotes from field names
- matched_field = matched_field.replace('"', "").replace("`", "").lower()
- column_names.append(matched_field)
+ column_names.append(matched_field.replace('"', "").replace("`", "").lower())
return column_names
diff --git a/metadata-ingestion/src/datahub/ingestion/source/looker/view_upstream.py b/metadata-ingestion/src/datahub/ingestion/source/looker/view_upstream.py
index 8cec6f2607774e..971181e4300d69 100644
--- a/metadata-ingestion/src/datahub/ingestion/source/looker/view_upstream.py
+++ b/metadata-ingestion/src/datahub/ingestion/source/looker/view_upstream.py
@@ -25,11 +25,13 @@
LookMLSourceReport,
)
from datahub.ingestion.source.looker.urn_functions import get_qualified_table_name
+from datahub.sql_parsing.schema_resolver import match_columns_to_schema
from datahub.sql_parsing.sqlglot_lineage import (
ColumnLineageInfo,
ColumnRef,
SqlParsingResult,
Urn,
+ create_and_cache_schema_resolver,
create_lineage_sql_parsed_result,
)
@@ -200,7 +202,7 @@ def _generate_fully_qualified_name(
class AbstractViewUpstream(ABC):
"""
Implementation of this interface extracts the view upstream as per the way the view is bound to datasets.
- For detail explanation please refer lookml_concept_context.LookerViewContext documentation.
+ For detail explanation, please refer lookml_concept_context.LookerViewContext documentation.
"""
view_context: LookerViewContext
@@ -236,6 +238,47 @@ def get_upstream_dataset_urn(self) -> List[Urn]:
def create_fields(self) -> List[ViewField]:
return [] # it is for the special case
+ def create_upstream_column_refs(
+ self, upstream_urn: str, downstream_looker_columns: List[str]
+ ) -> List[ColumnRef]:
+ """
+ - **`upstream_urn`**: The URN of the upstream dataset.
+
+ - **`expected_columns`**: These are the columns identified by the Looker connector as belonging to the `upstream_urn` dataset. However, there is potential for human error in specifying the columns of the upstream dataset. For example, a user might declare a column in lowercase, while on the actual platform, it may exist in uppercase, or vice versa.
+
+ - This function ensures consistency in column-level lineage by consulting GMS before creating the final `ColumnRef` instance, avoiding discrepancies.
+ """
+ schema_resolver = create_and_cache_schema_resolver(
+ platform=self.view_context.view_connection.platform,
+ platform_instance=self.view_context.view_connection.platform_instance,
+ env=self.view_context.view_connection.platform_env or self.config.env,
+ graph=self.ctx.graph,
+ )
+
+ urn, schema_info = schema_resolver.resolve_urn(urn=upstream_urn)
+
+ if schema_info:
+ actual_columns = match_columns_to_schema(
+ schema_info, downstream_looker_columns
+ )
+ else:
+ logger.info(
+ f"schema_info not found for dataset {urn} in GMS. Using expected_columns to form ColumnRef"
+ )
+ actual_columns = [column.lower() for column in downstream_looker_columns]
+
+ upstream_column_refs: List[ColumnRef] = []
+
+ for column in actual_columns:
+ upstream_column_refs.append(
+ ColumnRef(
+ column=column,
+ table=upstream_urn,
+ )
+ )
+
+ return upstream_column_refs
+
class SqlBasedDerivedViewUpstream(AbstractViewUpstream, ABC):
"""
@@ -372,15 +415,12 @@ def get_upstream_column_ref(
# in-case of "select * from look_ml_view.SQL_TABLE_NAME" or extra field are defined in the looker view which is
# referring to upstream table
if self._get_upstream_dataset_urn() and not upstreams_column_refs:
- upstreams_column_refs = [
- ColumnRef(
- table=self._get_upstream_dataset_urn()[
- 0
- ], # 0th index has table of from clause
- column=column,
- )
- for column in field_context.column_name_in_sql_attribute()
- ]
+ upstreams_column_refs = self.create_upstream_column_refs(
+ upstream_urn=self._get_upstream_dataset_urn()[
+ 0
+ ], # 0th index has table of from clause,
+ downstream_looker_columns=field_context.column_name_in_sql_attribute(),
+ )
# fix any derived view reference present in urn
upstreams_column_refs = resolve_derived_view_urn_of_col_ref(
@@ -487,18 +527,18 @@ def get_upstream_column_ref(
return upstream_column_refs
explore_urn: str = self._get_upstream_dataset_urn()[0]
+ expected_columns: List[str] = []
for column in field_context.column_name_in_sql_attribute():
if column in self._get_explore_column_mapping():
explore_column: Dict = self._get_explore_column_mapping()[column]
- upstream_column_refs.append(
- ColumnRef(
- column=explore_column.get("field", explore_column[NAME]),
- table=explore_urn,
- )
+ expected_columns.append(
+ explore_column.get("field", explore_column[NAME])
)
- return upstream_column_refs
+ return self.create_upstream_column_refs(
+ upstream_urn=explore_urn, downstream_looker_columns=expected_columns
+ )
def get_upstream_dataset_urn(self) -> List[Urn]:
return self._get_upstream_dataset_urn()
@@ -548,14 +588,10 @@ def __get_upstream_dataset_urn(self) -> Urn:
def get_upstream_column_ref(
self, field_context: LookerFieldContext
) -> List[ColumnRef]:
- upstream_column_ref: List[ColumnRef] = []
-
- for column_name in field_context.column_name_in_sql_attribute():
- upstream_column_ref.append(
- ColumnRef(table=self._get_upstream_dataset_urn(), column=column_name)
- )
-
- return upstream_column_ref
+ return self.create_upstream_column_refs(
+ upstream_urn=self._get_upstream_dataset_urn(),
+ downstream_looker_columns=field_context.column_name_in_sql_attribute(),
+ )
def get_upstream_dataset_urn(self) -> List[Urn]:
return [self._get_upstream_dataset_urn()]
@@ -609,15 +645,14 @@ def get_upstream_column_ref(
self, field_context: LookerFieldContext
) -> List[ColumnRef]:
upstream_column_ref: List[ColumnRef] = []
+
if not self._get_upstream_dataset_urn():
return upstream_column_ref
- for column_name in field_context.column_name_in_sql_attribute():
- upstream_column_ref.append(
- ColumnRef(table=self._get_upstream_dataset_urn()[0], column=column_name)
- )
-
- return upstream_column_ref
+ return self.create_upstream_column_refs(
+ upstream_urn=self._get_upstream_dataset_urn()[0],
+ downstream_looker_columns=field_context.column_name_in_sql_attribute(),
+ )
def get_upstream_dataset_urn(self) -> List[Urn]:
return self._get_upstream_dataset_urn()
diff --git a/metadata-ingestion/src/datahub/sql_parsing/schema_resolver.py b/metadata-ingestion/src/datahub/sql_parsing/schema_resolver.py
index e3f2fbc786b437..6aa10381a883ef 100644
--- a/metadata-ingestion/src/datahub/sql_parsing/schema_resolver.py
+++ b/metadata-ingestion/src/datahub/sql_parsing/schema_resolver.py
@@ -123,6 +123,13 @@ def get_urn_for_table(
)
return urn
+ def resolve_urn(self, urn: str) -> Tuple[str, Optional[SchemaInfo]]:
+ schema_info = self._resolve_schema_info(urn)
+ if schema_info:
+ return urn, schema_info
+
+ return urn, None
+
def resolve_table(self, table: _TableName) -> Tuple[str, Optional[SchemaInfo]]:
urn = self.get_urn_for_table(table)
@@ -293,3 +300,19 @@ def _convert_schema_field_list_to_info(
def _convert_schema_aspect_to_info(schema_metadata: SchemaMetadataClass) -> SchemaInfo:
return _convert_schema_field_list_to_info(schema_metadata.fields)
+
+
+def match_columns_to_schema(
+ schema_info: SchemaInfo, input_columns: List[str]
+) -> List[str]:
+ column_from_gms: List[str] = list(schema_info.keys()) # list() to silent lint
+
+ gms_column_map: Dict[str, str] = {
+ column.lower(): column for column in column_from_gms
+ }
+
+ output_columns: List[str] = [
+ gms_column_map.get(column.lower(), column) for column in input_columns
+ ]
+
+ return output_columns
diff --git a/metadata-ingestion/src/datahub/sql_parsing/sqlglot_lineage.py b/metadata-ingestion/src/datahub/sql_parsing/sqlglot_lineage.py
index 4ff68574bf20e6..f387618bfaec12 100644
--- a/metadata-ingestion/src/datahub/sql_parsing/sqlglot_lineage.py
+++ b/metadata-ingestion/src/datahub/sql_parsing/sqlglot_lineage.py
@@ -1181,6 +1181,45 @@ def sqlglot_lineage(
)
+@functools.lru_cache(maxsize=128)
+def create_and_cache_schema_resolver(
+ platform: str,
+ env: str,
+ graph: Optional[DataHubGraph] = None,
+ platform_instance: Optional[str] = None,
+ schema_aware: bool = True,
+) -> SchemaResolver:
+ return create_schema_resolver(
+ platform=platform,
+ env=env,
+ graph=graph,
+ platform_instance=platform_instance,
+ schema_aware=schema_aware,
+ )
+
+
+def create_schema_resolver(
+ platform: str,
+ env: str,
+ graph: Optional[DataHubGraph] = None,
+ platform_instance: Optional[str] = None,
+ schema_aware: bool = True,
+) -> SchemaResolver:
+ if graph and schema_aware:
+ return graph._make_schema_resolver(
+ platform=platform,
+ platform_instance=platform_instance,
+ env=env,
+ )
+
+ return SchemaResolver(
+ platform=platform,
+ platform_instance=platform_instance,
+ env=env,
+ graph=None,
+ )
+
+
def create_lineage_sql_parsed_result(
query: str,
default_db: Optional[str],
@@ -1191,21 +1230,17 @@ def create_lineage_sql_parsed_result(
graph: Optional[DataHubGraph] = None,
schema_aware: bool = True,
) -> SqlParsingResult:
+ schema_resolver = create_schema_resolver(
+ platform=platform,
+ platform_instance=platform_instance,
+ env=env,
+ schema_aware=schema_aware,
+ graph=graph,
+ )
+
+ needs_close: bool = True
if graph and schema_aware:
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(
diff --git a/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/data.model.lkml b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/data.model.lkml
new file mode 100644
index 00000000000000..95391f6a73e635
--- /dev/null
+++ b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/data.model.lkml
@@ -0,0 +1,6 @@
+connection: "my_connection"
+
+include: "top_10_employee_income_source.view.lkml"
+
+explore: top_10_employee_income_source {
+}
\ No newline at end of file
diff --git a/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/top_10_employee_income_source.view.lkml b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/top_10_employee_income_source.view.lkml
new file mode 100644
index 00000000000000..6037bab33c44f3
--- /dev/null
+++ b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution/top_10_employee_income_source.view.lkml
@@ -0,0 +1,18 @@
+view: top_10_employee_income_source {
+ sql_table_name: "db.public.employee"
+ ;;
+ dimension: id {
+ type: number
+ sql: ${TABLE}.id ;;
+ }
+
+ dimension: name {
+ type: string
+ sql: ${TABLE}.name ;;
+ }
+
+ dimension: source {
+ type: string
+ sql: ${TABLE}.source ;;
+ }
+}
\ No newline at end of file
diff --git a/metadata-ingestion/tests/integration/lookml/gms_schema_resolution_golden.json b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution_golden.json
new file mode 100644
index 00000000000000..9b0dd78ca1e8e0
--- /dev/null
+++ b/metadata-ingestion/tests/integration/lookml/gms_schema_resolution_golden.json
@@ -0,0 +1,358 @@
+[
+{
+ "entityType": "container",
+ "entityUrn": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "changeType": "UPSERT",
+ "aspectName": "containerProperties",
+ "aspect": {
+ "json": {
+ "customProperties": {
+ "platform": "looker",
+ "env": "PROD",
+ "project_name": "lkml_samples"
+ },
+ "name": "lkml_samples",
+ "env": "PROD"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "container",
+ "entityUrn": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "changeType": "UPSERT",
+ "aspectName": "status",
+ "aspect": {
+ "json": {
+ "removed": false
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "container",
+ "entityUrn": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "changeType": "UPSERT",
+ "aspectName": "dataPlatformInstance",
+ "aspect": {
+ "json": {
+ "platform": "urn:li:dataPlatform:looker"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "container",
+ "entityUrn": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "changeType": "UPSERT",
+ "aspectName": "subTypes",
+ "aspect": {
+ "json": {
+ "typeNames": [
+ "LookML Project"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "container",
+ "entityUrn": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "changeType": "UPSERT",
+ "aspectName": "browsePathsV2",
+ "aspect": {
+ "json": {
+ "path": [
+ {
+ "id": "Folders"
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "subTypes",
+ "aspect": {
+ "json": {
+ "typeNames": [
+ "View"
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "viewProperties",
+ "aspect": {
+ "json": {
+ "materialized": false,
+ "viewLogic": "view: top_10_employee_income_source {\n sql_table_name: \"db.public.employee\"\n ;;\n dimension: id {\n type: number\n sql: ${TABLE}.id ;;\n }\n\n dimension: name {\n type: string\n sql: ${TABLE}.name ;;\n }\n\n dimension: source {\n type: string\n sql: ${TABLE}.source ;;\n }\n}",
+ "viewLanguage": "lookml"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "container",
+ "aspect": {
+ "json": {
+ "container": "urn:li:container:78f22c19304954b15e8adb1d9809975e"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "proposedSnapshot": {
+ "com.linkedin.pegasus2avro.metadata.snapshot.DatasetSnapshot": {
+ "urn": "urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD)",
+ "aspects": [
+ {
+ "com.linkedin.pegasus2avro.common.BrowsePaths": {
+ "paths": [
+ "/Develop/lkml_samples/"
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.common.Status": {
+ "removed": false
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.dataset.UpstreamLineage": {
+ "upstreams": [
+ {
+ "auditStamp": {
+ "time": 1586847600000,
+ "actor": "urn:li:corpuser:datahub"
+ },
+ "dataset": "urn:li:dataset:(urn:li:dataPlatform:hive,public.employee,PROD)",
+ "type": "VIEW"
+ }
+ ],
+ "fineGrainedLineages": [
+ {
+ "upstreamType": "FIELD_SET",
+ "upstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,public.employee,PROD),Id)"
+ ],
+ "downstreamType": "FIELD",
+ "downstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD),id)"
+ ],
+ "confidenceScore": 1.0
+ },
+ {
+ "upstreamType": "FIELD_SET",
+ "upstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,public.employee,PROD),Name)"
+ ],
+ "downstreamType": "FIELD",
+ "downstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD),name)"
+ ],
+ "confidenceScore": 1.0
+ },
+ {
+ "upstreamType": "FIELD_SET",
+ "upstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,public.employee,PROD),source)"
+ ],
+ "downstreamType": "FIELD",
+ "downstreams": [
+ "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD),source)"
+ ],
+ "confidenceScore": 1.0
+ }
+ ]
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.schema.SchemaMetadata": {
+ "schemaName": "top_10_employee_income_source",
+ "platform": "urn:li:dataPlatform:looker",
+ "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": "id",
+ "nullable": false,
+ "description": "",
+ "label": "",
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.NumberType": {}
+ }
+ },
+ "nativeDataType": "number",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Dimension"
+ }
+ ]
+ },
+ "isPartOfKey": false
+ },
+ {
+ "fieldPath": "name",
+ "nullable": false,
+ "description": "",
+ "label": "",
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "string",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Dimension"
+ }
+ ]
+ },
+ "isPartOfKey": false
+ },
+ {
+ "fieldPath": "source",
+ "nullable": false,
+ "description": "",
+ "label": "",
+ "type": {
+ "type": {
+ "com.linkedin.pegasus2avro.schema.StringType": {}
+ }
+ },
+ "nativeDataType": "string",
+ "recursive": false,
+ "globalTags": {
+ "tags": [
+ {
+ "tag": "urn:li:tag:Dimension"
+ }
+ ]
+ },
+ "isPartOfKey": false
+ }
+ ],
+ "primaryKeys": []
+ }
+ },
+ {
+ "com.linkedin.pegasus2avro.dataset.DatasetProperties": {
+ "customProperties": {
+ "looker.file.path": "top_10_employee_income_source.view.lkml",
+ "looker.model": "data"
+ },
+ "name": "top_10_employee_income_source",
+ "tags": []
+ }
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "dataset",
+ "entityUrn": "urn:li:dataset:(urn:li:dataPlatform:looker,lkml_samples.view.top_10_employee_income_source,PROD)",
+ "changeType": "UPSERT",
+ "aspectName": "browsePathsV2",
+ "aspect": {
+ "json": {
+ "path": [
+ {
+ "id": "Develop"
+ },
+ {
+ "id": "urn:li:container:78f22c19304954b15e8adb1d9809975e",
+ "urn": "urn:li:container:78f22c19304954b15e8adb1d9809975e"
+ }
+ ]
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+},
+{
+ "entityType": "tag",
+ "entityUrn": "urn:li:tag:Dimension",
+ "changeType": "UPSERT",
+ "aspectName": "tagKey",
+ "aspect": {
+ "json": {
+ "name": "Dimension"
+ }
+ },
+ "systemMetadata": {
+ "lastObserved": 1586847600000,
+ "runId": "lookml-test",
+ "lastRunId": "no-run-id-provided"
+ }
+}
+]
\ No newline at end of file
diff --git a/metadata-ingestion/tests/integration/lookml/test_lookml.py b/metadata-ingestion/tests/integration/lookml/test_lookml.py
index 4cd2777dc7dcad..940e7f36675f79 100644
--- a/metadata-ingestion/tests/integration/lookml/test_lookml.py
+++ b/metadata-ingestion/tests/integration/lookml/test_lookml.py
@@ -1,8 +1,8 @@
import logging
import pathlib
-from typing import Any, List
+from typing import Any, List, Optional, Tuple
from unittest import mock
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pydantic
import pytest
@@ -25,6 +25,7 @@
MetadataChangeEventClass,
UpstreamLineageClass,
)
+from datahub.sql_parsing.schema_resolver import SchemaInfo, SchemaResolver
from tests.test_helpers import mce_helpers
from tests.test_helpers.state_helpers import get_current_checkpoint_from_pipeline
@@ -62,7 +63,7 @@ def get_default_recipe(output_file_path, base_folder_path):
@freeze_time(FROZEN_TIME)
def test_lookml_ingest(pytestconfig, tmp_path, mock_time):
- """Test backwards compatibility with previous form of config with new flags turned off"""
+ """Test backwards compatibility with a previous form of config with new flags turned off"""
test_resources_dir = pytestconfig.rootpath / "tests/integration/lookml"
mce_out_file = "expected_output.json"
@@ -1013,3 +1014,40 @@ def test_drop_hive(pytestconfig, tmp_path, mock_time):
output_path=tmp_path / mce_out_file,
golden_path=golden_path,
)
+
+
+@freeze_time(FROZEN_TIME)
+def test_gms_schema_resolution(pytestconfig, tmp_path, mock_time):
+ test_resources_dir = pytestconfig.rootpath / "tests/integration/lookml"
+ mce_out_file = "drop_hive_dot.json"
+
+ new_recipe = get_default_recipe(
+ f"{tmp_path}/{mce_out_file}",
+ f"{test_resources_dir}/gms_schema_resolution",
+ )
+
+ new_recipe["source"]["config"]["connection_to_platform_map"] = {
+ "my_connection": "hive"
+ }
+
+ return_value: Tuple[str, Optional[SchemaInfo]] = (
+ "fake_dataset_urn",
+ {
+ "Id": "String",
+ "Name": "String",
+ "source": "String",
+ },
+ )
+
+ with patch.object(SchemaResolver, "resolve_urn", return_value=return_value):
+ pipeline = Pipeline.create(new_recipe)
+ pipeline.run()
+ pipeline.pretty_print_summary()
+ pipeline.raise_from_status(raise_warnings=True)
+
+ golden_path = test_resources_dir / "gms_schema_resolution_golden.json"
+ mce_helpers.check_golden_file(
+ pytestconfig,
+ output_path=tmp_path / mce_out_file,
+ golden_path=golden_path,
+ )
diff --git a/metadata-ingestion/tests/unit/sql_parsing/test_schemaresolver.py b/metadata-ingestion/tests/unit/sql_parsing/test_schemaresolver.py
index e5fa980bec4522..67222531d3bc15 100644
--- a/metadata-ingestion/tests/unit/sql_parsing/test_schemaresolver.py
+++ b/metadata-ingestion/tests/unit/sql_parsing/test_schemaresolver.py
@@ -1,7 +1,12 @@
-from datahub.sql_parsing.schema_resolver import SchemaResolver, _TableName
+from datahub.sql_parsing.schema_resolver import (
+ SchemaInfo,
+ SchemaResolver,
+ _TableName,
+ match_columns_to_schema,
+)
-def test_basic_schema_resolver():
+def create_default_schema_resolver(urn: str) -> SchemaResolver:
schema_resolver = SchemaResolver(
platform="redshift",
env="PROD",
@@ -9,18 +14,51 @@ def test_basic_schema_resolver():
)
schema_resolver.add_raw_schema_info(
- urn="urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.public.test_table,PROD)",
+ urn=urn,
schema_info={"name": "STRING"},
)
+ return schema_resolver
+
+
+def test_basic_schema_resolver():
+ input_urn = (
+ "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.public.test_table,PROD)"
+ )
+
+ schema_resolver = create_default_schema_resolver(urn=input_urn)
+
urn, schema = schema_resolver.resolve_table(
_TableName(database="my_db", db_schema="public", table="test_table")
)
- assert (
- urn
- == "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.public.test_table,PROD)"
+
+ assert urn == input_urn
+
+ assert schema
+
+ assert schema["name"]
+
+ assert schema_resolver.schema_count() == 1
+
+
+def test_resolve_urn():
+ input_urn: str = (
+ "urn:li:dataset:(urn:li:dataPlatform:redshift,my_db.public.test_table,PROD)"
+ )
+
+ schema_resolver = create_default_schema_resolver(urn=input_urn)
+
+ schema_resolver.add_raw_schema_info(
+ urn=input_urn,
+ schema_info={"name": "STRING"},
)
+
+ urn, schema = schema_resolver.resolve_urn(urn=input_urn)
+
+ assert urn == input_urn
+
assert schema
+
assert schema["name"]
assert schema_resolver.schema_count() == 1
@@ -62,3 +100,13 @@ def test_get_urn_for_table_not_lower_should_keep_capital_letters():
== "urn:li:dataset:(urn:li:dataPlatform:mssql,Uppercased-Instance.Database.DataSet.Table,PROD)"
)
assert schema_resolver.schema_count() == 0
+
+
+def test_match_columns_to_schema():
+ schema_info: SchemaInfo = {"id": "string", "Name": "string", "Address": "string"}
+
+ output_columns = match_columns_to_schema(
+ schema_info, input_columns=["Id", "name", "address", "weight"]
+ )
+
+ assert output_columns == ["id", "Name", "Address", "weight"]