soda-bigquery 4.0.5__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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: soda-bigquery
3
+ Version: 4.0.5
4
+ Requires-Dist: soda-core==4.0.5
5
+ Requires-Dist: google-cloud-bigquery>=2.25.0
6
+ Dynamic: requires-dist
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python
2
+
3
+ from setuptools import setup
4
+
5
+ package_name = "soda-bigquery"
6
+ package_version = "4.0.5"
7
+ description = "Soda BigQuery V4"
8
+
9
+ requires = [
10
+ f"soda-core=={package_version}",
11
+ "google-cloud-bigquery>=2.25.0",
12
+ ]
13
+
14
+ setup(
15
+ name=package_name,
16
+ version=package_version,
17
+ install_requires=requires,
18
+ package_dir={"": "src"},
19
+ entry_points={
20
+ "soda.plugins.data_source.bigquery": [
21
+ "BigqueryDataSourceImpl = soda_bigquery.common.data_sources.bigquery_data_source:BigQueryDataSourceImpl",
22
+ ],
23
+ },
24
+ )
@@ -0,0 +1,245 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from soda_bigquery.common.data_sources.bigquery_data_source_connection import (
6
+ BigQueryDataSource as BigQueryDataSourceModel,
7
+ )
8
+ from soda_bigquery.common.data_sources.bigquery_data_source_connection import (
9
+ BigQueryDataSourceConnection,
10
+ )
11
+ from soda_core.common.data_source_connection import DataSourceConnection
12
+ from soda_core.common.data_source_impl import DataSourceImpl
13
+ from soda_core.common.logging_constants import soda_logger
14
+ from soda_core.common.metadata_types import DataSourceNamespace, SodaDataTypeName
15
+ from soda_core.common.sql_ast import (
16
+ COLUMN,
17
+ CONCAT_WS,
18
+ COUNT,
19
+ DISTINCT,
20
+ LITERAL,
21
+ REGEX_LIKE,
22
+ STRING_HASH,
23
+ TUPLE,
24
+ VALUES,
25
+ WITH,
26
+ )
27
+ from soda_core.common.sql_dialect import SqlDialect
28
+ from soda_core.common.statements.metadata_tables_query import MetadataTablesQuery
29
+
30
+ logger: logging.Logger = soda_logger
31
+
32
+
33
+ @dataclass
34
+ class BigQueryDataSourceNamespace(DataSourceNamespace):
35
+ project_id: str
36
+ dataset: str
37
+ location: Optional[str] = None
38
+
39
+ def get_namespace_elements(self) -> list[str]:
40
+ if self.location:
41
+ return [self.location, self.project_id]
42
+ else:
43
+ return [self.project_id, self.dataset]
44
+
45
+ def get_database_for_metadata_query(self) -> str | None:
46
+ return self.project_id
47
+
48
+ def get_schema_for_metadata_query(self) -> str:
49
+ return self.dataset
50
+
51
+
52
+ class BigQueryDataSourceImpl(DataSourceImpl, model_class=BigQueryDataSourceModel):
53
+ def __init__(self, data_source_model: BigQueryDataSourceModel, connection: Optional[DataSourceConnection] = None):
54
+ super().__init__(data_source_model=data_source_model, connection=connection)
55
+ self.cached_location = None
56
+
57
+ def _create_sql_dialect(self) -> SqlDialect:
58
+ return BigQuerySqlDialect(data_source_impl=self)
59
+
60
+ def _create_data_source_connection(self) -> DataSourceConnection:
61
+ return BigQueryDataSourceConnection(
62
+ name=self.data_source_model.name, connection_properties=self.data_source_model.connection_properties
63
+ )
64
+
65
+ def get_location(self) -> str:
66
+ if self.cached_location is not None:
67
+ location = self.cached_location
68
+ elif self.data_source_model.connection_properties.location is not None:
69
+ location = self.data_source_model.connection_properties.location
70
+ else:
71
+ result = self.execute_query("SELECT @@location")
72
+ location = result.rows[0][0]
73
+ logger.info(f"Detected BigQuery location: {location}")
74
+ self.cached_location = location
75
+ return location
76
+
77
+ def create_metadata_tables_query(self) -> MetadataTablesQuery:
78
+ super_metadata_tables_query = MetadataTablesQuery(
79
+ sql_dialect=self.sql_dialect,
80
+ data_source_connection=self.data_source_connection,
81
+ prefixes=[f"region-{self.get_location()}"],
82
+ )
83
+ return super_metadata_tables_query
84
+
85
+ def build_columns_metadata_query_str(self, dataset_prefixes: list[str], dataset_name: str) -> str:
86
+ table_namespace: DataSourceNamespace = BigQueryDataSourceNamespace(
87
+ project_id=dataset_prefixes[0], dataset=dataset_prefixes[1]
88
+ )
89
+
90
+ # BigQuery must be able to override to get the location
91
+ return self.sql_dialect.build_columns_metadata_query_str(
92
+ table_namespace=table_namespace, table_name=dataset_name
93
+ )
94
+
95
+ def _build_table_namespace_for_schema_query(self, prefixes: list[str]) -> tuple[DataSourceNamespace, str]:
96
+ table_namespace: DataSourceNamespace = BigQueryDataSourceNamespace(
97
+ project_id=prefixes[0],
98
+ dataset=None, # We only need the project id to query the schemas, as it's always in the `INFORMATION_SCHEMA`
99
+ )
100
+ schema_name = self.extract_schema_from_prefix(prefixes)
101
+ if schema_name is None:
102
+ raise ValueError(f"Cannot determine schema name from prefixes: {prefixes}")
103
+
104
+ return table_namespace, schema_name
105
+
106
+
107
+ class BigQuerySqlDialect(SqlDialect):
108
+ DEFAULT_QUOTE_CHAR = "`"
109
+
110
+ SODA_DATA_TYPE_SYNONYMS = (
111
+ (SodaDataTypeName.TEXT, SodaDataTypeName.VARCHAR, SodaDataTypeName.CHAR),
112
+ (SodaDataTypeName.INTEGER, SodaDataTypeName.BIGINT, SodaDataTypeName.SMALLINT),
113
+ (SodaDataTypeName.NUMERIC, SodaDataTypeName.DECIMAL),
114
+ (SodaDataTypeName.TIMESTAMP, SodaDataTypeName.TIMESTAMP_TZ), # Bigquery does not support timezones
115
+ (SodaDataTypeName.FLOAT, SodaDataTypeName.DOUBLE),
116
+ )
117
+
118
+ def get_data_source_data_type_name_by_soda_data_type_names(self) -> dict[str, str]:
119
+ return {
120
+ SodaDataTypeName.CHAR: "string", # BigQuery only has STRING
121
+ SodaDataTypeName.VARCHAR: "string",
122
+ SodaDataTypeName.TEXT: "string", # alias for varchar
123
+ SodaDataTypeName.SMALLINT: "int64", # all integers → INT64
124
+ SodaDataTypeName.INTEGER: "int64",
125
+ SodaDataTypeName.BIGINT: "int64",
126
+ SodaDataTypeName.DECIMAL: "numeric", # NUMERIC (fixed precision: 38 digits, 9 decimals)
127
+ SodaDataTypeName.NUMERIC: "numeric",
128
+ SodaDataTypeName.FLOAT: "float64", # FLOAT & DOUBLE → FLOAT64
129
+ SodaDataTypeName.DOUBLE: "float64",
130
+ SodaDataTypeName.TIMESTAMP: "timestamp", # UTC-based
131
+ SodaDataTypeName.TIMESTAMP_TZ: "timestamp", # still just TIMESTAMP in BigQuery
132
+ SodaDataTypeName.DATE: "date",
133
+ SodaDataTypeName.TIME: "time",
134
+ SodaDataTypeName.BOOLEAN: "bool",
135
+ }
136
+
137
+ def get_soda_data_type_name_by_data_source_data_type_names(self) -> dict[str, SodaDataTypeName]:
138
+ return {
139
+ "string": SodaDataTypeName.VARCHAR,
140
+ "smallint": SodaDataTypeName.SMALLINT,
141
+ "int64": SodaDataTypeName.BIGINT,
142
+ "numeric": SodaDataTypeName.NUMERIC,
143
+ "float64": SodaDataTypeName.DOUBLE,
144
+ "double precision": SodaDataTypeName.DOUBLE,
145
+ "timestamp": SodaDataTypeName.TIMESTAMP,
146
+ "date": SodaDataTypeName.DATE,
147
+ "time": SodaDataTypeName.TIME,
148
+ "bool": SodaDataTypeName.BOOLEAN,
149
+ }
150
+
151
+ def _get_data_type_name_synonyms(self) -> list[list[str]]:
152
+ return [
153
+ ["int64", "integer"],
154
+ ["bool", "boolean"],
155
+ ["numeric", "decimal"],
156
+ ["bignumeric", "bigdecimal"],
157
+ ]
158
+
159
+ def information_schema_namespace_elements(self, data_source_namespace: BigQueryDataSourceNamespace) -> list[str]:
160
+ return [data_source_namespace.project_id, data_source_namespace.dataset, "INFORMATION_SCHEMA"]
161
+
162
+ def default_casify(self, identifier: str) -> str:
163
+ return identifier.upper()
164
+
165
+ def _build_tuple_sql(self, tuple: TUPLE) -> str:
166
+ if tuple.check_context(COUNT) and tuple.check_context(DISTINCT):
167
+ return self._build_tuple_sql_in_distinct(tuple)
168
+ return f"{super()._build_tuple_sql(tuple)}"
169
+
170
+ def _build_tuple_sql_in_distinct(self, tuple: TUPLE) -> str:
171
+ return f"TO_JSON_STRING(STRUCT({super()._build_tuple_sql(tuple)}))"
172
+
173
+ def _build_regex_like_sql(self, matches: REGEX_LIKE) -> str:
174
+ expression: str = self.build_expression_sql(matches.expression)
175
+ return f"REGEXP_CONTAINS({expression}, r'{matches.regex_pattern}')"
176
+
177
+ def supports_data_type_character_maximum_length(self) -> bool:
178
+ return False
179
+
180
+ def supports_data_type_numeric_precision(self) -> bool:
181
+ return False
182
+
183
+ def supports_cte_alias_columns(self) -> bool:
184
+ return False
185
+
186
+ def supports_data_type_numeric_scale(self) -> bool:
187
+ return False
188
+
189
+ def supports_data_type_datetime_precision(self) -> bool:
190
+ return False
191
+
192
+ def sql_expr_timestamp_literal(self, datetime_in_iso8601: str) -> str:
193
+ return f"timestamp('{datetime_in_iso8601}')"
194
+
195
+ def sql_expr_timestamp_truncate_day(self, timestamp_literal: str) -> str:
196
+ return f"date_trunc(timestamp({timestamp_literal}), day)"
197
+
198
+ def sql_expr_timestamp_add_day(self, timestamp_literal: str) -> str:
199
+ return f"{timestamp_literal} + interval 1 day"
200
+
201
+ def literal_string(self, value: str) -> Optional[str]:
202
+ if value is None:
203
+ return None
204
+ return "'''" + self.escape_string(value) + "'''"
205
+
206
+ def build_cte_values_sql(self, values: VALUES, alias_columns: list[COLUMN] | None) -> str:
207
+ # The first select row should have column aliases
208
+ # Remaining rows don't need aliases
209
+ def build_literal_with_alias(literal, alias: COLUMN | None) -> str:
210
+ return f"{self.literal(literal)} AS {self.quote_column(alias.name)}"
211
+
212
+ literal_rows: list[str] = []
213
+ for tuple in values.values:
214
+ if alias_columns:
215
+ literal_sqls: list[str] = []
216
+ for i in range(len(tuple.expressions)):
217
+ literal: LITERAL = tuple.expressions[i]
218
+ alias: COLUMN = alias_columns[i]
219
+ literal_sqls.append(build_literal_with_alias(literal=literal, alias=alias))
220
+ literal_rows.append(", ".join(literal_sql for literal_sql in literal_sqls))
221
+ alias_columns = None
222
+ else:
223
+ literal_rows.append(", ".join(self.build_expression_sql(e) for e in tuple.expressions))
224
+
225
+ select_rows: list[str] = [f"SELECT {literal_row}" for literal_row in literal_rows]
226
+
227
+ return "\nUNION ALL ".join([select_row for select_row in select_rows])
228
+
229
+ def _build_cte_with_sql_line(self, with_element: WITH) -> str:
230
+ return f"WITH {self.quote_default(with_element.alias)} AS ("
231
+
232
+ def get_max_sql_statement_length(self) -> int:
233
+ # This is a VERY conservative limit. We can probably get away with more
234
+ # However, BigQuery started complaining that the statement was "too complicated" with a length of 1MB.
235
+ return 200 * 1024 # 200KB
236
+
237
+ def get_preferred_number_of_rows_for_insert(self) -> int:
238
+ return 500
239
+
240
+ def _build_concat_ws_sql(self, concat_ws: CONCAT_WS) -> str:
241
+ elements: str = f", '{concat_ws.separator}', ".join(self.build_expression_sql(e) for e in concat_ws.expressions)
242
+ return f"CONCAT({elements})"
243
+
244
+ def _build_string_hash_sql(self, string_hash: STRING_HASH) -> str:
245
+ return f"to_hex({super()._build_string_hash_sql(string_hash)})"
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from abc import ABC
6
+ from typing import Literal, Optional, Union
7
+
8
+ from google.api_core.client_info import ClientInfo
9
+ from google.auth import default, impersonated_credentials
10
+ from google.cloud import bigquery
11
+ from google.cloud.bigquery import dbapi
12
+ from google.cloud.bigquery.table import Row
13
+ from google.oauth2.service_account import Credentials
14
+ from pydantic import Field, SecretStr
15
+ from soda_core.common.data_source_connection import DataSourceConnection
16
+ from soda_core.common.logging_constants import soda_logger
17
+ from soda_core.model.data_source.data_source import DataSourceBase
18
+ from soda_core.model.data_source.data_source_connection_properties import (
19
+ DataSourceConnectionProperties,
20
+ )
21
+
22
+ logger: logging.Logger = soda_logger
23
+
24
+
25
+ CONTEXT_AUTHENTICATION_DESCRIPTION = "Use context authentication"
26
+
27
+
28
+ class BigQueryConnectionProperties(DataSourceConnectionProperties, ABC):
29
+ project_id: Optional[str] = Field(None, description="BigQuery project ID")
30
+ storage_project_id: Optional[str] = Field(None, description="BigQuery storage project ID")
31
+ location: Optional[str] = Field(None, description="BigQuery location")
32
+ client_options: Optional[dict] = Field(None, description="Client options")
33
+ labels: Optional[dict] = Field({}, description="Labels")
34
+ auth_scopes: Optional[list[str]] = Field(
35
+ [
36
+ "https://www.googleapis.com/auth/bigquery",
37
+ "https://www.googleapis.com/auth/cloud-platform",
38
+ "https://www.googleapis.com/auth/drive",
39
+ ],
40
+ description="Authentication scopes",
41
+ )
42
+ impersonation_account: Optional[str] = Field(None, description="Impersonation account")
43
+ delegates: Optional[list[str]] = Field(None, description="Delegates")
44
+
45
+
46
+ class BigQueryJSONStringAuth(BigQueryConnectionProperties):
47
+ """BigQuery authentication using JSON string"""
48
+
49
+ use_context_auth: Optional[Literal[False]] = Field(False, description=CONTEXT_AUTHENTICATION_DESCRIPTION)
50
+ account_info_json: SecretStr = Field(..., description="Service account JSON as string", min_length=1)
51
+
52
+
53
+ class BigQueryJSONFileAuth(BigQueryConnectionProperties):
54
+ """BigQuery authentication using JSON file path"""
55
+
56
+ use_context_auth: Optional[Literal[False]] = Field(False, description=CONTEXT_AUTHENTICATION_DESCRIPTION)
57
+ account_info_json_path: str = Field(..., description="Path to service account JSON file", min_length=1)
58
+
59
+
60
+ class BigQueryContextAuth(BigQueryConnectionProperties):
61
+ """BigQuery authentication using context.
62
+
63
+ If use_context_auth is True, then application default credentials will be used.
64
+ The user may optionally provide JSON credentials; they will be ignored.
65
+ """
66
+
67
+ use_context_auth: Literal[True] = Field(description=CONTEXT_AUTHENTICATION_DESCRIPTION)
68
+
69
+
70
+ class BigQueryDataSource(DataSourceBase, ABC):
71
+ type: Literal["bigquery"] = Field("bigquery")
72
+
73
+ connection_properties: Union[
74
+ BigQueryJSONStringAuth,
75
+ BigQueryJSONFileAuth,
76
+ BigQueryContextAuth,
77
+ ] = Field(..., alias="connection", description="BigQuery connection configuration")
78
+
79
+
80
+ class BigQueryDataSourceConnection(DataSourceConnection):
81
+ def __init__(self, name: str, connection_properties: DataSourceConnectionProperties):
82
+ super().__init__(name, connection_properties)
83
+
84
+ def _load_project_id_and_credentials(self, config: BigQueryConnectionProperties):
85
+ if isinstance(config, BigQueryContextAuth):
86
+ logger.info("Using application default credentials.")
87
+ self.credentials, self.project_id = default()
88
+ return
89
+
90
+ if isinstance(config, BigQueryJSONFileAuth):
91
+ account_info_dict = json.load(open(config.account_info_json_path))
92
+ elif isinstance(config, BigQueryJSONStringAuth):
93
+ account_info_dict = json.loads(config.account_info_json.get_secret_value())
94
+ self.credentials = Credentials.from_service_account_info(
95
+ account_info_dict,
96
+ scopes=config.auth_scopes,
97
+ )
98
+ self.project_id = account_info_dict.get("project_id")
99
+
100
+ def _load_optional_impersonated_credentials(self, config: BigQueryConnectionProperties):
101
+ if config.impersonation_account:
102
+ logger.info("Using impersonation of Service Account.")
103
+ if config.delegates:
104
+ logger.info("Using Service Account delegates.")
105
+ delegates = config.delegates
106
+ else:
107
+ delegates = None
108
+ self.credentials = impersonated_credentials.Credentials(
109
+ source_credentials=self.credentials,
110
+ target_principal=str(config.impersonation_account),
111
+ target_scopes=config.auth_scopes,
112
+ delegates=delegates,
113
+ )
114
+
115
+ def _apply_optional_params(self, config: BigQueryConnectionProperties):
116
+ # Users can optionally overwrite in the connection properties
117
+ self.project_id = config.project_id if config.project_id else self.project_id
118
+ self.location = config.location
119
+ self.client_options = config.client_options
120
+
121
+ # Storage project ID is currently not used, because the project is configured via the DQN in the data contract.
122
+ # When we implement discovery, we'll need to use this value.
123
+ self.storage_project_id = config.storage_project_id if config.storage_project_id else self.project_id
124
+
125
+ self.labels = config.labels
126
+
127
+ def _create_connection(
128
+ self,
129
+ config: BigQueryConnectionProperties,
130
+ ):
131
+ self._load_project_id_and_credentials(config)
132
+ self._load_optional_impersonated_credentials(config)
133
+ self._apply_optional_params(config)
134
+
135
+ client_info = ClientInfo(
136
+ user_agent="soda-core",
137
+ )
138
+ default_query_job_config = bigquery.QueryJobConfig(labels=self.labels)
139
+ self.client = bigquery.Client(
140
+ project=self.project_id,
141
+ credentials=self.credentials,
142
+ default_query_job_config=default_query_job_config,
143
+ client_info=client_info,
144
+ location=config.location,
145
+ client_options=self.client_options,
146
+ )
147
+
148
+ return dbapi.Connection(self.client)
149
+
150
+ def format_rows(self, rows: list[Row]) -> list[tuple]:
151
+ formatted_rows = [tuple(r.values()) for r in rows]
152
+ return formatted_rows
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ from helpers.data_source_test_helper import DataSourceTestHelper
7
+
8
+
9
+ class BigQueryDataSourceTestHelper(DataSourceTestHelper):
10
+ def _create_database_name(self) -> str | None:
11
+ # Parse the dataset name from the account info json
12
+ account_info_json = json.loads(os.getenv("BIGQUERY_ACCOUNT_INFO_JSON", "{}"))
13
+ database_name = account_info_json.get("project_id", "soda-testing-dataset")
14
+ return database_name
15
+
16
+ def _create_data_source_yaml_str(self) -> str:
17
+ """
18
+ Called in _create_data_source_impl to initialized self.data_source_impl
19
+ self.database_name and self.schema_name are available if appropriate for the data source type
20
+ """
21
+ return f"""
22
+ type: bigquery
23
+ name: {self.name}
24
+ connection:
25
+ account_info_json: '{os.getenv("BIGQUERY_ACCOUNT_INFO_JSON", "")}'
26
+
27
+ """
28
+ # location: '{os.getenv("BIGQUERY_LOCATION", "US")}'
29
+
30
+ def _get_contract_data_type_dict(self) -> dict[str, str]:
31
+ """
32
+ DataSourceTestHelpers can override this method as an easy way
33
+ to customize the get_schema_check_sql_type behavior
34
+ """
35
+ return self._get_create_table_sql_type_dict()
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: soda-bigquery
3
+ Version: 4.0.5
4
+ Requires-Dist: soda-core==4.0.5
5
+ Requires-Dist: google-cloud-bigquery>=2.25.0
6
+ Dynamic: requires-dist
@@ -0,0 +1,10 @@
1
+ setup.py
2
+ src/soda_bigquery.egg-info/PKG-INFO
3
+ src/soda_bigquery.egg-info/SOURCES.txt
4
+ src/soda_bigquery.egg-info/dependency_links.txt
5
+ src/soda_bigquery.egg-info/entry_points.txt
6
+ src/soda_bigquery.egg-info/requires.txt
7
+ src/soda_bigquery.egg-info/top_level.txt
8
+ src/soda_bigquery/common/data_sources/bigquery_data_source.py
9
+ src/soda_bigquery/common/data_sources/bigquery_data_source_connection.py
10
+ src/soda_bigquery/test_helpers/bigquery_data_source_test_helper.py
@@ -0,0 +1,2 @@
1
+ [soda.plugins.data_source.bigquery]
2
+ BigqueryDataSourceImpl = soda_bigquery.common.data_sources.bigquery_data_source:BigQueryDataSourceImpl
@@ -0,0 +1,2 @@
1
+ soda-core==4.0.5
2
+ google-cloud-bigquery>=2.25.0
@@ -0,0 +1 @@
1
+ soda_bigquery