[ER Diagrams] Add ER diagram APIs and sample data (#18021)

* Add ER diag APIs and sample data

* fix pylint

* formatting fixes2

* fixed es client return

* fixed os client return

* supported TableDetailPage tabs as classBase for supporting collate only tabs

* Added schema Apis

* change the base class to .ts and move the component in the util files

* beautify function arguments

* Added optimizations

* Ingestion changes

* svg dimension change

* supported class base tab in databaseSchema

* supported classBase action button in schema table name column

* added further keys data for constraint modal

* fix sonar issue

* remove old method to override edit action on column and shifted to DisplayNameModal for fields

* supported table right panel component to further extends on collate side

* minor fix around duplicate constraint

* added support to update table constraints and column constraints in the UI

* code optimization and minor fixes

* review comments and multi col fix

* added queryFilter option in NodeSuggestion and tableConstrainst to fetch and use only in service tables

---------

Co-authored-by: Ashish Gupta <ashish@getcollate.io>
This commit is contained in:
Onkar Ravgan 2024-10-28 20:26:19 +05:30 committed by GitHub
parent 9d91325af8
commit 4a0c8406e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2998 additions and 524 deletions

View File

@ -0,0 +1,8 @@
{
"id": null,
"name": "default",
"service": {
"id": "b946d870-03b2-4d33-a075-13665a7a76b9",
"type": "MYSQL"
}
}

View File

@ -0,0 +1,8 @@
{
"id": null,
"name": "posts_db",
"service": {
"id": "b946d870-03b2-4d33-a075-13665a7a76b9",
"type": "MYSQL"
}
}

View File

@ -0,0 +1,20 @@
{
"type": "mysql",
"serviceName": "mysql_sample",
"serviceConnection": {
"config": {
"type": "Mysql",
"hostPort": "localhost:3306",
"username": "openmetadata_user",
"authType": {
"password": "openmetadata_password"
},
"databaseSchema": "posts_db"
}
},
"sourceConfig": {
"config": {
"type": "DatabaseMetadata"
}
}
}

View File

@ -0,0 +1,573 @@
{
"tables": [
{
"name": "Tags",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.new_er_database.Tags",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "tag_id",
"dataType": "INT",
"dataLength": 1,
"description": null,
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "name",
"dataType": "VARCHAR",
"dataLength": 100,
"dataTypeDisplay": "varchar(100)",
"description": null,
"constraint": "NOT_NULL",
"ordinalPosition": 2
}
],
"databaseSchema": {
"id": "5f40fbdc-7652-4bb5-8dd8-5834c382b8cf",
"type": "databaseSchema",
"name": "new_er_database",
"fullyQualifiedName": "mysql_sample.default.new_er_database",
"description": null,
"displayName": "new_er_database",
"deleted": false,
"inherited": null
},
"database": {
"id": "9ec40d31-2cc3-434b-b79e-93a22ffb695b",
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"id": "93fd8fbb-cecd-46b5-ae9a-0f6cda13a923",
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "Users",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.new_er_database.Users",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "user_id",
"dataType": "INT",
"dataLength": 1,
"dataTypeDisplay": "int",
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "username",
"dataType": "VARCHAR",
"dataLength": 50,
"dataTypeDisplay": "varchar(50)",
"constraint": "NOT_NULL",
"ordinalPosition": 2
},
{
"name": "email",
"dataType": "VARCHAR",
"dataLength": 100,
"dataTypeDisplay": "varchar(100)",
"constraint": "NOT_NULL",
"ordinalPosition": 3
},
{
"name": "created_at",
"dataType": "TIMESTAMP",
"dataLength": 1,
"dataTypeDisplay": "timestamp",
"constraint": "NULL",
"ordinalPosition": 4
}
],
"databaseSchema": {
"id": "5f40fbdc-7652-4bb5-8dd8-5834c382b8cf",
"type": "databaseSchema",
"name": "new_er_database",
"fullyQualifiedName": "mysql_sample.default.new_er_database",
"description": null,
"displayName": "new_er_database",
"deleted": false,
"inherited": null
},
"database": {
"id": "9ec40d31-2cc3-434b-b79e-93a22ffb695b",
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"id": "93fd8fbb-cecd-46b5-ae9a-0f6cda13a923",
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "Categories",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.posts_db.Categories",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "category_id",
"displayName": null,
"dataType": "INT",
"dataLength": 100,
"dataTypeDisplay": "int",
"description": null,
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "name",
"dataType": "VARCHAR",
"dataLength": 100,
"dataTypeDisplay": "varchar(100)",
"description": null,
"constraint": "NOT_NULL",
"ordinalPosition": 2
}
],
"databaseSchema": {
"type": "databaseSchema",
"name": "posts_db",
"fullyQualifiedName": "mysql_sample.default.posts_db",
"description": null,
"displayName": "posts_db",
"deleted": false,
"inherited": null
},
"database": {
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "Comments",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.posts_db.Comments",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "comment_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "post_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "NULL",
"ordinalPosition": 2
},
{
"name": "user_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "NULL",
"ordinalPosition": 3
},
{
"name": "comment",
"displayName": null,
"dataType": "TEXT",
"dataTypeDisplay": "text",
"description": null,
"constraint": "NOT_NULL",
"ordinalPosition": 4
},
{
"name": "created_at",
"displayName": null,
"dataType": "TIMESTAMP",
"dataTypeDisplay": "timestamp",
"description": null,
"constraint": "NULL",
"ordinalPosition": 5
}
],
"tableConstraints": [
{
"constraintType": "FOREIGN_KEY",
"columns": [
"post_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Posts.post_id"
],
"relationshipType": "MANY_TO_ONE"
},
{
"constraintType": "FOREIGN_KEY",
"columns": [
"user_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Users.user_id"
],
"relationshipType": "MANY_TO_ONE"
}
],
"databaseSchema": {
"type": "databaseSchema",
"name": "posts_db",
"fullyQualifiedName": "mysql_sample.default.posts_db",
"description": null,
"displayName": "posts_db",
"deleted": false,
"inherited": null
},
"database": {
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "Posts",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.posts_db.Posts",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "post_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "user_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "NULL",
"ordinalPosition": 2
},
{
"name": "category_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "NULL",
"ordinalPosition": 3
},
{
"name": "title",
"displayName": null,
"dataType": "VARCHAR",
"arrayDataType": null,
"dataLength": 255,
"precision": null,
"scale": null,
"dataTypeDisplay": "varchar(255)",
"description": null,
"constraint": "NOT_NULL",
"ordinalPosition": 4
},
{
"name": "content",
"displayName": null,
"dataType": "TEXT",
"dataTypeDisplay": "text",
"description": null,
"constraint": "NOT_NULL",
"ordinalPosition": 5
},
{
"name": "created_at",
"displayName": null,
"dataType": "TIMESTAMP",
"dataTypeDisplay": "timestamp",
"description": null,
"constraint": "NULL",
"ordinalPosition": 6
}
],
"tableConstraints": [
{
"constraintType": "FOREIGN_KEY",
"columns": [
"user_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Users.user_id"
],
"relationshipType": "MANY_TO_ONE"
},
{
"constraintType": "FOREIGN_KEY",
"columns": [
"category_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Categories.category_id"
],
"relationshipType": "MANY_TO_ONE"
}
],
"databaseSchema": {
"id": "3b2c045a-03ea-4303-abf3-082ac4e73804",
"type": "databaseSchema",
"name": "posts_db",
"fullyQualifiedName": "mysql_sample.default.posts_db",
"description": null,
"displayName": "posts_db",
"deleted": false,
"inherited": null
},
"database": {
"id": "c1a9f3bf-8bb8-43e3-beeb-e4c2293b977a",
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"id": "c0382692-7cf3-40b7-9aa7-b14bf2cbecdd",
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "PostTags",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.posts_db.PostTags",
"description": "testdesc2",
"tableType": "Regular",
"columns": [
{
"name": "post_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": null,
"ordinalPosition": 1
},
{
"name": "tag_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": null,
"ordinalPosition": 2
}
],
"tableConstraints": [
{
"constraintType": "PRIMARY_KEY",
"columns": [
"post_id",
"tag_id"
],
"referredColumns": null,
"relationshipType": null
},
{
"constraintType": "FOREIGN_KEY",
"columns": [
"post_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Posts.post_id"
],
"relationshipType": "MANY_TO_ONE"
},
{
"constraintType": "FOREIGN_KEY",
"columns": [
"tag_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Tags.tag_id"
],
"relationshipType": "MANY_TO_ONE"
}
],
"databaseSchema": {
"type": "databaseSchema",
"name": "posts_db",
"fullyQualifiedName": "mysql_sample.default.posts_db",
"description": null,
"displayName": "posts_db",
"deleted": false,
"inherited": null
},
"database": {
"id": "c1a9f3bf-8bb8-43e3-beeb-e4c2293b977a",
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"id": "c0382692-7cf3-40b7-9aa7-b14bf2cbecdd",
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
},
{
"name": "Profiles",
"displayName": null,
"fullyQualifiedName": "mysql_sample.default.posts_db.Profiles",
"description": null,
"tableType": "Regular",
"columns": [
{
"name": "profile_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "PRIMARY_KEY",
"ordinalPosition": 1
},
{
"name": "user_id",
"displayName": null,
"dataType": "INT",
"dataTypeDisplay": "int",
"description": null,
"constraint": "UNIQUE",
"ordinalPosition": 2
},
{
"name": "bio",
"displayName": null,
"dataType": "TEXT",
"dataTypeDisplay": "text",
"description": null,
"constraint": "NULL",
"ordinalPosition": 3
}
],
"tableConstraints": [
{
"constraintType": "FOREIGN_KEY",
"columns": [
"user_id"
],
"referredColumns": [
"mysql_sample.default.posts_db.Users.user_id"
],
"relationshipType": "ONE_TO_ONE"
}
],
"databaseSchema": {
"id": "3b2c045a-03ea-4303-abf3-082ac4e73804",
"type": "databaseSchema",
"name": "posts_db",
"fullyQualifiedName": "mysql_sample.default.posts_db",
"description": null,
"displayName": "posts_db",
"deleted": false,
"inherited": null
},
"database": {
"id": "c1a9f3bf-8bb8-43e3-beeb-e4c2293b977a",
"type": "database",
"name": "default",
"fullyQualifiedName": "mysql_sample.default",
"description": null,
"displayName": "default",
"deleted": false,
"inherited": null
},
"service": {
"id": "c0382692-7cf3-40b7-9aa7-b14bf2cbecdd",
"type": "databaseService",
"name": "mysql_sample",
"fullyQualifiedName": "mysql_sample",
"description": null,
"displayName": "mysql_sample",
"deleted": false,
"inherited": null
}
}
]
}

View File

@ -11,13 +11,14 @@
"""
Generic source to build SQL connectors.
"""
import copy
import math
import time
import traceback
from abc import ABC
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from typing import Any, Iterable, List, Optional, Tuple, Union, cast
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast
from pydantic import BaseModel
from sqlalchemy.engine import Connection
@ -38,6 +39,7 @@ from metadata.generated.schema.api.lineage.addLineage import AddLineageRequest
from metadata.generated.schema.entity.data.database import Database
from metadata.generated.schema.entity.data.databaseSchema import DatabaseSchema
from metadata.generated.schema.entity.data.table import (
Column,
ConstraintType,
Table,
TableConstraint,
@ -63,6 +65,7 @@ from metadata.ingestion.api.models import Either
from metadata.ingestion.connections.session import create_and_bind_thread_safe_session
from metadata.ingestion.models.ometa_classification import OMetaTagAndClassification
from metadata.ingestion.models.ometa_lineage import OMetaLineageRequest
from metadata.ingestion.models.patch_request import PatchedEntity, PatchRequest
from metadata.ingestion.models.topology import Queue
from metadata.ingestion.ometa.ometa_api import OpenMetadata
from metadata.ingestion.source.connections import (
@ -75,6 +78,7 @@ from metadata.ingestion.source.database.sqlalchemy_source import SqlAlchemySourc
from metadata.ingestion.source.database.stored_procedures_mixin import QueryByProcedure
from metadata.ingestion.source.models import TableView
from metadata.utils import fqn
from metadata.utils.constraints import get_relationship_type
from metadata.utils.db_utils import get_view_lineage
from metadata.utils.execution_time_tracker import (
ExecutionTimeTrackerContextMap,
@ -88,6 +92,13 @@ from metadata.utils.ssl_manager import SSLManager, check_ssl_and_init
logger = ingestion_logger()
class ColumnAndReferredColumn(BaseModel):
table_name: str
schema_name: str
db_name: Optional[str]
column: Dict
class TableNameAndType(BaseModel):
"""
Helper model for passing down
@ -141,6 +152,7 @@ class CommonDbSourceService(
self.database_source_state = set()
self.context.get_global().table_views = []
self.context.get_global().table_constrains = []
self.context.get_global().foreign_tables = []
self.context.set_threads(self.source_config.threads)
super().__init__()
@ -518,7 +530,12 @@ class CommonDbSourceService(
)
table_constraints = self.update_table_constraints(
table_constraints, foreign_columns
schema_name=schema_name,
table_name=table_name,
db_name=self.context.get().database,
table_constraints=table_constraints,
foreign_columns=foreign_columns,
columns=columns,
)
description = (
@ -697,7 +714,74 @@ class CommonDbSourceService(
else:
yield from self._process_view_def_serial()
def _get_foreign_constraints(self, foreign_columns) -> List[TableConstraint]:
def _prepare_foreign_constraints( # pylint: disable=too-many-arguments, too-many-locals
self,
supports_database: bool,
column: Dict,
table_name: str,
schema_name: str,
db_name: str,
columns: List[Column],
add_to_global: bool = True,
):
"""
Method to prepare the foreign constraints
"""
referred_column_fqns = []
if supports_database:
database_name = column.get("referred_database")
else:
database_name = self.context.get().database
referred_table_fqn = fqn.build(
metadata=self.metadata,
entity_type=Table,
table_name=column.get("referred_table"),
schema_name=column.get("referred_schema"),
database_name=database_name,
service_name=self.context.get().database_service,
)
referred_table = self.metadata.get_by_name(entity=Table, fqn=referred_table_fqn)
if referred_table:
for referred_column in column.get("referred_columns"):
col_fqn = fqn._build( # pylint: disable=protected-access
referred_table_fqn, referred_column, quote=False
)
if col_fqn:
referred_column_fqns.append(FullyQualifiedEntityName(col_fqn))
else:
if add_to_global:
column_and_referred_columns = ColumnAndReferredColumn(
table_name=table_name,
schema_name=schema_name,
db_name=db_name,
column=column,
)
self.context.get_global().foreign_tables.append(
column_and_referred_columns
)
return None
relationship_type = None
if referred_table:
relationship_type = get_relationship_type(
column, # sqlalchemy foreign column
referred_table.columns, # referred table columns
columns, # current table om columns
)
return TableConstraint(
constraintType=ConstraintType.FOREIGN_KEY,
columns=column.get("constrained_columns"),
referredColumns=referred_column_fqns,
relationshipType=relationship_type,
)
def _get_foreign_constraints(
self,
table_name,
schema_name,
db_name,
foreign_columns: List[Dict],
columns: List[Column],
) -> List[TableConstraint]:
"""
Search the referred table for foreign constraints
and get referred column fqn
@ -706,48 +790,31 @@ class CommonDbSourceService(
foreign_constraints = []
for column in foreign_columns:
referred_column_fqns = []
if supports_database:
database_name = column.get("referred_database")
else:
database_name = self.context.get().database
referred_table_fqn = fqn.build(
metadata=self.metadata,
entity_type=Table,
table_name=column.get("referred_table"),
schema_name=column.get("referred_schema"),
database_name=database_name,
service_name=self.context.get().database_service,
)
if referred_table_fqn:
for referred_column in column.get("referred_columns"):
col_fqn = fqn._build(
referred_table_fqn, referred_column, quote=False
)
if col_fqn:
referred_column_fqns.append(FullyQualifiedEntityName(col_fqn))
else:
# do not build partial foreign constraint. It will updated in next run.
continue
foreign_constraints.append(
TableConstraint(
constraintType=ConstraintType.FOREIGN_KEY,
columns=column.get("constrained_columns"),
referredColumns=referred_column_fqns,
)
foreign_constraint = self._prepare_foreign_constraints(
supports_database, column, table_name, schema_name, db_name, columns
)
if foreign_constraint:
foreign_constraints.append(foreign_constraint)
return foreign_constraints
@calculate_execution_time()
def update_table_constraints(
self, table_constraints, foreign_columns
self,
table_name,
schema_name,
db_name,
table_constraints,
foreign_columns,
columns,
) -> List[TableConstraint]:
"""
From topology.
process the table constraints of all tables
"""
foreign_table_constraints = self._get_foreign_constraints(foreign_columns)
foreign_table_constraints = self._get_foreign_constraints(
table_name, schema_name, db_name, foreign_columns, columns
)
if foreign_table_constraints:
if table_constraints:
table_constraints.extend(foreign_table_constraints)
@ -816,3 +883,55 @@ class CommonDbSourceService(
"""
By default the source url is not supported for
"""
def yield_table_constraints(self) -> Iterable[Either[PatchedEntity]]:
"""
Process remaining table constraints by patching the table
"""
supports_database = hasattr(self.service_connection, "supportsDatabase")
for foreign_table in self.context.get_global().foreign_tables or []:
try:
foreign_constraints = []
table_fqn = fqn.build(
metadata=self.metadata,
entity_type=Table,
service_name=self.context.get().database_service,
database_name=foreign_table.db_name,
schema_name=foreign_table.schema_name,
table_name=foreign_table.table_name,
)
table = self.metadata.get_by_name(entity=Table, fqn=table_fqn)
if table:
foreign_constraint = self._prepare_foreign_constraints(
supports_database,
foreign_table.column,
foreign_table.table_name,
foreign_table.schema_name,
foreign_table.db_name,
table.columns,
False,
)
if foreign_constraint:
foreign_constraints.append(foreign_constraint)
# send the patch request
if foreign_constraints:
new_entity = copy.deepcopy(table)
new_entity.tableConstraints = (
new_entity.tableConstraints or []
) + foreign_constraints
patch_request = PatchRequest(
original_entity=table,
new_entity=new_entity,
override_metadata=True,
)
yield Either(right=patch_request)
except Exception as exc:
yield Either(
left=StackTraceError(
name=str(foreign_table.table_name),
error=f"Error to yield tableConstraints for {str(foreign_table.table_name)}: {exc}",
stackTrace=traceback.format_exc(),
)
)

View File

@ -118,6 +118,7 @@ class DatabaseServiceTopology(ServiceTopology):
post_process=[
"yield_view_lineage",
"yield_external_table_lineage",
"yield_table_constraints",
],
)
database: Annotated[
@ -352,7 +353,13 @@ class DatabaseServiceSource(
"""
def update_table_constraints(
self, table_constraints: List[TableConstraint], foreign_columns: []
self,
table_name,
schema_name,
db_name,
table_constraints: List[TableConstraint],
foreign_columns: [],
columns,
) -> List[TableConstraint]:
"""
process the table constraints of all tables
@ -538,7 +545,7 @@ class DatabaseServiceSource(
self.inspector, "get_table_owner"
):
owner_name = self.inspector.get_table_owner(
connection=self.connection, # pylint: disable=no-member.fetchall()
connection=self.connection, # pylint: disable=no-member
table_name=table_name,
schema=self.context.get().database_schema,
)
@ -609,6 +616,11 @@ class DatabaseServiceSource(
Process external table lineage
"""
def yield_table_constraints(self) -> Iterable[Either[AddLineageRequest]]:
"""
Process remaining table constraints by patching the table
"""
def test_connection(self) -> None:
test_connection_fn = get_test_connection_fn(self.service_connection)
result = test_connection_fn(

View File

@ -242,6 +242,41 @@ class SampleDataSource(
entity=DatabaseService,
config=WorkflowSource(**self.glue_database_service_json),
)
# MYSQL service for er diagrams
self.mysql_database_service_json = json.load(
open( # pylint: disable=consider-using-with
sample_data_folder + "/mysql/database_service.json",
"r",
encoding=UTF_8,
)
)
self.mysql_database = json.load(
open( # pylint: disable=consider-using-with
sample_data_folder + "/mysql/database.json",
"r",
encoding=UTF_8,
)
)
self.mysql_database_schema = json.load(
open( # pylint: disable=consider-using-with
sample_data_folder + "/mysql/database_schema.json",
"r",
encoding=UTF_8,
)
)
self.mysql_tables = json.load(
open( # pylint: disable=consider-using-with
sample_data_folder + "/mysql/tables.json",
"r",
encoding=UTF_8,
)
)
self.mysql_database_service = self.metadata.get_service_or_create(
entity=DatabaseService,
config=WorkflowSource(**self.mysql_database_service_json),
)
self.database_service_json = json.load(
open( # pylint: disable=consider-using-with
sample_data_folder + "/datasets/service.json",
@ -615,6 +650,7 @@ class SampleDataSource(
yield from self.ingest_users()
yield from self.ingest_tables()
yield from self.ingest_glue()
yield from self.ingest_mysql()
yield from self.ingest_stored_procedures()
yield from self.ingest_topics()
yield from self.ingest_charts()
@ -666,6 +702,55 @@ class SampleDataSource(
yield Either(right=team_to_ingest)
def ingest_mysql(self) -> Iterable[Either[Entity]]:
"""Ingest Sample Data for mysql database source including ER diagrams metadata"""
db = CreateDatabaseRequest(
name=self.mysql_database["name"],
service=self.mysql_database_service.fullyQualifiedName,
)
yield Either(right=db)
database_entity = fqn.build(
self.metadata,
entity_type=Database,
service_name=self.mysql_database_service.fullyQualifiedName.root,
database_name=db.name.root,
)
database_object = self.metadata.get_by_name(
entity=Database, fqn=database_entity
)
schema = CreateDatabaseSchemaRequest(
name=self.mysql_database_schema["name"],
database=database_object.fullyQualifiedName,
)
yield Either(right=schema)
database_schema_entity = fqn.build(
self.metadata,
entity_type=DatabaseSchema,
service_name=self.mysql_database_service.fullyQualifiedName.root,
database_name=db.name.root,
schema_name=schema.name.root,
)
database_schema_object = self.metadata.get_by_name(
entity=DatabaseSchema, fqn=database_schema_entity
)
for table in self.mysql_tables["tables"]:
table_request = CreateTableRequest(
name=table["name"],
description=table["description"],
columns=table["columns"],
databaseSchema=database_schema_object.fullyQualifiedName,
tableConstraints=table.get("tableConstraints"),
tableType=table["tableType"],
)
yield Either(right=table_request)
def ingest_glue(self) -> Iterable[Either[Entity]]:
"""Ingest Sample Data for glue database source"""

View File

@ -325,7 +325,7 @@ class UnitycatalogSource(
) = self.get_table_constraints(table.table_constraints)
table_constraints = self.update_table_constraints(
primary_constraints, foreign_constraints
primary_constraints, foreign_constraints, columns
)
table_request = CreateTableRequest(
@ -436,7 +436,7 @@ class UnitycatalogSource(
return table_constraints
def update_table_constraints(
self, table_constraints, foreign_columns
self, table_constraints, foreign_columns, columns
) -> List[TableConstraint]:
"""
From topology.

View File

@ -0,0 +1,65 @@
# Copyright 2024 Collate
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Define constraints helper methods useful for the metadata ingestion
"""
from typing import Dict, List
from metadata.generated.schema.entity.data.table import (
Column,
ConstraintType,
RelationshipType,
)
from metadata.ingestion.ometa.utils import model_str
def _is_column_unique(column: Dict, columns: List[Column]) -> bool:
"""
Method to check if the column in unique in the table
"""
if column and len(column) > 0:
constrained_column = column[0]
for col in columns or []:
if model_str(col.name) == constrained_column:
if col.constraint and col.constraint.value in {
ConstraintType.UNIQUE.value,
ConstraintType.PRIMARY_KEY.value,
}:
return True
break
return False
def get_relationship_type(
column: Dict, referred_table_columns: List[Column], columns: List[Column]
) -> str:
"""
Determine the type of relationship (one-to-one, one-to-many, etc.)
"""
# Check if the column is unique in the current table
is_unique_in_current_table = _is_column_unique(
column.get("constrained_columns"), columns
)
# Check if the referred column is unique in the referred table
is_unique_in_referred_table = _is_column_unique(
column.get("referred_columns"), referred_table_columns
)
if is_unique_in_current_table and is_unique_in_referred_table:
return RelationshipType.ONE_TO_ONE
if is_unique_in_current_table:
return RelationshipType.ONE_TO_MANY
if is_unique_in_referred_table:
return RelationshipType.MANY_TO_ONE
return RelationshipType.MANY_TO_MANY

View File

@ -15,6 +15,7 @@ package org.openmetadata.service.resources.databases;
import static org.openmetadata.common.utils.CommonUtil.listOf;
import es.org.elasticsearch.action.search.SearchResponse;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -644,6 +645,42 @@ public class DatabaseSchemaResource
return addHref(uriInfo, databaseSchema);
}
@GET
@Path("/entityRelationship")
@Operation(
operationId = "searchSchemaEntityRelationship",
summary = "Search Schema Entity Relationship",
responses = {
@ApiResponse(
responseCode = "200",
description = "search response",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = SearchResponse.class)))
})
public Response searchSchemaEntityRelationship(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "fqn") @QueryParam("fqn") String fqn,
@Parameter(description = "upstreamDepth") @QueryParam("upstreamDepth") int upstreamDepth,
@Parameter(description = "downstreamDepth") @QueryParam("downstreamDepth")
int downstreamDepth,
@Parameter(
description =
"Elasticsearch query that will be combined with the query_string query generator from the `query` argument")
@QueryParam("query_filter")
String queryFilter,
@Parameter(description = "Filter documents by deleted param. By default deleted is false")
@QueryParam("includeDeleted")
@DefaultValue("false")
boolean deleted)
throws IOException {
return Entity.getSearchRepository()
.searchSchemaEntityRelationship(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
}
private DatabaseSchema getDatabaseSchema(CreateDatabaseSchema create, String user) {
return repository
.copy(new DatabaseSchema(), create, user)

View File

@ -15,6 +15,7 @@ package org.openmetadata.service.resources.databases;
import static org.openmetadata.common.utils.CommonUtil.listOf;
import es.org.elasticsearch.action.search.SearchResponse;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -127,7 +128,8 @@ public class TableResource extends EntityResource<Table, TableRepository> {
MetadataOperation.EDIT_QUERIES,
MetadataOperation.EDIT_DATA_PROFILE,
MetadataOperation.EDIT_SAMPLE_DATA,
MetadataOperation.EDIT_LINEAGE);
MetadataOperation.EDIT_LINEAGE,
MetadataOperation.EDIT_ENTITY_RELATIONSHIP);
}
public static class TableList extends ResultList<Table> {
@ -1219,6 +1221,42 @@ public class TableResource extends EntityResource<Table, TableRepository> {
.toResponse();
}
@GET
@Path("/entityRelationship")
@Operation(
operationId = "searchEntityRelationship",
summary = "Search Entity Relationship",
responses = {
@ApiResponse(
responseCode = "200",
description = "search response",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = SearchResponse.class)))
})
public Response searchEntityRelationship(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "fqn") @QueryParam("fqn") String fqn,
@Parameter(description = "upstreamDepth") @QueryParam("upstreamDepth") int upstreamDepth,
@Parameter(description = "downstreamDepth") @QueryParam("downstreamDepth")
int downstreamDepth,
@Parameter(
description =
"Elasticsearch query that will be combined with the query_string query generator from the `query` argument")
@QueryParam("query_filter")
String queryFilter,
@Parameter(description = "Filter documents by deleted param. By default deleted is false")
@QueryParam("includeDeleted")
@DefaultValue("false")
boolean deleted)
throws IOException {
return Entity.getSearchRepository()
.searchEntityRelationship(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
}
public static Table validateNewTable(Table table) {
table.setId(UUID.randomUUID());
DatabaseUtil.validateConstraints(table.getColumns(), table.getTableConstraints());

View File

@ -6,6 +6,7 @@ import java.io.IOException;
import java.security.KeyStoreException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.json.JsonArray;
@ -77,6 +78,12 @@ public interface SearchClient {
String ADD_UPDATE_LINEAGE =
"boolean docIdExists = false; for (int i = 0; i < ctx._source.lineage.size(); i++) { if (ctx._source.lineage[i].doc_id.equalsIgnoreCase(params.lineageData.doc_id)) { ctx._source.lineage[i] = params.lineageData; docIdExists = true; break;}}if (!docIdExists) {ctx._source.lineage.add(params.lineageData);}";
// The script is used for updating the entityRelationship attribute of the entity in ES
// It checks if any duplicate entry is present based on the doc_id and updates only if it is not
// present
String ADD_UPDATE_ENTITY_RELATIONSHIP =
"boolean docIdExists = false; for (int i = 0; i < ctx._source.entityRelationship.size(); i++) { if (ctx._source.entityRelationship[i].doc_id.equalsIgnoreCase(params.entityRelationshipData.doc_id)) { ctx._source.entityRelationship[i] = params.entityRelationshipData; docIdExists = true; break;}}if (!docIdExists) {ctx._source.entityRelationship.add(params.entityRelationshipData);}";
String UPDATE_ADDED_DELETE_GLOSSARY_TAGS =
"if (ctx._source.tags != null) { for (int i = ctx._source.tags.size() - 1; i >= 0; i--) { if (params.tagDeleted != null) { for (int j = 0; j < params.tagDeleted.size(); j++) { if (ctx._source.tags[i].tagFQN.equalsIgnoreCase(params.tagDeleted[j].tagFQN)) { ctx._source.tags.remove(i); } } } } } if (ctx._source.tags == null) { ctx._source.tags = []; } if (params.tagAdded != null) { ctx._source.tags.addAll(params.tagAdded); } ctx._source.tags = ctx._source.tags .stream() .distinct() .sorted((o1, o2) -> o1.tagFQN.compareTo(o2.tagFQN)) .collect(Collectors.toList());";
String REMOVE_TEST_SUITE_CHILDREN_SCRIPT =
@ -98,6 +105,37 @@ public interface SearchClient {
String NOT_IMPLEMENTED_ERROR_TYPE = "NOT_IMPLEMENTED";
String ENTITY_RELATIONSHIP_DIRECTION_ENTITY = "entityRelationship.entity.fqnHash.keyword";
String ENTITY_RELATIONSHIP_DIRECTION_RELATED_ENTITY =
"entityRelationship.relatedEntity.fqnHash.keyword";
Set<String> FIELDS_TO_REMOVE_ENTITY_RELATIONSHIP =
Set.of(
"suggest",
"service_suggest",
"column_suggest",
"schema_suggest",
"database_suggest",
"lifeCycle",
"fqnParts",
"chart_suggest",
"field_suggest",
"lineage",
"entityRelationship",
"customMetrics",
"descriptionStatus",
"columnNames",
"totalVotes",
"usageSummary",
"dataProducts",
"tags",
"followers",
"domain",
"votes",
"tier",
"changeDescription");
boolean isClientAvailable();
ElasticSearchConfiguration.SearchType getSearchType();
@ -142,9 +180,17 @@ public interface SearchClient {
String entityType)
throws IOException;
Response searchEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException;
Response searchDataQualityLineage(
String fqn, int upstreamDepth, String queryFilter, boolean deleted) throws IOException;
Response searchSchemaEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException;
/*
Used for listing knowledge page hierarchy for a given parent and page type, used in Elastic/Open SearchClientExtension
*/
@ -211,6 +257,11 @@ public interface SearchClient {
void updateLineage(
String indexName, Pair<String, String> fieldAndValue, Map<String, Object> lineagaData);
void updateEntityRelationship(
String indexName,
Pair<String, String> fieldAndValue,
Map<String, Object> entityRelationshipData);
Response listDataInsightChartResult(
Long startTs,
Long endTs,

View File

@ -837,11 +837,25 @@ public class SearchRepository {
fqn, upstreamDepth, downstreamDepth, queryFilter, deleted, entityType);
}
public Response searchEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
return searchClient.searchEntityRelationship(
fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
}
public Response searchDataQualityLineage(
String fqn, int upstreamDepth, String queryFilter, boolean deleted) throws IOException {
return searchClient.searchDataQualityLineage(fqn, upstreamDepth, queryFilter, deleted);
}
public Response searchSchemaEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
return searchClient.searchSchemaEntityRelationship(
fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
}
public Map<String, Object> searchLineageForExport(
String fqn,
int upstreamDepth,

View File

@ -12,6 +12,7 @@ import static org.openmetadata.service.Entity.FIELD_NAME;
import static org.openmetadata.service.Entity.GLOSSARY_TERM;
import static org.openmetadata.service.Entity.QUERY;
import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA;
import static org.openmetadata.service.Entity.TABLE;
import static org.openmetadata.service.exception.CatalogGenericExceptionMapper.getResponse;
import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD;
import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD_KEYWORD;
@ -146,6 +147,7 @@ import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart;
import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChartResultList;
import org.openmetadata.schema.dataInsight.custom.FormulaHolder;
import org.openmetadata.schema.entity.data.EntityHierarchy__1;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.service.configuration.elasticsearch.ElasticSearchConfiguration;
import org.openmetadata.schema.tests.DataQualityReport;
import org.openmetadata.schema.type.EntityReference;
@ -156,6 +158,8 @@ import org.openmetadata.service.Entity;
import org.openmetadata.service.dataInsight.DataInsightAggregatorInterface;
import org.openmetadata.service.jdbi3.DataInsightChartRepository;
import org.openmetadata.service.jdbi3.DataInsightSystemChartRepository;
import org.openmetadata.service.jdbi3.ListFilter;
import org.openmetadata.service.jdbi3.TableRepository;
import org.openmetadata.service.jdbi3.TestCaseResultRepository;
import org.openmetadata.service.search.SearchAggregation;
import org.openmetadata.service.search.SearchClient;
@ -749,6 +753,123 @@ public class ElasticSearchClient implements SearchClient {
return Response.status(OK).entity(responseMap).build();
}
private void getEntityRelationship(
String fqn,
int depth,
Set<Map<String, Object>> edges,
Set<Map<String, Object>> nodes,
String queryFilter,
String direction,
boolean deleted)
throws IOException {
if (depth <= 0) {
return;
}
es.org.elasticsearch.action.search.SearchRequest searchRequest =
new es.org.elasticsearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery(direction, FullyQualifiedName.buildHash(fqn))));
if (CommonUtil.nullOrEmpty(deleted)) {
searchSourceBuilder.query(
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery(direction, FullyQualifiedName.buildHash(fqn)))
.must(QueryBuilders.termQuery("deleted", deleted)));
}
if (!nullOrEmpty(queryFilter) && !queryFilter.equals("{}")) {
try {
XContentParser filterParser =
XContentType.JSON
.xContent()
.createParser(xContentRegistry, LoggingDeprecationHandler.INSTANCE, queryFilter);
es.org.elasticsearch.index.query.QueryBuilder filter =
SearchSourceBuilder.fromXContent(filterParser).query();
es.org.elasticsearch.index.query.BoolQueryBuilder newQuery =
QueryBuilders.boolQuery().must(searchSourceBuilder.query()).filter(filter);
searchSourceBuilder.query(newQuery);
} catch (Exception ex) {
LOG.warn("Error parsing query_filter from query parameters, ignoring filter", ex);
}
}
searchRequest.source(searchSourceBuilder.size(1000));
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
List<Map<String, Object>> entityRelationship =
(List<Map<String, Object>>) hit.getSourceAsMap().get("entityRelationship");
HashMap<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE_ENTITY_RELATIONSHIP);
nodes.add(tempMap);
for (Map<String, Object> er : entityRelationship) {
Map<String, String> entity = (HashMap<String, String>) er.get("entity");
Map<String, String> relatedEntity = (HashMap<String, String>) er.get("relatedEntity");
if (direction.equalsIgnoreCase(ENTITY_RELATIONSHIP_DIRECTION_ENTITY)) {
if (!edges.contains(er) && entity.get("fqn").equals(fqn)) {
edges.add(er);
getEntityRelationship(
relatedEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted);
}
} else {
if (!edges.contains(er) && relatedEntity.get("fqn").equals(fqn)) {
edges.add(er);
getEntityRelationship(
entity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted);
}
}
}
}
}
public Map<String, Object> searchEntityRelationshipInternal(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap = new HashMap<>();
Set<Map<String, Object>> edges = new HashSet<>();
Set<Map<String, Object>> nodes = new HashSet<>();
es.org.elasticsearch.action.search.SearchRequest searchRequest =
new es.org.elasticsearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery().must(QueryBuilders.termQuery("fullyQualifiedName", fqn)));
searchRequest.source(searchSourceBuilder.size(1000));
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
Map<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE);
responseMap.put("entity", tempMap);
}
getEntityRelationship(
fqn,
downstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_ENTITY,
deleted);
getEntityRelationship(
fqn,
upstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_RELATED_ENTITY,
deleted);
responseMap.put("edges", edges);
responseMap.put("nodes", nodes);
return responseMap;
}
@Override
public Response searchEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap =
searchEntityRelationshipInternal(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
return Response.status(OK).entity(responseMap).build();
}
@Override
public Response searchDataQualityLineage(
String fqn, int upstreamDepth, String queryFilter, boolean deleted) throws IOException {
@ -761,6 +882,80 @@ public class ElasticSearchClient implements SearchClient {
return Response.status(OK).entity(responseMap).build();
}
public Map<String, Object> searchSchemaEntityRelationshipInternal(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap = new HashMap<>();
Set<Map<String, Object>> edges = new HashSet<>();
Set<Map<String, Object>> nodes = new HashSet<>();
es.org.elasticsearch.action.search.SearchRequest searchRequest =
new es.org.elasticsearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery().must(QueryBuilders.termQuery("fullyQualifiedName", fqn)));
searchRequest.source(searchSourceBuilder.size(1000));
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
Map<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE);
responseMap.put("entity", tempMap);
}
TableRepository repository = (TableRepository) Entity.getEntityRepository(TABLE);
ListFilter filter = new ListFilter(Include.NON_DELETED).addQueryParam("databaseSchema", fqn);
List<Table> tables =
repository.listAll(repository.getFields("tableConstraints, displayName, owners"), filter);
for (Table table : tables) {
getEntityRelationship(
table.getFullyQualifiedName(),
downstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_ENTITY,
deleted);
getEntityRelationship(
table.getFullyQualifiedName(),
upstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_RELATED_ENTITY,
deleted);
}
// Add the remaining tables from the list into the nodes
// These will the one's that do not have any entity relationship
for (Table table : tables) {
boolean tablePresent = false;
for (Map<String, Object> node : nodes) {
if (table.getId().toString().equals(node.get("id"))) {
tablePresent = true;
break;
}
}
if (!tablePresent) {
HashMap<String, Object> tableMap = new HashMap<>(JsonUtils.getMap(table));
tableMap.keySet().removeAll(FIELDS_TO_REMOVE_ENTITY_RELATIONSHIP);
tableMap.put("entityType", "table");
nodes.add(tableMap);
}
}
responseMap.put("edges", edges);
responseMap.put("nodes", nodes);
return responseMap;
}
@Override
public Response searchSchemaEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap =
searchSchemaEntityRelationshipInternal(
fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
return Response.status(OK).entity(responseMap).build();
}
private void getLineage(
String fqn,
int depth,
@ -1850,6 +2045,28 @@ public class ElasticSearchClient implements SearchClient {
}
}
public void updateEntityRelationship(
String indexName,
Pair<String, String> fieldAndValue,
Map<String, Object> entityRelationshipData) {
if (isClientAvailable) {
UpdateByQueryRequest updateByQueryRequest = new UpdateByQueryRequest(indexName);
updateByQueryRequest.setQuery(
new MatchQueryBuilder(fieldAndValue.getKey(), fieldAndValue.getValue())
.operator(Operator.AND));
Map<String, Object> params =
Collections.singletonMap("entityRelationshipData", entityRelationshipData);
Script script =
new Script(
ScriptType.INLINE,
Script.DEFAULT_SCRIPT_LANG,
ADD_UPDATE_ENTITY_RELATIONSHIP,
params);
updateByQueryRequest.setScript(script);
updateElasticSearchByQuery(updateByQueryRequest);
}
}
@Override
public void updateLineage(
String indexName, Pair<String, String> fieldAndValue, Map<String, Object> lineageData) {

View File

@ -1,13 +1,16 @@
package org.openmetadata.service.search.indexes;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import static org.openmetadata.schema.type.Include.NON_DELETED;
import static org.openmetadata.service.Entity.FIELD_DESCRIPTION;
import static org.openmetadata.service.Entity.FIELD_DISPLAY_NAME;
import static org.openmetadata.service.Entity.getEntityByName;
import static org.openmetadata.service.jdbi3.LineageRepository.buildRelationshipDetailsMap;
import static org.openmetadata.service.search.EntityBuilderConstant.DISPLAY_NAME_KEYWORD;
import static org.openmetadata.service.search.EntityBuilderConstant.FIELD_DISPLAY_NAME_NGRAM;
import static org.openmetadata.service.search.EntityBuilderConstant.FULLY_QUALIFIED_NAME;
import static org.openmetadata.service.search.EntityBuilderConstant.FULLY_QUALIFIED_NAME_PARTS;
import static org.openmetadata.service.util.FullyQualifiedName.getParentFQN;
import java.util.ArrayList;
import java.util.Collections;
@ -16,14 +19,22 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
import org.openmetadata.common.utils.CommonUtil;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.LineageDetails;
import org.openmetadata.schema.type.Relationship;
import org.openmetadata.schema.type.TableConstraint;
import org.openmetadata.service.Entity;
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.search.SearchClient;
import org.openmetadata.service.search.SearchIndexUtils;
import org.openmetadata.service.search.models.IndexMapping;
import org.openmetadata.service.search.models.SearchSuggest;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
@ -31,6 +42,7 @@ import org.openmetadata.service.util.JsonUtils;
public interface SearchIndex {
Set<String> DEFAULT_EXCLUDED_FIELDS =
Set.of("changeDescription", "lineage.pipeline.changeDescription", "connection");
public static final SearchClient searchClient = Entity.getSearchRepository().getSearchClient();
default Map<String, Object> buildSearchIndexDoc() {
// Build Index Doc
@ -162,6 +174,146 @@ public interface SearchIndex {
return data;
}
static List<Map<String, Object>> populateEntityRelationshipData(Table entity) {
List<Map<String, Object>> constraints = new ArrayList<>();
if (CommonUtil.nullOrEmpty(entity.getTableConstraints())) {
return constraints;
}
for (TableConstraint tableConstraint : entity.getTableConstraints()) {
if (!tableConstraint
.getConstraintType()
.value()
.equalsIgnoreCase(TableConstraint.ConstraintType.FOREIGN_KEY.value())) {
continue;
}
for (String referredColumn : tableConstraint.getReferredColumns()) {
String relatedEntityFQN = getParentFQN(referredColumn);
Table relatedEntity;
try {
relatedEntity = getEntityByName(Entity.TABLE, relatedEntityFQN, "*", NON_DELETED);
IndexMapping destinationIndexMapping =
Entity.getSearchRepository()
.getIndexMapping(relatedEntity.getEntityReference().getType());
String destinationIndexName =
destinationIndexMapping.getIndexName(Entity.getSearchRepository().getClusterAlias());
Map<String, Object> relationshipsMap = buildRelationshipsMap(entity, relatedEntity);
int relatedEntityIndex =
checkRelatedEntity(relatedEntity.getFullyQualifiedName(), constraints);
if (relatedEntityIndex >= 0) {
updateExistingConstraint(
entity,
tableConstraint,
constraints.get(relatedEntityIndex),
destinationIndexName,
relatedEntity,
referredColumn);
} else {
addNewConstraint(
entity,
tableConstraint,
constraints,
relationshipsMap,
destinationIndexName,
relatedEntity,
referredColumn);
}
} catch (EntityNotFoundException ex) {
}
}
}
return constraints;
}
static int checkRelatedEntity(String relatedEntityFQN, List<Map<String, Object>> constraints) {
int index = 0;
for (Map<String, Object> constraint : constraints) {
Map<String, Object> relatedConstraintEntity =
(Map<String, Object>) constraint.get("relatedEntity");
if (relatedConstraintEntity.get("fqn").equals(relatedEntityFQN)) {
return index;
}
index++;
}
return -1;
}
private static Map<String, Object> buildRelationshipsMap(
EntityInterface entity, Table relatedEntity) {
Map<String, Object> relationshipsMap = new HashMap<>();
relationshipsMap.put("entity", buildEntityRefMap(entity.getEntityReference()));
relationshipsMap.put("relatedEntity", buildEntityRefMap(relatedEntity.getEntityReference()));
relationshipsMap.put(
"doc_id", entity.getId().toString() + "-" + relatedEntity.getId().toString());
return relationshipsMap;
}
private static void updateRelatedEntityIndex(
String destinationIndexName, Table relatedEntity, Map<String, Object> constraint) {
Pair<String, String> to = new ImmutablePair<>("_id", relatedEntity.getId().toString());
searchClient.updateEntityRelationship(destinationIndexName, to, constraint);
}
private static void updateExistingConstraint(
EntityInterface entity,
TableConstraint tableConstraint,
Map<String, Object> presentConstraint,
String destinationIndexName,
Table relatedEntity,
String referredColumn) {
for (String currentColumn : tableConstraint.getColumns()) {
if (currentColumn.equals(FullyQualifiedName.getColumnName(referredColumn))) {
String columnFQN = FullyQualifiedName.add(entity.getFullyQualifiedName(), currentColumn);
Map<String, Object> columnMap = new HashMap<>();
columnMap.put("columnFQN", columnFQN);
columnMap.put("relatedColumnFQN", referredColumn);
columnMap.put("relationshipType", tableConstraint.getRelationshipType());
List<Map<String, Object>> presentColumns =
(List<Map<String, Object>>) presentConstraint.get("columns");
presentColumns.add(columnMap);
updateRelatedEntityIndex(destinationIndexName, relatedEntity, presentConstraint);
break;
}
}
}
private static void addNewConstraint(
EntityInterface entity,
TableConstraint tableConstraint,
List<Map<String, Object>> constraints,
Map<String, Object> relationshipsMap,
String destinationIndexName,
Table relatedEntity,
String referredColumn) {
for (String currentColumn : tableConstraint.getColumns()) {
if (currentColumn.equals(FullyQualifiedName.getColumnName(referredColumn))) {
List<Map<String, Object>> columns = new ArrayList<>();
String columnFQN = FullyQualifiedName.add(entity.getFullyQualifiedName(), currentColumn);
Map<String, Object> columnMap = new HashMap<>();
columnMap.put("columnFQN", columnFQN);
columnMap.put("relatedColumnFQN", referredColumn);
columnMap.put("relationshipType", tableConstraint.getRelationshipType());
columns.add(columnMap);
relationshipsMap.put("columns", columns);
constraints.add(JsonUtils.getMap(relationshipsMap));
updateRelatedEntityIndex(destinationIndexName, relatedEntity, relationshipsMap);
}
}
}
static Map<String, Object> buildEntityRefMap(EntityReference entityRef) {
Map<String, Object> details = new HashMap<>();
details.put("id", entityRef.getId().toString());
details.put("type", entityRef.getType());
details.put("fqn", entityRef.getFullyQualifiedName());
details.put("fqnHash", FullyQualifiedName.buildHash(entityRef.getFullyQualifiedName()));
return details;
}
static Map<String, Float> getDefaultFields() {
Map<String, Float> fields = new HashMap<>();
fields.put(DISPLAY_NAME_KEYWORD, 10.0f);

View File

@ -103,6 +103,7 @@ public record TableIndex(Table table) implements ColumnIndex {
doc.put("service", getEntityWithDisplayName(table.getService()));
doc.put("database", getEntityWithDisplayName(table.getDatabase()));
doc.put("lineage", SearchIndex.getLineageData(table.getEntityReference()));
doc.put("entityRelationship", SearchIndex.populateEntityRelationshipData(table));
doc.put("databaseSchema", getEntityWithDisplayName(table.getDatabaseSchema()));
return doc;
}

View File

@ -11,6 +11,7 @@ import static org.openmetadata.service.Entity.FIELD_DISPLAY_NAME;
import static org.openmetadata.service.Entity.GLOSSARY_TERM;
import static org.openmetadata.service.Entity.QUERY;
import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA;
import static org.openmetadata.service.Entity.TABLE;
import static org.openmetadata.service.exception.CatalogGenericExceptionMapper.getResponse;
import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD;
import static org.openmetadata.service.search.EntityBuilderConstant.API_RESPONSE_SCHEMA_FIELD_KEYWORD;
@ -66,6 +67,7 @@ import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChart;
import org.openmetadata.schema.dataInsight.custom.DataInsightCustomChartResultList;
import org.openmetadata.schema.dataInsight.custom.FormulaHolder;
import org.openmetadata.schema.entity.data.EntityHierarchy__1;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.service.configuration.elasticsearch.ElasticSearchConfiguration;
import org.openmetadata.schema.tests.DataQualityReport;
import org.openmetadata.schema.type.EntityReference;
@ -76,6 +78,8 @@ import org.openmetadata.service.Entity;
import org.openmetadata.service.dataInsight.DataInsightAggregatorInterface;
import org.openmetadata.service.jdbi3.DataInsightChartRepository;
import org.openmetadata.service.jdbi3.DataInsightSystemChartRepository;
import org.openmetadata.service.jdbi3.ListFilter;
import org.openmetadata.service.jdbi3.TableRepository;
import org.openmetadata.service.jdbi3.TestCaseResultRepository;
import org.openmetadata.service.search.SearchAggregation;
import org.openmetadata.service.search.SearchClient;
@ -227,6 +231,7 @@ public class OpenSearchClient implements SearchClient {
"fqnParts",
"chart_suggest",
"field_suggest");
private static final List<String> SOURCE_FIELDS_TO_EXCLUDE =
Stream.concat(FIELDS_TO_REMOVE.stream(), Stream.of("schemaDefinition", "customMetrics"))
.toList();
@ -752,6 +757,123 @@ public class OpenSearchClient implements SearchClient {
return Response.status(OK).entity(responseMap).build();
}
private void getEntityRelationship(
String fqn,
int depth,
Set<Map<String, Object>> edges,
Set<Map<String, Object>> nodes,
String queryFilter,
String direction,
boolean deleted)
throws IOException {
if (depth <= 0) {
return;
}
os.org.opensearch.action.search.SearchRequest searchRequest =
new os.org.opensearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery(direction, FullyQualifiedName.buildHash(fqn))));
if (CommonUtil.nullOrEmpty(deleted)) {
searchSourceBuilder.query(
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery(direction, FullyQualifiedName.buildHash(fqn)))
.must(QueryBuilders.termQuery("deleted", deleted)));
}
if (!nullOrEmpty(queryFilter) && !queryFilter.equals("{}")) {
try {
XContentParser filterParser =
XContentType.JSON
.xContent()
.createParser(X_CONTENT_REGISTRY, LoggingDeprecationHandler.INSTANCE, queryFilter);
QueryBuilder filter = SearchSourceBuilder.fromXContent(filterParser).query();
BoolQueryBuilder newQuery =
QueryBuilders.boolQuery().must(searchSourceBuilder.query()).filter(filter);
searchSourceBuilder.query(newQuery);
} catch (Exception ex) {
LOG.warn("Error parsing query_filter from query parameters, ignoring filter", ex);
}
}
searchRequest.source(searchSourceBuilder.size(1000));
os.org.opensearch.action.search.SearchResponse searchResponse =
client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
List<Map<String, Object>> entityRelationship =
(List<Map<String, Object>>) hit.getSourceAsMap().get("entityRelationship");
HashMap<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE_ENTITY_RELATIONSHIP);
nodes.add(tempMap);
for (Map<String, Object> er : entityRelationship) {
Map<String, String> entity = (HashMap<String, String>) er.get("entity");
Map<String, String> relatedEntity = (HashMap<String, String>) er.get("relatedEntity");
if (direction.equalsIgnoreCase(ENTITY_RELATIONSHIP_DIRECTION_ENTITY)) {
if (!edges.contains(er) && entity.get("fqn").equals(fqn)) {
edges.add(er);
getEntityRelationship(
relatedEntity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted);
}
} else {
if (!edges.contains(er) && relatedEntity.get("fqn").equals(fqn)) {
edges.add(er);
getEntityRelationship(
entity.get("fqn"), depth - 1, edges, nodes, queryFilter, direction, deleted);
}
}
}
}
}
public Map<String, Object> searchEntityRelationshipInternal(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap = new HashMap<>();
Set<Map<String, Object>> edges = new HashSet<>();
Set<Map<String, Object>> nodes = new HashSet<>();
os.org.opensearch.action.search.SearchRequest searchRequest =
new os.org.opensearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery().must(QueryBuilders.termQuery("fullyQualifiedName", fqn)));
searchRequest.source(searchSourceBuilder.size(1000));
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
HashMap<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE);
responseMap.put("entity", tempMap);
}
getEntityRelationship(
fqn,
downstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_ENTITY,
deleted);
getEntityRelationship(
fqn,
upstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_RELATED_ENTITY,
deleted);
responseMap.put("edges", edges);
responseMap.put("nodes", nodes);
return responseMap;
}
@Override
public Response searchEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap =
searchEntityRelationshipInternal(fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
return Response.status(OK).entity(responseMap).build();
}
@Override
public Response searchDataQualityLineage(
String fqn, int upstreamDepth, String queryFilter, boolean deleted) throws IOException {
@ -764,6 +886,78 @@ public class OpenSearchClient implements SearchClient {
return Response.status(OK).entity(responseMap).build();
}
public Map<String, Object> searchSchemaEntityRelationshipInternal(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap = new HashMap<>();
Set<Map<String, Object>> edges = new HashSet<>();
Set<Map<String, Object>> nodes = new HashSet<>();
os.org.opensearch.action.search.SearchRequest searchRequest =
new os.org.opensearch.action.search.SearchRequest(
Entity.getSearchRepository().getIndexOrAliasName(GLOBAL_SEARCH_ALIAS));
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(
QueryBuilders.boolQuery().must(QueryBuilders.termQuery("fullyQualifiedName", fqn)));
searchRequest.source(searchSourceBuilder.size(1000));
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
for (var hit : searchResponse.getHits().getHits()) {
HashMap<String, Object> tempMap = new HashMap<>(JsonUtils.getMap(hit.getSourceAsMap()));
tempMap.keySet().removeAll(FIELDS_TO_REMOVE);
responseMap.put("entity", tempMap);
}
TableRepository repository = (TableRepository) Entity.getEntityRepository(TABLE);
ListFilter filter = new ListFilter(Include.NON_DELETED).addQueryParam("databaseSchema", fqn);
List<Table> tables =
repository.listAll(repository.getFields("tableConstraints, displayName, owners"), filter);
for (Table table : tables) {
getEntityRelationship(
table.getFullyQualifiedName(),
downstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_ENTITY,
deleted);
getEntityRelationship(
table.getFullyQualifiedName(),
upstreamDepth,
edges,
nodes,
queryFilter,
ENTITY_RELATIONSHIP_DIRECTION_RELATED_ENTITY,
deleted);
}
// Add the remaining tables from the list into the nodes
// These will the one's that do not have any entity relationship
for (Table table : tables) {
boolean tablePresent = false;
for (Map<String, Object> node : nodes) {
if (table.getId().toString().equals(node.get("id"))) {
tablePresent = true;
break;
}
}
if (!tablePresent) {
HashMap<String, Object> tableMap = new HashMap<>(JsonUtils.getMap(table));
tableMap.keySet().removeAll(FIELDS_TO_REMOVE_ENTITY_RELATIONSHIP);
tableMap.put("entityType", "table");
nodes.add(tableMap);
}
}
responseMap.put("edges", edges);
responseMap.put("nodes", nodes);
return responseMap;
}
public Response searchSchemaEntityRelationship(
String fqn, int upstreamDepth, int downstreamDepth, String queryFilter, boolean deleted)
throws IOException {
Map<String, Object> responseMap =
searchSchemaEntityRelationshipInternal(
fqn, upstreamDepth, downstreamDepth, queryFilter, deleted);
return Response.status(OK).entity(responseMap).build();
}
private void getLineage(
String fqn,
int depth,
@ -1839,6 +2033,29 @@ public class OpenSearchClient implements SearchClient {
}
}
@Override
public void updateEntityRelationship(
String indexName,
Pair<String, String> fieldAndValue,
Map<String, Object> entityRelationshipData) {
if (isClientAvailable) {
UpdateByQueryRequest updateByQueryRequest = new UpdateByQueryRequest(indexName);
updateByQueryRequest.setQuery(
new MatchQueryBuilder(fieldAndValue.getKey(), fieldAndValue.getValue())
.operator(Operator.AND));
Map<String, Object> params =
Collections.singletonMap("entityRelationshipData", entityRelationshipData);
Script script =
new Script(
ScriptType.INLINE,
Script.DEFAULT_SCRIPT_LANG,
ADD_UPDATE_ENTITY_RELATIONSHIP,
params);
updateByQueryRequest.setScript(script);
updateOpenSearchByQuery(updateByQueryRequest);
}
}
@SneakyThrows
private void updateOpenSearchByQuery(UpdateByQueryRequest updateByQueryRequest) {
if (updateByQueryRequest != null && isClientAvailable) {

View File

@ -587,6 +587,9 @@
"lineage": {
"type" : "object"
},
"entityRelationship": {
"type" : "object"
},
"serviceType": {
"type": "keyword",
"normalizer": "lowercase_normalizer"

View File

@ -210,6 +210,15 @@
"$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName"
},
"default": null
},
"relationshipType": {
"type": "string",
"enum": [
"ONE_TO_ONE",
"ONE_TO_MANY",
"MANY_TO_ONE",
"MANY_TO_MANY"
]
}
},
"additionalProperties": false

View File

@ -30,6 +30,7 @@
"EditDescription",
"EditDisplayName",
"EditLineage",
"EditEntityRelationship",
"EditPolicy",
"EditOwners",
"EditQueries",

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"><path stroke="currentColor" stroke-width=".3" d="M7.571 14.85H5.943v-2.825h4.114v2.825H7.572Zm7.34-13.266v2.391H1.089V1.584c0-.258.18-.434.363-.434h13.096c.182 0 .363.176.363.434Zm.739 0c0-.662-.477-1.234-1.102-1.234H1.452C.827.35.35.922.35 1.584v12.452c0 .871.626 1.614 1.436 1.614h12.428c.81 0 1.436-.743 1.436-1.614V1.584ZM1.09 8.4h4.113v2.825H1.09V8.4Zm0-.8V4.775h4.113V7.6H1.09Zm4.853-2.825h4.114V7.6H5.943V4.775Zm4.853 0h4.115V7.6h-4.115V4.775ZM14.911 8.4v2.825h-4.115V8.4h4.115Zm-9.708 6.45H1.786c-.366 0-.697-.346-.697-.814v-2.01h4.114v2.824Zm.74-3.625V8.4h4.114v2.825H5.943Zm8.27 3.625h-3.417v-2.825h4.115v2.011c0 .468-.33.814-.697.814Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path stroke="currentColor" stroke-width=".3" d="M7.571 14.85H5.943v-2.825h4.114v2.825H7.572Zm7.34-13.266v2.391H1.089V1.584c0-.258.18-.434.363-.434h13.096c.182 0 .363.176.363.434Zm.739 0c0-.662-.477-1.234-1.102-1.234H1.452C.827.35.35.922.35 1.584v12.452c0 .871.626 1.614 1.436 1.614h12.428c.81 0 1.436-.743 1.436-1.614V1.584ZM1.09 8.4h4.113v2.825H1.09V8.4Zm0-.8V4.775h4.113V7.6H1.09Zm4.853-2.825h4.114V7.6H5.943V4.775Zm4.853 0h4.115V7.6h-4.115V4.775ZM14.911 8.4v2.825h-4.115V8.4h4.115Zm-9.708 6.45H1.786c-.366 0-.697-.346-.697-.814v-2.01h4.114v2.824Zm.74-3.625V8.4h4.114v2.825H5.943Zm8.27 3.625h-3.417v-2.825h4.115v2.011c0 .468-.33.814-.697.814Z"/></svg>

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 734 B

View File

@ -20,7 +20,7 @@ export type Props = {
hasDescriptionEditAccess: boolean;
hasTagEditAccess: boolean;
isReadOnly?: boolean;
testCaseSummary?: TestSummary;
onThreadLinkSelect: (value: string, threadType?: ThreadType) => void;
onUpdate: (columns: Table['columns']) => Promise<void>;
testCaseSummary?: TestSummary;
};

View File

@ -12,7 +12,7 @@
*/
import { FilterOutlined } from '@ant-design/icons';
import { Button, Tooltip, Typography } from 'antd';
import { Button, Form, Select, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { ExpandableConfig } from 'antd/lib/table/interface';
import {
@ -36,7 +36,10 @@ import {
ICON_DIMENSION,
NO_DATA_PLACEHOLDER,
} from '../../../constants/constants';
import { TABLE_SCROLL_VALUE } from '../../../constants/Table.constants';
import {
COLUMN_CONSTRAINT_TYPE_OPTIONS,
TABLE_SCROLL_VALUE,
} from '../../../constants/Table.constants';
import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider';
import {
OperationPermission,
@ -71,22 +74,29 @@ import FilterTablePlaceHolder from '../../common/ErrorWithPlaceholder/FilterTabl
import Table from '../../common/Table/Table';
import TestCaseStatusSummaryIndicator from '../../common/TestCaseStatusSummaryIndicator/TestCaseStatusSummaryIndicator.component';
import EntityNameModal from '../../Modals/EntityNameModal/EntityNameModal.component';
import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface';
import {
EntityName,
EntityNameWithAdditionFields,
} from '../../Modals/EntityNameModal/EntityNameModal.interface';
import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
import { ColumnFilter } from '../ColumnFilter/ColumnFilter.component';
import TableDescription from '../TableDescription/TableDescription.component';
import TableTags from '../TableTags/TableTags.component';
import { SchemaTableProps, TableCellRendered } from './SchemaTable.interface';
import {
SchemaTableProps,
TableCellRendered,
UpdatedColumnFieldData,
} from './SchemaTable.interface';
const SchemaTable = ({
searchText,
onUpdate,
hasDescriptionEditAccess,
hasTagEditAccess,
isReadOnly = false,
onThreadLinkSelect,
table,
testCaseSummary,
onUpdate,
onThreadLinkSelect,
}: SchemaTableProps) => {
const { theme } = useApplicationStore();
const { t } = useTranslation();
@ -171,12 +181,7 @@ const SchemaTable = ({
field,
value,
columns,
}: {
fqn: string;
field: keyof Column;
value?: string;
columns: Column[];
}) => {
}: UpdatedColumnFieldData) => {
columns?.forEach((col) => {
if (col.fullyQualifiedName === fqn) {
set(col, field, value);
@ -310,18 +315,27 @@ const SchemaTable = ({
setEditColumnDisplayName(record);
};
const handleEditDisplayName = async ({ displayName }: EntityName) => {
const handleEditColumnData = async (data: EntityName) => {
const { displayName, constraint } = data as EntityNameWithAdditionFields;
if (
!isUndefined(editColumnDisplayName) &&
editColumnDisplayName.fullyQualifiedName
) {
const tableCols = cloneDeep(tableColumns);
updateColumnFields({
fqn: editColumnDisplayName.fullyQualifiedName,
value: isEmpty(displayName) ? undefined : displayName,
field: 'displayName',
columns: tableCols,
});
updateColumnFields({
fqn: editColumnDisplayName.fullyQualifiedName,
value: isEmpty(constraint) ? undefined : constraint,
field: 'constraint',
columns: tableCols,
});
await onUpdate(tableCols);
setEditColumnDisplayName(undefined);
} else {
@ -372,13 +386,14 @@ const SchemaTable = ({
{getEntityName(record)}
</Typography.Text>
) : null}
{(tablePermissions?.EditAll ||
tablePermissions?.EditDisplayName) &&
!isReadOnly && (
<Tooltip
placement="right"
title={t('label.edit-entity', {
entity: t('label.display-name'),
entity: t('label.column'),
})}>
<Button
className="cursor-pointer hover-cell-icon w-fit-content"
@ -516,6 +531,19 @@ const SchemaTable = ({
]
);
const additionalFieldsInEntityNameModal = (
<Form.Item
label={t('label.entity-type-plural', {
entity: t('label.constraint'),
})}
name="constraint">
<Select
data-testid="constraint-type-select"
options={COLUMN_CONSTRAINT_TYPE_OPTIONS}
/>
</Form.Item>
);
useEffect(() => {
setExpandedRowKeys(nestedTableFqnKeys);
}, [searchText]);
@ -568,13 +596,14 @@ const SchemaTable = ({
)}
{editColumnDisplayName && (
<EntityNameModal
additionalFields={additionalFieldsInEntityNameModal}
entity={editColumnDisplayName}
title={`${t('label.edit-entity', {
entity: t('label.column'),
})}: "${editColumnDisplayName?.name}"`}
visible={Boolean(editColumnDisplayName)}
onCancel={() => setEditColumnDisplayName(undefined)}
onSave={handleEditDisplayName}
onSave={handleEditColumnData}
/>
)}
</>

View File

@ -41,3 +41,10 @@ export interface EditColumnTag {
column: Column;
index: number;
}
export interface UpdatedColumnFieldData {
fqn: string;
field: keyof Column;
value?: string;
columns: Column[];
}

View File

@ -27,7 +27,7 @@ import { PAGE_SIZE } from '../../../constants/constants';
import { EntityType, FqnPart } from '../../../enums/entity.enum';
import { SearchIndex } from '../../../enums/search.enum';
import { EntityReference } from '../../../generated/entity/type';
import { searchData } from '../../../rest/miscAPI';
import { searchQuery } from '../../../rest/searchAPI';
import { getPartialNameFromTableFQN } from '../../../utils/CommonUtils';
import { getEntityNodeIcon } from '../../../utils/EntityLineageUtils';
import { getEntityName } from '../../../utils/EntityUtils';
@ -40,10 +40,12 @@ import './node-suggestion.less';
interface EntitySuggestionProps extends HTMLAttributes<HTMLDivElement> {
onSelectHandler: (value: EntityReference) => void;
entityType: string;
queryFilter?: Record<string, unknown>;
}
const NodeSuggestions: FC<EntitySuggestionProps> = ({
entityType,
queryFilter,
onSelectHandler,
}) => {
const { t } = useTranslation();
@ -67,16 +69,15 @@ const NodeSuggestions: FC<EntitySuggestionProps> = ({
const getSearchResults = async (value: string) => {
try {
const data = await searchData<ExploreSearchIndex>(
value,
1,
PAGE_SIZE,
'',
'',
'',
(entityType as ExploreSearchIndex) ?? SearchIndex.TABLE
);
const sources = data.data.hits.hits.map((hit) => hit._source);
const data = await searchQuery({
query: value,
searchIndex: (entityType as ExploreSearchIndex) ?? SearchIndex.TABLE,
queryFilter: queryFilter,
pageNumber: 1,
pageSize: PAGE_SIZE,
includeDeleted: false,
});
const sources = data.hits.hits.map((hit) => hit._source);
setData(sources);
} catch (error) {
showErrorToast(

View File

@ -14,7 +14,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { SearchIndex } from '../../../enums/search.enum';
import { searchData } from '../../../rest/miscAPI';
import { searchQuery } from '../../../rest/searchAPI';
import NodeSuggestions from './NodeSuggestions.component';
const mockProps = {
@ -34,8 +34,8 @@ const entityType = [
SearchIndex.DASHBOARD_DATA_MODEL,
];
jest.mock('../../../rest/miscAPI', () => ({
searchData: jest.fn().mockImplementation(() => Promise.resolve()),
jest.mock('../../../rest/searchAPI', () => ({
searchQuery: jest.fn().mockImplementation(() => Promise.resolve()),
}));
describe('Test NodeSuggestions Component', () => {
@ -52,15 +52,21 @@ describe('Test NodeSuggestions Component', () => {
entityType.forEach((value) => {
it(`Suggest & Suggest API for ${value} should work properly`, async () => {
jest.useFakeTimers('modern');
const mockSearchData = searchData as jest.Mock;
const mockSearchQueryData = searchQuery as jest.Mock;
const searchValue = 'sale';
await act(async () => {
render(<NodeSuggestions {...mockProps} entityType={value} />);
});
// 1st call on page load with empty search string and respective searchIndex
expect(mockSearchData.mock.calls[0][0]).toBe('');
expect(mockSearchData.mock.calls[0][6]).toEqual(value);
expect(mockSearchQueryData.mock.calls[0][0]).toStrictEqual({
includeDeleted: false,
pageNumber: 1,
pageSize: 10,
query: '',
queryFilter: undefined,
searchIndex: value,
});
const suggestionNode = await screen.findByTestId('suggestion-node');
const searchInput = await screen.findByRole('combobox');
@ -77,7 +83,7 @@ describe('Test NodeSuggestions Component', () => {
jest.runAllTimers();
});
expect(mockSearchData.mock.instances).toHaveLength(2);
expect(mockSearchQueryData.mock.instances).toHaveLength(2);
});
});
});

View File

@ -10,7 +10,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Form, Input, Modal, Typography } from 'antd';
import { Button, Form, FormProps, Input, Modal, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ENTITY_NAME_REGEX } from '../../../constants/regex.constants';
@ -26,12 +26,13 @@ const EntityNameModal: React.FC<EntityNameModalProps> = ({
// By default its disabled, send allowRename true to get the functionality
allowRename = false,
nameValidationRules = [],
additionalFields,
}) => {
const { t } = useTranslation();
const [form] = Form.useForm<{ name: string; displayName: string }>();
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const handleSave = async (obj: { name: string; displayName: string }) => {
const handleSave: FormProps['onFinish'] = async (obj) => {
setIsLoading(true);
await form.validateFields();
// Error must be handled by the parent component
@ -40,7 +41,7 @@ const EntityNameModal: React.FC<EntityNameModalProps> = ({
};
useEffect(() => {
form.setFieldsValue({ name: entity.name, displayName: entity.displayName });
form.setFieldsValue(entity);
}, [visible]);
return (
@ -96,6 +97,8 @@ const EntityNameModal: React.FC<EntityNameModalProps> = ({
<Form.Item label={t('label.display-name')} name="displayName">
<Input placeholder={t('message.enter-display-name')} />
</Form.Item>
{additionalFields}
</Form>
</Modal>
);

View File

@ -11,9 +11,14 @@
* limitations under the License.
*/
import { Rule } from 'antd/lib/form';
import { Constraint } from '../../../generated/entity/data/table';
export type EntityName = { name: string; displayName: string };
export type EntityNameWithAdditionFields = EntityName & {
constraint: Constraint;
};
export interface EntityNameModalProps {
visible: boolean;
allowRename?: boolean;
@ -22,4 +27,5 @@ export interface EntityNameModalProps {
entity: Partial<EntityName>;
title: string;
nameValidationRules?: Rule[];
additionalFields?: React.ReactNode;
}

View File

@ -11,7 +11,12 @@
* limitations under the License.
*/
import { ConstraintType } from '../generated/entity/data/table';
import {
Constraint,
ConstraintType,
RelationshipType,
} from '../generated/entity/data/table';
import i18n from '../utils/i18next/LocalUtil';
export const TABLE_SCROLL_VALUE = { x: 1200 };
@ -19,3 +24,41 @@ export const SUPPORTED_TABLE_CONSTRAINTS = [
ConstraintType.ForeignKey,
ConstraintType.PrimaryKey,
];
export const COLUMN_CONSTRAINT_TYPE_OPTIONS = [
{
label: i18n.t('label.primary-key'),
value: Constraint.PrimaryKey,
},
{
label: i18n.t('label.not-null'),
value: Constraint.NotNull,
},
{
label: i18n.t('label.null'),
value: Constraint.Null,
},
{
label: i18n.t('label.unique'),
value: Constraint.Unique,
},
];
export const RELATIONSHIP_TYPE_OPTION = [
{
label: i18n.t('label.one-to-one'),
value: RelationshipType.OneToOne,
},
{
label: i18n.t('label.one-to-many'),
value: RelationshipType.OneToMany,
},
{
label: i18n.t('label.many-to-one'),
value: RelationshipType.ManyToOne,
},
{
label: i18n.t('label.many-to-many'),
value: RelationshipType.ManyToMany,
},
];

View File

@ -683,6 +683,8 @@
"manage-entity": "{{entity}} verwalten",
"manage-rule": "Regeln verwalten",
"mandatory": "Obligatorisch",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "März",
"mark-all-deleted-table-plural": "Alle gelöschten Tabellen markieren",
"mark-deleted-entity": "{{entity}} als gelöscht markieren",
@ -805,6 +807,8 @@
"on-demand": "Auf Abruf",
"on-lowercase": "auf",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Öffnen",
"open-lowercase": "öffnen",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Ablehnen",
"reject-all": "Reject All",
"rejected": "Rejected",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Verwandte Begriffe",
"relationship": "Relationship",
"relevance": "Relevanz",
"remove": "Entfernen",
"remove-entity": "{{entity}} entfernen",

View File

@ -683,6 +683,8 @@
"manage-entity": "Manage {{entity}}",
"manage-rule": "Manage Rule",
"mandatory": "Mandatory",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "March",
"mark-all-deleted-table-plural": "Mark All Deleted Tables",
"mark-deleted-entity": "Mark Deleted {{entity}}",
@ -805,6 +807,8 @@
"on-demand": "On Demand",
"on-lowercase": "on",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Open",
"open-lowercase": "open",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Reject",
"reject-all": "Reject All",
"rejected": "Rejected",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Related Terms",
"relationship": "Relationship",
"relevance": "Relevance",
"remove": "Remove",
"remove-entity": "Remove {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "Administrar {{entity}}",
"manage-rule": "Administrar regla",
"mandatory": "Obligatorio",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "Marzo",
"mark-all-deleted-table-plural": "Marcar Todas las Tablas como Eliminadas",
"mark-deleted-entity": "Marcar {{entity}} como Eliminado",
@ -805,6 +807,8 @@
"on-demand": "Bajo Demanda",
"on-lowercase": "en",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Abrir",
"open-lowercase": "abrir",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Rechazar",
"reject-all": "Reject All",
"rejected": "Rechazado",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Términos relacionados",
"relationship": "Relationship",
"relevance": "Relevancia",
"remove": "Eliminar",
"remove-entity": "Eliminar {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "Gérer {{entity}}",
"manage-rule": "Gérer les Règles",
"mandatory": "Obligatoire",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "Mars",
"mark-all-deleted-table-plural": "Marquer Toutes les Tables Supprimées",
"mark-deleted-entity": "Marquer {{entity}} Supprimé·e",
@ -805,6 +807,8 @@
"on-demand": "Sur Demande",
"on-lowercase": "sur",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Ouvrir",
"open-lowercase": "ouvrir",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Rejeter",
"reject-all": "Rejeter Tout",
"rejected": "Rejeté",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Termes Liés",
"relationship": "Relationship",
"relevance": "Pertinence",
"remove": "Retirer",
"remove-entity": "Retirer un·e {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "נהל {{entity}}",
"manage-rule": "נהל כלל",
"mandatory": "חובה",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "מרץ",
"mark-all-deleted-table-plural": "סמן את כל הטבלאות שנמחקו",
"mark-deleted-entity": "סמן {{entity}} שנמחק",
@ -805,6 +807,8 @@
"on-demand": "על פי דרישה",
"on-lowercase": "על",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "פתוח",
"open-lowercase": "פתוח",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "דחה",
"reject-all": "Reject All",
"rejected": "נדחה",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "מונחים קשורים",
"relationship": "Relationship",
"relevance": "רלוונטיות",
"remove": "הסר",
"remove-entity": "הסר {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "{{entity}}の管理",
"manage-rule": "ルールの管理",
"mandatory": "Mandatory",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "3月",
"mark-all-deleted-table-plural": "削除済みの全テーブルをマーク",
"mark-deleted-entity": "Mark Deleted {{entity}}",
@ -805,6 +807,8 @@
"on-demand": "On Demand",
"on-lowercase": "の上の",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "開く",
"open-lowercase": "開く",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Reject",
"reject-all": "Reject All",
"rejected": "Rejected",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "関連する用語",
"relationship": "Relationship",
"relevance": "Relevance",
"remove": "除外",
"remove-entity": "{{entity}}を除外",

View File

@ -683,6 +683,8 @@
"manage-entity": "Beheer {{entity}}",
"manage-rule": "Beheer regel",
"mandatory": "Verplicht",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "maart",
"mark-all-deleted-table-plural": "Markeer alle verwijderde tabellen",
"mark-deleted-entity": "Markeer verwijderde {{entity}}",
@ -805,6 +807,8 @@
"on-demand": "Op verzoek",
"on-lowercase": "op",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Open",
"open-lowercase": "open",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Weigeren",
"reject-all": "Reject All",
"rejected": "Geweigerd",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Gerelateerde termen",
"relationship": "Relationship",
"relevance": "Relevantie",
"remove": "Verwijderen",
"remove-entity": "Verwijder {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "مدیریت {{entity}}",
"manage-rule": "مدیریت قانون",
"mandatory": "اجباری",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "مارس",
"mark-all-deleted-table-plural": "علامت‌گذاری همه جداول حذف شده",
"mark-deleted-entity": "علامت‌گذاری حذف {{entity}}",
@ -805,6 +807,8 @@
"on-demand": "درخواست‌شده",
"on-lowercase": "روی",
"one-reply": "1 پاسخ",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "باز",
"open-lowercase": "باز",
"open-metadata": "متادیتای باز",
@ -940,8 +944,10 @@
"reject": "رد کردن",
"reject-all": "رد کردن همه",
"rejected": "رد شد",
"related-column": "Related Column",
"related-metric-plural": "شاخص‌های مرتبط",
"related-term-plural": "اصطلاحات مرتبط",
"relationship": "Relationship",
"relevance": "مربوط بودن",
"remove": "حذف",
"remove-entity": "حذف {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "Gerenciar {{entity}}",
"manage-rule": "Gerenciar Regra",
"mandatory": "Obrigatório",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "Março",
"mark-all-deleted-table-plural": "Marcar Todas as Tabelas Excluídas",
"mark-deleted-entity": "Marcar {{entity}} Como Excluída",
@ -805,6 +807,8 @@
"on-demand": "Sob Demanda",
"on-lowercase": "em",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Abrir",
"open-lowercase": "abrir",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Rejeitar",
"reject-all": "Reject All",
"rejected": "Rejeitado",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Termos Relacionados",
"relationship": "Relationship",
"relevance": "Relevância",
"remove": "Remover",
"remove-entity": "Remover {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "Управление {{entity}}",
"manage-rule": "Правила управления",
"mandatory": "Обязательный",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "Март",
"mark-all-deleted-table-plural": "Отметить все удаленные таблицы",
"mark-deleted-entity": "Отметить как удаленное {{entity}}",
@ -805,6 +807,8 @@
"on-demand": "По запросу",
"on-lowercase": "на",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "Открыто",
"open-lowercase": "открыть",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "Reject",
"reject-all": "Reject All",
"rejected": "Rejected",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "Связанные термины",
"relationship": "Relationship",
"relevance": "Актуальность",
"remove": "Удалить",
"remove-entity": "Удалить {{entity}}",

View File

@ -683,6 +683,8 @@
"manage-entity": "管理{{entity}}",
"manage-rule": "管理规则",
"mandatory": "必填",
"many-to-many": "Many to Many",
"many-to-one": "Many to One",
"march": "三月",
"mark-all-deleted-table-plural": "标记所有已删除的表",
"mark-deleted-entity": "标记已删除的{{entity}}",
@ -805,6 +807,8 @@
"on-demand": "即时",
"on-lowercase": "on",
"one-reply": "1 Reply",
"one-to-many": "One to Many",
"one-to-one": "One to One",
"open": "打开",
"open-lowercase": "打开",
"open-metadata": "OpenMetadata",
@ -940,8 +944,10 @@
"reject": "拒绝",
"reject-all": "拒绝全部",
"rejected": "已拒绝",
"related-column": "Related Column",
"related-metric-plural": "Related Metrics",
"related-term-plural": "关联术语",
"relationship": "Relationship",
"relevance": "相关性",
"remove": "删除",
"remove-entity": "删除{{entity}}",

View File

@ -25,22 +25,15 @@ import React, {
} from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import ActivityFeedProvider, {
useActivityFeedProvider,
} from '../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import { ActivityFeedTab } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
import { useActivityFeedProvider } from '../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import ActivityThreadPanel from '../../components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanel';
import { withActivityFeed } from '../../components/AppRouter/withActivityFeed';
import { CustomPropertyTable } from '../../components/common/CustomPropertyTable/CustomPropertyTable';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../components/common/Loader/Loader';
import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface';
import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels';
import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component';
import { DataAssetsHeader } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component';
import ProfilerSettings from '../../components/Database/Profiler/ProfilerSettings/ProfilerSettings';
import { QueryVote } from '../../components/Database/TableQueries/TableQueries.interface';
import EntityRightPanel from '../../components/Entity/EntityRightPanel/EntityRightPanel';
import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameModal.interface';
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
import {
@ -50,7 +43,6 @@ import {
ROUTES,
} from '../../constants/constants';
import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants';
import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../constants/ResizablePanel.constants';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import {
OperationPermission,
@ -72,7 +64,6 @@ import { Include } from '../../generated/type/include';
import { TagLabel } from '../../generated/type/tagLabel';
import { useFqn } from '../../hooks/useFqn';
import { FeedCounts } from '../../interface/feed.interface';
import StoredProcedureTab from '../../pages/StoredProcedure/StoredProcedureTab';
import {
getDatabaseSchemaDetailsByFQN,
patchDatabaseSchemaDetails,
@ -87,13 +78,13 @@ import {
getFeedCounts,
sortTagsCaseInsensitive,
} from '../../utils/CommonUtils';
import databaseSchemaClassBase from '../../utils/DatabaseSchemaClassBase';
import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { getTagsWithoutTier, getTierTags } from '../../utils/TableUtils';
import { createTagObject, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
import SchemaTablesTab from './SchemaTablesTab';
const DatabaseSchemaPage: FunctionComponent = () => {
const { postFeed, deleteFeed, updateFeed } = useActivityFeedProvider();
@ -572,135 +563,67 @@ const DatabaseSchemaPage: FunctionComponent = () => {
});
};
const tabs: TabsProps['items'] = [
{
label: (
<TabsLabel
count={tableData.paging.total}
id={EntityTabs.TABLE}
isActive={activeTab === EntityTabs.TABLE}
name={t('label.table-plural')}
/>
),
key: EntityTabs.TABLE,
children: (
<Row gutter={[0, 16]} wrap={false}>
<Col className="tab-content-height-with-resizable-panel" span={24}>
<ResizablePanels
firstPanel={{
className: 'entity-resizable-panel-container',
children: (
<div className="p-t-sm m-x-lg">
<SchemaTablesTab
currentTablesPage={currentTablesPage}
databaseSchemaDetails={databaseSchema}
description={description}
editDescriptionPermission={editDescriptionPermission}
isEdit={isEdit}
showDeletedTables={showDeletedTables}
tableData={tableData}
tableDataLoading={tableDataLoading}
tablePaginationHandler={tablePaginationHandler}
onCancel={onEditCancel}
onDescriptionEdit={onDescriptionEdit}
onDescriptionUpdate={onDescriptionUpdate}
onShowDeletedTablesChange={handleShowDeletedTables}
onThreadLinkSelect={onThreadLinkSelect}
/>
</div>
),
...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL,
}}
secondPanel={{
children: (
<div data-testid="entity-right-panel">
<EntityRightPanel<EntityType.DATABASE_SCHEMA>
customProperties={databaseSchema}
dataProducts={databaseSchema?.dataProducts ?? []}
domain={databaseSchema?.domain}
editCustomAttributePermission={
editCustomAttributePermission
}
editTagPermission={editTagsPermission}
entityFQN={decodedDatabaseSchemaFQN}
entityId={databaseSchema?.id ?? ''}
entityType={EntityType.DATABASE_SCHEMA}
selectedTags={tags}
viewAllPermission={viewAllPermission}
onExtensionUpdate={handleExtensionUpdate}
onTagSelectionChange={handleTagSelection}
onThreadLinkSelect={onThreadLinkSelect}
/>
</div>
),
...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL,
className:
'entity-resizable-right-panel-container entity-resizable-panel-container',
}}
/>
</Col>
</Row>
),
},
{
label: (
<TabsLabel
count={storedProcedureCount}
id={EntityTabs.STORED_PROCEDURE}
isActive={activeTab === EntityTabs.STORED_PROCEDURE}
name={t('label.stored-procedure-plural')}
/>
),
key: EntityTabs.STORED_PROCEDURE,
children: <StoredProcedureTab />,
},
{
label: (
<TabsLabel
count={feedCount.totalCount}
id={EntityTabs.ACTIVITY_FEED}
isActive={activeTab === EntityTabs.ACTIVITY_FEED}
name={t('label.activity-feed-plural')}
/>
),
key: EntityTabs.ACTIVITY_FEED,
children: (
<ActivityFeedProvider>
<ActivityFeedTab
refetchFeed
entityFeedTotalCount={feedCount.totalCount}
entityType={EntityType.DATABASE_SCHEMA}
fqn={databaseSchema.fullyQualifiedName ?? ''}
onFeedUpdate={getEntityFeedCount}
onUpdateEntityDetails={fetchDatabaseSchemaDetails}
onUpdateFeedCount={handleFeedCount}
/>
</ActivityFeedProvider>
),
},
{
label: (
<TabsLabel
id={EntityTabs.CUSTOM_PROPERTIES}
name={t('label.custom-property-plural')}
/>
),
key: EntityTabs.CUSTOM_PROPERTIES,
children: databaseSchema && (
<div className="m-sm">
<CustomPropertyTable<EntityType.DATABASE_SCHEMA>
className=""
entityDetails={databaseSchema}
entityType={EntityType.DATABASE_SCHEMA}
handleExtensionUpdate={handleExtensionUpdate}
hasEditAccess={editCustomAttributePermission}
hasPermission={viewAllPermission}
isVersionView={false}
/>
</div>
),
},
];
const tabs: TabsProps['items'] = useMemo(
() =>
databaseSchemaClassBase.getDatabaseSchemaPageTabs({
feedCount,
tableData,
activeTab,
currentTablesPage,
databaseSchema,
description,
editDescriptionPermission,
isEdit,
showDeletedTables,
tableDataLoading,
editCustomAttributePermission,
editTagsPermission,
decodedDatabaseSchemaFQN,
tags,
viewAllPermission,
storedProcedureCount,
onEditCancel,
handleExtensionUpdate,
handleTagSelection,
onThreadLinkSelect,
tablePaginationHandler,
onDescriptionEdit,
onDescriptionUpdate,
handleShowDeletedTables,
getEntityFeedCount,
fetchDatabaseSchemaDetails,
handleFeedCount,
}),
[
feedCount,
tableData,
activeTab,
currentTablesPage,
databaseSchema,
description,
editDescriptionPermission,
isEdit,
showDeletedTables,
tableDataLoading,
editCustomAttributePermission,
editTagsPermission,
decodedDatabaseSchemaFQN,
tags,
viewAllPermission,
storedProcedureCount,
handleExtensionUpdate,
handleTagSelection,
onThreadLinkSelect,
tablePaginationHandler,
onEditCancel,
onDescriptionEdit,
onDescriptionUpdate,
handleShowDeletedTables,
getEntityFeedCount,
fetchDatabaseSchemaDetails,
handleFeedCount,
]
);
const updateVote = async (data: QueryVote, id: string) => {
try {

View File

@ -10,122 +10,191 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Space, Tooltip, Typography } from 'antd';
import { Button, Space, Tooltip, Typography } from 'antd';
import { isEmpty, map } from 'lodash';
import React, { FC, Fragment, useMemo } from 'react';
import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ReactComponent as IconEdit } from '../../../assets/svg/edit-new.svg';
import { ReactComponent as PlusIcon } from '../../../assets/svg/plus-primary.svg';
import TagButton from '../../../components/common/TagButton/TagButton.component';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { DE_ACTIVE_COLOR, ICON_DIMENSION } from '../../../constants/constants';
import { SUPPORTED_TABLE_CONSTRAINTS } from '../../../constants/Table.constants';
import { EntityType, FqnPart } from '../../../enums/entity.enum';
import { ConstraintType, Table } from '../../../generated/entity/data/table';
import { getPartialNameFromTableFQN } from '../../../utils/CommonUtils';
import entityUtilClassBase from '../../../utils/EntityUtilClassBase';
import ForeignKeyConstraint from './ForeignKeyConstraint';
import PrimaryKeyConstraint from './PrimaryKeyConstraint';
import './table-constraints.less';
import TableConstraintsModal from './TableConstraintsModal/TableConstraintsModal.component';
interface TableConstraintsProps {
constraints: Table['tableConstraints'];
hasPermission: boolean;
tableDetails?: Table;
onUpdate: (updateData: Table['tableConstraints']) => Promise<void>;
}
const TableConstraints: FC<TableConstraintsProps> = ({ constraints }) => {
const TableConstraints: FC<TableConstraintsProps> = ({
tableDetails,
hasPermission,
onUpdate,
}) => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const supportedConstraints = useMemo(
() =>
constraints?.filter((constraint) =>
tableDetails?.tableConstraints?.filter((constraint) =>
SUPPORTED_TABLE_CONSTRAINTS.includes(
constraint.constraintType as ConstraintType
)
) ?? [],
[constraints]
[tableDetails?.tableConstraints]
);
if (isEmpty(supportedConstraints)) {
return null;
}
const handleOpenEditConstraintModal = useCallback(
() => setIsModalOpen(true),
[]
);
const handleCloseEditConstraintModal = useCallback(
() => setIsModalOpen(false),
[]
);
const handleSubmit = async (values: Table['tableConstraints']) => {
await onUpdate(values);
setIsModalOpen(false);
};
return (
<Space className="p-b-sm" direction="vertical">
<Typography.Text className="right-panel-label">
{t('label.table-constraints')}
</Typography.Text>
{supportedConstraints.map(
({ constraintType, columns, referredColumns }, index) => {
if (constraintType === ConstraintType.PrimaryKey) {
return (
<div className="d-flex constraint-columns" key={index}>
<Space
className="constraint-icon-container"
direction="vertical"
size={0}>
{columns?.map((column, index) => (
<Fragment key={column}>
{(columns?.length ?? 0) - 1 !== index ? (
<PrimaryKeyConstraint />
) : null}
</Fragment>
))}
</Space>
<>
<Space className="p-b-sm" direction="vertical">
<Space size="middle">
<Typography.Text className="right-panel-label">
{t('label.table-constraints')}
</Typography.Text>
<Space direction="vertical" size={16}>
{columns?.map((column) => (
<Typography.Text
className="w-60"
ellipsis={{ tooltip: true }}
key={column}>
{column}
</Typography.Text>
))}
</Space>
</div>
);
}
if (constraintType === ConstraintType.ForeignKey) {
return (
<Space className="constraint-columns" key={index}>
<ForeignKeyConstraint />
<Space direction="vertical" size={16}>
<Typography.Text>{columns?.join(', ')}</Typography.Text>
<div data-testid="referred-column-name">
{map(referredColumns, (referredColumn) => (
<Tooltip
placement="top"
title={referredColumn}
trigger="hover">
<Link
className="no-underline"
to={entityUtilClassBase.getEntityLink(
EntityType.TABLE,
getPartialNameFromTableFQN(
referredColumn,
[
FqnPart.Service,
FqnPart.Database,
FqnPart.Schema,
FqnPart.Table,
],
FQN_SEPARATOR_CHAR
)
)}>
<Typography.Text className="truncate referred-column-name">
{referredColumn}
</Typography.Text>
</Link>
</Tooltip>
{hasPermission && !isEmpty(supportedConstraints) && (
<Tooltip
placement="right"
title={t('label.edit-entity', {
entity: t('label.table-constraint-plural'),
})}>
<Button
className="cursor-pointer hover-cell-icon w-fit-content"
data-testid="edit-displayName-button"
style={{
color: DE_ACTIVE_COLOR,
padding: 0,
border: 'none',
background: 'transparent',
}}
onClick={handleOpenEditConstraintModal}>
<IconEdit
style={{ color: DE_ACTIVE_COLOR, ...ICON_DIMENSION }}
/>
</Button>
</Tooltip>
)}
</Space>
{hasPermission && supportedConstraints.length === 0 && (
<TagButton
className="text-primary cursor-pointer"
dataTestId="synonym-add-button"
icon={<PlusIcon height={16} name="plus" width={16} />}
label={t('label.add')}
tooltip=""
onClick={handleOpenEditConstraintModal}
/>
)}
{supportedConstraints.map(
({ constraintType, columns, referredColumns }, index) => {
if (constraintType === ConstraintType.PrimaryKey) {
return (
<div className="d-flex constraint-columns" key={index}>
<Space
className="constraint-icon-container"
direction="vertical"
size={0}>
{columns?.map((column, index) => (
<Fragment key={column}>
{(columns?.length ?? 0) - 1 !== index ? (
<PrimaryKeyConstraint />
) : null}
</Fragment>
))}
</div>
</Space>
</Space>
);
}
</Space>
return null;
}
<Space direction="vertical" size={16}>
{columns?.map((column) => (
<Typography.Text
className="w-60"
ellipsis={{ tooltip: true }}
key={column}>
{column}
</Typography.Text>
))}
</Space>
</div>
);
}
if (constraintType === ConstraintType.ForeignKey) {
return (
<Space className="constraint-columns" key={index}>
<ForeignKeyConstraint />
<Space direction="vertical" size={16}>
<Typography.Text>{columns?.join(', ')}</Typography.Text>
<div data-testid="referred-column-name">
{map(referredColumns, (referredColumn) => (
<Tooltip
placement="top"
title={referredColumn}
trigger="hover">
<Link
className="no-underline"
to={entityUtilClassBase.getEntityLink(
EntityType.TABLE,
getPartialNameFromTableFQN(
referredColumn,
[
FqnPart.Service,
FqnPart.Database,
FqnPart.Schema,
FqnPart.Table,
],
FQN_SEPARATOR_CHAR
)
)}>
<Typography.Text className="truncate referred-column-name">
{referredColumn}
</Typography.Text>
</Link>
</Tooltip>
))}
</div>
</Space>
</Space>
);
}
return null;
}
)}
</Space>
{isModalOpen && (
<TableConstraintsModal
constraint={supportedConstraints}
tableDetails={tableDetails}
onClose={handleCloseEditConstraintModal}
onSave={handleSubmit}
/>
)}
</Space>
</>
);
};

View File

@ -0,0 +1,293 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Icon from '@ant-design/icons/lib/components/Icon';
import { Button, Col, Form, Modal, Row, Select } from 'antd';
import { AxiosError } from 'axios';
import { debounce, isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as IconDelete } from '../../../../assets/svg/ic-delete.svg';
import { ReactComponent as PlusIcon } from '../../../../assets/svg/plus-primary.svg';
import { PAGE_SIZE } from '../../../../constants/constants';
import { RELATIONSHIP_TYPE_OPTION } from '../../../../constants/Table.constants';
import { SearchIndex } from '../../../../enums/search.enum';
import { ConstraintType, Table } from '../../../../generated/entity/data/table';
import { searchQuery } from '../../../../rest/searchAPI';
import { getServiceNameQueryFilter } from '../../../../utils/ServiceUtils';
import { showErrorToast } from '../../../../utils/ToastUtils';
import {
SelectOptions,
TableConstraintForm,
TableConstraintModalProps,
} from './TableConstraintsModal.interface';
const TableConstraintsModal = ({
tableDetails,
constraint,
onSave,
onClose,
}: TableConstraintModalProps) => {
const { t } = useTranslation();
const [form] = Form.useForm<{ constraint: TableConstraintForm[] }>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRelatedColumnLoading, setIsRelatedColumnLoading] =
useState<boolean>(false);
const [searchValue, setSearchValue] = useState<string>('');
const [relatedColumns, setRelatedColumns] = useState<SelectOptions[]>([]);
const tableColumnNameOptions = useMemo(
() =>
tableDetails?.columns.map((item) => ({
label: item.name,
value: item.name,
})) ?? [],
[tableDetails?.columns]
);
const getSearchResults = async (value: string) => {
setIsRelatedColumnLoading(true);
try {
const data = await searchQuery({
query: value,
searchIndex: SearchIndex.TABLE,
queryFilter: getServiceNameQueryFilter(
tableDetails?.service?.name ?? ''
),
pageNumber: 1,
pageSize: PAGE_SIZE,
includeDeleted: false,
});
const sources = data.hits.hits.map((hit) => hit._source);
const allColumns = sources.reduce((acc: SelectOptions[], cv: Table) => {
const columnOption = cv.columns
.map((item) => ({
label: item.fullyQualifiedName ?? '',
value: item.fullyQualifiedName ?? '',
}))
.filter(Boolean);
return [...acc, ...columnOption];
}, []);
setRelatedColumns(allColumns);
} catch (error) {
showErrorToast(
error as AxiosError,
t('server.entity-fetch-error', {
entity: t('label.suggestion-lowercase-plural'),
})
);
} finally {
setIsRelatedColumnLoading(false);
}
};
const debounceOnSearch = useCallback(debounce(getSearchResults, 300), []);
const handleSearch = (value: string): void => {
setSearchValue(value);
debounceOnSearch(value);
};
const handleSubmit = async (obj: { constraint: TableConstraintForm[] }) => {
try {
setIsLoading(true);
await form.validateFields();
const constraintData = obj.constraint.map((item) => ({
...item,
columns: [item.columns],
referredColumns: [item.referredColumns],
constraintType: ConstraintType.ForeignKey,
}));
await onSave(constraintData);
} catch (_) {
// Nothing here
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const filteredConstraints = !isEmpty(constraint)
? constraint
?.filter((item) => item.constraintType !== ConstraintType.PrimaryKey)
.map((item) => ({
columns: item.columns?.[0],
relationshipType: item.relationshipType,
referredColumns: item.referredColumns?.[0],
}))
: [
{
columns: '',
relationshipType: '',
referredColumns: '',
},
];
form.setFieldValue('constraint', filteredConstraints);
}, [constraint]);
useEffect(() => {
getSearchResults(searchValue);
}, []);
return (
<Modal
centered
destroyOnClose
open
closable={false}
data-testid="table-constraint-modal"
footer={[
<Button
disabled={isLoading}
key="cancel-btn"
type="link"
onClick={onClose}>
{t('label.cancel')}
</Button>,
<Button
data-testid="save-btn"
key="save-btn"
loading={isLoading}
type="primary"
onClick={form.submit}>
{t('label.save')}
</Button>,
]}
maskClosable={false}
title={t(`label.${isEmpty(constraint) ? 'add' : 'update'}-entity`, {
entity: t('label.table-constraint-plural'),
})}
width={600}
onCancel={onClose}>
<Form
className="table-constraint-form"
form={form}
layout="vertical"
onFinish={handleSubmit}>
<Form.List name="constraint">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Row gutter={8} key={key}>
<Col span={12}>
<Form.Item
className="w-full"
{...restField}
label={t('label.entity-name', {
entity: t('label.column'),
})}
name={[name, 'columns']}
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.entity-name', {
entity: t('label.column'),
}),
}),
},
]}>
<Select
data-testid={`${key}-column-type-select`}
options={tableColumnNameOptions}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
{...restField}
label={t('label.entity-type-plural', {
entity: t('label.relationship'),
})}
name={[name, 'relationshipType']}
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.entity-type-plural', {
entity: t('label.relationship'),
}),
}),
},
]}>
<Select
data-testid={`${key}-relationship-type-select`}
options={RELATIONSHIP_TYPE_OPTION}
/>
</Form.Item>
</Col>
<Col span={23}>
<Form.Item
{...restField}
label={t('label.related-column')}
name={[name, 'referredColumns']}
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.related-column'),
}),
},
]}>
<Select
showSearch
data-testid={`${key}-related-column-select`}
loading={isRelatedColumnLoading}
options={relatedColumns}
onClick={(e) => e.stopPropagation()}
onSearch={handleSearch}
/>
</Form.Item>
</Col>
<Col span={1}>
<Button
data-testid={`${key}-delete-constraint-button`}
icon={
<Icon
className="align-middle"
component={IconDelete}
style={{ fontSize: '16px' }}
/>
}
size="small"
type="text"
onClick={() => remove(name)}
/>
</Col>
</Row>
))}
<Form.Item>
<Button
className="text-primary d-flex items-center"
data-testid="add-constraint-button"
icon={<PlusIcon className="anticon" />}
size="small"
onClick={() => add()}>
{t('label.add')}
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</Modal>
);
};
export default TableConstraintsModal;

View File

@ -1,5 +1,5 @@
/*
* Copyright 2022 Collate.
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@ -10,8 +10,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RelationshipType,
Table,
TableConstraint,
} from '../../../../generated/entity/data/table';
import { TabSpecificField } from '../enums/entity.enum';
export interface TableConstraintModalProps {
constraint: Table['tableConstraints'];
tableDetails?: Table;
onClose: () => void;
onSave: (values: TableConstraint[]) => Promise<void>;
}
// eslint-disable-next-line max-len
export const defaultFields = `${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.USAGE_SUMMARY},${TabSpecificField.DOMAIN},${TabSpecificField.DATA_PRODUCTS}`;
export interface TableConstraintForm {
columns: string;
referredColumns: string;
relationshipType: RelationshipType;
}
export interface SelectOptions {
label: string;
value: string;
}

View File

@ -12,39 +12,29 @@
* limitations under the License.
*/
import { Col, Row, Space, Tabs, Typography } from 'antd';
import { Col, Row, Space, Tabs } from 'antd';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import { compare } from 'fast-json-patch';
import { get, isEmpty, isEqual, isUndefined } from 'lodash';
import { isEmpty, isEqual, isUndefined } from 'lodash';
import { EntityTags } from 'Models';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import { useActivityFeedProvider } from '../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import { ActivityFeedTab } from '../../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
import ActivityThreadPanel from '../../components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanel';
import { withActivityFeed } from '../../components/AppRouter/withActivityFeed';
import { withSuggestions } from '../../components/AppRouter/withSuggestions';
import { CustomPropertyTable } from '../../components/common/CustomPropertyTable/CustomPropertyTable';
import DescriptionV1 from '../../components/common/EntityDescription/DescriptionV1';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../components/common/Loader/Loader';
import QueryViewer from '../../components/common/QueryViewer/QueryViewer.component';
import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels';
import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component';
import { DataAssetsHeader } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component';
import TableProfiler from '../../components/Database/Profiler/TableProfiler/TableProfiler';
import SampleDataTableComponent from '../../components/Database/SampleDataTable/SampleDataTable.component';
import SchemaTab from '../../components/Database/SchemaTab/SchemaTab.component';
import TableQueries from '../../components/Database/TableQueries/TableQueries';
import { QueryVote } from '../../components/Database/TableQueries/TableQueries.interface';
import EntityRightPanel from '../../components/Entity/EntityRightPanel/EntityRightPanel';
import IncidentManager from '../../components/IncidentManager/IncidentManager.component';
import Lineage from '../../components/Lineage/Lineage.component';
import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameModal.interface';
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
import { SourceType } from '../../components/SearchedData/SearchedData.interface';
import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants';
import {
getEntityDetailsPath,
@ -54,7 +44,6 @@ import {
import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants';
import { mockDatasetData } from '../../constants/mockTourData.constants';
import { COMMON_RESIZABLE_PANEL_CONFIG } from '../../constants/ResizablePanel.constants';
import LineageProvider from '../../context/LineageProvider/LineageProvider';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import {
OperationPermission,
@ -108,6 +97,7 @@ import EntityLink from '../../utils/EntityLink';
import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import tableClassBase from '../../utils/TableClassBase';
import { getTagsWithoutTier, getTierTags } from '../../utils/TableUtils';
import { createTagObject, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
@ -452,6 +442,19 @@ const TableDetailsPageV1: React.FC = () => {
}
};
const onTableConstraintsUpdate = async (
updatedTableConstraints: Table['tableConstraints']
) => {
if (!tableDetails) {
return;
}
const updatedTableDetails = {
...tableDetails,
tableConstraints: updatedTableConstraints,
};
await onTableUpdate(updatedTableDetails, 'tableConstraints');
};
const onColumnsUpdate = async (updateColumns: Table['columns']) => {
if (tableDetails && !isEqual(tableDetails.columns, updateColumns)) {
const updatedTableDetails = {
@ -598,7 +601,9 @@ const TableDetailsPageV1: React.FC = () => {
direction="vertical"
size="large">
<TableConstraints
constraints={tableDetails?.tableConstraints}
hasPermission={editAllPermission && !deleted}
tableDetails={tableDetails}
onUpdate={onTableConstraintsUpdate}
/>
</Space>
}
@ -655,222 +660,51 @@ const TableDetailsPageV1: React.FC = () => {
);
const tabs = useMemo(() => {
const allTabs = [
{
label: <TabsLabel id={EntityTabs.SCHEMA} name={t('label.schema')} />,
key: EntityTabs.SCHEMA,
children: schemaTab,
},
{
label: (
<TabsLabel
count={feedCount.totalCount}
id={EntityTabs.ACTIVITY_FEED}
isActive={activeTab === EntityTabs.ACTIVITY_FEED}
name={t('label.activity-feed-and-task-plural')}
/>
),
key: EntityTabs.ACTIVITY_FEED,
children: (
<ActivityFeedTab
refetchFeed
columns={tableDetails?.columns}
entityFeedTotalCount={feedCount.totalCount}
entityType={EntityType.TABLE}
fqn={tableDetails?.fullyQualifiedName ?? ''}
owners={tableDetails?.owners}
onFeedUpdate={getEntityFeedCount}
onUpdateEntityDetails={fetchTableDetails}
onUpdateFeedCount={handleFeedCount}
/>
),
},
{
label: (
<TabsLabel
id={EntityTabs.SAMPLE_DATA}
name={t('label.sample-data')}
/>
),
key: EntityTabs.SAMPLE_DATA,
children:
!isTourOpen && !viewSampleDataPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<SampleDataTableComponent
isTableDeleted={deleted}
owners={tableDetails?.owners ?? []}
permissions={tablePermissions}
tableId={tableDetails?.id ?? ''}
/>
),
},
{
label: (
<TabsLabel
count={queryCount}
id={EntityTabs.TABLE_QUERIES}
isActive={activeTab === EntityTabs.TABLE_QUERIES}
name={t('label.query-plural')}
/>
),
key: EntityTabs.TABLE_QUERIES,
children: !viewQueriesPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<TableQueries
isTableDeleted={deleted}
tableId={tableDetails?.id ?? ''}
/>
),
},
{
label: (
<TabsLabel
id={EntityTabs.PROFILER}
name={t('label.profiler-amp-data-quality')}
/>
),
key: EntityTabs.PROFILER,
children:
!isTourOpen && !viewProfilerPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<TableProfiler
permissions={tablePermissions}
table={tableDetails}
testCaseSummary={testCaseSummary}
/>
),
},
{
label: (
<TabsLabel
id={EntityTabs.INCIDENTS}
name={t('label.incident-plural')}
/>
),
key: EntityTabs.INCIDENTS,
children:
tablePermissions.ViewAll || tablePermissions.ViewTests ? (
<div className="p-x-lg p-b-lg p-t-md">
<IncidentManager
isIncidentPage={false}
tableDetails={tableDetails}
/>
</div>
) : (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
),
},
{
label: <TabsLabel id={EntityTabs.LINEAGE} name={t('label.lineage')} />,
key: EntityTabs.LINEAGE,
children: (
<LineageProvider>
<Lineage
deleted={deleted}
entity={tableDetails as SourceType}
entityType={EntityType.TABLE}
hasEditAccess={editLineagePermission}
/>
</LineageProvider>
),
},
{
label: (
<TabsLabel id={EntityTabs.DBT} name={t('label.dbt-lowercase')} />
),
isHidden: !(
tableDetails?.dataModel?.sql || tableDetails?.dataModel?.rawSql
),
key: EntityTabs.DBT,
children: (
<QueryViewer
sqlQuery={
get(tableDetails, 'dataModel.sql', '') ||
get(tableDetails, 'dataModel.rawSql', '')
}
title={
<Space className="p-y-xss">
<Typography.Text className="text-grey-muted">
{`${t('label.path')}:`}
</Typography.Text>
<Typography.Text>
{tableDetails?.dataModel?.path}
</Typography.Text>
</Space>
}
/>
),
},
{
label: (
<TabsLabel
id={
isViewTableType
? EntityTabs.VIEW_DEFINITION
: EntityTabs.SCHEMA_DEFINITION
}
name={
isViewTableType
? t('label.view-definition')
: t('label.schema-definition')
}
/>
),
isHidden: isUndefined(tableDetails?.schemaDefinition),
key: isViewTableType
? EntityTabs.VIEW_DEFINITION
: EntityTabs.SCHEMA_DEFINITION,
children: (
<QueryViewer sqlQuery={tableDetails?.schemaDefinition ?? ''} />
),
},
{
label: (
<TabsLabel
id={EntityTabs.CUSTOM_PROPERTIES}
name={t('label.custom-property-plural')}
/>
),
key: EntityTabs.CUSTOM_PROPERTIES,
children: tableDetails && (
<div className="m-sm">
<CustomPropertyTable<EntityType.TABLE>
entityDetails={tableDetails}
entityType={EntityType.TABLE}
handleExtensionUpdate={onExtensionUpdate}
hasEditAccess={editCustomAttributePermission}
hasPermission={viewAllPermission}
/>
</div>
),
},
];
return allTabs.filter((data) => !data.isHidden);
return tableClassBase
.getTableDetailPageTabs({
schemaTab,
queryCount,
isTourOpen,
tablePermissions,
activeTab,
deleted,
tableDetails,
totalFeedCount: feedCount.totalCount,
onExtensionUpdate,
getEntityFeedCount,
handleFeedCount,
viewAllPermission,
editCustomAttributePermission,
viewSampleDataPermission,
viewQueriesPermission,
viewProfilerPermission,
editLineagePermission,
fetchTableDetails,
testCaseSummary,
isViewTableType,
})
.filter((data) => !data.isHidden);
}, [
schemaTab,
queryCount,
isTourOpen,
tablePermissions,
activeTab,
schemaTab,
deleted,
tableDetails,
feedCount.totalCount,
entityName,
onExtensionUpdate,
getEntityFeedCount,
handleFeedCount,
tableDetails?.dataModel,
viewAllPermission,
editCustomAttributePermission,
viewSampleDataPermission,
viewQueriesPermission,
viewProfilerPermission,
editLineagePermission,
fetchTableDetails,
testCaseSummary,
isViewTableType,
]);
const onTierUpdate = useCallback(

View File

@ -102,6 +102,11 @@
right: 12px;
}
// left
.left-3 {
left: 12px;
}
//bottom
.bottom-full {
bottom: 100%;

View File

@ -0,0 +1,67 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabsProps } from 'antd';
import { EntityTags, PagingResponse } from 'Models';
import { PagingHandlerParams } from '../components/common/NextPrevious/NextPrevious.interface';
import { EntityTabs } from '../enums/entity.enum';
import { DatabaseSchema } from '../generated/entity/data/databaseSchema';
import { Table } from '../generated/entity/data/table';
import { ThreadType } from '../generated/entity/feed/thread';
import { FeedCounts } from '../interface/feed.interface';
import { getDataBaseSchemaPageBaseTabs } from './DatabaseSchemaDetailsUtils';
export interface DatabaseSchemaPageTabProps {
feedCount: FeedCounts;
tableData: PagingResponse<Table[]>;
activeTab: EntityTabs;
currentTablesPage: number;
databaseSchema: DatabaseSchema;
description: string;
editDescriptionPermission: boolean;
isEdit: boolean;
showDeletedTables: boolean;
tableDataLoading: boolean;
editCustomAttributePermission: boolean;
editTagsPermission: boolean;
decodedDatabaseSchemaFQN: string;
tags: any[];
viewAllPermission: boolean;
storedProcedureCount: number;
handleExtensionUpdate: (schema: DatabaseSchema) => Promise<void>;
handleTagSelection: (selectedTags: EntityTags[]) => Promise<void>;
onThreadLinkSelect: (link: string, threadType?: ThreadType) => void;
tablePaginationHandler: ({
cursorType,
currentPage,
}: PagingHandlerParams) => void;
onEditCancel: () => void;
onDescriptionEdit: () => void;
onDescriptionUpdate: (updatedHTML: string) => Promise<void>;
handleShowDeletedTables: (value: boolean) => void;
getEntityFeedCount: () => void;
fetchDatabaseSchemaDetails: () => Promise<void>;
handleFeedCount: (data: FeedCounts) => void;
}
class DatabaseSchemaClassBase {
public getDatabaseSchemaPageTabs(
databaseSchemaTabData: DatabaseSchemaPageTabProps
): TabsProps['items'] {
return getDataBaseSchemaPageBaseTabs(databaseSchemaTabData);
}
}
const databaseSchemaClassBase = new DatabaseSchemaClassBase();
export default databaseSchemaClassBase;
export { DatabaseSchemaClassBase };

View File

@ -0,0 +1,190 @@
/*
* Copyright 2022 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Col, Row } from 'antd';
import { t } from 'i18next';
import React from 'react';
import ActivityFeedProvider from '../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import { ActivityFeedTab } from '../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
import { CustomPropertyTable } from '../components/common/CustomPropertyTable/CustomPropertyTable';
import ResizablePanels from '../components/common/ResizablePanels/ResizablePanels';
import TabsLabel from '../components/common/TabsLabel/TabsLabel.component';
import EntityRightPanel from '../components/Entity/EntityRightPanel/EntityRightPanel';
import { COMMON_RESIZABLE_PANEL_CONFIG } from '../constants/ResizablePanel.constants';
import { EntityTabs, EntityType, TabSpecificField } from '../enums/entity.enum';
import SchemaTablesTab from '../pages/DatabaseSchemaPage/SchemaTablesTab';
import StoredProcedureTab from '../pages/StoredProcedure/StoredProcedureTab';
import { DatabaseSchemaPageTabProps } from './DatabaseSchemaClassBase';
// eslint-disable-next-line max-len
export const defaultFields = `${TabSpecificField.TAGS},${TabSpecificField.OWNERS},${TabSpecificField.USAGE_SUMMARY},${TabSpecificField.DOMAIN},${TabSpecificField.DATA_PRODUCTS}`;
export const getDataBaseSchemaPageBaseTabs = ({
feedCount,
tableData,
activeTab,
currentTablesPage,
databaseSchema,
description,
editDescriptionPermission,
isEdit,
showDeletedTables,
tableDataLoading,
editCustomAttributePermission,
editTagsPermission,
decodedDatabaseSchemaFQN,
tags,
viewAllPermission,
storedProcedureCount,
onEditCancel,
handleExtensionUpdate,
handleTagSelection,
onThreadLinkSelect,
tablePaginationHandler,
onDescriptionEdit,
onDescriptionUpdate,
handleShowDeletedTables,
getEntityFeedCount,
fetchDatabaseSchemaDetails,
handleFeedCount,
}: DatabaseSchemaPageTabProps) => {
return [
{
label: (
<TabsLabel
count={tableData.paging.total}
id={EntityTabs.TABLE}
isActive={activeTab === EntityTabs.TABLE}
name={t('label.table-plural')}
/>
),
key: EntityTabs.TABLE,
children: (
<Row gutter={[0, 16]} wrap={false}>
<Col className="tab-content-height-with-resizable-panel" span={24}>
<ResizablePanels
firstPanel={{
className: 'entity-resizable-panel-container',
children: (
<div className="p-t-sm m-x-lg">
<SchemaTablesTab
currentTablesPage={currentTablesPage}
databaseSchemaDetails={databaseSchema}
description={description}
editDescriptionPermission={editDescriptionPermission}
isEdit={isEdit}
showDeletedTables={showDeletedTables}
tableData={tableData}
tableDataLoading={tableDataLoading}
tablePaginationHandler={tablePaginationHandler}
onCancel={onEditCancel}
onDescriptionEdit={onDescriptionEdit}
onDescriptionUpdate={onDescriptionUpdate}
onShowDeletedTablesChange={handleShowDeletedTables}
onThreadLinkSelect={onThreadLinkSelect}
/>
</div>
),
...COMMON_RESIZABLE_PANEL_CONFIG.LEFT_PANEL,
}}
secondPanel={{
children: (
<div data-testid="entity-right-panel">
<EntityRightPanel<EntityType.DATABASE_SCHEMA>
customProperties={databaseSchema}
dataProducts={databaseSchema?.dataProducts ?? []}
domain={databaseSchema?.domain}
editCustomAttributePermission={
editCustomAttributePermission
}
editTagPermission={editTagsPermission}
entityFQN={decodedDatabaseSchemaFQN}
entityId={databaseSchema?.id ?? ''}
entityType={EntityType.DATABASE_SCHEMA}
selectedTags={tags}
viewAllPermission={viewAllPermission}
onExtensionUpdate={handleExtensionUpdate}
onTagSelectionChange={handleTagSelection}
onThreadLinkSelect={onThreadLinkSelect}
/>
</div>
),
...COMMON_RESIZABLE_PANEL_CONFIG.RIGHT_PANEL,
className:
'entity-resizable-right-panel-container entity-resizable-panel-container',
}}
/>
</Col>
</Row>
),
},
{
label: (
<TabsLabel
count={storedProcedureCount}
id={EntityTabs.STORED_PROCEDURE}
isActive={activeTab === EntityTabs.STORED_PROCEDURE}
name={t('label.stored-procedure-plural')}
/>
),
key: EntityTabs.STORED_PROCEDURE,
children: <StoredProcedureTab />,
},
{
label: (
<TabsLabel
count={feedCount.totalCount}
id={EntityTabs.ACTIVITY_FEED}
isActive={activeTab === EntityTabs.ACTIVITY_FEED}
name={t('label.activity-feed-plural')}
/>
),
key: EntityTabs.ACTIVITY_FEED,
children: (
<ActivityFeedProvider>
<ActivityFeedTab
refetchFeed
entityFeedTotalCount={feedCount.totalCount}
entityType={EntityType.DATABASE_SCHEMA}
fqn={databaseSchema.fullyQualifiedName ?? ''}
onFeedUpdate={getEntityFeedCount}
onUpdateEntityDetails={fetchDatabaseSchemaDetails}
onUpdateFeedCount={handleFeedCount}
/>
</ActivityFeedProvider>
),
},
{
label: (
<TabsLabel
id={EntityTabs.CUSTOM_PROPERTIES}
name={t('label.custom-property-plural')}
/>
),
key: EntityTabs.CUSTOM_PROPERTIES,
children: databaseSchema && (
<div className="m-sm">
<CustomPropertyTable<EntityType.DATABASE_SCHEMA>
className=""
entityDetails={databaseSchema}
entityType={EntityType.DATABASE_SCHEMA}
handleExtensionUpdate={handleExtensionUpdate}
hasEditAccess={editCustomAttributePermission}
hasPermission={viewAllPermission}
isVersionView={false}
/>
</div>
),
},
];
};

View File

@ -521,3 +521,11 @@ export const getServiceDisplayNameQueryFilter = (displayName: string) => ({
},
},
});
export const getServiceNameQueryFilter = (serviceName: string) => ({
query: {
match: {
'service.name.keyword': serviceName,
},
},
});

View File

@ -0,0 +1,54 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface';
import { EntityTabs } from '../enums/entity.enum';
import { Table } from '../generated/entity/data/table';
import { TestSummary } from '../generated/tests/testCase';
import { FeedCounts } from '../interface/feed.interface';
import { getTableDetailPageBaseTabs } from './TableUtils';
export interface TableDetailPageTabProps {
queryCount: number;
isTourOpen: boolean;
activeTab: EntityTabs;
totalFeedCount: number;
schemaTab: JSX.Element;
isViewTableType: boolean;
viewAllPermission: boolean;
viewQueriesPermission: boolean;
editLineagePermission: boolean;
viewProfilerPermission: boolean;
viewSampleDataPermission: boolean;
tablePermissions: OperationPermission;
editCustomAttributePermission: boolean;
deleted?: boolean;
tableDetails?: Table;
testCaseSummary?: TestSummary;
getEntityFeedCount: () => void;
fetchTableDetails: () => Promise<void>;
onExtensionUpdate: (updatedData: Table) => Promise<void>;
handleFeedCount: (data: FeedCounts) => void;
}
class TableClassBase {
public getTableDetailPageTabs(
tableDetailsPageProps: TableDetailPageTabProps
) {
return getTableDetailPageBaseTabs(tableDetailsPageProps);
}
}
const tableClassBase = new TableClassBase();
export default tableClassBase;
export { TableClassBase };

View File

@ -12,11 +12,12 @@
*/
import Icon, { SearchOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import { Space, Tooltip, Typography } from 'antd';
import { ExpandableConfig } from 'antd/lib/table/interface';
import classNames from 'classnames';
import { t } from 'i18next';
import {
get,
isUndefined,
lowerCase,
omit,
@ -79,12 +80,23 @@ import { ReactComponent as TagIcon } from '../assets/svg/tag.svg';
import { ReactComponent as TaskIcon } from '../assets/svg/task-ic.svg';
import { ReactComponent as TeamIcon } from '../assets/svg/teams.svg';
import { ReactComponent as UserIcon } from '../assets/svg/user.svg';
import { ActivityFeedTab } from '../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
import { CustomPropertyTable } from '../components/common/CustomPropertyTable/CustomPropertyTable';
import ErrorPlaceHolder from '../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import QueryViewer from '../components/common/QueryViewer/QueryViewer.component';
import TabsLabel from '../components/common/TabsLabel/TabsLabel.component';
import TableProfiler from '../components/Database/Profiler/TableProfiler/TableProfiler';
import SampleDataTableComponent from '../components/Database/SampleDataTable/SampleDataTable.component';
import TableQueries from '../components/Database/TableQueries/TableQueries';
import IncidentManager from '../components/IncidentManager/IncidentManager.component';
import Lineage from '../components/Lineage/Lineage.component';
import { SourceType } from '../components/SearchedData/SearchedData.interface';
import { NON_SERVICE_TYPE_ASSETS } from '../constants/Assets.constants';
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
import { DE_ACTIVE_COLOR, TEXT_BODY_COLOR } from '../constants/constants';
import { EntityType, FqnPart } from '../enums/entity.enum';
import LineageProvider from '../context/LineageProvider/LineageProvider';
import { ERROR_PLACEHOLDER_TYPE } from '../enums/common.enum';
import { EntityTabs, EntityType, FqnPart } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { ConstraintTypes, PrimaryTableDataTypes } from '../enums/table.enum';
import { SearchIndexField } from '../generated/entity/data/searchIndex';
@ -104,6 +116,7 @@ import EntityLink from './EntityLink';
import searchClassBase from './SearchClassBase';
import serviceUtilClassBase from './ServiceUtilClassBase';
import { ordinalize } from './StringsUtils';
import { TableDetailPageTabProps } from './TableClassBase';
import { TableFieldsInfoCommonEntities } from './TableUtils.interface';
export const getUsagePercentile = (pctRank: number, isLiteral = false) => {
@ -608,3 +621,212 @@ export const updateFieldTags = <T extends TableFieldsInfoCommonEntities>(
}
});
};
export const getTableDetailPageBaseTabs = ({
schemaTab,
queryCount,
isTourOpen,
tablePermissions,
activeTab,
deleted,
tableDetails,
totalFeedCount,
onExtensionUpdate,
getEntityFeedCount,
handleFeedCount,
viewAllPermission,
editCustomAttributePermission,
viewSampleDataPermission,
viewQueriesPermission,
viewProfilerPermission,
editLineagePermission,
fetchTableDetails,
testCaseSummary,
isViewTableType,
}: TableDetailPageTabProps) => {
return [
{
label: <TabsLabel id={EntityTabs.SCHEMA} name={t('label.schema')} />,
key: EntityTabs.SCHEMA,
children: schemaTab,
},
{
label: (
<TabsLabel
count={totalFeedCount}
id={EntityTabs.ACTIVITY_FEED}
isActive={activeTab === EntityTabs.ACTIVITY_FEED}
name={t('label.activity-feed-and-task-plural')}
/>
),
key: EntityTabs.ACTIVITY_FEED,
children: (
<ActivityFeedTab
refetchFeed
columns={tableDetails?.columns}
entityFeedTotalCount={totalFeedCount}
entityType={EntityType.TABLE}
fqn={tableDetails?.fullyQualifiedName ?? ''}
owners={tableDetails?.owners}
onFeedUpdate={getEntityFeedCount}
onUpdateEntityDetails={fetchTableDetails}
onUpdateFeedCount={handleFeedCount}
/>
),
},
{
label: (
<TabsLabel id={EntityTabs.SAMPLE_DATA} name={t('label.sample-data')} />
),
key: EntityTabs.SAMPLE_DATA,
children:
!isTourOpen && !viewSampleDataPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<SampleDataTableComponent
isTableDeleted={deleted}
owners={tableDetails?.owners ?? []}
permissions={tablePermissions}
tableId={tableDetails?.id ?? ''}
/>
),
},
{
label: (
<TabsLabel
count={queryCount}
id={EntityTabs.TABLE_QUERIES}
isActive={activeTab === EntityTabs.TABLE_QUERIES}
name={t('label.query-plural')}
/>
),
key: EntityTabs.TABLE_QUERIES,
children: !viewQueriesPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<TableQueries
isTableDeleted={deleted}
tableId={tableDetails?.id ?? ''}
/>
),
},
{
label: (
<TabsLabel
id={EntityTabs.PROFILER}
name={t('label.profiler-amp-data-quality')}
/>
),
key: EntityTabs.PROFILER,
children:
!isTourOpen && !viewProfilerPermission ? (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
) : (
<TableProfiler
permissions={tablePermissions}
table={tableDetails}
testCaseSummary={testCaseSummary}
/>
),
},
{
label: (
<TabsLabel
id={EntityTabs.INCIDENTS}
name={t('label.incident-plural')}
/>
),
key: EntityTabs.INCIDENTS,
children:
tablePermissions.ViewAll || tablePermissions.ViewTests ? (
<div className="p-x-lg p-b-lg p-t-md">
<IncidentManager
isIncidentPage={false}
tableDetails={tableDetails}
/>
</div>
) : (
<ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />
),
},
{
label: <TabsLabel id={EntityTabs.LINEAGE} name={t('label.lineage')} />,
key: EntityTabs.LINEAGE,
children: (
<LineageProvider>
<Lineage
deleted={deleted}
entity={tableDetails as SourceType}
entityType={EntityType.TABLE}
hasEditAccess={editLineagePermission}
/>
</LineageProvider>
),
},
{
label: <TabsLabel id={EntityTabs.DBT} name={t('label.dbt-lowercase')} />,
isHidden: !(
tableDetails?.dataModel?.sql || tableDetails?.dataModel?.rawSql
),
key: EntityTabs.DBT,
children: (
<QueryViewer
sqlQuery={
get(tableDetails, 'dataModel.sql', '') ||
get(tableDetails, 'dataModel.rawSql', '')
}
title={
<Space className="p-y-xss">
<Typography.Text className="text-grey-muted">
{`${t('label.path')}:`}
</Typography.Text>
<Typography.Text>{tableDetails?.dataModel?.path}</Typography.Text>
</Space>
}
/>
),
},
{
label: (
<TabsLabel
id={
isViewTableType
? EntityTabs.VIEW_DEFINITION
: EntityTabs.SCHEMA_DEFINITION
}
name={
isViewTableType
? t('label.view-definition')
: t('label.schema-definition')
}
/>
),
isHidden: isUndefined(tableDetails?.schemaDefinition),
key: isViewTableType
? EntityTabs.VIEW_DEFINITION
: EntityTabs.SCHEMA_DEFINITION,
children: <QueryViewer sqlQuery={tableDetails?.schemaDefinition ?? ''} />,
},
{
label: (
<TabsLabel
id={EntityTabs.CUSTOM_PROPERTIES}
name={t('label.custom-property-plural')}
/>
),
key: EntityTabs.CUSTOM_PROPERTIES,
children: tableDetails && (
<div className="m-sm">
<CustomPropertyTable<EntityType.TABLE>
entityDetails={tableDetails}
entityType={EntityType.TABLE}
handleExtensionUpdate={onExtensionUpdate}
hasEditAccess={editCustomAttributePermission}
hasPermission={viewAllPermission}
/>
</div>
),
},
];
};