google-pso-data-validator 7.3.0__tar.gz → 7.5.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 (90) hide show
  1. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/PKG-INFO +5 -2
  2. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/README.md +2 -0
  3. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/__main__.py +8 -5
  4. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/cli_tools.py +17 -7
  5. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/combiner.py +156 -63
  6. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/config_manager.py +67 -16
  7. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/consts.py +10 -2
  8. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/data_validation.py +37 -65
  9. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/partition_builder.py +21 -14
  10. google_pso_data_validator-7.5.0/data_validation/raw_query.py +74 -0
  11. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/bigquery.py +3 -3
  12. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/text.py +6 -4
  13. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/PKG-INFO +5 -2
  14. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/setup.py +1 -1
  15. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/__init__.py +35 -1
  16. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/client.py +51 -1
  17. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/registry.py +8 -2
  18. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/__init__.py +30 -0
  19. google_pso_data_validator-7.3.0/data_validation/raw_query.py +0 -47
  20. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/LICENSE +0 -0
  21. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/__init__.py +0 -0
  22. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/app.py +0 -0
  23. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/client_info.py +0 -0
  24. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/clients.py +0 -0
  25. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/exceptions.py +0 -0
  26. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/find_tables.py +0 -0
  27. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/gcs_helper.py +0 -0
  28. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/jellyfish_distance.py +0 -0
  29. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/metadata.py +0 -0
  30. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/__init__.py +0 -0
  31. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/partition_row_builder.py +0 -0
  32. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/query_builder.py +0 -0
  33. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/random_row_builder.py +0 -0
  34. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/__init__.py +0 -0
  35. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/schema_validation.py +0 -0
  36. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/secret_manager.py +0 -0
  37. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/state_manager.py +0 -0
  38. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/util.py +0 -0
  39. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/data_validation/validation_builder.py +0 -0
  40. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/SOURCES.txt +0 -0
  41. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/dependency_links.txt +0 -0
  42. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/entry_points.txt +0 -0
  43. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/requires.txt +0 -0
  44. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/top_level.txt +0 -0
  45. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/setup.cfg +0 -0
  46. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/__init__.py +0 -0
  47. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/api.py +0 -0
  48. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/operations.py +0 -0
  49. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_biquery/__init__.py +0 -0
  50. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_biquery/api.py +0 -0
  51. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/__init__.py +0 -0
  52. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/api.py +0 -0
  53. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/client.py +0 -0
  54. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/compiler.py +0 -0
  55. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/datatypes.py +0 -0
  56. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/registry.py +0 -0
  57. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/tests/__init__.py +0 -0
  58. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/tests/conftest.py +0 -0
  59. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/to_pandas.py +0 -0
  60. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/__init__.py +0 -0
  61. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/api.py +0 -0
  62. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/compiler.py +0 -0
  63. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/datatypes.py +0 -0
  64. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/registry.py +0 -0
  65. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_impala/__init__.py +0 -0
  66. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_impala/api.py +0 -0
  67. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/__init__.py +0 -0
  68. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/api.py +0 -0
  69. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/datatypes.py +0 -0
  70. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/registry.py +0 -0
  71. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/__init__.py +0 -0
  72. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/base_sql_compiler/__init__.py +0 -0
  73. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/base_sql_compiler/select_builder.py +0 -0
  74. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/compiler.py +0 -0
  75. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/api.py +0 -0
  76. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/compiler.py +0 -0
  77. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/datatypes.py +0 -0
  78. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/registry.py +0 -0
  79. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/__init__.py +0 -0
  80. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/datatypes.py +0 -0
  81. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/__init__.py +0 -0
  82. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/api.py +0 -0
  83. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/compiler.py +0 -0
  84. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/__init__.py +0 -0
  85. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/api.py +0 -0
  86. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/datatypes.py +0 -0
  87. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/api.py +0 -0
  88. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/compiler.py +0 -0
  89. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/datatypes.py +0 -0
  90. {google_pso_data_validator-7.3.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/registry.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: google-pso-data-validator
3
- Version: 7.3.0
3
+ Version: 7.5.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
@@ -51,6 +51,7 @@ Dynamic: author-email
51
51
  Dynamic: classifier
52
52
  Dynamic: description
53
53
  Dynamic: description-content-type
54
+ Dynamic: license-file
54
55
  Dynamic: provides-extra
55
56
  Dynamic: requires-dist
56
57
  Dynamic: requires-python
@@ -651,6 +652,8 @@ data-validation query
651
652
  The connection name to be queried
652
653
  --query or -q QUERY
653
654
  The raw query to run against the supplied connection
655
+ [--format or -f {minimal,python}]
656
+ Format for query output (default: python)
654
657
  ```
655
658
 
656
659
  ### Building Matched Table Lists
@@ -593,6 +593,8 @@ data-validation query
593
593
  The connection name to be queried
594
594
  --query or -q QUERY
595
595
  The raw query to run against the supplied connection
596
+ [--format or -f {minimal,python}]
597
+ Format for query output (default: python)
596
598
  ```
597
599
 
598
600
  ### Building Matched Table Lists
@@ -16,9 +16,9 @@ import json
16
16
  import logging
17
17
  import os
18
18
  import sys
19
+ from typing import TYPE_CHECKING
19
20
 
20
21
  from yaml import Dumper, dump
21
- from argparse import Namespace
22
22
  from typing import List
23
23
  from data_validation import (
24
24
  cli_tools,
@@ -34,6 +34,9 @@ from data_validation.data_validation import DataValidation
34
34
  from data_validation.find_tables import find_tables_using_string_matching
35
35
  from data_validation.partition_builder import PartitionBuilder
36
36
 
37
+ if TYPE_CHECKING:
38
+ from argparse import Namespace
39
+
37
40
  # by default yaml dumps lists as pointers. This disables that feature
38
41
  Dumper.ignore_aliases = lambda *args: True
39
42
 
@@ -246,7 +249,7 @@ def _get_comparison_config(
246
249
  return config_manager.build_config_comparison_fields(comparison_fields)
247
250
 
248
251
 
249
- def build_config_from_args(args: Namespace, config_manager: ConfigManager):
252
+ def build_config_from_args(args: "Namespace", config_manager: ConfigManager):
250
253
  """This function is used to append build configs to the config manager for all validation commands and generate-table-partitions.
251
254
  Instead of having two separate commands, e.g. validate row and validate custom-query row, generate-table-partitions
252
255
  uses implicit choice of table or custom-query. A user can specify either tables or source/target query/file,
@@ -337,7 +340,7 @@ def build_config_from_args(args: Namespace, config_manager: ConfigManager):
337
340
 
338
341
 
339
342
  def build_config_managers_from_args(
340
- args: Namespace, validate_cmd: str = None
343
+ args: "Namespace", validate_cmd: str = None
341
344
  ) -> List[ConfigManager]:
342
345
  """Return a list of config managers ready to execute."""
343
346
 
@@ -596,7 +599,7 @@ def store_json_config_file(args, config_managers):
596
599
  cli_tools.store_validation(config_file_path, json_config)
597
600
 
598
601
 
599
- def partition_and_store_config_files(args: Namespace) -> None:
602
+ def partition_and_store_config_files(args: "Namespace") -> None:
600
603
  """Build multiple YAML Config files using user specified partition logic
601
604
 
602
605
  Args:
@@ -691,7 +694,7 @@ def main():
691
694
  print(find_tables_using_string_matching(args))
692
695
  elif args.command == "query":
693
696
  raw_query.print_raw_query_output(
694
- raw_query.run_raw_query_against_connection(args)
697
+ raw_query.run_raw_query_against_connection(args), format=args.output_format
695
698
  )
696
699
  elif args.command == "validate":
697
700
  validate(args)
@@ -52,8 +52,7 @@ import sys
52
52
  import uuid
53
53
  import os
54
54
  import math
55
- from argparse import Namespace
56
- from typing import Dict, List, Optional
55
+ from typing import Dict, List, Optional, TYPE_CHECKING
57
56
  from yaml import Dumper, Loader, dump, load
58
57
 
59
58
  from data_validation import (
@@ -66,6 +65,9 @@ from data_validation import (
66
65
  )
67
66
  from data_validation.validation_builder import list_to_sublists
68
67
 
68
+ if TYPE_CHECKING:
69
+ from argparse import Namespace
70
+
69
71
 
70
72
  CONNECTION_SOURCE_FIELDS = {
71
73
  "BigQuery": [
@@ -191,7 +193,7 @@ VALIDATE_SCHEMA_HELP_TEXT = "Run a schema validation"
191
193
  VALIDATE_CUSTOM_QUERY_HELP_TEXT = "Run a custom query validation"
192
194
 
193
195
 
194
- def _check_custom_query_args(parser: argparse.ArgumentParser, parsed_args: Namespace):
196
+ def _check_custom_query_args(parser: argparse.ArgumentParser, parsed_args: "Namespace"):
195
197
  # This is where we make additional checks if the arguments provided are what we expect
196
198
  # For example, only one of -tbls and custom query options can be provided
197
199
  if hasattr(parsed_args, "tables_list") and hasattr(
@@ -223,7 +225,7 @@ def _check_custom_query_args(parser: argparse.ArgumentParser, parsed_args: Names
223
225
  return # old format - only one of them is present
224
226
 
225
227
 
226
- def get_parsed_args() -> Namespace:
228
+ def get_parsed_args() -> "Namespace":
227
229
  """Return ArgParser with configured CLI arguments."""
228
230
  parser = configure_arg_parser()
229
231
  args = ["--help"] if len(sys.argv) == 1 else None
@@ -393,6 +395,14 @@ def _configure_raw_query(subparsers):
393
395
  )
394
396
  query_parser.add_argument("--conn", "-c", help="Connection name to query")
395
397
  query_parser.add_argument("--query", "-q", help="Raw query to execute")
398
+ query_parser.add_argument(
399
+ "--format",
400
+ "-f",
401
+ dest="output_format",
402
+ choices=consts.RAW_QUERY_FORMAT_TYPES,
403
+ default=consts.FORMAT_TYPE_PYTHON,
404
+ help=f"Format for query output (default: {consts.FORMAT_TYPE_PYTHON})",
405
+ )
396
406
 
397
407
 
398
408
  def _configure_validation_config_parser(subparsers):
@@ -1027,7 +1037,7 @@ def _add_common_arguments(
1027
1037
  optional_arguments.add_argument(
1028
1038
  "--format",
1029
1039
  "-fmt",
1030
- default="table",
1040
+ default=consts.FORMAT_TYPE_TABLE,
1031
1041
  help="Set the format for printing command output, Supported formats are (text, csv, json, table). Defaults "
1032
1042
  "to table",
1033
1043
  )
@@ -1443,7 +1453,7 @@ def _concat_column_count_configs(
1443
1453
  return return_list
1444
1454
 
1445
1455
 
1446
- def get_pre_build_configs(args: Namespace, validate_cmd: str) -> List[Dict]:
1456
+ def get_pre_build_configs(args: "Namespace", validate_cmd: str) -> List[Dict]:
1447
1457
  """Return a dict of configurations to build ConfigManager object"""
1448
1458
 
1449
1459
  def cols_from_arg(concat_arg: str, client, table_obj: dict, query_str: str) -> list:
@@ -1503,7 +1513,7 @@ def get_pre_build_configs(args: Namespace, validate_cmd: str) -> List[Dict]:
1503
1513
  target_client = clients.get_data_client(mgr.get_connection_config(args.target_conn))
1504
1514
 
1505
1515
  # Get format: text, csv, json, table. Default is table
1506
- format = args.format if args.format else "table"
1516
+ format = args.format if args.format else consts.FORMAT_TYPE_TABLE
1507
1517
 
1508
1518
  # Get random row arguments. Only in row validations these attributes can be present.
1509
1519
  # Bad coding here, but keeping it so as not to introduce a breaking change. See
@@ -22,10 +22,12 @@ import datetime
22
22
  import functools
23
23
  import json
24
24
  import logging
25
+ from typing import TYPE_CHECKING
26
+
25
27
  import ibis
26
28
  import ibis.expr.datatypes as dt
29
+ import pandas
27
30
 
28
- from typing import TYPE_CHECKING
29
31
  from data_validation import consts
30
32
 
31
33
  if TYPE_CHECKING:
@@ -34,22 +36,109 @@ if TYPE_CHECKING:
34
36
  from data_validation.metadata import RunMetadata, ValidationMetadata
35
37
 
36
38
 
39
+ # At around 140 columns we hit RecursionError when unioning Ibis subqueries.
40
+ # This constant is a threshold at which we slice up the input Dataframes
41
+ # and stitch them back together again after Ibis processing.
42
+ COMBINER_COLUMN_SLICE_WIDTH = 120
43
+
44
+ COMBINER_GET_SUMMARY_EXC_TEXT = (
45
+ "Error while generating summary report of row validation results"
46
+ )
47
+
48
+
37
49
  def generate_report(
38
- client,
39
50
  run_metadata: "RunMetadata",
40
- source: "IbisTable",
41
- target: "IbisTable",
51
+ source_df: "DataFrame",
52
+ target_df: "DataFrame",
42
53
  join_on_fields=(),
43
54
  is_value_comparison=False,
44
55
  verbose=False,
45
- ):
56
+ ) -> "DataFrame":
57
+ """Combine results into a report.
58
+
59
+ This function is a wrapper around _generate_report_slice(). _generate_report_slice() does the main work, this
60
+ wrapper simply manages the input columns and stitches the results back together.
61
+ This is because validations of > 140(ish) columns trigger a RecursionError when unioning Ibis subqueries.
62
+ In this method we pass in column slices of the incoming Dataframes and combine the results.
63
+
64
+ It is a bit of a hack but I cannot find a way to optimize the Ibis processing. It appears to be
65
+ inefficient in that we create a subquery for each validation (column) in _calculate_differences() and
66
+ then union them all. We then do the same on the source/target table expressions to join it all back
67
+ together again. I (nj1973) spent a singificant amount of time trying to understand/optimize the Ibis
68
+ processing but fell back on this simpler (less risky) workaround.
69
+
70
+ Returns:
71
+ pandas.DataFrame:
72
+ A pandas DataFrame with the results of the validation in the same
73
+ schema as the report table.
74
+ """
75
+ _check_schema_names(source_df, target_df)
76
+
77
+ join_on_fields = tuple(join_on_fields)
78
+
79
+ validation_columns = run_metadata.validations.keys()
80
+ # slice_thresholds is a list of points at which we should break up the Dataframe by column.
81
+ # e.g. [10, 20, 30] would mean process columns 0-9, 10-19 and 20-the max column.
82
+ # 1. len(...) / COMBINER_COLUMN_SLICE_WIDTH: Divides total columns by the slice width to get the number of slices.
83
+ # 2. int(...) + 1: int()+1 is effectively ceil() which is what we want to get the actual whole number of slices
84
+ # 3. _ * COMBINER_COLUMN_SLICE_WIDTH: Multiplies each number by the slice width to get actual column counts for each slice.
85
+ slice_thresholds = [
86
+ (_ * COMBINER_COLUMN_SLICE_WIDTH)
87
+ for _ in range(int(len(validation_columns) / COMBINER_COLUMN_SLICE_WIDTH) + 1)
88
+ ]
89
+
90
+ result_df = None
91
+ # Process the input Dataframes in slices of columns to avoid "RecursionError"s.
92
+ for slice_start in slice_thresholds:
93
+ columns_in_vertical_slice = list(validation_columns)[
94
+ slice_start : slice_start + COMBINER_COLUMN_SLICE_WIDTH
95
+ ]
96
+ # Ensure any join columns are included in the column slice.
97
+ columns_in_vertical_slice.extend(
98
+ set(join_on_fields) - set(columns_in_vertical_slice)
99
+ )
100
+ interim_result_df = _generate_report_slice(
101
+ run_metadata,
102
+ source_df[columns_in_vertical_slice],
103
+ target_df[columns_in_vertical_slice],
104
+ join_on_fields=join_on_fields,
105
+ is_value_comparison=is_value_comparison,
106
+ verbose=verbose,
107
+ )
108
+ if result_df is None:
109
+ result_df = interim_result_df
110
+ else:
111
+ result_df = pandas.concat([result_df, interim_result_df])
112
+
113
+ # Get the first validation metadata object to fill source and/or target empty table names.
114
+ first = run_metadata.validations[next(iter(run_metadata.validations))]
115
+ if first.validation_type != consts.CUSTOM_QUERY:
116
+ result_df.source_table_name.fillna(
117
+ first.get_table_name(consts.RESULT_TYPE_SOURCE), inplace=True
118
+ )
119
+ result_df.target_table_name.fillna(
120
+ first.get_table_name(consts.RESULT_TYPE_TARGET), inplace=True
121
+ )
122
+
123
+ _get_summary(run_metadata, result_df, source_df, target_df)
124
+
125
+ return result_df
126
+
127
+
128
+ def _generate_report_slice(
129
+ run_metadata: "RunMetadata",
130
+ source_df: "DataFrame",
131
+ target_df: "DataFrame",
132
+ join_on_fields=(),
133
+ is_value_comparison=False,
134
+ verbose=False,
135
+ ) -> "DataFrame":
46
136
  """Combine results into a report.
47
137
 
48
138
  Args:
49
- client (ibis.client.Client): Ibis client used to combine results.
50
139
  run_metadata: Metadata about the run and validations.
51
- source (ibis.QUERY): Ibis query / table object.
52
- target (ibis.QUERY): Ibis query / table object.
140
+ source_df: Dataframe contains results of source query.
141
+ target_df: Dataframe contains results of target query.
53
142
  join_on_fields (Sequence[str]):
54
143
  A collection of column names to use to join source and target.
55
144
  These are the columns that both the source and target queries
@@ -63,16 +152,15 @@ def generate_report(
63
152
  A pandas DataFrame with the results of the validation in the same
64
153
  schema as the report table.
65
154
  """
66
- join_on_fields = tuple(join_on_fields)
67
-
68
- source_names = source.schema().names
69
- target_names = target.schema().names
155
+ client = ibis.pandas.connect(
156
+ {
157
+ consts.RESULT_TYPE_SOURCE: source_df,
158
+ consts.RESULT_TYPE_TARGET: target_df,
159
+ }
160
+ )
161
+ source = client.table(consts.RESULT_TYPE_SOURCE)
162
+ target = client.table(consts.RESULT_TYPE_TARGET)
70
163
 
71
- if source_names != target_names:
72
- raise ValueError(
73
- "Expected source and target to have same schema, got "
74
- f"{consts.RESULT_TYPE_SOURCE}: {source_names}; {consts.RESULT_TYPE_TARGET}: {target_names}"
75
- )
76
164
  differences_pivot = _calculate_differences(
77
165
  source, target, join_on_fields, run_metadata.validations, is_value_comparison
78
166
  )
@@ -81,18 +169,18 @@ def generate_report(
81
169
  source_pivot = _pivot_result(
82
170
  source, join_on_fields, run_metadata.validations, consts.RESULT_TYPE_SOURCE
83
171
  )
84
- source_df = client.execute(source_pivot)
172
+ source_pivot_df = client.execute(source_pivot)
85
173
 
86
174
  target_pivot = _pivot_result(
87
175
  target, join_on_fields, run_metadata.validations, consts.RESULT_TYPE_TARGET
88
176
  )
89
- target_df = client.execute(target_pivot)
177
+ target_pivot_df = client.execute(target_pivot)
90
178
 
91
179
  con = ibis.pandas.connect(
92
180
  {
93
- consts.RESULT_TYPE_SOURCE: source_df,
94
- "differences": differences_df,
95
- consts.RESULT_TYPE_TARGET: target_df,
181
+ consts.RESULT_TYPE_SOURCE: source_pivot_df,
182
+ consts.RESULT_TYPE_DIFFERENCES: differences_df,
183
+ consts.RESULT_TYPE_TARGET: target_pivot_df,
96
184
  }
97
185
  )
98
186
  joined = _join_pivots(
@@ -107,19 +195,6 @@ def generate_report(
107
195
 
108
196
  result_df = client.execute(documented)
109
197
  result_df.validation_status.fillna(consts.VALIDATION_STATUS_FAIL, inplace=True)
110
-
111
- # get the first validation metadata object to fill source and/or target empty table names
112
- first = run_metadata.validations[next(iter(run_metadata.validations))]
113
- if first.validation_type != consts.CUSTOM_QUERY:
114
- result_df.source_table_name.fillna(
115
- first.get_table_name(consts.RESULT_TYPE_SOURCE), inplace=True
116
- )
117
- result_df.target_table_name.fillna(
118
- first.get_table_name(consts.RESULT_TYPE_TARGET), inplace=True
119
- )
120
-
121
- _get_summary(run_metadata, result_df, source_df, target_df)
122
-
123
198
  return result_df
124
199
 
125
200
 
@@ -279,6 +354,18 @@ def _calculate_differences(
279
354
  return differences_pivot
280
355
 
281
356
 
357
+ def _check_schema_names(source_df, target_df):
358
+ """Check that the two input Dataframes have matching column names."""
359
+ source_names = tuple(source_df.columns)
360
+ target_names = tuple(target_df.columns)
361
+
362
+ if source_names != target_names:
363
+ raise ValueError(
364
+ "Expected source and target to have same schema, got "
365
+ f"{consts.RESULT_TYPE_SOURCE}: {source_names}; {consts.RESULT_TYPE_TARGET}: {target_names}"
366
+ )
367
+
368
+
282
369
  def _pivot_result(
283
370
  result: "IbisTable",
284
371
  join_on_fields: tuple,
@@ -441,12 +528,19 @@ def _get_summary(
441
528
  ):
442
529
  """Logs a summary report/stats of row validation results."""
443
530
  try:
444
- if result_df.loc[0, consts.VALIDATION_TYPE] == consts.ROW_VALIDATION:
445
- # Vectorized calculations for all counts
531
+ if result_df.empty:
532
+ return
533
+
534
+ if (result_df.loc[0, consts.VALIDATION_TYPE] == consts.ROW_VALIDATION) or (
535
+ # Check for custom-query row validation which always should have primary keys (not null)
536
+ result_df.loc[0, consts.VALIDATION_TYPE] == consts.CUSTOM_QUERY
537
+ and result_df.loc[0, consts.CONFIG_PRIMARY_KEYS]
538
+ ):
539
+ # Vectorized calculations for all counts.
446
540
  success_condition = (
447
541
  result_df[consts.VALIDATION_STATUS] == consts.VALIDATION_STATUS_SUCCESS
448
542
  )
449
- fail_condition = ~success_condition # Invert success for fail condition
543
+ fail_condition = ~success_condition # Invert success for fail condition.
450
544
 
451
545
  source_not_in_target = (
452
546
  result_df[consts.SOURCE_AGG_VALUE].notnull()
@@ -462,33 +556,32 @@ def _get_summary(
462
556
  )
463
557
 
464
558
  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
- }
559
+ json.dumps(
560
+ {
561
+ consts.CONFIG_RUN_ID: run_metadata.run_id,
562
+ consts.CONFIG_START_TIME: run_metadata.start_time.isoformat(),
563
+ consts.CONFIG_END_TIME: run_metadata.end_time.isoformat(),
564
+ # Explicit conversion of numpy's int64 values to int for JSON serializability
565
+ consts.TOTAL_SOURCE_ROWS: int(source_df.shape[0]),
566
+ consts.TOTAL_TARGET_ROWS: int(target_df.shape[0]),
567
+ consts.TOTAL_ROWS_VALIDATED: int(result_df.shape[0]),
568
+ # Using .sum() on boolean Series for much faster counting
569
+ consts.TOTAL_ROWS_SUCCESS: int(success_condition.sum()),
570
+ consts.TOTAL_ROWS_FAIL: int(fail_condition.sum()),
571
+ consts.FAILED_SOURCE_NOT_IN_TARGET: int(
572
+ (fail_condition & source_not_in_target).sum()
573
+ ),
574
+ consts.FAILED_TARGET_NOT_IN_SOURCE: int(
575
+ (fail_condition & target_not_in_source).sum()
576
+ ),
577
+ consts.FAILED_PRESENT_IN_BOTH_TABLES: int(
578
+ (fail_condition & present_in_both_tables).sum()
579
+ ),
580
+ }
581
+ )
489
582
  )
490
583
  except Exception as e:
491
584
  logging.warning(
492
- f"Error while generating summary report of row validation results: {e}",
585
+ f"{COMBINER_GET_SUMMARY_EXC_TEXT}: {e}",
493
586
  exc_info=True,
494
587
  )
@@ -16,7 +16,7 @@ import copy
16
16
  import logging
17
17
  import string
18
18
  import random
19
- from typing import TYPE_CHECKING, Optional, Union, List, Dict
19
+ from typing import TYPE_CHECKING, Dict, List, Optional, Union, Tuple
20
20
 
21
21
  import google.oauth2.service_account
22
22
  import ibis.expr.datatypes as dt
@@ -66,6 +66,9 @@ class ConfigManager(object):
66
66
  if self.validation_type not in consts.CONFIG_TYPES:
67
67
  raise ValueError(f"Unknown Configuration Type: {self.validation_type}")
68
68
  self._comparison_max_col_length = None
69
+ # For some engines we need to know the actual raw data type rather than the Ibis canonical type.
70
+ self._source_raw_data_types = None
71
+ self._target_raw_data_types = None
69
72
 
70
73
  @property
71
74
  def config(self):
@@ -94,6 +97,50 @@ class ConfigManager(object):
94
97
 
95
98
  return self._target_conn
96
99
 
100
+ def get_source_raw_data_types(self) -> Dict[str, Tuple]:
101
+ """Return raw data type information from source system.
102
+
103
+ The raw data type is the source/target engine type, for example it might
104
+ be "NCLOB" or "char" when the Ibis type simply states "string".
105
+ The data is cached in state when fetched for the first time.
106
+ The retuen value is keyed on the casefolded column name and the tuple is
107
+ the remaining 6 elements of the DB API cursor description specification."""
108
+ if self._source_raw_data_types is None:
109
+ if hasattr(self.source_client, "raw_column_metadata"):
110
+ raw_data_types = self.source_client.raw_column_metadata(
111
+ database=self.source_schema,
112
+ table=self.source_table,
113
+ query=self.source_query,
114
+ )
115
+ self._source_raw_data_types = {
116
+ _[0].casefold(): _[1:] for _ in raw_data_types
117
+ }
118
+ else:
119
+ self._source_raw_data_types = {}
120
+ return self._source_raw_data_types
121
+
122
+ def get_target_raw_data_types(self) -> Dict[str, Tuple]:
123
+ """Return raw data type information from target system.
124
+
125
+ The raw data type is the source/target engine type, for example it might
126
+ be "NCLOB" or "char" when the Ibis type simply states "string".
127
+ The data is cached in state when fetched for the first time.
128
+ The retuen value is keyed on the casefolded column name and the tuple is
129
+ the remaining 6 elements of the DB API cursor description specification."""
130
+ if self._target_raw_data_types is None:
131
+ if hasattr(self.target_client, "raw_column_metadata"):
132
+ raw_data_types = self.target_client.raw_column_metadata(
133
+ database=self.target_schema,
134
+ table=self.target_table,
135
+ query=self.target_query,
136
+ )
137
+ self._target_raw_data_types = {
138
+ _[0].casefold(): _[1:] for _ in raw_data_types
139
+ }
140
+ else:
141
+ self._target_raw_data_types = {}
142
+ return self._target_raw_data_types
143
+
97
144
  def close_client_connections(self):
98
145
  """Attempt to clean up any source/target connections, based on the client types.
99
146
 
@@ -138,10 +185,6 @@ class ConfigManager(object):
138
185
  """Return if the validation should perform a case insensitive match."""
139
186
  return self._config.get(consts.CONFIG_CASE_INSENSITIVE_MATCH) or False
140
187
 
141
- def process_in_memory(self):
142
- """Return whether to process in memory or on a remote platform."""
143
- return True
144
-
145
188
  @property
146
189
  def max_recursive_query_size(self):
147
190
  """Return Aggregates from Config"""
@@ -459,7 +502,7 @@ class ConfigManager(object):
459
502
  cols_filter_list = consts.COLUMN_FILTER_LIST
460
503
  # handler that display results either to output or in a file
461
504
  return TextResultHandler(
462
- self._config.get(consts.CONFIG_FORMAT, "table"),
505
+ self._config.get(consts.CONFIG_FORMAT, consts.FORMAT_TYPE_TABLE),
463
506
  self.filter_status,
464
507
  cols_filter_list,
465
508
  )
@@ -486,7 +529,9 @@ class ConfigManager(object):
486
529
  table_id=table_id,
487
530
  credentials=credentials,
488
531
  api_endpoint=api_endpoint,
489
- text_format=self._config.get(consts.CONFIG_FORMAT, "table"),
532
+ text_format=self._config.get(
533
+ consts.CONFIG_FORMAT, consts.FORMAT_TYPE_TABLE
534
+ ),
490
535
  )
491
536
  else:
492
537
  raise ValueError(f"Unknown ResultHandler Class: {result_type}")
@@ -874,7 +919,7 @@ class ConfigManager(object):
874
919
  target_column_ibis_type: dt.DataType,
875
920
  margin: int = 0,
876
921
  ) -> bool:
877
- """Identifies Decimal columns that will cause problems in a Pandas Dataframe.
922
+ """Identifies numeric columns that will cause problems in a Pandas Dataframe.
878
923
 
879
924
  i.e. are of greater precision than a 64bit int/real can hold.
880
925
 
@@ -883,17 +928,23 @@ class ConfigManager(object):
883
928
  """
884
929
  return bool(
885
930
  (
886
- isinstance(source_column_ibis_type, dt.Decimal)
887
- and (
888
- source_column_ibis_type.precision is None
889
- or source_column_ibis_type.precision > (18 - margin)
931
+ (isinstance(source_column_ibis_type, dt.Int64) and margin > 0)
932
+ or (
933
+ isinstance(source_column_ibis_type, dt.Decimal)
934
+ and (
935
+ source_column_ibis_type.precision is None
936
+ or source_column_ibis_type.precision > (18 - margin)
937
+ )
890
938
  )
891
939
  )
892
940
  and (
893
- isinstance(target_column_ibis_type, dt.Decimal)
894
- and (
895
- target_column_ibis_type.precision is None
896
- or target_column_ibis_type.precision > (18 - margin)
941
+ (isinstance(target_column_ibis_type, dt.Int64) and margin > 0)
942
+ or (
943
+ isinstance(target_column_ibis_type, dt.Decimal)
944
+ and (
945
+ target_column_ibis_type.precision is None
946
+ or target_column_ibis_type.precision > (18 - margin)
947
+ )
897
948
  )
898
949
  )
899
950
  )
@@ -166,8 +166,16 @@ COUNT_STAR = "{count_star}"
166
166
  # Validation metadata
167
167
  RESULT_TYPE_SOURCE = "source"
168
168
  RESULT_TYPE_TARGET = "target"
169
-
170
- FORMAT_TYPES = ["csv", "json", "table", "text"]
169
+ RESULT_TYPE_DIFFERENCES = "differences"
170
+
171
+ FORMAT_TYPE_CSV = "csv"
172
+ FORMAT_TYPE_JSON = "json"
173
+ FORMAT_TYPE_MINIMAL = "minimal"
174
+ FORMAT_TYPE_PYTHON = "python"
175
+ FORMAT_TYPE_TABLE = "table"
176
+ FORMAT_TYPE_TEXT = "text"
177
+ FORMAT_TYPES = [FORMAT_TYPE_CSV, FORMAT_TYPE_JSON, FORMAT_TYPE_TABLE, FORMAT_TYPE_TEXT]
178
+ RAW_QUERY_FORMAT_TYPES = [FORMAT_TYPE_MINIMAL, FORMAT_TYPE_PYTHON]
171
179
 
172
180
  # Text Result Handler column filter list
173
181
  COLUMN_FILTER_LIST = [