google-pso-data-validator 7.4.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 (89) hide show
  1. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/PKG-INFO +3 -2
  2. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/combiner.py +156 -63
  3. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/config_manager.py +48 -5
  4. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/consts.py +1 -0
  5. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/data_validation.py +37 -65
  6. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/partition_builder.py +15 -10
  7. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/PKG-INFO +3 -2
  8. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/setup.py +1 -1
  9. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/__init__.py +35 -1
  10. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/client.py +51 -1
  11. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/__init__.py +30 -0
  12. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/LICENSE +0 -0
  13. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/README.md +0 -0
  14. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/__init__.py +0 -0
  15. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/__main__.py +0 -0
  16. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/app.py +0 -0
  17. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/cli_tools.py +0 -0
  18. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/client_info.py +0 -0
  19. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/clients.py +0 -0
  20. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/exceptions.py +0 -0
  21. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/find_tables.py +0 -0
  22. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/gcs_helper.py +0 -0
  23. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/jellyfish_distance.py +0 -0
  24. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/metadata.py +0 -0
  25. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/__init__.py +0 -0
  26. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/partition_row_builder.py +0 -0
  27. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/query_builder.py +0 -0
  28. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/query_builder/random_row_builder.py +0 -0
  29. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/raw_query.py +0 -0
  30. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/__init__.py +0 -0
  31. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/bigquery.py +0 -0
  32. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/result_handlers/text.py +0 -0
  33. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/schema_validation.py +0 -0
  34. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/secret_manager.py +0 -0
  35. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/state_manager.py +0 -0
  36. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/util.py +0 -0
  37. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/data_validation/validation_builder.py +0 -0
  38. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/SOURCES.txt +0 -0
  39. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/dependency_links.txt +0 -0
  40. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/entry_points.txt +0 -0
  41. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/requires.txt +0 -0
  42. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/google_pso_data_validator.egg-info/top_level.txt +0 -0
  43. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/setup.cfg +0 -0
  44. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/__init__.py +0 -0
  45. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/api.py +0 -0
  46. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_addon/operations.py +0 -0
  47. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_biquery/__init__.py +0 -0
  48. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_biquery/api.py +0 -0
  49. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/__init__.py +0 -0
  50. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/api.py +0 -0
  51. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/client.py +0 -0
  52. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/compiler.py +0 -0
  53. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/datatypes.py +0 -0
  54. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/registry.py +0 -0
  55. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/tests/__init__.py +0 -0
  56. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/tests/conftest.py +0 -0
  57. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_cloud_spanner/to_pandas.py +0 -0
  58. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/__init__.py +0 -0
  59. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/api.py +0 -0
  60. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/compiler.py +0 -0
  61. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/datatypes.py +0 -0
  62. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_db2/registry.py +0 -0
  63. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_impala/__init__.py +0 -0
  64. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_impala/api.py +0 -0
  65. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/__init__.py +0 -0
  66. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/api.py +0 -0
  67. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/datatypes.py +0 -0
  68. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mssql/registry.py +0 -0
  69. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/__init__.py +0 -0
  70. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/base_sql_compiler/__init__.py +0 -0
  71. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/base_sql_compiler/select_builder.py +0 -0
  72. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_mysql/compiler.py +0 -0
  73. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/api.py +0 -0
  74. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/compiler.py +0 -0
  75. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/datatypes.py +0 -0
  76. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_oracle/registry.py +0 -0
  77. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/__init__.py +0 -0
  78. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/datatypes.py +0 -0
  79. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_postgres/registry.py +0 -0
  80. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/__init__.py +0 -0
  81. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/api.py +0 -0
  82. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_redshift/compiler.py +0 -0
  83. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/__init__.py +0 -0
  84. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/api.py +0 -0
  85. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_snowflake/datatypes.py +0 -0
  86. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/api.py +0 -0
  87. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/compiler.py +0 -0
  88. {google_pso_data_validator-7.4.0 → google_pso_data_validator-7.5.0}/third_party/ibis/ibis_teradata/datatypes.py +0 -0
  89. {google_pso_data_validator-7.4.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.4.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
@@ -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"""
@@ -166,6 +166,7 @@ COUNT_STAR = "{count_star}"
166
166
  # Validation metadata
167
167
  RESULT_TYPE_SOURCE = "source"
168
168
  RESULT_TYPE_TARGET = "target"
169
+ RESULT_TYPE_DIFFERENCES = "differences"
169
170
 
170
171
  FORMAT_TYPE_CSV = "csv"
171
172
  FORMAT_TYPE_JSON = "json"
@@ -115,9 +115,7 @@ class DataValidation(object):
115
115
  "Schema validation", self.schema_validator.execute
116
116
  )
117
117
  else:
118
- result_df = self._execute_validation(
119
- self.validation_builder, process_in_memory=True
120
- )
118
+ result_df = self._execute_validation(self.validation_builder)
121
119
 
122
120
  # Call Result Handler to Manage Results
123
121
  return self.result_handler.execute(result_df)
@@ -227,13 +225,10 @@ class DataValidation(object):
227
225
  clause recursively until the individual row differences can be
228
226
  identified.
229
227
  """
230
- process_in_memory = self.config_manager.process_in_memory()
231
228
  past_results = []
232
229
  if len(grouped_fields) > 0:
233
230
  validation_builder.add_query_group(grouped_fields[0])
234
- result_df = self._execute_validation(
235
- validation_builder, process_in_memory=process_in_memory
236
- )
231
+ result_df = self._execute_validation(validation_builder)
237
232
 
238
233
  for grouped_key in result_df[consts.GROUP_BY_COLUMNS].unique():
239
234
  # Validations are viewed separtely, but queried together.
@@ -267,11 +262,7 @@ class DataValidation(object):
267
262
  )
268
263
  )
269
264
  elif self.config_manager.primary_keys and len(grouped_fields) == 0:
270
- past_results.append(
271
- self._execute_validation(
272
- validation_builder, process_in_memory=process_in_memory
273
- )
274
- )
265
+ past_results.append(self._execute_validation(validation_builder))
275
266
 
276
267
  # elif self.config_manager.primary_keys:
277
268
  # validation_builder.add_config_query_groups(self.config_manager.primary_keys)
@@ -302,7 +293,7 @@ class DataValidation(object):
302
293
  }
303
294
  validation_builder.add_filter(filter_field)
304
295
 
305
- def _execute_validation(self, validation_builder, process_in_memory=True):
296
+ def _execute_validation(self, validation_builder):
306
297
  """Execute Against a Supplied Validation Builder"""
307
298
 
308
299
  self.run_metadata.validations = validation_builder.get_metadata()
@@ -329,67 +320,48 @@ class DataValidation(object):
329
320
  )
330
321
  )
331
322
 
332
- if process_in_memory:
333
- futures = []
334
- with ThreadPoolExecutor() as executor:
335
- # Submit the two query network calls concurrently
336
- futures.append(
337
- executor.submit(
338
- util.timed_call,
339
- "Source query",
340
- self.config_manager.source_client.execute,
341
- source_query,
342
- )
323
+ futures = []
324
+ with ThreadPoolExecutor() as executor:
325
+ # Submit the two query network calls concurrently
326
+ futures.append(
327
+ executor.submit(
328
+ util.timed_call,
329
+ "Source query",
330
+ self.config_manager.source_client.execute,
331
+ source_query,
343
332
  )
344
- futures.append(
345
- executor.submit(
346
- util.timed_call,
347
- "Target query",
348
- self.config_manager.target_client.execute,
349
- target_query,
350
- )
333
+ )
334
+ futures.append(
335
+ executor.submit(
336
+ util.timed_call,
337
+ "Target query",
338
+ self.config_manager.target_client.execute,
339
+ target_query,
351
340
  )
352
- source_df = futures[0].result()
353
- target_df = futures[1].result()
354
-
355
- pandas_client = ibis.pandas.connect(
356
- {
357
- consts.RESULT_TYPE_SOURCE: source_df,
358
- consts.RESULT_TYPE_TARGET: target_df,
359
- }
360
341
  )
342
+ source_df = futures[0].result()
343
+ target_df = futures[1].result()
361
344
 
362
- try:
363
- result_df = util.timed_call(
364
- "Generate report",
365
- combiner.generate_report,
366
- pandas_client,
367
- self.run_metadata,
368
- pandas_client.table(consts.RESULT_TYPE_SOURCE),
369
- pandas_client.table(consts.RESULT_TYPE_TARGET),
370
- join_on_fields=join_on_fields,
371
- is_value_comparison=is_value_comparison,
372
- verbose=self.verbose,
373
- )
374
- except Exception as e:
375
- if self.verbose:
376
- logging.error("-- ** Logging Source DF ** --")
377
- logging.error(source_df.dtypes)
378
- logging.error(source_df)
379
- logging.error("-- ** Logging Target DF ** --")
380
- logging.error(target_df.dtypes)
381
- logging.error(target_df)
382
- raise e
383
- else:
384
- result_df = combiner.generate_report(
385
- self.config_manager.source_client,
345
+ try:
346
+ result_df = util.timed_call(
347
+ "Generate report",
348
+ combiner.generate_report,
386
349
  self.run_metadata,
387
- source_query,
388
- target_query,
350
+ source_df,
351
+ target_df,
389
352
  join_on_fields=join_on_fields,
390
353
  is_value_comparison=is_value_comparison,
391
354
  verbose=self.verbose,
392
355
  )
356
+ except Exception as e:
357
+ if self.verbose:
358
+ logging.error("-- ** Logging Source DF ** --")
359
+ logging.error(source_df.dtypes)
360
+ logging.error(source_df)
361
+ logging.error("-- ** Logging Target DF ** --")
362
+ logging.error(target_df.dtypes)
363
+ logging.error(target_df)
364
+ raise e
393
365
 
394
366
  return result_df
395
367
 
@@ -17,6 +17,7 @@ import ibis
17
17
  import pandas
18
18
  import logging
19
19
  import re
20
+ import datetime
20
21
  from typing import List, Dict, TYPE_CHECKING
21
22
 
22
23
  from data_validation import cli_tools, consts
@@ -253,12 +254,13 @@ class PartitionBuilder:
253
254
 
254
255
  def less_than_value(table, keys, values):
255
256
  key_column = table.__getattr__(keys[0])
256
- if key_column.type().is_date():
257
- # Ensure date PKs are treated as date literals as per #1191
258
- value = values[0].date()
259
- else:
260
- value = values[0]
261
-
257
+ # Due to issue 1474, the type can be datetime.datetime or datetime.date
258
+ value = (
259
+ values[0].date()
260
+ if key_column.type().is_date()
261
+ and isinstance(values[0], datetime.datetime)
262
+ else values[0]
263
+ )
262
264
  if len(keys) == 1:
263
265
  return key_column < value
264
266
  else:
@@ -269,10 +271,13 @@ class PartitionBuilder:
269
271
 
270
272
  def geq_value(table, keys, values):
271
273
  key_column = table.__getattr__(keys[0])
272
- if key_column.type().is_date():
273
- value = values[0].date()
274
- else:
275
- value = values[0]
274
+ # Due to issue 1474, the type can be datetime.datetime or datetime.date
275
+ value = (
276
+ values[0].date()
277
+ if key_column.type().is_date()
278
+ and isinstance(values[0], datetime.datetime)
279
+ else values[0]
280
+ )
276
281
 
277
282
  if len(keys) == 1:
278
283
  return key_column >= value
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: google-pso-data-validator
3
- Version: 7.4.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
@@ -18,7 +18,7 @@ import setuptools
18
18
 
19
19
  name = "google-pso-data-validator"
20
20
  description = "A package to enable easy data validation"
21
- version = "7.4.0"
21
+ version = "7.5.0"
22
22
  release_status = "Development Status :: 4 - Beta"
23
23
 
24
24
  with open("README.md", "r") as fh:
@@ -11,6 +11,9 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
+
15
+ from typing import Iterable, Literal, Tuple
16
+
14
17
  import sqlalchemy as sa
15
18
  from sqlalchemy.dialects.oracle.base import (
16
19
  OracleIdentifierPreparer,
@@ -20,7 +23,6 @@ from sqlalchemy.dialects.oracle.cx_oracle import OracleDialect_cx_oracle
20
23
  import re
21
24
 
22
25
  import ibis.expr.datatypes as dt
23
- from typing import Iterable, Literal, Tuple
24
26
  from ibis.backends.base.sql.alchemy import BaseAlchemyBackend
25
27
  from third_party.ibis.ibis_oracle.compiler import OracleCompiler
26
28
  from third_party.ibis.ibis_oracle.datatypes import _get_type
@@ -178,3 +180,35 @@ class Backend(BaseAlchemyBackend):
178
180
  list_pk_col_sql, parameters=(database.upper(), table.upper())
179
181
  )
180
182
  return [_[0] for _ in result.cursor.fetchall()]
183
+
184
+ def raw_column_metadata(
185
+ self, database: str = None, table: str = None, query: str = None
186
+ ) -> Iterable[Tuple]:
187
+ """Partner method to _metadata that retains raw data type information instead of converting to Ibis types.
188
+ This works in the same way as _metadata by running a query over the DVT source, either schema.table or a
189
+ custom query, and fetching the first row. From the cursor we can detect data types of the row's columns.
190
+
191
+ Returns:
192
+ list: A list of tuples containing the standard 7 DB API fields:
193
+ https://peps.python.org/pep-0249/#description
194
+ """
195
+
196
+ def strip_prefix(s: str):
197
+ if s.startswith("DB_TYPE_"):
198
+ return s[8:]
199
+ else:
200
+ return s
201
+
202
+ assert (database and table) or query, "We should never receive all args=None"
203
+ if database and table:
204
+ source = f'"{database}"."{table}"'.upper()
205
+ elif query:
206
+ source = f"({query})"
207
+
208
+ with self.begin() as con:
209
+ result = con.exec_driver_sql(f"SELECT * FROM {source} t0 WHERE ROWNUM <= 1")
210
+ cursor = result.cursor
211
+ yield from (
212
+ (column[0], strip_prefix(column[1].name), *column[2:])
213
+ for column in cursor.description
214
+ )
@@ -12,7 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from typing import Literal
15
+ from typing import Iterable, Literal, Tuple
16
16
 
17
17
  import ibis.expr.datatypes as dt
18
18
  import ibis.expr.schema as sch
@@ -156,7 +156,57 @@ def _list_primary_key_columns(self, database: str, table: str) -> list:
156
156
  return [_[0] for _ in result.cursor.fetchall()]
157
157
 
158
158
 
159
+ def _raw_column_metadata(
160
+ self, database: str = None, table: str = None, query: str = None
161
+ ) -> Iterable[Tuple]:
162
+ """Partner method to _metadata that retains raw data type information instead of converting to Ibis types.
163
+ This works in the same way as _metadata by running a query over the DVT source, either schema.table or a
164
+ custom query, and fetching the first row. From the cursor we can detect data types of the row's columns.
165
+
166
+ Returns:
167
+ list: A list of tuples containing the standard 7 DB API fields:
168
+ https://peps.python.org/pep-0249/#description
169
+ """
170
+
171
+ assert (database and table) or query, "We should never receive all args=None"
172
+ if database and table:
173
+ source = f'"{database}"."{table}"'
174
+ elif query:
175
+ source = f"({query})"
176
+
177
+ with self.begin() as con:
178
+ cur = con.exec_driver_sql(f"SELECT * FROM {source} t0 LIMIT 0")
179
+ qry_cols = [
180
+ f"('{column.name}'::text,"
181
+ + f"{column.type_code},"
182
+ + f"{column.display_size if column.display_size else 'NULL'}::int,"
183
+ + f"{column.internal_size if column.internal_size else 'NULL'}::int,"
184
+ + f"{column.precision if column.precision else 'NULL'}::int,"
185
+ + f"{column.scale if column.scale is not None else 'NULL'}::int,"
186
+ + f"{column.null_ok if column.null_ok else 'NULL'}::int, {idx})"
187
+ for idx, column in enumerate(cur.cursor.description)
188
+ ]
189
+ sql = f"""SELECT name, format_type(t0.type_code, NULL) AS data_type
190
+ , display_size, internal_size, precision, scale, null_ok
191
+ FROM UNNEST(array[{','.join(qry_cols)}])
192
+ AS t0(name text, type_code int, display_size int, internal_size int, precision int, scale int, null_ok int, col_order int)
193
+ ORDER BY col_order"""
194
+ yield from (
195
+ (
196
+ column.name,
197
+ column.data_type,
198
+ column.display_size,
199
+ column.internal_size,
200
+ column.precision,
201
+ column.scale,
202
+ column.null_ok,
203
+ )
204
+ for column in con.exec_driver_sql(sql)
205
+ )
206
+
207
+
159
208
  PostgresBackend._metadata = _metadata
160
209
  PostgresBackend.list_databases = list_schemas
161
210
  PostgresBackend.do_connect = do_connect
162
211
  PostgresBackend.list_primary_key_columns = _list_primary_key_columns
212
+ PostgresBackend.raw_column_metadata = _raw_column_metadata
@@ -11,6 +11,9 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
+
15
+ from typing import Iterable, Tuple
16
+
14
17
  import pandas
15
18
  import warnings
16
19
 
@@ -272,6 +275,33 @@ class Backend(BaseSQLBackend):
272
275
  )
273
276
  return list(tables_df.ColumnName.str.rstrip())
274
277
 
278
+ def raw_column_metadata(
279
+ self, database: str = None, table: str = None, query: str = None
280
+ ) -> Iterable[Tuple]:
281
+ """Partner method to _get_schema_using_query that retains raw data type information instead of converting
282
+ to Ibis types. This works in the same way as _get_schema_using_query by running a query over the DVT
283
+ source, either schema.table or a custom query, and fetching the first row. From the cursor we can detect
284
+ data types of the row's columns.
285
+
286
+ Returns:
287
+ list: A list of tuples containing the standard 7 DB API fields:
288
+ https://peps.python.org/pep-0249/#description
289
+ """
290
+
291
+ assert (database and table) or query, "We should never receive all args=None"
292
+ if database and table:
293
+ source = f'"{database}"."{table}"'.upper()
294
+ elif query:
295
+ source = f"({query})"
296
+
297
+ cur = self.raw_sql(f"SELECT TOP 1 * FROM {source} t0")
298
+ # resets the state of the cursor and closes operation
299
+ cur.fetchall()
300
+ yield from (
301
+ (column[0], raw_type, *column[2:])
302
+ for column, raw_type in zip(cur.description, cur.columntypename)
303
+ )
304
+
275
305
  # Methods we need to implement for BaseSQLBackend
276
306
  def create_table(self):
277
307
  pass