quollio-core 0.4.19__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {quollio_core-0.4.19 → quollio_core-0.5.3}/PKG-INFO +3 -2
  2. {quollio_core-0.4.19 → quollio_core-0.5.3}/pyproject.toml +1 -0
  3. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/__init__.py +1 -1
  4. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_lineage_column_level.sql +17 -0
  5. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_lineage_table_level.sql +17 -0
  6. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_sqllineage_sources.sql +18 -0
  7. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_stats_profiling_columns.sql +19 -3
  8. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/helper/core.py +7 -0
  9. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/sqllineage.py +13 -9
  10. quollio_core-0.5.3/quollio_core/profilers/teradata/lineage.py +176 -0
  11. quollio_core-0.5.3/quollio_core/profilers/teradata/stats.py +224 -0
  12. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/qdc.py +0 -7
  13. quollio_core-0.5.3/quollio_core/repository/ssm.py +59 -0
  14. quollio_core-0.5.3/quollio_core/repository/teradata.py +117 -0
  15. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/snowflake.py +62 -6
  16. quollio_core-0.5.3/quollio_core/teradata.py +268 -0
  17. {quollio_core-0.4.19 → quollio_core-0.5.3}/LICENSE +0 -0
  18. {quollio_core-0.4.19 → quollio_core-0.5.3}/README.md +0 -0
  19. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/bigquery.py +0 -0
  20. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/bricks.py +0 -0
  21. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/.gitignore +0 -0
  22. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/README.md +0 -0
  23. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/analyses/.gitkeep +0 -0
  24. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/dbt_project.yml +0 -0
  25. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/macros/.gitkeep +0 -0
  26. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/models/quollio_lineage_column_level.sql +0 -0
  27. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/models/quollio_lineage_column_level.yml +0 -0
  28. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/models/quollio_lineage_table_level.sql +0 -0
  29. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/models/quollio_lineage_table_level.yml +0 -0
  30. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/models/sources.yml +0 -0
  31. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/packages_hub.yml +0 -0
  32. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/packages_local.yml +0 -0
  33. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/profiles/profiles_template.yml +0 -0
  34. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/seeds/.gitkeep +0 -0
  35. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/databricks/snapshots/.gitkeep +0 -0
  36. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/README.md +0 -0
  37. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/analyses/.gitkeep +0 -0
  38. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/dbt_project.yml +0 -0
  39. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/macros/.gitkeep +0 -0
  40. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/macros/materialization/divided_view.sql +0 -0
  41. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_lineage_table_level.sql +0 -0
  42. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_lineage_table_level.yml +0 -0
  43. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_lineage_view_level.sql +0 -0
  44. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_lineage_view_level.yml +0 -0
  45. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_sqllineage_sources.sql +0 -0
  46. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_sqllineage_sources.yml +0 -0
  47. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_stats_columns.sql +0 -0
  48. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_stats_columns.yml +0 -0
  49. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_stats_profiling_columns.sql +0 -0
  50. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/quollio_stats_profiling_columns.yml +0 -0
  51. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/models/sources.yml +0 -0
  52. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/packages_hub.yml +0 -0
  53. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/packages_local.yml +0 -0
  54. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/profiles/profiles_template.yml +0 -0
  55. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/seeds/.gitkeep +0 -0
  56. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/redshift/snapshots/.gitkeep +0 -0
  57. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/seeds/.gitkeep +0 -0
  58. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/README.md +0 -0
  59. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/analyses/.gitkeep +0 -0
  60. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/dbt_project.yml +0 -0
  61. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/macros/.gitkeep +0 -0
  62. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/macros/materialization/divided_view.sql +0 -0
  63. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_lineage_column_level.yml +0 -0
  64. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_lineage_table_level.yml +0 -0
  65. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_sqllineage_sources.yml +0 -0
  66. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_stats_columns.sql +0 -0
  67. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_stats_columns.yml +0 -0
  68. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/quollio_stats_profiling_columns.yml +0 -0
  69. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/models/sources.yml +0 -0
  70. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/packages_hub.yml +0 -0
  71. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/packages_local.yml +0 -0
  72. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/profiles/profiles_template.yml +0 -0
  73. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/seeds/.gitkeep +0 -0
  74. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/dbt_projects/snowflake/snapshots/.gitkeep +0 -0
  75. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/helper/__init__.py +0 -0
  76. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/helper/env_default.py +0 -0
  77. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/helper/log.py +0 -0
  78. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/helper/log_utils.py +0 -0
  79. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/__init__.py +0 -0
  80. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/bigquery.py +0 -0
  81. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/databricks.py +0 -0
  82. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/lineage.py +0 -0
  83. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/redshift.py +0 -0
  84. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/snowflake.py +0 -0
  85. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/profilers/stats.py +0 -0
  86. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/redshift.py +0 -0
  87. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/__init__.py +0 -0
  88. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/bigquery.py +0 -0
  89. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/databricks.py +0 -0
  90. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/dbt.py +0 -0
  91. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/redshift.py +0 -0
  92. {quollio_core-0.4.19 → quollio_core-0.5.3}/quollio_core/repository/snowflake.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: quollio-core
3
- Version: 0.4.19
3
+ Version: 0.5.3
4
4
  Summary: Quollio Core
5
5
  Author-email: quollio-dev <qt.dev@quollio.com>
6
6
  Maintainer-email: RyoAriyama <ryo.arym@gmail.com>, tharuta <35373297+TakumiHaruta@users.noreply.github.com>
@@ -37,6 +37,7 @@ Requires-Dist: google-cloud-bigquery==3.22.0
37
37
  Requires-Dist: google-cloud-datacatalog==3.19.0
38
38
  Requires-Dist: google-cloud-datacatalog-lineage==0.3.6
39
39
  Requires-Dist: google-api-python-client==2.131.0
40
+ Requires-Dist: teradatasql==20.0.0.15
40
41
  Requires-Dist: black>=22.3.0 ; extra == "test"
41
42
  Requires-Dist: coverage>=7.3.2 ; extra == "test"
42
43
  Requires-Dist: isort>=5.10.1 ; extra == "test"
@@ -49,6 +49,7 @@ dependencies = [
49
49
  ,"google-cloud-datacatalog==3.19.0"
50
50
  ,"google-cloud-datacatalog-lineage==0.3.6"
51
51
  ,"google-api-python-client==2.131.0"
52
+ ,"teradatasql==20.0.0.15"
52
53
  ]
53
54
  dynamic = ["version", "description"]
54
55
 
@@ -1,4 +1,4 @@
1
1
  """Quollio Core"""
2
2
 
3
- __version__ = "0.4.19"
3
+ __version__ = "0.5.3"
4
4
  __author__ = "Quollio Technologies, Inc"
@@ -95,6 +95,23 @@ UNION
95
95
  {{ source('account_usage', 'TABLES') }}
96
96
  WHERE
97
97
  DELETED IS NULL
98
+ AND (
99
+ {% if var('target_databases_method') == 'ALLOWLIST' %}
100
+ {% if var('target_databases') %}
101
+ TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }})
102
+ {% else %}
103
+ 1=0 -- If no databases specified in allowlist, deny all
104
+ {% endif %}
105
+ {% elif var('target_databases_method') == 'DENYLIST' %}
106
+ {% if var('target_databases') %}
107
+ NOT (TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }}))
108
+ {% else %}
109
+ 1=1 -- If no databases specified in denylist, include all
110
+ {% endif %}
111
+ {% else %}
112
+ 1=1 -- Default case: allow all databases
113
+ {% endif %}
114
+ )
98
115
  ), exists_upstream_column_lineage AS (
99
116
  SELECT
100
117
  downstream_table_name
@@ -49,6 +49,23 @@ WITH table_lineage_history AS (
49
49
  {{ source('account_usage', 'TABLES') }}
50
50
  WHERE
51
51
  DELETED IS NULL
52
+ AND (
53
+ {% if var('target_databases_method') == 'ALLOWLIST' %}
54
+ {% if var('target_databases') %}
55
+ TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }})
56
+ {% else %}
57
+ 1=0 -- If no databases specified in allowlist, deny all
58
+ {% endif %}
59
+ {% elif var('target_databases_method') == 'DENYLIST' %}
60
+ {% if var('target_databases') %}
61
+ NOT (TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }}))
62
+ {% else %}
63
+ 1=1 -- If no databases specified in denylist, include all
64
+ {% endif %}
65
+ {% else %}
66
+ 1=1 -- Default case: allow all databases
67
+ {% endif %}
68
+ )
52
69
  ), upstream_exists_table AS (
53
70
  SELECT
54
71
  downstream_table_name AS "DOWNSTREAM_TABLE_NAME"
@@ -48,3 +48,21 @@ on
48
48
  lst.query_id = qt.query_id
49
49
  where
50
50
  qt.query_id is not null
51
+ AND (
52
+ {% if var('target_databases_method') == 'ALLOWLIST' %}
53
+ {% if var('target_databases') %}
54
+ database_name LIKE ANY ({{ var('target_databases')|join(",") }})
55
+ {% else %}
56
+ 1=0 -- If no databases specified in allowlist, deny all
57
+ {% endif %}
58
+ {% elif var('target_databases_method') == 'DENYLIST' %}
59
+ {% if var('target_databases') %}
60
+ NOT (database_name LIKE ANY ({{ var('target_databases')|join(",") }}))
61
+ {% else %}
62
+ 1=1 -- If no databases specified in denylist, include all
63
+ {% endif %}
64
+ {% else %}
65
+ 1=1 -- Default case: allow all databases
66
+ {% endif %}
67
+ )
68
+
@@ -28,7 +28,7 @@ WITH columns AS (
28
28
  FROM
29
29
  {{ source('account_usage', 'GRANTS_TO_ROLES') }}
30
30
  WHERE
31
- granted_on in ('TABLE', 'MATERIALIZED VIEW')
31
+ granted_on in ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
32
32
  AND grantee_name = '{{ var("query_role") }}'
33
33
  AND privilege in ('SELECT', 'OWNERSHIP')
34
34
  AND deleted_on IS NULL
@@ -87,10 +87,26 @@ WITH columns AS (
87
87
  , data_type
88
88
  , case when data_type in('NUMBER','DECIMAL', 'DEC', 'NUMERIC',
89
89
  'INT', 'INTEGER', 'BIGINT', 'SMALLINT',
90
- 'TINYINT', 'BYTEINT')
90
+ 'TINYINT', 'BYTEINT', 'FLOAT')
91
91
  THEN true
92
92
  else false END AS is_calculable
93
93
  FROM
94
94
  implicit_columns_removed
95
- )
95
+ WHERE
96
+ {% if var('target_databases_method') == 'ALLOWLIST' %}
97
+ {% if var('target_databases') %}
98
+ TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }})
99
+ {% else %}
100
+ 1=0 -- If no databases specified in allowlist, deny all
101
+ {% endif %}
102
+ {% elif var('target_databases_method') == 'DENYLIST' %}
103
+ {% if var('target_databases') %}
104
+ NOT (TABLE_CATALOG LIKE ANY ({{ var('target_databases')|join(",") }}))
105
+ {% else %}
106
+ 1=1 -- If no databases specified in denylist, include all
107
+ {% endif %}
108
+ {% else %}
109
+ 1=1 -- Default case: allow all databases
110
+ {% endif %}
111
+ )
96
112
  select * from final
@@ -35,3 +35,10 @@ def setup_dbt_profile(connections_json: Dict[str, str], template_path: str, temp
35
35
 
36
36
  def trim_prefix(s: str, prefix: str) -> str:
37
37
  return s.lstrip(prefix)
38
+
39
+
40
+ def is_valid_domain(domain: str, domain_type: str) -> bool:
41
+ if domain_type == "VPC_ENDPOINT":
42
+ return domain.endswith("/api")
43
+ else:
44
+ return domain.endswith(".com")
@@ -67,15 +67,19 @@ class SQLLineage:
67
67
  dest_schema = dest_schema.upper() if dest_schema is not None else None
68
68
 
69
69
  # MEMO: Complement sql with dialect, source database and source schema info.
70
- optimized_stmt: sqlglot.Expression = optimizer.qualify.qualify(
71
- statement,
72
- dialect=dialect,
73
- catalog=src_db,
74
- db=src_schema,
75
- qualify_columns=False,
76
- validate_qualify_columns=False,
77
- identify=False,
78
- )
70
+ # MEMO: Skipping qualify because it normalizes the table names.
71
+ if dialect == "teradata":
72
+ optimized_stmt = statement
73
+ else:
74
+ optimized_stmt: sqlglot.Expression = optimizer.qualify.qualify(
75
+ statement,
76
+ dialect=dialect,
77
+ catalog=src_db,
78
+ db=src_schema,
79
+ qualify_columns=False,
80
+ validate_qualify_columns=False,
81
+ identify=False,
82
+ )
79
83
 
80
84
  orig_dest_table = Table(table="")
81
85
  dest_table = Table(table="")
@@ -0,0 +1,176 @@
1
+ import os
2
+ from collections import OrderedDict
3
+ from typing import Dict, List, Set, Tuple, Union
4
+
5
+ from sqlglot import ParseError
6
+
7
+ from quollio_core.helper.log_utils import error_handling_decorator, logger
8
+ from quollio_core.profilers.sqllineage import SQLLineage, Table
9
+ from quollio_core.repository import qdc
10
+ from quollio_core.repository import teradata as teradata_repo
11
+
12
+
13
+ @error_handling_decorator
14
+ def load_lineage(
15
+ conn_config: teradata_repo.TeradataConfig,
16
+ endpoint: str = None,
17
+ tenant_id: str = None,
18
+ qdc_client: qdc.QDCExternalAPIClient = None,
19
+ page_size: int = None,
20
+ system_database: str = None,
21
+ ) -> None:
22
+ page_size = page_size or int(os.environ.get("TERADATA_PAGE_SIZE", 1000))
23
+ offset = 0
24
+ all_lineage_results = []
25
+
26
+ # Use system_database from config if not provided
27
+ system_database = system_database or conn_config.system_database
28
+
29
+ with teradata_repo.new_teradata_client(conn_config) as conn:
30
+ while True:
31
+ query = f"""
32
+ SELECT
33
+ a.QueryID,
34
+ TRIM(a.SqlTextInfo) AS SqlTextInfo,
35
+ a.SqlRowNo,
36
+ TRIM(d.DatabaseName) AS DefaultDatabase
37
+ FROM {system_database}.QryLogSQLV a
38
+ JOIN {system_database}.QryLogV b
39
+ ON a.QueryID = b.QueryID
40
+ JOIN {system_database}.DatabasesV d
41
+ ON b.DefaultDatabase = d.DatabaseName
42
+ WHERE
43
+ UPPER(TRIM(SqlTextInfo)) LIKE 'CREATE TABLE%'
44
+ OR UPPER(TRIM(SqlTextInfo)) LIKE 'CREATE VIEW%'
45
+ OR UPPER(TRIM(SqlTextInfo)) LIKE 'INSERT%'
46
+ OR UPPER(TRIM(SqlTextInfo)) LIKE 'MERGE%'
47
+ OR UPPER(TRIM(SqlTextInfo)) LIKE 'UPDATE%'
48
+ QUALIFY ROW_NUMBER() OVER (ORDER BY a.QueryID, a.SqlRowNo) > {offset}
49
+ AND ROW_NUMBER() OVER (ORDER BY a.QueryID, a.SqlRowNo) <= {offset + page_size}
50
+ """
51
+
52
+ rows = teradata_repo.execute_query(query, conn)
53
+ if not rows:
54
+ break
55
+
56
+ logger.info(f"Concatenating split queries for page {offset // page_size + 1}...")
57
+ concatenated_queries = concatenate_split_queries(rows)
58
+
59
+ logger.info("Processing SQL statements and extracting lineage...")
60
+ lineage_results = process_sql_statements(concatenated_queries)
61
+ all_lineage_results.extend(lineage_results)
62
+
63
+ if len(rows) < page_size:
64
+ break
65
+
66
+ offset += page_size
67
+
68
+ logger.info(f"Lineage extraction complete. Found {len(all_lineage_results)} unique entries.")
69
+ for entry in all_lineage_results:
70
+ if len(entry) > 1:
71
+ logger.debug(f"Destination table: {entry[1]}")
72
+ else:
73
+ logger.debug("Destination table: Not available (out of bounds)")
74
+
75
+ if len(entry) > 0 and isinstance(entry[0], list):
76
+ logger.debug("Source tables:")
77
+ for src_table in entry[0]:
78
+ logger.debug(f" - {src_table}")
79
+ else:
80
+ logger.debug("Source tables: Not available (out of bounds or invalid type)")
81
+
82
+ logger.debug("---")
83
+
84
+ sql_lineage = SQLLineage()
85
+ update_table_lineage_inputs = [
86
+ sql_lineage.gen_lineage_input(
87
+ tenant_id=tenant_id, endpoint=endpoint, src_tables=src_tables, dest_table=dest_table
88
+ )
89
+ for src_tables, dest_table in all_lineage_results
90
+ ]
91
+
92
+ table_req_count = 0
93
+ logger.info(f"Starting to update lineage information for {len(update_table_lineage_inputs)} tables.")
94
+ for update_table_lineage_input in update_table_lineage_inputs:
95
+ logger.info(
96
+ f"Generating table lineage. downstream: {update_table_lineage_input.downstream_database_name}"
97
+ f" -> {update_table_lineage_input.downstream_table_name}"
98
+ )
99
+ try:
100
+ status_code = qdc_client.update_lineage_by_id(
101
+ global_id=update_table_lineage_input.downstream_global_id,
102
+ payload=update_table_lineage_input.upstreams.as_dict(),
103
+ )
104
+ if status_code == 200:
105
+ table_req_count += 1
106
+ else:
107
+ logger.error(
108
+ f"Failed to update lineage for {update_table_lineage_input.downstream_table_name}.\
109
+ Status code: {status_code}"
110
+ )
111
+ except Exception as e:
112
+ logger.error(
113
+ f"Exception occurred while updating lineage for {update_table_lineage_input.downstream_table_name}: {e}"
114
+ )
115
+ logger.info(f"Generating table lineage is finished. {table_req_count} lineages are ingested.")
116
+
117
+
118
+ @error_handling_decorator
119
+ def extract_lineage(sql_statement: str, default_database: str = None) -> Tuple[Set[Table], Table]:
120
+ try:
121
+ logger.debug(f"Parsing SQL: {sql_statement}")
122
+ sql_lineage = SQLLineage()
123
+ source_tables, dest_table = sql_lineage.get_table_level_lineage_source(sql=sql_statement, dialect="teradata")
124
+
125
+ source_tables = {Table(db=t.db_schema or default_database, db_schema="", table=t.table) for t in source_tables}
126
+ dest_table = Table(db=dest_table.db_schema or default_database, db_schema="", table=dest_table.table)
127
+
128
+ return source_tables, dest_table
129
+ except ParseError as e:
130
+ logger.error(f"Error parsing SQL: {e}")
131
+ logger.debug(f"Problematic SQL: {sql_statement}")
132
+ except AttributeError as e:
133
+ logger.error(f"Attribute error while extracting lineage: {e}")
134
+ logger.debug(f"Problematic SQL: {sql_statement}")
135
+ except Exception as e:
136
+ logger.error(f"Unexpected error while extracting lineage: {e}")
137
+ logger.debug(f"Problematic SQL: {sql_statement}")
138
+ return set(), Table(db="", table="")
139
+
140
+
141
+ @error_handling_decorator
142
+ def process_sql_statements(queries: List[Union[str, Dict[str, Union[str, int]]]]) -> List[Tuple[Set[Table], Table]]:
143
+ lineage_dict = OrderedDict()
144
+ for query in queries:
145
+ if isinstance(query, str):
146
+ sql = query
147
+ default_database = None
148
+ else:
149
+ sql = query["SqlTextInfo"]
150
+ default_database = query.get("DefaultDatabase")
151
+
152
+ source_tables, dest_table = extract_lineage(sql, default_database)
153
+ if dest_table.table and source_tables:
154
+ if dest_table in lineage_dict:
155
+ logger.info(f"Merging duplicate entry for {dest_table}")
156
+ # Merge source tables
157
+ lineage_dict[dest_table] = lineage_dict[dest_table].union(source_tables)
158
+ else:
159
+ lineage_dict[dest_table] = source_tables
160
+ return [(src_tables, dest_table) for dest_table, src_tables in lineage_dict.items()]
161
+
162
+
163
+ def concatenate_split_queries(rows: List[Dict[str, Union[str, int]]]) -> List[Dict[str, Union[str, int]]]:
164
+ queries = {}
165
+ for row in rows:
166
+ query_id = row["QueryID"]
167
+ sql_text = row["SqlTextInfo"]
168
+ default_database = row["DefaultDatabase"]
169
+ if query_id not in queries:
170
+ queries[query_id] = {"SqlTextInfo": [], "DefaultDatabase": default_database}
171
+ queries[query_id]["SqlTextInfo"].append(sql_text)
172
+
173
+ return [
174
+ {"SqlTextInfo": "".join(query["SqlTextInfo"]), "DefaultDatabase": query["DefaultDatabase"]}
175
+ for query in queries.values()
176
+ ]
@@ -0,0 +1,224 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from quollio_core.helper.log_utils import error_handling_decorator, logger
4
+ from quollio_core.profilers.stats import gen_table_stats_payload
5
+ from quollio_core.repository import qdc
6
+ from quollio_core.repository import teradata as teradata_repo
7
+
8
+ NUMERIC_TYPES = ["D", "F", "I1", "I2", "I8", "I", "N"]
9
+
10
+ # I, I1, I2, I8 - INT TYPES INTEGER, BYTEINT, SMALLINT, BIGINT
11
+ # F - Float
12
+ # D - Decimal
13
+ # N - Number
14
+
15
+
16
+ def quote_identifier(identifier: str) -> str:
17
+ return f'"{identifier}"'
18
+
19
+
20
+ @error_handling_decorator
21
+ def load_stats(
22
+ conn_config: teradata_repo.TeradataConfig,
23
+ sample_percent: Optional[float] = None,
24
+ endpoint: Optional[str] = None,
25
+ tenant_id: Optional[str] = None,
26
+ qdc_client: Optional[qdc.QDCExternalAPIClient] = None,
27
+ target_databases: Optional[List[str]] = None,
28
+ target_databases_method: str = "DENYLIST",
29
+ stats_items: Optional[List[str]] = None,
30
+ system_database: Optional[str] = None,
31
+ ) -> None:
32
+ stats_list = []
33
+ numerical_columns = 0
34
+ non_numerical_columns = 0
35
+ logger.info(
36
+ f"Starting statistics collection. " f"Sample percent: {sample_percent if sample_percent is not None else 'N/A'}"
37
+ )
38
+
39
+ # Use system_database from config if not provided
40
+ system_database = system_database or conn_config.system_database
41
+
42
+ with teradata_repo.new_teradata_client(conn_config) as conn:
43
+ try:
44
+ tables = teradata_repo.get_table_list(conn, target_databases, target_databases_method, system_database)
45
+ for table in tables:
46
+ logger.debug(f"Processing table: {table}")
47
+ database_name = table["DatabaseName"]
48
+ table_name = table["TableName"]
49
+
50
+ logger.info(f"Processing table {database_name}.{table_name}")
51
+ columns = teradata_repo.get_column_list(
52
+ conn, database_name=database_name, table_name=table_name, system_database=system_database
53
+ )
54
+ logger.debug(f"Columns: {columns}")
55
+
56
+ for column in columns:
57
+ column_name = column["ColumnName"]
58
+ column_type = column["ColumnType"]
59
+ if column_type is None:
60
+ column_type = ""
61
+ else:
62
+ column_type = column_type.strip()
63
+
64
+ is_numerical = column_type in NUMERIC_TYPES
65
+ if is_numerical:
66
+ numerical_columns += 1
67
+ else:
68
+ non_numerical_columns += 1
69
+
70
+ stats_sql = generate_column_statistics_sql(
71
+ database_name,
72
+ table_name,
73
+ column_name,
74
+ column_type,
75
+ sample_percent if is_numerical else None,
76
+ stats_items,
77
+ )
78
+ logger.debug(f"Generated SQL for column {column_name}: {stats_sql}")
79
+
80
+ try:
81
+ result = teradata_repo.execute_query(stats_sql, conn)
82
+ logger.debug(f"Query result for column {column_name}: {result}")
83
+ if result:
84
+ column_stats = parse_column_statistics_result(
85
+ result[0], database_name, table_name, column_name, stats_items, is_numerical
86
+ )
87
+ stats_list.append(column_stats)
88
+ except Exception as e:
89
+ logger.error(
90
+ f"Failed to collect statistics for {database_name}.{table_name}.{column_name}: {e}"
91
+ )
92
+
93
+ except Exception as e:
94
+ logger.error(f"Error during statistics collection: {e}")
95
+
96
+ logger.info("Statistics collection completed successfully.")
97
+
98
+ logger.debug(f"Stats list: {stats_list}")
99
+ payloads = gen_table_stats_payload(stats=stats_list, tenant_id=tenant_id, endpoint=endpoint)
100
+ logger.debug(f"Generated payloads: {payloads}")
101
+
102
+ req_count = 0
103
+ for payload in payloads:
104
+ logger.info(f"Generating table stats. asset: {payload.db} -> {payload.table} -> {payload.column}")
105
+ status_code = qdc_client.update_stats_by_id(
106
+ global_id=payload.global_id,
107
+ payload=payload.body.get_column_stats(),
108
+ )
109
+ if status_code == 200:
110
+ req_count += 1
111
+
112
+ logger.info(
113
+ f"Loading statistics is finished. {req_count} statistics are ingested. "
114
+ f"Numerical columns: {numerical_columns}, Non-numerical columns: {non_numerical_columns}"
115
+ )
116
+
117
+
118
+ @error_handling_decorator
119
+ def parse_column_statistics_result(
120
+ result: Dict[str, Any],
121
+ database_name: str,
122
+ table_name: str,
123
+ column_name: str,
124
+ stats_items: Optional[List[str]] = None,
125
+ is_numerical: bool = False,
126
+ ) -> Dict[str, Any]:
127
+ stats_dict = {
128
+ "DB_NAME": database_name,
129
+ "SCHEMA_NAME": "",
130
+ "TABLE_NAME": table_name,
131
+ "COLUMN_NAME": column_name,
132
+ }
133
+
134
+ if stats_items:
135
+ for item in stats_items:
136
+ if item == "cardinality" and "num_uniques" in result:
137
+ stats_dict["CARDINALITY"] = result["num_uniques"]
138
+ elif item == "number_of_null" and "num_nulls" in result:
139
+ stats_dict["NULL_COUNT"] = result["num_nulls"] # Changed from NUM_NULLS to NULL_COUNT
140
+
141
+ if is_numerical:
142
+ if item == "min" and "min_value" in result:
143
+ stats_dict["MIN_VALUE"] = str(result["min_value"])
144
+ elif item == "max" and "max_value" in result:
145
+ stats_dict["MAX_VALUE"] = str(result["max_value"])
146
+ elif item == "median" and "median_value" in result:
147
+ stats_dict["MEDIAN_VALUE"] = str(result["median_value"])
148
+ elif item == "mean" and "avg_value" in result:
149
+ stats_dict["AVG_VALUE"] = str(result["avg_value"])
150
+ elif item == "stddev" and "stddev_value" in result:
151
+ stats_dict["STDDEV_VALUE"] = str(result["stddev_value"])
152
+ elif item == "mode" and "mode_value" in result and is_numerical:
153
+ stats_dict["MODE_VALUE"] = str(result["mode_value"])
154
+
155
+ return stats_dict
156
+
157
+
158
+ @error_handling_decorator
159
+ def generate_column_statistics_sql(
160
+ database_name: str,
161
+ table_name: str,
162
+ column_name: str,
163
+ column_type: str,
164
+ sample_percent: Optional[float] = None,
165
+ stats_items: Optional[List[str]] = None,
166
+ ) -> str:
167
+ quoted_column = quote_identifier(column_name)
168
+ quoted_database = quote_identifier(database_name)
169
+
170
+ # Handle the case where table_name might include a database
171
+ if "." in table_name:
172
+ schema, table = table_name.split(".", 1)
173
+ quoted_table = f"{quote_identifier(schema)}.{quote_identifier(table)}"
174
+ else:
175
+ quoted_table = quote_identifier(table_name)
176
+
177
+ stats_clauses = []
178
+ mode_query = ""
179
+
180
+ if stats_items:
181
+ if "cardinality" in stats_items:
182
+ stats_clauses.append(f"COUNT(DISTINCT {quoted_column}) AS num_uniques")
183
+ if "number_of_null" in stats_items:
184
+ stats_clauses.append(f"SUM(CASE WHEN {quoted_column} IS NULL THEN 1 ELSE 0 END) AS num_nulls")
185
+
186
+ if column_type in NUMERIC_TYPES:
187
+ if "min" in stats_items:
188
+ stats_clauses.append(f"MIN(CAST({quoted_column} AS FLOAT)) AS min_value")
189
+ if "max" in stats_items:
190
+ stats_clauses.append(f"MAX(CAST({quoted_column} AS FLOAT)) AS max_value")
191
+ if "median" in stats_items:
192
+ stats_clauses.append(f"MEDIAN(CAST({quoted_column} AS FLOAT)) AS median_value")
193
+ if "mean" in stats_items:
194
+ stats_clauses.append(f"AVG(CAST({quoted_column} AS FLOAT)) AS avg_value")
195
+ if "stddev" in stats_items:
196
+ stats_clauses.append(f"STDDEV_SAMP(CAST({quoted_column} AS FLOAT)) AS stddev_value")
197
+ if "mode" in stats_items:
198
+ mode_query = (
199
+ f"WITH MODE_VALUE AS ("
200
+ f" SELECT {quoted_column}, COUNT(*) as freq "
201
+ f" FROM {quoted_database}.{quoted_table} "
202
+ )
203
+
204
+ if sample_percent is not None and 0 < sample_percent <= 99:
205
+ sample_fraction = sample_percent / 100
206
+ mode_query += f" SAMPLE {sample_fraction} "
207
+
208
+ mode_query += (
209
+ f" GROUP BY {quoted_column} " f" QUALIFY ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) = 1" f") "
210
+ )
211
+ stats_clauses.append(f"(SELECT {quoted_column} FROM MODE_VALUE) AS mode_value")
212
+
213
+ if not stats_clauses:
214
+ logger.warning(f"No statistics selected for column {column_name}. Skipping this column.")
215
+ return ""
216
+
217
+ query = f"{mode_query}" f"SELECT {', '.join(stats_clauses)} " f"FROM {quoted_database}.{quoted_table}"
218
+
219
+ if sample_percent is not None and 0 < sample_percent <= 99:
220
+ sample_fraction = sample_percent / 100
221
+ query += f" SAMPLE {sample_fraction}"
222
+
223
+ logger.debug(f"Generated SQL query for {quoted_database}.{quoted_table}.{quoted_column}: {query}")
224
+ return query
@@ -25,9 +25,6 @@ class QDCExternalAPIClient:
25
25
  Tried to find a package for oauth0 client credentials flow,
26
26
  but any of them contains bugs or lacks of features to handle the token refresh when it's expired
27
27
  """
28
- is_domain_valid = is_valid_domain(domain=self.base_url)
29
- if not is_domain_valid:
30
- raise ValueError("The format of quollio API URL is invalid. The URL must end with `.com`")
31
28
 
32
29
  url = f"{self.base_url}/oauth2/token"
33
30
  creds = f"{self.client_id}:{self.client_secret}"
@@ -108,7 +105,3 @@ class QDCExternalAPIClient:
108
105
 
109
106
  def initialize_qdc_client(api_url: str, client_id: str, client_secret: str) -> QDCExternalAPIClient:
110
107
  return QDCExternalAPIClient(base_url=api_url, client_id=client_id, client_secret=client_secret)
111
-
112
-
113
- def is_valid_domain(domain: str) -> bool:
114
- return domain.endswith(".com")
@@ -0,0 +1,59 @@
1
+ import logging
2
+ import os
3
+ from typing import Tuple
4
+
5
+ import boto3
6
+ from botocore.exceptions import ClientError
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def get_parameter_by_assume_role(key: str, region: str = "ap-northeast-1") -> Tuple[str, Exception]:
12
+ tenant_id = os.getenv("TENANT_ID")
13
+ if not _is_str_valid(tenant_id):
14
+ return ("", Exception("TENANT_ID is not set in get_parameter_by_assume_role."))
15
+ qdc_account_id = os.getenv("QDC_ACCOUNT_ID")
16
+ if not _is_valid_aws_account_id(qdc_account_id):
17
+ return ("", Exception("QDC_ACCOUNT_ID is not set in get_parameter_by_assume_role."))
18
+ qdc_region = os.getenv("QDC_REGION")
19
+ if not _is_str_valid(qdc_region):
20
+ return ("", Exception("QDC_REGION is not set in get_parameter_by_assume_role."))
21
+
22
+ sts_assume_role_arn = "arn:aws:iam::{account_id}:role/qdc-{tenant_id}-cross-account-access".format(
23
+ account_id=qdc_account_id, tenant_id=tenant_id
24
+ )
25
+
26
+ session = boto3.Session(region_name=region)
27
+ sts = session.client("sts", endpoint_url="https://sts.{region}.amazonaws.com".format(region=qdc_region))
28
+ assumed_role_object = sts.assume_role(
29
+ RoleArn=sts_assume_role_arn,
30
+ RoleSessionName="AssumeRoleSession",
31
+ )
32
+ credentials = assumed_role_object["Credentials"]
33
+
34
+ try:
35
+ ssm = session.client(
36
+ "ssm",
37
+ endpoint_url="https://ssm.{region}.amazonaws.com".format(region=qdc_region),
38
+ aws_access_key_id=credentials["AccessKeyId"],
39
+ aws_secret_access_key=credentials["SecretAccessKey"],
40
+ aws_session_token=credentials["SessionToken"],
41
+ )
42
+ res = ssm.get_parameter(Name=key, WithDecryption=True)
43
+ return (res["Parameter"]["Value"], None)
44
+ except ClientError as e:
45
+ logger.error(
46
+ "Failed to run ssm.get_parameter().\
47
+ Please check the value stored in parameter store is correct. error: {err}".format(
48
+ err=e
49
+ )
50
+ )
51
+ return ("", e)
52
+
53
+
54
+ def _is_valid_aws_account_id(s: str) -> bool:
55
+ return s is not None and len(s) == 12 and s.isdigit()
56
+
57
+
58
+ def _is_str_valid(s: str) -> bool:
59
+ return s is not None and s != ""