google-pso-data-validator 7.1.0__tar.gz → 7.3.0__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 (91) hide show
  1. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/PKG-INFO +9 -1
  2. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/__main__.py +4 -1
  3. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/cli_tools.py +61 -20
  4. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/combiner.py +141 -50
  5. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/config_manager.py +23 -6
  6. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/consts.py +25 -14
  7. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/data_validation.py +6 -3
  8. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/gcs_helper.py +22 -0
  9. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/result_handlers/bigquery.py +1 -1
  10. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/secret_manager.py +6 -2
  11. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/state_manager.py +25 -1
  12. google_pso_data_validator-7.3.0/data_validation/util.py +64 -0
  13. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/PKG-INFO +9 -1
  14. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/SOURCES.txt +1 -0
  15. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/requires.txt +9 -0
  16. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/setup.py +10 -1
  17. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_addon/operations.py +66 -108
  18. google_pso_data_validator-7.3.0/third_party/ibis/ibis_mssql/registry.py +98 -0
  19. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_oracle/__init__.py +16 -1
  20. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_oracle/registry.py +2 -1
  21. google_pso_data_validator-7.3.0/third_party/ibis/ibis_postgres/registry.py +28 -0
  22. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_snowflake/api.py +4 -1
  23. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_teradata/__init__.py +3 -10
  24. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_teradata/api.py +5 -0
  25. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_teradata/registry.py +3 -3
  26. google_pso_data_validator-7.1.0/data_validation/util.py +0 -24
  27. google_pso_data_validator-7.1.0/third_party/ibis/ibis_mssql/registry.py +0 -49
  28. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/LICENSE +0 -0
  29. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/README.md +0 -0
  30. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/__init__.py +0 -0
  31. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/app.py +0 -0
  32. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/client_info.py +0 -0
  33. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/clients.py +0 -0
  34. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/exceptions.py +0 -0
  35. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/find_tables.py +0 -0
  36. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/jellyfish_distance.py +0 -0
  37. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/metadata.py +0 -0
  38. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/partition_builder.py +0 -0
  39. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/query_builder/__init__.py +0 -0
  40. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/query_builder/partition_row_builder.py +0 -0
  41. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/query_builder/query_builder.py +0 -0
  42. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/query_builder/random_row_builder.py +0 -0
  43. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/raw_query.py +0 -0
  44. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/result_handlers/__init__.py +0 -0
  45. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/result_handlers/text.py +0 -0
  46. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/schema_validation.py +0 -0
  47. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/data_validation/validation_builder.py +0 -0
  48. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/dependency_links.txt +0 -0
  49. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/entry_points.txt +0 -0
  50. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/google_pso_data_validator.egg-info/top_level.txt +0 -0
  51. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/setup.cfg +0 -0
  52. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_addon/__init__.py +0 -0
  53. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_addon/api.py +0 -0
  54. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_biquery/__init__.py +0 -0
  55. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_biquery/api.py +0 -0
  56. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/__init__.py +0 -0
  57. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/api.py +0 -0
  58. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/client.py +0 -0
  59. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/compiler.py +0 -0
  60. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/datatypes.py +0 -0
  61. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/registry.py +0 -0
  62. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/tests/__init__.py +0 -0
  63. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/tests/conftest.py +0 -0
  64. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_cloud_spanner/to_pandas.py +0 -0
  65. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_db2/__init__.py +0 -0
  66. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_db2/api.py +0 -0
  67. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_db2/compiler.py +0 -0
  68. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_db2/datatypes.py +0 -0
  69. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_db2/registry.py +0 -0
  70. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_impala/__init__.py +0 -0
  71. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_impala/api.py +0 -0
  72. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mssql/__init__.py +0 -0
  73. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mssql/api.py +0 -0
  74. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mssql/datatypes.py +0 -0
  75. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mysql/__init__.py +0 -0
  76. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mysql/base_sql_compiler/__init__.py +0 -0
  77. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mysql/base_sql_compiler/select_builder.py +0 -0
  78. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_mysql/compiler.py +0 -0
  79. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_oracle/api.py +0 -0
  80. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_oracle/compiler.py +0 -0
  81. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_oracle/datatypes.py +0 -0
  82. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_postgres/__init__.py +0 -0
  83. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_postgres/client.py +0 -0
  84. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_postgres/datatypes.py +0 -0
  85. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_redshift/__init__.py +0 -0
  86. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_redshift/api.py +0 -0
  87. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_redshift/compiler.py +0 -0
  88. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_snowflake/__init__.py +0 -0
  89. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_snowflake/datatypes.py +0 -0
  90. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_teradata/compiler.py +0 -0
  91. {google_pso_data_validator-7.1.0 → google_pso_data_validator-7.3.0}/third_party/ibis/ibis_teradata/datatypes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: google-pso-data-validator
3
- Version: 7.1.0
3
+ Version: 7.3.0
4
4
  Summary: A package to enable easy data validation
5
5
  Author: PSO DVT Engineering team
6
6
  Author-email: data-validator-eng@google.com
@@ -38,6 +38,14 @@ Provides-Extra: apache-airflow
38
38
  Requires-Dist: 1.10.11; extra == "apache-airflow"
39
39
  Provides-Extra: pyspark
40
40
  Requires-Dist: 3.0.0; extra == "pyspark"
41
+ Provides-Extra: develop
42
+ Requires-Dist: black==22.3.0; extra == "develop"
43
+ Requires-Dist: flake8; extra == "develop"
44
+ Requires-Dist: freezegun; extra == "develop"
45
+ Requires-Dist: pyfakefs==4.6.2; extra == "develop"
46
+ Requires-Dist: pytest; extra == "develop"
47
+ Requires-Dist: pytest-cov; extra == "develop"
48
+ Requires-Dist: pytest-timeout; extra == "develop"
41
49
  Dynamic: author
42
50
  Dynamic: author-email
43
51
  Dynamic: classifier
@@ -570,7 +570,6 @@ def run_validations(args, config_managers):
570
570
  Args:
571
571
  config_managers (list[ConfigManager]): List of config manager instances.
572
572
  """
573
- # TODO(issue/31): Add parallel execution logic
574
573
  for config_manager in config_managers:
575
574
  run_validation(config_manager, dry_run=args.dry_run, verbose=args.verbose)
576
575
 
@@ -640,6 +639,10 @@ def run_connections(args):
640
639
  """Run commands related to connection management."""
641
640
  if args.connect_cmd == "list":
642
641
  cli_tools.list_connections()
642
+ elif args.connect_cmd == "delete":
643
+ cli_tools.delete_connection(args.connection_name)
644
+ elif args.connect_cmd == "describe":
645
+ cli_tools.describe_connection(args.connection_name, args.output_format)
643
646
  elif args.connect_cmd == "add":
644
647
  conn = cli_tools.get_connection_config_from_args(args)
645
648
  # Test getting a client to validate connection details
@@ -52,12 +52,18 @@ import sys
52
52
  import uuid
53
53
  import os
54
54
  import math
55
- import re
56
55
  from argparse import Namespace
57
56
  from typing import Dict, List, Optional
58
57
  from yaml import Dumper, Loader, dump, load
59
58
 
60
- from data_validation import clients, consts, find_tables, state_manager, gcs_helper
59
+ from data_validation import (
60
+ clients,
61
+ consts,
62
+ find_tables,
63
+ state_manager,
64
+ gcs_helper,
65
+ util,
66
+ )
61
67
  from data_validation.validation_builder import list_to_sublists
62
68
 
63
69
 
@@ -468,6 +474,27 @@ def _configure_connection_parser(subparsers):
468
474
  )
469
475
  _configure_database_specific_parsers(add_parser)
470
476
 
477
+ delete_parser = connect_subparsers.add_parser(
478
+ "delete", help="Delete an existing connection"
479
+ )
480
+ delete_parser.add_argument(
481
+ "--connection-name", "-c", required=True, help="Name of connection to delete"
482
+ )
483
+ describe_parser = connect_subparsers.add_parser(
484
+ "describe", help="Describe an existing connection"
485
+ )
486
+ describe_parser.add_argument(
487
+ "--connection-name", "-c", required=True, help="Name of connection to describe"
488
+ )
489
+ describe_parser.add_argument(
490
+ "--format",
491
+ "-f",
492
+ dest="output_format",
493
+ choices=["json", "yaml"],
494
+ default="yaml",
495
+ help="Output format for the configuration (default: yaml)",
496
+ )
497
+
471
498
 
472
499
  def _configure_database_specific_parsers(parser):
473
500
  """Configure a separate subparser for each supported DB."""
@@ -1085,13 +1112,29 @@ def store_connection(connection_name, conn):
1085
1112
  mgr.create_connection(connection_name, conn)
1086
1113
 
1087
1114
 
1115
+ def delete_connection(connection_name):
1116
+ """Delete the connection config under the given name."""
1117
+ mgr = state_manager.StateManager()
1118
+ mgr.delete_connection(connection_name)
1119
+
1120
+
1088
1121
  def list_connections():
1089
1122
  """List all saved connections."""
1090
1123
  mgr = state_manager.StateManager()
1091
1124
  connections = mgr.list_connections()
1092
1125
  for conn_name in connections:
1093
1126
  source_type = mgr.get_connection_config(conn_name).get("source_type")
1094
- logging.info(f"Connection Name: {conn_name} : {source_type}")
1127
+ print(f"Connection Name: {conn_name}")
1128
+ print(f"Source Type: {source_type}\n")
1129
+ return connections
1130
+
1131
+
1132
+ def describe_connection(connection_name, output_format):
1133
+ """Return yaml connection details for a specific connection"""
1134
+ mgr = state_manager.StateManager()
1135
+ connection_details = mgr.describe_connection(connection_name, output_format)
1136
+ print(connection_details)
1137
+ return connection_details
1095
1138
 
1096
1139
 
1097
1140
  def get_connection(connection_name):
@@ -1165,31 +1208,29 @@ def get_filters(filter_value: str) -> List[Dict]:
1165
1208
  If only one filter is specified, it applies to both source and target
1166
1209
  For a doc on regular expression for filters see docs/internal/filters_regex.md
1167
1210
  """
1168
-
1169
- single_filter = r"([^':]*('[^']*')*)*"
1170
- double_filter = (
1171
- r"(?P<source>" + single_filter + r"):(?P<target>" + single_filter + r")"
1172
- )
1173
- filter_config = []
1174
- if result := re.fullmatch(single_filter, filter_value):
1175
- if result.group(0) == "":
1211
+ filters = util.split_not_in_quotes(filter_value, ":")
1212
+ if len(filters) not in (1, 2):
1213
+ raise argparse.ArgumentTypeError("Unable to parse filter arguments.")
1214
+ filters = [_.strip() for _ in filters]
1215
+ if len(filters) == 1:
1216
+ if not filters[0]:
1176
1217
  raise argparse.ArgumentTypeError("Empty string not allowed in filter")
1177
1218
  filter_dict = {
1178
1219
  "type": "custom",
1179
- "source": result.group(0),
1180
- "target": result.group(0),
1220
+ "source": filters[0],
1221
+ "target": filters[0],
1181
1222
  }
1182
- elif result := re.fullmatch(double_filter, filter_value):
1183
- if result.group("source") == "" or result.group("target") == "":
1223
+ elif len(filters) == 2:
1224
+ if not filters[0] or not filters[1]:
1184
1225
  raise argparse.ArgumentTypeError("Empty string not allowed in filter")
1185
1226
  filter_dict = {
1186
1227
  "type": "custom",
1187
- "source": result.group("source"),
1188
- "target": result.group("target"),
1228
+ "source": filters[0],
1229
+ "target": filters[1],
1189
1230
  }
1190
- else:
1191
- raise argparse.ArgumentTypeError("Unable to parse filter arguments.")
1192
- filter_config.append(filter_dict)
1231
+ filter_config = [
1232
+ filter_dict,
1233
+ ]
1193
1234
  return filter_config
1194
1235
 
1195
1236
 
@@ -25,17 +25,20 @@ import logging
25
25
  import ibis
26
26
  import ibis.expr.datatypes as dt
27
27
 
28
+ from typing import TYPE_CHECKING
28
29
  from data_validation import consts
29
30
 
30
- DEFAULT_SOURCE = "source"
31
- DEFAULT_TARGET = "target"
31
+ if TYPE_CHECKING:
32
+ from pandas import DataFrame
33
+ import ibis.expr.types.relations.table as IbisTable
34
+ from data_validation.metadata import RunMetadata, ValidationMetadata
32
35
 
33
36
 
34
37
  def generate_report(
35
38
  client,
36
- run_metadata,
37
- source,
38
- target,
39
+ run_metadata: "RunMetadata",
40
+ source: "IbisTable",
41
+ target: "IbisTable",
39
42
  join_on_fields=(),
40
43
  is_value_comparison=False,
41
44
  verbose=False,
@@ -44,8 +47,7 @@ def generate_report(
44
47
 
45
48
  Args:
46
49
  client (ibis.client.Client): Ibis client used to combine results.
47
- run_metadata (data_validation.metadata.RunMetadata):
48
- Metadata about the run and validations.
50
+ run_metadata: Metadata about the run and validations.
49
51
  source (ibis.QUERY): Ibis query / table object.
50
52
  target (ibis.QUERY): Ibis query / table object.
51
53
  join_on_fields (Sequence[str]):
@@ -69,7 +71,7 @@ def generate_report(
69
71
  if source_names != target_names:
70
72
  raise ValueError(
71
73
  "Expected source and target to have same schema, got "
72
- f"source: {source_names} target: {target_names}"
74
+ f"{consts.RESULT_TYPE_SOURCE}: {source_names}; {consts.RESULT_TYPE_TARGET}: {target_names}"
73
75
  )
74
76
  differences_pivot = _calculate_differences(
75
77
  source, target, join_on_fields, run_metadata.validations, is_value_comparison
@@ -87,13 +89,17 @@ def generate_report(
87
89
  target_df = client.execute(target_pivot)
88
90
 
89
91
  con = ibis.pandas.connect(
90
- {"source": source_df, "differences": differences_df, "target": target_df}
92
+ {
93
+ consts.RESULT_TYPE_SOURCE: source_df,
94
+ "differences": differences_df,
95
+ consts.RESULT_TYPE_TARGET: target_df,
96
+ }
91
97
  )
92
98
  joined = _join_pivots(
93
99
  con.tables.source, con.tables.target, con.tables.differences, join_on_fields
94
100
  )
95
101
 
96
- documented = _add_metadata(joined, run_metadata)
102
+ documented, run_metadata = _add_metadata(joined, run_metadata)
97
103
 
98
104
  if verbose:
99
105
  logging.debug("-- ** Combiner Query ** --")
@@ -112,20 +118,26 @@ def generate_report(
112
118
  first.get_table_name(consts.RESULT_TYPE_TARGET), inplace=True
113
119
  )
114
120
 
121
+ _get_summary(run_metadata, result_df, source_df, target_df)
122
+
115
123
  return result_df
116
124
 
117
125
 
118
126
  def _calculate_difference(
119
- field_differences,
127
+ field_differences: "IbisTable",
120
128
  datatype: dt.DataType,
121
129
  target_type: dt.DataType,
122
- validation,
123
- is_value_comparison,
130
+ validation: "ValidationMetadata",
131
+ is_value_comparison: bool,
124
132
  ):
125
133
  pct_threshold = ibis.literal(validation.threshold)
126
134
  if datatype.is_timestamp() or datatype.is_date():
127
- source_value = field_differences["differences_source_value"].epoch_seconds()
128
- target_value = field_differences["differences_target_value"].epoch_seconds()
135
+ source_value = (
136
+ field_differences["differences_source_value"].epoch_seconds().cast("int64")
137
+ )
138
+ target_value = (
139
+ field_differences["differences_target_value"].epoch_seconds().cast("int64")
140
+ )
129
141
  elif datatype.is_boolean() or (target_type and target_type.is_boolean()):
130
142
  source_value = field_differences["differences_source_value"].cast("boolean")
131
143
  target_value = field_differences["differences_target_value"].cast("boolean")
@@ -206,12 +218,16 @@ def _calculate_difference(
206
218
  difference.name("difference"),
207
219
  pct_difference.name("pct_difference"),
208
220
  pct_threshold.name("pct_threshold"),
209
- validation_status.name("validation_status"),
221
+ validation_status.name(consts.VALIDATION_STATUS),
210
222
  )
211
223
 
212
224
 
213
225
  def _calculate_differences(
214
- source, target, join_on_fields, validations, is_value_comparison
226
+ source: "IbisTable",
227
+ target: "IbisTable",
228
+ join_on_fields: tuple,
229
+ validations: "dict[ValidationMetadata]",
230
+ is_value_comparison: bool,
215
231
  ):
216
232
  """Calculate differences between source and target fields.
217
233
 
@@ -263,7 +279,12 @@ def _calculate_differences(
263
279
  return differences_pivot
264
280
 
265
281
 
266
- def _pivot_result(result, join_on_fields, validations, result_type):
282
+ def _pivot_result(
283
+ result: "IbisTable",
284
+ join_on_fields: tuple,
285
+ validations: "dict[ValidationMetadata]",
286
+ result_type: str,
287
+ ):
267
288
  all_fields = frozenset(result.schema().names)
268
289
  validation_fields = (
269
290
  all_fields - frozenset(join_on_fields)
@@ -281,19 +302,21 @@ def _pivot_result(result, join_on_fields, validations, result_type):
281
302
  ibis.literal("{")
282
303
  + ibis.literal(", ").join(validation.primary_keys)
283
304
  + ibis.literal("}")
284
- ).name("primary_keys")
305
+ ).name(consts.CONFIG_PRIMARY_KEYS)
285
306
  else:
286
- primary_keys = ibis.literal(None).cast("string").name("primary_keys")
307
+ primary_keys = (
308
+ ibis.literal(None).cast("string").name(consts.CONFIG_PRIMARY_KEYS)
309
+ )
287
310
 
288
311
  pivots.append(
289
312
  result.projection(
290
313
  (
291
314
  ibis.literal(field).name("validation_name"),
292
315
  ibis.literal(validation.validation_type).name(
293
- "validation_type"
316
+ consts.VALIDATION_TYPE
294
317
  ),
295
318
  ibis.literal(validation.aggregation_type).name(
296
- "aggregation_type"
319
+ consts.AGGREGATION_TYPE
297
320
  ),
298
321
  ibis.literal(validation.get_table_name(result_type)).name(
299
322
  "table_name"
@@ -329,7 +352,12 @@ def _as_json(expr):
329
352
  )
330
353
 
331
354
 
332
- def _join_pivots(source, target, differences, join_on_fields):
355
+ def _join_pivots(
356
+ source: "IbisTable",
357
+ target: "IbisTable",
358
+ differences: "IbisTable",
359
+ join_on_fields: tuple,
360
+ ):
333
361
  if join_on_fields:
334
362
  join_values = []
335
363
  for field in join_on_fields:
@@ -342,62 +370,125 @@ def _join_pivots(source, target, differences, join_on_fields):
342
370
 
343
371
  group_by_columns = (
344
372
  ibis.literal("{") + ibis.literal(", ").join(join_values) + ibis.literal("}")
345
- ).name("group_by_columns")
373
+ ).name(consts.GROUP_BY_COLUMNS)
346
374
  else:
347
- group_by_columns = ibis.literal(None).cast("string").name("group_by_columns")
375
+ group_by_columns = (
376
+ ibis.literal(None).cast("string").name(consts.GROUP_BY_COLUMNS)
377
+ )
348
378
 
349
379
  join_keys = ("validation_name",) + join_on_fields
350
380
  source_difference = source.join(differences, join_keys, how="outer")[
351
381
  [source[field] for field in join_keys]
352
382
  + [
353
- source["validation_type"],
354
- source["aggregation_type"],
355
- source["table_name"],
383
+ source[consts.VALIDATION_TYPE],
384
+ source[consts.AGGREGATION_TYPE],
385
+ source[consts.CONFIG_TABLE_NAME],
356
386
  source["column_name"],
357
- source["primary_keys"],
387
+ source[consts.CONFIG_PRIMARY_KEYS],
358
388
  source["num_random_rows"],
359
389
  source["agg_value"],
360
390
  differences["difference"],
361
391
  differences["pct_difference"],
362
392
  differences["pct_threshold"],
363
- differences["validation_status"],
393
+ differences[consts.VALIDATION_STATUS],
364
394
  ]
365
395
  ]
366
396
  joined = source_difference.join(target, join_keys, how="outer")[
367
397
  source_difference["validation_name"],
368
- source_difference["validation_type"]
369
- .fillna(target["validation_type"])
370
- .name("validation_type"),
371
- source_difference["aggregation_type"]
372
- .fillna(target["aggregation_type"])
373
- .name("aggregation_type"),
374
- source_difference["table_name"].name("source_table_name"),
375
- source_difference["column_name"].name("source_column_name"),
376
- source_difference["agg_value"].name("source_agg_value"),
377
- target["table_name"].name("target_table_name"),
378
- target["column_name"].name("target_column_name"),
379
- target["agg_value"].name("target_agg_value"),
398
+ source_difference[consts.VALIDATION_TYPE]
399
+ .fillna(target[consts.VALIDATION_TYPE])
400
+ .name(consts.VALIDATION_TYPE),
401
+ source_difference[consts.AGGREGATION_TYPE]
402
+ .fillna(target[consts.AGGREGATION_TYPE])
403
+ .name(consts.AGGREGATION_TYPE),
404
+ source_difference["table_name"].name(consts.SOURCE_TABLE_NAME),
405
+ source_difference["column_name"].name(consts.SOURCE_COLUMN_NAME),
406
+ source_difference["agg_value"].name(consts.SOURCE_AGG_VALUE),
407
+ target["table_name"].name(consts.TARGET_TABLE_NAME),
408
+ target["column_name"].name(consts.TARGET_COLUMN_NAME),
409
+ target["agg_value"].name(consts.TARGET_AGG_VALUE),
380
410
  group_by_columns,
381
- source_difference["primary_keys"],
411
+ source_difference[consts.CONFIG_PRIMARY_KEYS],
382
412
  source_difference["num_random_rows"],
383
413
  source_difference["difference"],
384
414
  source_difference["pct_difference"],
385
415
  source_difference["pct_threshold"],
386
- source_difference["validation_status"],
416
+ source_difference[consts.VALIDATION_STATUS],
387
417
  ]
388
418
  return joined
389
419
 
390
420
 
391
- def _add_metadata(joined, run_metadata):
421
+ def _add_metadata(joined: "IbisTable", run_metadata: "RunMetadata"):
392
422
  # TODO: Add source and target queries to metadata
393
423
  run_metadata.end_time = datetime.datetime.now(datetime.timezone.utc)
394
424
 
395
425
  joined = joined[
396
426
  joined,
397
- ibis.literal(run_metadata.run_id).name("run_id"),
398
- ibis.literal(run_metadata.labels).name("labels"),
399
- ibis.literal(run_metadata.start_time).name("start_time"),
400
- ibis.literal(run_metadata.end_time).name("end_time"),
427
+ ibis.literal(run_metadata.run_id).name(consts.CONFIG_RUN_ID),
428
+ ibis.literal(run_metadata.labels).name(consts.CONFIG_LABELS),
429
+ ibis.literal(run_metadata.start_time).name(consts.CONFIG_START_TIME),
430
+ ibis.literal(run_metadata.end_time).name(consts.CONFIG_END_TIME),
401
431
  ]
402
432
 
403
- return joined
433
+ return (joined, run_metadata)
434
+
435
+
436
+ def _get_summary(
437
+ run_metadata: "RunMetadata",
438
+ result_df: "DataFrame",
439
+ source_df: "DataFrame",
440
+ target_df: "DataFrame",
441
+ ):
442
+ """Logs a summary report/stats of row validation results."""
443
+ try:
444
+ if result_df.loc[0, consts.VALIDATION_TYPE] == consts.ROW_VALIDATION:
445
+ # Vectorized calculations for all counts
446
+ success_condition = (
447
+ result_df[consts.VALIDATION_STATUS] == consts.VALIDATION_STATUS_SUCCESS
448
+ )
449
+ fail_condition = ~success_condition # Invert success for fail condition
450
+
451
+ source_not_in_target = (
452
+ result_df[consts.SOURCE_AGG_VALUE].notnull()
453
+ & result_df[consts.TARGET_AGG_VALUE].isnull()
454
+ )
455
+ target_not_in_source = (
456
+ result_df[consts.SOURCE_AGG_VALUE].isnull()
457
+ & result_df[consts.TARGET_AGG_VALUE].notnull()
458
+ )
459
+ present_in_both_tables = (
460
+ result_df[consts.SOURCE_AGG_VALUE].notnull()
461
+ & result_df[consts.TARGET_AGG_VALUE].notnull()
462
+ )
463
+
464
+ logging.info(
465
+ {
466
+ consts.CONFIG_RUN_ID: run_metadata.run_id,
467
+ consts.CONFIG_START_TIME: run_metadata.start_time.strftime(
468
+ "%Y-%m-%d %H:%M:%S %Z"
469
+ ),
470
+ consts.CONFIG_END_TIME: run_metadata.end_time.strftime(
471
+ "%Y-%m-%d %H:%M:%S %Z"
472
+ ),
473
+ consts.TOTAL_SOURCE_ROWS: source_df.shape[0],
474
+ consts.TOTAL_TARGET_ROWS: target_df.shape[0],
475
+ consts.TOTAL_ROWS_VALIDATED: result_df.shape[0],
476
+ # Using .sum() on boolean Series for much faster counting
477
+ consts.TOTAL_ROWS_SUCCESS: success_condition.sum(),
478
+ consts.TOTAL_ROWS_FAIL: fail_condition.sum(),
479
+ consts.FAILED_SOURCE_NOT_IN_TARGET: (
480
+ fail_condition & source_not_in_target
481
+ ).sum(),
482
+ consts.FAILED_TARGET_NOT_IN_SOURCE: (
483
+ fail_condition & target_not_in_source
484
+ ).sum(),
485
+ consts.FAILED_PRESENT_IN_BOTH_TABLES: (
486
+ fail_condition & present_in_both_tables
487
+ ).sum(),
488
+ }
489
+ )
490
+ except Exception as e:
491
+ logging.warning(
492
+ f"Error while generating summary report of row validation results: {e}",
493
+ exc_info=True,
494
+ )
@@ -784,7 +784,9 @@ class ConfigManager(object):
784
784
  column_position: int,
785
785
  ) -> dict:
786
786
  """Append calculated field for length() or epoch_seconds(timestamp) for preprocessing before column validation aggregation."""
787
- depth, cast_type = 0, None
787
+ depth = 0
788
+ cast_type = None
789
+ final_cast_type = None
788
790
  if any(_ in ["json", "!json"] for _ in [column_type, target_column_type]):
789
791
  # JSON data which needs casting to string before we apply a length function.
790
792
  pre_calculated_config = self.build_and_append_pre_agg_calc_config(
@@ -829,6 +831,10 @@ class ConfigManager(object):
829
831
  depth = 1
830
832
 
831
833
  calc_func = "epoch_seconds"
834
+ if agg_type == consts.CONFIG_TYPE_SUM:
835
+ # It is possible to exceed int64 when summing epoch_seconds therefore cast to string.
836
+ # See issue 1391 for details.
837
+ final_cast_type = "string"
832
838
 
833
839
  elif column_type == "int32" or column_type == "!int32":
834
840
  calc_func = consts.CONFIG_CAST
@@ -856,6 +862,9 @@ class ConfigManager(object):
856
862
  ),
857
863
  consts.CONFIG_TYPE: agg_type,
858
864
  }
865
+ if final_cast_type:
866
+ # Adding to dict this way to avoid adding a lot of empty cast attributes.
867
+ aggregate_config[consts.CONFIG_CAST] = final_cast_type
859
868
 
860
869
  return aggregate_config
861
870
 
@@ -863,22 +872,28 @@ class ConfigManager(object):
863
872
  self,
864
873
  source_column_ibis_type: dt.DataType,
865
874
  target_column_ibis_type: dt.DataType,
875
+ margin: int = 0,
866
876
  ) -> bool:
867
- """Identifies Decimal columns that will cause problems in a Pandas Dataframe, i.e.
868
- is of greater precision than a 64bit int/real can hold."""
877
+ """Identifies Decimal columns that will cause problems in a Pandas Dataframe.
878
+
879
+ i.e. are of greater precision than a 64bit int/real can hold.
880
+
881
+ margin: Allows us to lower the precision threshold. This is helpful when summing column
882
+ values that are okay by themselves but cumulativaly could overflow a 64bit value.
883
+ """
869
884
  return bool(
870
885
  (
871
886
  isinstance(source_column_ibis_type, dt.Decimal)
872
887
  and (
873
888
  source_column_ibis_type.precision is None
874
- or source_column_ibis_type.precision > 18
889
+ or source_column_ibis_type.precision > (18 - margin)
875
890
  )
876
891
  )
877
892
  and (
878
893
  isinstance(target_column_ibis_type, dt.Decimal)
879
894
  and (
880
895
  target_column_ibis_type.precision is None
881
- or target_column_ibis_type.precision > 18
896
+ or target_column_ibis_type.precision > (18 - margin)
882
897
  )
883
898
  )
884
899
  )
@@ -1042,7 +1057,9 @@ class ConfigManager(object):
1042
1057
  consts.CONFIG_TYPE: agg_type,
1043
1058
  }
1044
1059
  if self._decimal_column_too_big_for_pandas(
1045
- source_column_ibis_type, target_column_ibis_type
1060
+ source_column_ibis_type,
1061
+ target_column_ibis_type,
1062
+ margin=(2 if agg_type == consts.CONFIG_TYPE_SUM else 0),
1046
1063
  ):
1047
1064
  aggregate_config[consts.CONFIG_CAST] = "string"
1048
1065
 
@@ -56,6 +56,8 @@ CONFIG_CASE_INSENSITIVE_MATCH = "case_insensitive_match"
56
56
  CONFIG_ROW_CONCAT = "concat"
57
57
  CONFIG_ROW_HASH = "hash"
58
58
  CONFIG_RUN_ID = "run_id"
59
+ CONFIG_START_TIME = "start_time"
60
+ CONFIG_END_TIME = "end_time"
59
61
  CONFIG_SOURCE_COLUMN = "source_column"
60
62
  CONFIG_TARGET_COLUMN = "target_column"
61
63
  CONFIG_THRESHOLD = "threshold"
@@ -142,12 +144,21 @@ TARGET_AGG_VALUE = "target_agg_value"
142
144
  VALIDATION_STATUS = "validation_status"
143
145
  VALIDATION_STATUS_SUCCESS = "success"
144
146
  VALIDATION_STATUS_FAIL = "fail"
145
- # TODO: update if we start to support other statuses
146
147
  VALIDATION_STATUSES = [
147
148
  VALIDATION_STATUS_SUCCESS,
148
149
  VALIDATION_STATUS_FAIL,
149
150
  ]
150
151
 
152
+ # Summary stats of Row Validation results
153
+ TOTAL_SOURCE_ROWS = "total_source_rows"
154
+ TOTAL_TARGET_ROWS = "total_target_rows"
155
+ TOTAL_ROWS_VALIDATED = "total_rows_validated"
156
+ TOTAL_ROWS_SUCCESS = "total_rows_success_validation_status"
157
+ TOTAL_ROWS_FAIL = "total_rows_fail_validation_status"
158
+ FAILED_SOURCE_NOT_IN_TARGET = "failed_rows_present_in_source_not_in_target"
159
+ FAILED_TARGET_NOT_IN_SOURCE = "failed_rows_present_in_target_not_in_source"
160
+ FAILED_PRESENT_IN_BOTH_TABLES = "failed_rows_present_in_both_source_and_target"
161
+
151
162
  # SQL Template Formatting
152
163
  # TODO: should this be managed in query_builder if that is the only place its used?
153
164
  COUNT_STAR = "{count_star}"
@@ -160,25 +171,25 @@ FORMAT_TYPES = ["csv", "json", "table", "text"]
160
171
 
161
172
  # Text Result Handler column filter list
162
173
  COLUMN_FILTER_LIST = [
163
- "aggregation_type",
164
- "end_time",
165
- "labels",
174
+ AGGREGATION_TYPE,
175
+ CONFIG_END_TIME,
176
+ CONFIG_LABELS,
166
177
  "pct_threshold",
167
- "start_time",
168
- "target_table_name",
169
- "target_column_name",
178
+ CONFIG_START_TIME,
179
+ TARGET_TABLE_NAME,
180
+ TARGET_COLUMN_NAME,
170
181
  "difference",
171
- "primary_keys",
172
- "group_by_columns",
182
+ CONFIG_PRIMARY_KEYS,
183
+ GROUP_BY_COLUMNS,
173
184
  "num_random_rows",
174
185
  ]
175
186
  SCHEMA_VALIDATION_COLUMN_FILTER_LIST = [
176
- "start_time",
177
- "end_time",
178
- "aggregation_type",
187
+ CONFIG_START_TIME,
188
+ CONFIG_END_TIME,
189
+ AGGREGATION_TYPE,
179
190
  "difference",
180
- "primary_keys",
181
- "group_by_columns",
191
+ CONFIG_PRIMARY_KEYS,
192
+ GROUP_BY_COLUMNS,
182
193
  "num_random_rows",
183
194
  "pct_threshold",
184
195
  ]
@@ -353,7 +353,10 @@ class DataValidation(object):
353
353
  target_df = futures[1].result()
354
354
 
355
355
  pandas_client = ibis.pandas.connect(
356
- {combiner.DEFAULT_SOURCE: source_df, combiner.DEFAULT_TARGET: target_df}
356
+ {
357
+ consts.RESULT_TYPE_SOURCE: source_df,
358
+ consts.RESULT_TYPE_TARGET: target_df,
359
+ }
357
360
  )
358
361
 
359
362
  try:
@@ -362,8 +365,8 @@ class DataValidation(object):
362
365
  combiner.generate_report,
363
366
  pandas_client,
364
367
  self.run_metadata,
365
- pandas_client.table(combiner.DEFAULT_SOURCE),
366
- pandas_client.table(combiner.DEFAULT_TARGET),
368
+ pandas_client.table(consts.RESULT_TYPE_SOURCE),
369
+ pandas_client.table(consts.RESULT_TYPE_TARGET),
367
370
  join_on_fields=join_on_fields,
368
371
  is_value_comparison=is_value_comparison,
369
372
  verbose=self.verbose,