collate-data-diff 0.11.2__py3-none-any.whl

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 (54) hide show
  1. collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
  2. collate_data_diff-0.11.2.dist-info/METADATA +77 -0
  3. collate_data_diff-0.11.2.dist-info/RECORD +54 -0
  4. collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
  5. collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
  6. data_diff/__init__.py +180 -0
  7. data_diff/__main__.py +618 -0
  8. data_diff/abcs/__init__.py +0 -0
  9. data_diff/abcs/compiler.py +13 -0
  10. data_diff/abcs/database_types.py +308 -0
  11. data_diff/cloud/__init__.py +2 -0
  12. data_diff/cloud/data_source.py +318 -0
  13. data_diff/cloud/datafold_api.py +304 -0
  14. data_diff/config.py +127 -0
  15. data_diff/databases/__init__.py +17 -0
  16. data_diff/databases/_connect.py +306 -0
  17. data_diff/databases/base.py +1291 -0
  18. data_diff/databases/bigquery.py +315 -0
  19. data_diff/databases/clickhouse.py +203 -0
  20. data_diff/databases/databricks.py +248 -0
  21. data_diff/databases/duckdb.py +192 -0
  22. data_diff/databases/mssql.py +229 -0
  23. data_diff/databases/mysql.py +159 -0
  24. data_diff/databases/oracle.py +195 -0
  25. data_diff/databases/postgresql.py +258 -0
  26. data_diff/databases/presto.py +197 -0
  27. data_diff/databases/redshift.py +217 -0
  28. data_diff/databases/snowflake.py +207 -0
  29. data_diff/databases/trino.py +50 -0
  30. data_diff/databases/vertica.py +160 -0
  31. data_diff/dbt.py +604 -0
  32. data_diff/dbt_config_validators.py +65 -0
  33. data_diff/dbt_parser.py +523 -0
  34. data_diff/diff_tables.py +416 -0
  35. data_diff/errors.py +74 -0
  36. data_diff/format.py +359 -0
  37. data_diff/hashdiff_tables.py +264 -0
  38. data_diff/info_tree.py +62 -0
  39. data_diff/joindiff_tables.py +399 -0
  40. data_diff/lexicographic_space.py +240 -0
  41. data_diff/parse_time.py +74 -0
  42. data_diff/py.typed +0 -0
  43. data_diff/queries/__init__.py +0 -0
  44. data_diff/queries/api.py +200 -0
  45. data_diff/queries/ast_classes.py +798 -0
  46. data_diff/queries/base.py +24 -0
  47. data_diff/queries/extras.py +29 -0
  48. data_diff/query_utils.py +56 -0
  49. data_diff/schema.py +52 -0
  50. data_diff/table_segment.py +286 -0
  51. data_diff/thread_utils.py +98 -0
  52. data_diff/tracking.py +237 -0
  53. data_diff/utils.py +625 -0
  54. data_diff/version.py +1 -0
@@ -0,0 +1,523 @@
1
+ from argparse import Namespace
2
+ from collections import defaultdict
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, List, Dict, Tuple, Set, Optional
6
+
7
+ import attrs
8
+ import yaml
9
+ from pydantic import BaseModel
10
+
11
+ from packaging.version import parse as parse_version
12
+ from dbt.config.renderer import ProfileRenderer
13
+ from data_diff.dbt_config_validators import ManifestJsonConfig, RunResultsJsonConfig
14
+
15
+ from data_diff.errors import (
16
+ DataDiffDbtBigQueryUnsupportedMethodError,
17
+ DataDiffDbtConnectionNotImplementedError,
18
+ DataDiffDbtCoreNoRunnerError,
19
+ DataDiffDbtNoSuccessfulModelsInRunError,
20
+ DataDiffDbtProfileNotFoundError,
21
+ DataDiffDbtRedshiftPasswordOnlyError,
22
+ DataDiffDbtRunResultsVersionError,
23
+ DataDiffDbtSelectNoMatchingModelsError,
24
+ DataDiffDbtSelectUnexpectedError,
25
+ DataDiffDbtSnowflakeSetConnectionError,
26
+ DataDiffSimpleSelectNotFound,
27
+ )
28
+
29
+ from data_diff.utils import getLogger, get_from_dict_with_raise
30
+
31
+
32
+ logger = getLogger(__name__)
33
+
34
+
35
+ # getting this dbt_runner will only succeed in dbt-core>=1.5
36
+ # it's needed for `--select` functionality
37
+ def try_get_dbt_runner():
38
+ try:
39
+ from dbt.cli.main import dbtRunner
40
+ except ImportError:
41
+ dbtRunner = None
42
+
43
+ if dbtRunner is not None:
44
+ dbt_runner = dbtRunner()
45
+ else:
46
+ dbt_runner = None
47
+
48
+ return dbt_runner
49
+
50
+
51
+ # ProfileRenderer.render_data() fails without instantiating global flag MACRO_DEBUGGING in dbt-core 1.5
52
+ # hacky but seems to be a bug on dbt's end
53
+ def try_set_dbt_flags() -> None:
54
+ try:
55
+ from dbt.flags import set_flags
56
+
57
+ set_flags(Namespace(MACRO_DEBUGGING=False))
58
+ except:
59
+ pass
60
+
61
+
62
+ RUN_RESULTS_PATH = "target/run_results.json"
63
+ MANIFEST_PATH = "target/manifest.json"
64
+ PROJECT_FILE = "dbt_project.yml"
65
+ PROFILES_FILE = "profiles.yml"
66
+ LOWER_DBT_V = "1.0.0"
67
+ UPPER_DBT_V = "1.8.0"
68
+
69
+
70
+ # https://github.com/dbt-labs/dbt-core/blob/c952d44ec5c2506995fbad75320acbae49125d3d/core/dbt/cli/resolvers.py#L6
71
+ def default_project_dir() -> Path:
72
+ paths = list(Path.cwd().parents)
73
+ paths.insert(0, Path.cwd())
74
+ return next((x for x in paths if (x / PROJECT_FILE).exists()), Path.cwd())
75
+
76
+
77
+ # https://github.com/dbt-labs/dbt-core/blob/c952d44ec5c2506995fbad75320acbae49125d3d/core/dbt/cli/resolvers.py#L12
78
+ def default_profiles_dir() -> Path:
79
+ return Path.cwd() if (Path.cwd() / PROFILES_FILE).exists() else Path.home() / ".dbt"
80
+
81
+
82
+ def legacy_profiles_dir() -> Path:
83
+ return Path.home() / ".dbt"
84
+
85
+
86
+ class TDatadiffModelConfig(BaseModel):
87
+ where_filter: Optional[str] = None
88
+ include_columns: List[str] = []
89
+ exclude_columns: List[str] = []
90
+
91
+
92
+ class TDatadiffConfig(BaseModel):
93
+ prod_database: Optional[str] = None
94
+ prod_schema: Optional[str] = None
95
+ prod_custom_schema: Optional[str] = None
96
+ datasource_id: Optional[int] = None
97
+
98
+
99
+ @attrs.define(frozen=False, init=False)
100
+ class DbtParser:
101
+ dbt_runner: Optional[Any] # dbt.cli.main.dbtRunner if installed
102
+ project_dir: Path
103
+ connection: Dict[str, Any]
104
+ project_dict: Dict[str, Any]
105
+ dev_manifest_obj: ManifestJsonConfig
106
+ prod_manifest_obj: Optional[ManifestJsonConfig]
107
+ dbt_user_id: str
108
+ dbt_version: str
109
+ dbt_project_id: str
110
+ requires_upper: bool
111
+ threads: Optional[int]
112
+ unique_columns: Dict[str, Set[str]]
113
+ profiles_dir: Path
114
+
115
+ def __init__(
116
+ self,
117
+ profiles_dir_override: Optional[str] = None,
118
+ project_dir_override: Optional[str] = None,
119
+ state: Optional[str] = None,
120
+ ) -> None:
121
+ super().__init__()
122
+
123
+ try_set_dbt_flags()
124
+ self.dbt_runner = try_get_dbt_runner()
125
+ self.project_dir = Path(project_dir_override or default_project_dir())
126
+ self.connection = {}
127
+ self.project_dict = self.get_project_dict()
128
+ self.dev_manifest_obj = self.get_manifest_obj(self.project_dir / MANIFEST_PATH)
129
+ self.prod_manifest_obj = None
130
+ if state:
131
+ self.prod_manifest_obj = self.get_manifest_obj(Path(state))
132
+
133
+ self.dbt_user_id = self.dev_manifest_obj.metadata.user_id
134
+ self.dbt_version = self.dev_manifest_obj.metadata.dbt_version
135
+ self.dbt_project_id = self.dev_manifest_obj.metadata.project_id
136
+ self.requires_upper = False
137
+ self.threads = None
138
+ self.unique_columns = self.get_unique_columns()
139
+
140
+ if profiles_dir_override:
141
+ self.profiles_dir = Path(profiles_dir_override)
142
+ elif parse_version(self.dbt_version) < parse_version("1.3.0"):
143
+ self.profiles_dir = legacy_profiles_dir()
144
+ else:
145
+ self.profiles_dir = default_profiles_dir()
146
+
147
+ def get_datadiff_config(self) -> TDatadiffConfig:
148
+ data_diff_vars = self.project_dict.get("vars", {}).get("data_diff", {})
149
+ prod_database = data_diff_vars.get("prod_database")
150
+ prod_schema = data_diff_vars.get("prod_schema")
151
+ prod_custom_schema = data_diff_vars.get("prod_custom_schema")
152
+ datasource_id = data_diff_vars.get("datasource_id")
153
+ config = TDatadiffConfig(
154
+ prod_database=prod_database,
155
+ prod_schema=prod_schema,
156
+ prod_custom_schema=prod_custom_schema,
157
+ datasource_id=datasource_id,
158
+ )
159
+ logger.info(f"config: {config}")
160
+ return config
161
+
162
+ def get_datadiff_model_config(self, model_meta: dict) -> TDatadiffModelConfig:
163
+ where_filter = None
164
+ include_columns = []
165
+ exclude_columns = []
166
+
167
+ if "datafold" in model_meta and "datadiff" in model_meta["datafold"]:
168
+ config = model_meta["datafold"]["datadiff"]
169
+ where_filter = config.get("filter")
170
+ include_columns = config.get("include_columns") or []
171
+ exclude_columns = config.get("exclude_columns") or []
172
+
173
+ return TDatadiffModelConfig(
174
+ where_filter=where_filter, include_columns=include_columns, exclude_columns=exclude_columns
175
+ )
176
+
177
+ def get_models(self, dbt_selection: Optional[str] = None):
178
+ dbt_version = parse_version(self.dbt_version)
179
+ if dbt_selection:
180
+ if (dbt_version.major, dbt_version.minor) >= (1, 5):
181
+ if self.dbt_runner:
182
+ return self.get_dbt_selection_models(dbt_selection)
183
+ # edge case if running data-diff from a separate env than dbt (likely local development)
184
+ else:
185
+ raise DataDiffDbtCoreNoRunnerError(
186
+ "data-diff is using a dbt-core version < 1.5, update the environment's dbt-core version via pip install 'dbt-core>=1.5' in order to use `--select`"
187
+ )
188
+ else:
189
+ # Naively get node named <dbt_selection>
190
+ logger.warning(
191
+ f"Full `--select` support requires dbt >= 1.5. Naively searching for a single model with name: '{dbt_selection}'."
192
+ )
193
+ return self.get_simple_model_selection(dbt_selection)
194
+ else:
195
+ return self.get_run_results_models()
196
+
197
+ def get_dbt_selection_models(self, dbt_selection: str) -> List[str]:
198
+ # log level and format settings needed to prevent dbt from printing to stdout
199
+ # ls command is used to get the list of model unique_ids
200
+ results = self.dbt_runner.invoke(
201
+ [
202
+ "--log-format",
203
+ "json",
204
+ "--log-level",
205
+ "none",
206
+ "ls",
207
+ "--select",
208
+ dbt_selection,
209
+ "--resource-type",
210
+ "model",
211
+ "--output",
212
+ "json",
213
+ "--output-keys",
214
+ "unique_id",
215
+ "--project-dir",
216
+ self.project_dir,
217
+ ]
218
+ )
219
+ if results.exception:
220
+ raise results.exception
221
+
222
+ if results.success and results.result:
223
+ model_list = [json.loads(model)["unique_id"] for model in results.result]
224
+ models = [self.dev_manifest_obj.nodes.get(x) for x in model_list]
225
+ return models
226
+
227
+ if not results.result:
228
+ raise DataDiffDbtSelectNoMatchingModelsError(f"No dbt models found for `--select {dbt_selection}`")
229
+
230
+ logger.debug(str(results))
231
+ raise DataDiffDbtSelectUnexpectedError("Encountered an unexpected error while finding `--select` models")
232
+
233
+ def get_simple_model_selection(self, dbt_selection: str):
234
+ model_nodes = dict(filter(lambda item: item[0].startswith("model."), self.dev_manifest_obj.nodes.items()))
235
+ model_unique_key_list = [k for k, v in model_nodes.items() if v.name == dbt_selection]
236
+
237
+ # name *should* always be unique, but just in case:
238
+ if len(model_unique_key_list) > 1:
239
+ logger.warning(
240
+ f"Found more than one model with name '{dbt_selection}' {model_unique_key_list}, using the first one."
241
+ )
242
+ elif len(model_unique_key_list) < 1:
243
+ raise DataDiffSimpleSelectNotFound(
244
+ f"Did not find a model node with name '{dbt_selection}' in the manifest."
245
+ )
246
+
247
+ model = model_nodes.get(model_unique_key_list[0])
248
+
249
+ return [model]
250
+
251
+ def get_run_results_models(self) -> List[ManifestJsonConfig.Nodes]:
252
+ with open(self.project_dir / RUN_RESULTS_PATH) as run_results:
253
+ logger.info(f"Parsing file {RUN_RESULTS_PATH}")
254
+ run_results_dict = json.load(run_results)
255
+ run_results_validated = RunResultsJsonConfig.parse_obj(run_results_dict)
256
+
257
+ dbt_version = parse_version(run_results_validated.metadata.dbt_version)
258
+
259
+ if dbt_version < parse_version(LOWER_DBT_V):
260
+ raise DataDiffDbtRunResultsVersionError(
261
+ f"Found dbt: v{dbt_version} Expected the dbt project's version to be >= {LOWER_DBT_V}"
262
+ )
263
+ if dbt_version >= parse_version(UPPER_DBT_V):
264
+ logger.warning(
265
+ f"{dbt_version} is a recent version of dbt and may not be fully tested with data-diff! \nPlease report any issues to https://github.com/datafold/data-diff/issues"
266
+ )
267
+
268
+ success_models = [x.unique_id for x in run_results_validated.results if x.status == x.Status.success]
269
+
270
+ models = [self.dev_manifest_obj.nodes.get(x) for x in success_models]
271
+ if not models:
272
+ raise DataDiffDbtNoSuccessfulModelsInRunError(
273
+ "Expected > 0 successful models runs from the last dbt command."
274
+ )
275
+
276
+ return models
277
+
278
+ def get_manifest_obj(self, path: Path) -> ManifestJsonConfig:
279
+ with open(path) as manifest:
280
+ logger.info(f"Parsing file {path}")
281
+ manifest_dict = json.load(manifest)
282
+ manifest_obj = ManifestJsonConfig.parse_obj(manifest_dict)
283
+ return manifest_obj
284
+
285
+ def get_project_dict(self):
286
+ with open(self.project_dir / PROJECT_FILE) as project:
287
+ logger.info(f"Parsing file {PROJECT_FILE}")
288
+ project_dict = yaml.safe_load(project)
289
+ return project_dict
290
+
291
+ def get_connection_creds(self) -> Tuple[Dict[str, str], str]:
292
+ profiles_path = self.profiles_dir / PROFILES_FILE
293
+ with open(profiles_path) as profiles:
294
+ logger.info(f"Parsing file {profiles_path}")
295
+ profiles = yaml.safe_load(profiles)
296
+
297
+ dbt_profile_var = self.project_dict.get("profile")
298
+
299
+ profile = get_from_dict_with_raise(
300
+ profiles,
301
+ dbt_profile_var,
302
+ DataDiffDbtProfileNotFoundError(f"No profile '{dbt_profile_var}' found in '{profiles_path}'."),
303
+ )
304
+ profile_target = get_from_dict_with_raise(
305
+ profile,
306
+ "target",
307
+ DataDiffDbtProfileNotFoundError(f"No target found in profile '{dbt_profile_var}' in '{profiles_path}'."),
308
+ )
309
+
310
+ # some use an env var in target:
311
+ rendered_profile_target = ProfileRenderer().render_data(profile_target)
312
+
313
+ outputs = get_from_dict_with_raise(
314
+ profile,
315
+ "outputs",
316
+ DataDiffDbtProfileNotFoundError(f"No outputs found in profile '{dbt_profile_var}' in '{profiles_path}'."),
317
+ )
318
+ credentials = get_from_dict_with_raise(
319
+ outputs,
320
+ rendered_profile_target,
321
+ DataDiffDbtProfileNotFoundError(
322
+ f"No credentials found for target '{rendered_profile_target}' in profile '{dbt_profile_var}' in '{profiles_path}'."
323
+ ),
324
+ )
325
+ conn_type = get_from_dict_with_raise(
326
+ credentials,
327
+ "type",
328
+ DataDiffDbtProfileNotFoundError(
329
+ f"No type found for target '{rendered_profile_target}' in profile '{dbt_profile_var}' in '{profiles_path}'."
330
+ ),
331
+ )
332
+ conn_type = conn_type.lower()
333
+
334
+ # resolve any jinja
335
+ return ProfileRenderer().render_data(credentials), conn_type
336
+
337
+ def set_connection(self):
338
+ credentials, conn_type = self.get_connection_creds()
339
+ self.set_casing_policy_for(conn_type)
340
+
341
+ if conn_type == "snowflake":
342
+ conn_info = {
343
+ "driver": conn_type,
344
+ "user": credentials.get("user"),
345
+ "account": credentials.get("account"),
346
+ "database": credentials.get("database"),
347
+ "warehouse": credentials.get("warehouse"),
348
+ "role": credentials.get("role"),
349
+ "schema": credentials.get("schema"),
350
+ "insecure_mode": credentials.get("insecure_mode", False),
351
+ "client_session_keep_alive": credentials.get("client_session_keep_alive", False),
352
+ }
353
+ self.threads = credentials.get("threads")
354
+
355
+ if credentials.get("private_key_path") is not None:
356
+ if credentials.get("password") is not None:
357
+ raise DataDiffDbtSnowflakeSetConnectionError("Cannot use password and key at the same time")
358
+ conn_info["key"] = credentials.get("private_key_path")
359
+ conn_info["private_key_passphrase"] = credentials.get("private_key_passphrase")
360
+ elif credentials.get("authenticator") is not None:
361
+ conn_info["authenticator"] = credentials.get("authenticator")
362
+ conn_info["password"] = credentials.get("password")
363
+ elif credentials.get("password") is not None:
364
+ conn_info["password"] = credentials.get("password")
365
+ else:
366
+ raise DataDiffDbtSnowflakeSetConnectionError("Snowflake: unsupported auth method")
367
+ elif conn_type == "bigquery":
368
+ supported_methods = ["oauth", "service-account"]
369
+ method = credentials.get("method")
370
+ # there are many connection types https://docs.getdbt.com/reference/warehouse-setups/bigquery-setup#oauth-via-gcloud
371
+ # this assumes that the user is auth'd via `gcloud auth application-default login`
372
+ if method not in supported_methods:
373
+ raise DataDiffDbtBigQueryUnsupportedMethodError(
374
+ f"Method: {method} is not in the current methods supported for Big Query ({supported_methods})."
375
+ )
376
+
377
+ conn_info = {
378
+ "driver": conn_type,
379
+ "project": credentials.get("project") or credentials.get("database"),
380
+ "dataset": credentials.get("dataset") or credentials.get("schema"),
381
+ "impersonate_service_account": credentials.get("impersonate_service_account"),
382
+ }
383
+
384
+ self.threads = credentials.get("threads")
385
+ if method == supported_methods[1]:
386
+ conn_info["keyfile"] = credentials.get("keyfile")
387
+
388
+ elif conn_type == "duckdb":
389
+ conn_info = {
390
+ "driver": conn_type,
391
+ "filepath": credentials.get("path"),
392
+ }
393
+ elif conn_type == "redshift":
394
+ if (credentials.get("pass") is None and credentials.get("password") is None) or credentials.get(
395
+ "method"
396
+ ) == "iam":
397
+ raise DataDiffDbtRedshiftPasswordOnlyError(
398
+ "Only password authentication is currently supported for Redshift."
399
+ )
400
+ conn_info = {
401
+ "driver": conn_type,
402
+ "host": credentials.get("host"),
403
+ "user": credentials.get("user"),
404
+ "password": credentials.get("password") or credentials.get("pass"),
405
+ "port": credentials.get("port"),
406
+ "dbname": credentials.get("dbname") or credentials.get("database"),
407
+ }
408
+ self.threads = credentials.get("threads")
409
+ elif conn_type == "databricks":
410
+ conn_info = {
411
+ "driver": conn_type,
412
+ "catalog": credentials.get("catalog") or credentials.get("database"),
413
+ "server_hostname": credentials.get("host"),
414
+ "http_path": credentials.get("http_path"),
415
+ "schema": credentials.get("schema"),
416
+ "access_token": credentials.get("token"),
417
+ }
418
+ self.threads = credentials.get("threads")
419
+ elif conn_type == "postgres":
420
+ conn_info = {
421
+ "driver": "postgresql",
422
+ "host": credentials.get("host"),
423
+ "user": credentials.get("user"),
424
+ "password": credentials.get("password") or credentials.get("pass"),
425
+ "port": credentials.get("port"),
426
+ "dbname": credentials.get("dbname") or credentials.get("database"),
427
+ }
428
+ self.threads = credentials.get("threads")
429
+ else:
430
+ raise DataDiffDbtConnectionNotImplementedError(f"Provider {conn_type} is not yet supported for dbt diffs")
431
+
432
+ self.connection = conn_info
433
+
434
+ def get_pk_from_model(self, node, unique_columns: dict, pk_tag: str) -> List[str]:
435
+ try:
436
+ # Get a set of all the column names
437
+ column_names = {name for name, params in node.columns.items()}
438
+ # Check if the tag is present on a table level
439
+ if pk_tag in node.meta:
440
+ # Get all the PKs that are also present as a column
441
+ pks = [pk for pk in pk_tag in node.meta[pk_tag] if pk in column_names]
442
+ if pks:
443
+ # If there are any left, return it
444
+ logger.debug("Found PKs via Table META: " + str(pks))
445
+ return pks
446
+
447
+ from_meta = [name for name, params in node.columns.items() if pk_tag in params.meta] or None
448
+ if from_meta:
449
+ logger.debug(f"Found PKs via META [{node.name}]: " + str(from_meta))
450
+ return from_meta
451
+
452
+ from_tags = [name for name, params in node.columns.items() if pk_tag in params.tags] or None
453
+ if from_tags:
454
+ logger.debug(f"Found PKs via Tags [{node.name}]: " + str(from_tags))
455
+ return from_tags
456
+ if node.unique_id in unique_columns:
457
+ from_uniq = unique_columns.get(node.unique_id)
458
+ if from_uniq is not None:
459
+ logger.debug(f"Found PKs via Uniqueness tests [{node.name}]: {str(from_uniq)}")
460
+ return list(from_uniq)
461
+
462
+ except (KeyError, IndexError, TypeError) as e:
463
+ raise e
464
+
465
+ logger.debug("Found no PKs")
466
+ return []
467
+
468
+ def get_unique_columns(self) -> Dict[str, Set[str]]:
469
+ manifest = self.dev_manifest_obj
470
+ cols_by_uid = defaultdict(set)
471
+ for node in manifest.nodes.values():
472
+ try:
473
+ if not (node.resource_type == "test" and hasattr(node, "test_metadata")):
474
+ continue
475
+
476
+ if not node.depends_on or not node.depends_on.nodes:
477
+ continue
478
+
479
+ uid = node.depends_on.nodes[0]
480
+
481
+ # sources can have tests and are not in manifest.nodes
482
+ # skip as source unique columns are not needed
483
+ if uid.startswith("source."):
484
+ continue
485
+
486
+ model_node = manifest.nodes[uid]
487
+ if node.test_metadata:
488
+ if node.test_metadata.name == "unique":
489
+ column_name: str = node.test_metadata.kwargs["column_name"]
490
+ for col in self._parse_concat_pk_definition(column_name):
491
+ if model_node is None or col in model_node.columns:
492
+ # skip anything that is not a column.
493
+ # for example, string literals used in concat
494
+ # like "pk1 || '-' || pk2"
495
+ cols_by_uid[uid].add(col)
496
+
497
+ elif node.test_metadata.name == "unique_combination_of_columns":
498
+ for col in node.test_metadata.kwargs["combination_of_columns"]:
499
+ cols_by_uid[uid].add(col)
500
+
501
+ except (KeyError, IndexError, TypeError) as e:
502
+ logger.warning("Failure while finding unique cols: %s", e)
503
+
504
+ return cols_by_uid
505
+
506
+ def _parse_concat_pk_definition(self, definition: str) -> List[str]:
507
+ definition = definition.strip()
508
+ if definition.lower().startswith("concat(") and definition.endswith(")"):
509
+ definition = definition[7:-1] # Removes concat( and )
510
+ columns = definition.split(",")
511
+ else:
512
+ columns = definition.split("||")
513
+
514
+ stripped_columns = [col.strip('" ()') for col in columns]
515
+ return stripped_columns
516
+
517
+ def set_casing_policy_for(self, connection_type: str):
518
+ """
519
+ Set casing policy for identifiers: database, schema, table, column, etc.
520
+ Correct policy depends on the type of the database, because some databases (e.g. Snowflake)
521
+ use upper case identifiers by default, while others (e.g. Postgres) use lower case.
522
+ """
523
+ self.requires_upper = connection_type == "snowflake"