|
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
14 | 14 |
|
15 | | -from datetime import datetime |
| 15 | +import datetime |
| 16 | +from decimal import Decimal |
16 | 17 | from typing import Any, Dict, List, Optional, Tuple, Union |
| 18 | +from uuid import UUID |
17 | 19 |
|
18 | 20 | import pyodbc |
19 | 21 | from loguru import logger |
| 22 | +from sqlalchemy import text |
20 | 23 |
|
21 | 24 | from dcs_core.core.common.errors import DataChecksDataSourcesConnectionError |
22 | 25 | from dcs_core.core.common.models.data_source_resource import RawColumnInfo |
@@ -683,6 +686,137 @@ def query_get_time_diff(self, table: str, field: str) -> int: |
683 | 686 | if result: |
684 | 687 | updated_time = result[0] |
685 | 688 | if isinstance(updated_time, str): |
686 | | - updated_time = datetime.strptime(updated_time, "%Y-%m-%d %H:%M:%S.%f") |
687 | | - return int((datetime.utcnow() - updated_time).total_seconds()) |
| 689 | + updated_time = datetime.datetime.strptime( |
| 690 | + updated_time, "%Y-%m-%d %H:%M:%S.%f" |
| 691 | + ) |
| 692 | + return int((datetime.datetime.utcnow() - updated_time).total_seconds()) |
688 | 693 | return 0 |
| 694 | + |
| 695 | + def build_table_metrics_query( |
| 696 | + self, |
| 697 | + table_name: str, |
| 698 | + column_info: list[dict], |
| 699 | + additional_queries: Optional[List[str]] = None, |
| 700 | + ) -> list[dict]: |
| 701 | + query_parts = [] |
| 702 | + if not column_info: |
| 703 | + return [] |
| 704 | + |
| 705 | + for col in column_info: |
| 706 | + name = col["column_name"] |
| 707 | + dtype = col["data_type"].lower() |
| 708 | + |
| 709 | + quoted_name = self.quote_column(name) |
| 710 | + |
| 711 | + query_parts.append(f"COUNT(DISTINCT {quoted_name}) AS [{name}_distinct]") |
| 712 | + query_parts.append( |
| 713 | + f"COUNT({quoted_name}) - COUNT(DISTINCT {quoted_name}) AS [{name}_duplicate]" |
| 714 | + ) |
| 715 | + query_parts.append( |
| 716 | + f"SUM(CASE WHEN {quoted_name} IS NULL THEN 1 ELSE 0 END) AS [{name}_is_null]" |
| 717 | + ) |
| 718 | + |
| 719 | + if dtype in ( |
| 720 | + "int", |
| 721 | + "integer", |
| 722 | + "bigint", |
| 723 | + "smallint", |
| 724 | + "tinyint", |
| 725 | + "decimal", |
| 726 | + "numeric", |
| 727 | + "float", |
| 728 | + "real", |
| 729 | + "money", |
| 730 | + "smallmoney", |
| 731 | + ): |
| 732 | + query_parts.append(f"MIN({quoted_name}) AS [{name}_min]") |
| 733 | + query_parts.append(f"MAX({quoted_name}) AS [{name}_max]") |
| 734 | + query_parts.append( |
| 735 | + f"AVG(CAST({quoted_name} AS FLOAT)) AS [{name}_average]" |
| 736 | + ) |
| 737 | + |
| 738 | + elif dtype in ("varchar", "nvarchar", "char", "nchar", "text", "ntext"): |
| 739 | + query_parts.append( |
| 740 | + f"MAX(LEN({quoted_name})) AS [{name}_max_character_length]" |
| 741 | + ) |
| 742 | + |
| 743 | + if additional_queries: |
| 744 | + query_parts.extend(additional_queries) |
| 745 | + |
| 746 | + qualified_table = self.qualified_table_name(table_name) |
| 747 | + query = f'SELECT\n {",\n ".join(query_parts)}\nFROM {qualified_table};' |
| 748 | + |
| 749 | + cursor = self.connection.cursor() |
| 750 | + try: |
| 751 | + cursor.execute(query) |
| 752 | + columns = [column[0] for column in cursor.description] |
| 753 | + result_row = cursor.fetchone() |
| 754 | + finally: |
| 755 | + cursor.close() |
| 756 | + |
| 757 | + row = dict(zip(columns, result_row)) |
| 758 | + |
| 759 | + def _normalize_metrics(value): |
| 760 | + """Safely normalize DB metric values for JSON serialization.""" |
| 761 | + if value is None: |
| 762 | + return None |
| 763 | + if isinstance(value, Decimal): |
| 764 | + return float(value) |
| 765 | + if isinstance(value, (int, float, bool)): |
| 766 | + return value |
| 767 | + if isinstance(value, (datetime.datetime, datetime.date)): |
| 768 | + return value.isoformat() |
| 769 | + if isinstance(value, UUID): |
| 770 | + return str(value) |
| 771 | + if isinstance(value, list): |
| 772 | + return [_normalize_metrics(v) for v in value] |
| 773 | + if isinstance(value, dict): |
| 774 | + return {k: _normalize_metrics(v) for k, v in value.items()} |
| 775 | + return str(value) |
| 776 | + |
| 777 | + column_wise = [] |
| 778 | + for col in column_info: |
| 779 | + name = col["column_name"] |
| 780 | + col_metrics = {} |
| 781 | + |
| 782 | + for key, value in row.items(): |
| 783 | + if key.startswith(f"{name}_"): |
| 784 | + metric_name = key[len(name) + 1 :] |
| 785 | + col_metrics[metric_name] = _normalize_metrics(value) |
| 786 | + |
| 787 | + column_wise.append({"column_name": name, "metrics": col_metrics}) |
| 788 | + return column_wise |
| 789 | + |
| 790 | + def fetch_sample_values_from_database( |
| 791 | + self, |
| 792 | + table_name: str, |
| 793 | + column_names: list[str], |
| 794 | + limit: int = 5, |
| 795 | + ) -> Tuple[List[Tuple], List[str]]: |
| 796 | + """ |
| 797 | + Fetch sample rows for specific columns from the given table (MSSQL version). |
| 798 | +
|
| 799 | + :param table_name: The name of the table. |
| 800 | + :param column_names: List of column names to fetch. |
| 801 | + :param limit: Number of rows to fetch. |
| 802 | + :return: Tuple of (list of row tuples, list of column names) |
| 803 | + """ |
| 804 | + qualified_table_name = self.qualified_table_name(table_name) |
| 805 | + |
| 806 | + if not column_names: |
| 807 | + raise ValueError("At least one column name must be provided") |
| 808 | + |
| 809 | + if len(column_names) == 1 and column_names[0] == "*": |
| 810 | + query = f"SELECT TOP {limit} * FROM {qualified_table_name}" |
| 811 | + else: |
| 812 | + columns = ", ".join([self.quote_column(col) for col in column_names]) |
| 813 | + query = f"SELECT TOP {limit} {columns} FROM {qualified_table_name}" |
| 814 | + |
| 815 | + cursor = self.connection.cursor() |
| 816 | + try: |
| 817 | + cursor.execute(query) |
| 818 | + column_names = [desc[0] for desc in cursor.description] |
| 819 | + rows = cursor.fetchall() |
| 820 | + finally: |
| 821 | + cursor.close() |
| 822 | + return rows, column_names |
0 commit comments