soda-redshift 4.0.5__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.
@@ -0,0 +1,199 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ from soda_core.common.data_source_connection import DataSourceConnection
5
+ from soda_core.common.data_source_impl import DataSourceImpl
6
+ from soda_core.common.logging_constants import soda_logger
7
+ from soda_core.common.metadata_types import DataSourceNamespace, SodaDataTypeName
8
+ from soda_core.common.sql_ast import (
9
+ COLUMN,
10
+ CONCAT,
11
+ CONCAT_WS,
12
+ COUNT,
13
+ DISTINCT,
14
+ FROM,
15
+ REGEX_LIKE,
16
+ TUPLE,
17
+ VALUES,
18
+ )
19
+ from soda_core.common.sql_dialect import SqlDialect
20
+ from soda_core.common.statements.metadata_tables_query import MetadataTablesQuery
21
+ from soda_redshift.common.data_sources.redshift_data_source_connection import (
22
+ RedshiftDataSource as RedshiftDataSourceModel,
23
+ )
24
+ from soda_redshift.common.data_sources.redshift_data_source_connection import (
25
+ RedshiftDataSourceConnection,
26
+ )
27
+ from soda_redshift.statements.redshift_metadata_tables_query import (
28
+ RedshiftMetadataTablesQuery,
29
+ )
30
+
31
+ logger: logging.Logger = soda_logger
32
+
33
+
34
+ REDSHIFT_DOUBLE_PRECISION = "double precision"
35
+ REDSHIFT_CHARACTER_VARYING = "character varying"
36
+
37
+
38
+ class RedshiftDataSourceImpl(DataSourceImpl, model_class=RedshiftDataSourceModel):
39
+ def __init__(self, data_source_model: RedshiftDataSourceModel, connection: Optional[DataSourceConnection] = None):
40
+ super().__init__(data_source_model=data_source_model, connection=connection)
41
+
42
+ def _create_sql_dialect(self) -> SqlDialect:
43
+ return RedshiftSqlDialect(data_source_impl=self)
44
+
45
+ def _create_data_source_connection(self) -> DataSourceConnection:
46
+ return RedshiftDataSourceConnection(
47
+ name=self.data_source_model.name, connection_properties=self.data_source_model.connection_properties
48
+ )
49
+
50
+ def create_metadata_tables_query(self) -> MetadataTablesQuery:
51
+ return RedshiftMetadataTablesQuery(
52
+ sql_dialect=self.sql_dialect, data_source_connection=self.data_source_connection
53
+ )
54
+
55
+
56
+ class RedshiftSqlDialect(SqlDialect):
57
+ SODA_DATA_TYPE_SYNONYMS = (
58
+ (SodaDataTypeName.TEXT, SodaDataTypeName.VARCHAR),
59
+ (SodaDataTypeName.NUMERIC, SodaDataTypeName.DECIMAL),
60
+ )
61
+
62
+ def get_data_source_data_type_name_by_soda_data_type_names(self) -> dict:
63
+ return {
64
+ SodaDataTypeName.CHAR: "char",
65
+ SodaDataTypeName.VARCHAR: "varchar",
66
+ SodaDataTypeName.TEXT: "varchar", # Redshift treats text as varchar(max)
67
+ SodaDataTypeName.SMALLINT: "smallint",
68
+ SodaDataTypeName.INTEGER: "integer",
69
+ SodaDataTypeName.BIGINT: "bigint",
70
+ SodaDataTypeName.NUMERIC: "numeric",
71
+ SodaDataTypeName.DECIMAL: "decimal",
72
+ SodaDataTypeName.FLOAT: "real", # Redshift float = real (float4)
73
+ SodaDataTypeName.DOUBLE: REDSHIFT_DOUBLE_PRECISION, # Redshift uses double precision (float8)
74
+ SodaDataTypeName.TIMESTAMP: "timestamp",
75
+ SodaDataTypeName.TIMESTAMP_TZ: "timestamptz",
76
+ SodaDataTypeName.DATE: "date",
77
+ SodaDataTypeName.TIME: "time",
78
+ SodaDataTypeName.BOOLEAN: "boolean",
79
+ }
80
+
81
+ def get_soda_data_type_name_by_data_source_data_type_names(self) -> dict[str, SodaDataTypeName]:
82
+ return {
83
+ # Character types
84
+ "char": SodaDataTypeName.CHAR,
85
+ "character": SodaDataTypeName.CHAR,
86
+ "nchar": SodaDataTypeName.CHAR,
87
+ "varchar": SodaDataTypeName.VARCHAR,
88
+ REDSHIFT_CHARACTER_VARYING: SodaDataTypeName.VARCHAR,
89
+ "nvarchar": SodaDataTypeName.VARCHAR,
90
+ "text": SodaDataTypeName.TEXT, # synonym, stored as varchar(max)
91
+ # Integer types
92
+ "smallint": SodaDataTypeName.SMALLINT,
93
+ "int2": SodaDataTypeName.SMALLINT,
94
+ "integer": SodaDataTypeName.INTEGER,
95
+ "int": SodaDataTypeName.INTEGER,
96
+ "int4": SodaDataTypeName.INTEGER,
97
+ "bigint": SodaDataTypeName.BIGINT,
98
+ "int8": SodaDataTypeName.BIGINT,
99
+ # Exact numeric types
100
+ "numeric": SodaDataTypeName.NUMERIC,
101
+ "decimal": SodaDataTypeName.DECIMAL,
102
+ # Approximate numeric types
103
+ "real": SodaDataTypeName.FLOAT, # float4
104
+ "float4": SodaDataTypeName.FLOAT,
105
+ REDSHIFT_DOUBLE_PRECISION: SodaDataTypeName.DOUBLE, # float8
106
+ "float8": SodaDataTypeName.DOUBLE,
107
+ "float": SodaDataTypeName.DOUBLE, # synonym for float8
108
+ # Date/time types
109
+ "timestamp": SodaDataTypeName.TIMESTAMP,
110
+ "timestamp without time zone": SodaDataTypeName.TIMESTAMP,
111
+ "timestamptz": SodaDataTypeName.TIMESTAMP_TZ,
112
+ "timestamp with time zone": SodaDataTypeName.TIMESTAMP_TZ,
113
+ "date": SodaDataTypeName.DATE,
114
+ "time": SodaDataTypeName.TIME,
115
+ "time without time zone": SodaDataTypeName.TIME,
116
+ # Boolean type
117
+ "boolean": SodaDataTypeName.BOOLEAN,
118
+ "bool": SodaDataTypeName.BOOLEAN,
119
+ }
120
+
121
+ def _get_data_type_name_synonyms(self) -> list[list[str]]:
122
+ return [
123
+ ["varchar", REDSHIFT_CHARACTER_VARYING],
124
+ ["char", "character"],
125
+ ["smallint", "int2"],
126
+ ["integer", "int", "int4"],
127
+ ["bigint", "int8"],
128
+ ["real", "float4", "float"],
129
+ [REDSHIFT_DOUBLE_PRECISION, "float8"],
130
+ ["timestamp", "timestamp without time zone"],
131
+ ["time", "time without time zone"],
132
+ ]
133
+
134
+ def _build_regex_like_sql(self, matches: REGEX_LIKE) -> str:
135
+ expression: str = self.build_expression_sql(matches.expression)
136
+ return f"{expression} ~ '{matches.regex_pattern}'"
137
+
138
+ def _build_tuple_sql(self, tuple: TUPLE) -> str:
139
+ if tuple.check_context(COUNT) and tuple.check_context(DISTINCT):
140
+ return self._build_tuple_sql_in_distinct(tuple)
141
+ if tuple.check_context(VALUES):
142
+ return f"{','.join(self.build_expression_sql(e) for e in tuple.expressions)}"
143
+ return f"{super()._build_tuple_sql(tuple)}"
144
+
145
+ def _build_tuple_sql_in_distinct(self, tuple: TUPLE) -> str:
146
+ """
147
+ Redshift does not support DISTINCT on tuples and has nothing like BigQuery's TO_JSON_STRING(STRUCT).
148
+
149
+ Instead we approximate TO_JSON_STRING(STRUCT) by concatting all the columns.
150
+ """
151
+
152
+ def format_element_expression(e: str) -> str:
153
+ """Use CHR(31) (unit seperator) as delimiter because it is highly unlikely to be appear in the data.
154
+ If it does appear, replace with string '#US#'. Also replace NULL with a string value to prevent
155
+ cascading NULLS in the concat operation."""
156
+ return f"REPLACE(COALESCE(CAST({e} AS VARCHAR), '__UNDEF__'), CHR(31), '#US#')"
157
+
158
+ concat_delim = " || CHR(31) || \n" # use ASCII unit separator as delimieter
159
+ elements: str = concat_delim.join(
160
+ format_element_expression(self.build_expression_sql(e)) for e in tuple.expressions
161
+ )
162
+ # Use FNV_HASH to convert the string rep into a hash value with a fixed length, will be more performant in COUNT DISTINCT
163
+ return f"FNV_HASH({elements})"
164
+
165
+ def build_cte_values_sql(self, values: VALUES, alias_columns: list[COLUMN] | None) -> str:
166
+ return "\nUNION ALL\n".join(["SELECT " + self.build_expression_sql(value) for value in values.values])
167
+
168
+ def data_type_has_parameter_character_maximum_length(self, data_type_name) -> bool:
169
+ return data_type_name.lower() in ["varchar", "char", REDSHIFT_CHARACTER_VARYING, "character"]
170
+
171
+ def data_type_has_parameter_numeric_precision(self, data_type_name) -> bool:
172
+ return data_type_name.lower() in ["numeric", "number", "decimal"]
173
+
174
+ def data_type_has_parameter_numeric_scale(self, data_type_name) -> bool:
175
+ return data_type_name.lower() in ["numeric", "number", "decimal"]
176
+
177
+ def data_type_has_parameter_datetime_precision(self, data_type_name) -> bool:
178
+ return False
179
+
180
+ def supports_data_type_datetime_precision(self) -> bool:
181
+ return False
182
+
183
+ def _build_concat_ws_sql(self, concat_ws: CONCAT_WS) -> str:
184
+ return f" || '{concat_ws.separator}' || ".join(self.build_expression_sql(e) for e in concat_ws.expressions)
185
+
186
+ def _build_concat_sql(self, concat: CONCAT) -> str:
187
+ return f" || ".join(self.build_expression_sql(e) for e in concat.expressions)
188
+
189
+ def supports_materialized_views(self) -> bool:
190
+ return True
191
+
192
+ def table_columns(self) -> str:
193
+ return self.default_casify("svv_columns")
194
+
195
+ def build_columns_metadata_from_clause(self, table_namespace: DataSourceNamespace) -> FROM:
196
+ return FROM(self.table_columns()).IN(self._pg_catalog_schema())
197
+
198
+ def _pg_catalog_schema(self) -> str:
199
+ return "pg_catalog"
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from ipaddress import IPv4Address, IPv6Address
5
+ from typing import Literal, Optional, Union
6
+
7
+ import boto3
8
+ import psycopg2
9
+ from pydantic import Field, IPvAnyAddress, SecretStr
10
+ from soda_core.common.aws_credentials import AwsCredentials
11
+ from soda_core.common.data_source_connection import DataSourceConnection
12
+ from soda_core.model.data_source.data_source import DataSourceBase
13
+ from soda_core.model.data_source.data_source_connection_properties import (
14
+ DataSourceConnectionProperties,
15
+ )
16
+
17
+
18
+ class RedshiftConnectionProperties(DataSourceConnectionProperties):
19
+ user: str = Field(..., description="Database username")
20
+ host: Union[str, IPvAnyAddress] = Field(..., description="Database host (hostname or IP address)")
21
+ port: Optional[int] = Field(5439, description="Database port (1-65535)", ge=1, le=65535)
22
+ database: str = Field(..., description="Database name", min_length=1, max_length=63)
23
+ connect_timeout: Optional[int] = Field(None, description="Connection timeout")
24
+ keepalives_idle: Optional[int] = Field(None, description="Keepalives idle")
25
+ keepalives_interval: Optional[int] = Field(None, description="Keepalives interval")
26
+ keepalives_count: Optional[int] = Field(None, description="Keepalives count")
27
+
28
+
29
+ class RedshiftUserPassConnection(RedshiftConnectionProperties):
30
+ password: SecretStr = Field(..., description="Database password")
31
+
32
+
33
+ class RedshiftKeyConnection(RedshiftConnectionProperties):
34
+ access_key_id: str = Field(..., description="AWS access key ID")
35
+ secret_access_key: SecretStr = Field(..., description="AWS secret access key")
36
+ session_token: Optional[str] = Field(None, description="AWS session token")
37
+ role_arn: Optional[str] = Field(None, description="AWS role ARN")
38
+ region: Optional[str] = Field("eu-west-1", description="AWS region")
39
+ profile_name: Optional[str] = Field(None, description="AWS profile name")
40
+ cluster_identifier: Optional[str] = Field(None, description="Redshift cluster identifier")
41
+
42
+
43
+ class RedshiftDataSource(DataSourceBase, ABC):
44
+ type: Literal["redshift"] = Field("redshift")
45
+
46
+ connection_properties: Union[
47
+ RedshiftUserPassConnection,
48
+ RedshiftKeyConnection,
49
+ ] = Field(..., alias="connection", description="Redshift connection configuration")
50
+
51
+
52
+ class RedshiftDataSourceConnection(DataSourceConnection):
53
+ def __init__(self, name: str, connection_properties: DataSourceConnectionProperties):
54
+ super().__init__(name, connection_properties)
55
+
56
+ def _extract_cluster_identifier(self):
57
+ if isinstance(self.host, (IPv4Address, IPv6Address)):
58
+ raise ValueError("Cluster identifier is required when using an IP address as host")
59
+ # strip protocol if present
60
+ host = self.host.split("://")[1] if "://" in self.host else self.host
61
+ return host.split(".")[0]
62
+
63
+ def _get_cluster_credentials(self, aws_credentials: AwsCredentials, cluster_identifier: Optional[str] = None):
64
+ resolved_aws_credentials = aws_credentials.resolve_role(
65
+ role_session_name="soda_redshift_get_cluster_credentials"
66
+ )
67
+
68
+ client = boto3.client(
69
+ "redshift",
70
+ region_name=resolved_aws_credentials.region_name,
71
+ aws_access_key_id=resolved_aws_credentials.access_key_id,
72
+ aws_secret_access_key=resolved_aws_credentials.secret_access_key,
73
+ aws_session_token=resolved_aws_credentials.session_token,
74
+ )
75
+
76
+ cluster_identifier = self._extract_cluster_identifier() if not cluster_identifier else cluster_identifier
77
+
78
+ user = self.user
79
+ db_name = self.database
80
+ # Note user is used here to get database credentials, therefore it's required even if AWS creds are provided
81
+ cluster_creds = client.get_cluster_credentials(
82
+ DbUser=user, DbName=db_name, ClusterIdentifier=cluster_identifier, AutoCreate=False, DurationSeconds=3600
83
+ )
84
+
85
+ return cluster_creds["DbUser"], cluster_creds["DbPassword"]
86
+
87
+ def _load_params(self, config: RedshiftConnectionProperties):
88
+ self.user = config.user
89
+ self.host = config.host
90
+ self.port = config.port
91
+ self.database = config.database
92
+ self.connect_timeout = config.connect_timeout
93
+
94
+ self.keepalives_params = {}
95
+ if config.keepalives_idle:
96
+ self.keepalives_params["keepalives_idle"] = config.keepalives_idle
97
+ if config.keepalives_interval:
98
+ self.keepalives_params["keepalives_interval"] = config.keepalives_interval
99
+ if config.keepalives_count:
100
+ self.keepalives_params["keepalives_count"] = config.keepalives_count
101
+
102
+ def _create_connection(
103
+ self,
104
+ config: RedshiftConnectionProperties,
105
+ ):
106
+ self._load_params(config)
107
+
108
+ if isinstance(config, RedshiftUserPassConnection):
109
+ self.password = config.password.get_secret_value()
110
+ elif isinstance(config, RedshiftKeyConnection):
111
+ aws_credentials = AwsCredentials(
112
+ access_key_id=config.access_key_id,
113
+ secret_access_key=config.secret_access_key.get_secret_value(),
114
+ role_arn=config.role_arn,
115
+ session_token=config.session_token,
116
+ region_name=config.region,
117
+ profile_name=config.profile_name,
118
+ )
119
+ self.user, self.password = self._get_cluster_credentials(aws_credentials, config.cluster_identifier)
120
+
121
+ # Redshift is case-insensitive by default unless explicitly enabled.
122
+ # It's possible customers may have enabled case-sensitivity in their databases, therefore we enable that in
123
+ # to support databases configured that way.
124
+ options = "-c enable_case_sensitive_identifier=on"
125
+
126
+ conn = psycopg2.connect(
127
+ user=self.user,
128
+ password=self.password,
129
+ host=self.host,
130
+ port=self.port,
131
+ connect_timeout=self.connect_timeout,
132
+ database=self.database,
133
+ options=options,
134
+ **self.keepalives_params,
135
+ )
136
+ conn.autocommit = True
137
+ return conn
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from soda_core.common.data_source_results import QueryResult
7
+ from soda_core.common.logging_constants import soda_logger
8
+ from soda_core.common.sql_ast import (
9
+ COLUMN,
10
+ EQ,
11
+ FROM,
12
+ LIKE,
13
+ LITERAL,
14
+ LOWER,
15
+ NOT_LIKE,
16
+ OR,
17
+ RAW_SQL,
18
+ SELECT,
19
+ UNION_ALL,
20
+ WHERE,
21
+ )
22
+ from soda_core.common.statements.metadata_tables_query import MetadataTablesQuery
23
+ from soda_core.common.statements.table_types import (
24
+ FullyQualifiedMaterializedViewName,
25
+ FullyQualifiedObjectName,
26
+ FullyQualifiedViewName,
27
+ TableType,
28
+ )
29
+
30
+ logger: logging.Logger = soda_logger
31
+
32
+
33
+ class RedshiftMetadataTablesQuery(MetadataTablesQuery):
34
+ def execute(
35
+ self,
36
+ database_name: Optional[str] = None,
37
+ schema_name: Optional[str] = None,
38
+ include_table_name_like_filters: Optional[list[str]] = None,
39
+ exclude_table_name_like_filters: Optional[list[str]] = None,
40
+ types_to_return: Optional[
41
+ list[TableType]
42
+ ] = None, # To make sure it's backwards compatible with the old behavior, when we use None it should default to [TableType.TABLE]
43
+ ) -> list[FullyQualifiedObjectName]:
44
+ if types_to_return is None:
45
+ types_to_return = [TableType.TABLE]
46
+ select_statement: UNION_ALL = self.build_sql_statement(
47
+ database_name=database_name,
48
+ schema_name=schema_name,
49
+ include_table_name_like_filters=include_table_name_like_filters,
50
+ exclude_table_name_like_filters=exclude_table_name_like_filters,
51
+ )
52
+ sql: str = self.sql_dialect.build_union_sql(select_statement)
53
+ query_result: QueryResult = self.data_source_connection.execute_query(sql)
54
+ return self.get_results(query_result, types_to_return)
55
+
56
+ def build_sql_statement(
57
+ self,
58
+ database_name: Optional[str] = None,
59
+ schema_name: Optional[str] = None,
60
+ include_table_name_like_filters: Optional[list[str]] = None,
61
+ exclude_table_name_like_filters: Optional[list[str]] = None,
62
+ ) -> UNION_ALL:
63
+ """
64
+ Builds the full SQL query statement to query table names from the data source metadata.
65
+
66
+ Redshift specific implementation that queries both regular tables and materialized views.
67
+ svv_tables does include materialized views, but does not indicate them as such, only as 'VIEW' in the table_type column.
68
+ Therefore, we need to query svv_mv_info separately to get the materialized views to be consistent with other datasources that support materialized views.
69
+ """
70
+ table_select = [
71
+ SELECT(
72
+ [
73
+ COLUMN("table_catalog"),
74
+ COLUMN("table_schema"),
75
+ COLUMN("table_name"),
76
+ COLUMN("table_type"),
77
+ ]
78
+ ),
79
+ FROM("svv_tables"),
80
+ ]
81
+ matview_select = [
82
+ SELECT(
83
+ [
84
+ COLUMN("database_name", field_alias="table_catalog"),
85
+ COLUMN("schema_name", field_alias="table_schema"),
86
+ COLUMN("name", field_alias="table_name"),
87
+ RAW_SQL("'MATERIALIZED VIEW' AS TABLE_TYPE"),
88
+ ]
89
+ ),
90
+ FROM("svv_mv_info"),
91
+ ]
92
+
93
+ statement = UNION_ALL([table_select, matview_select])
94
+
95
+ if database_name:
96
+ database_name_lower: str = database_name.lower()
97
+ table_select.append(WHERE(EQ(LOWER("table_catalog"), LITERAL(database_name_lower))))
98
+ matview_select.append(WHERE(EQ(LOWER("database_name"), LITERAL(database_name_lower))))
99
+
100
+ if schema_name:
101
+ table_select.append(WHERE(EQ(LOWER("table_schema"), LITERAL(schema_name.lower()))))
102
+ matview_select.append(WHERE(EQ(LOWER("schema_name"), LITERAL(schema_name.lower()))))
103
+
104
+ if include_table_name_like_filters:
105
+ table_select.append(
106
+ WHERE(
107
+ OR(
108
+ [
109
+ LIKE(LOWER(COLUMN("table_name")), LITERAL(include_table_name_like_filter.lower()))
110
+ for include_table_name_like_filter in include_table_name_like_filters
111
+ ]
112
+ )
113
+ )
114
+ )
115
+ matview_select.append(
116
+ WHERE(
117
+ OR(
118
+ [
119
+ LIKE(LOWER(COLUMN("name")), LITERAL(include_table_name_like_filter.lower()))
120
+ for include_table_name_like_filter in include_table_name_like_filters
121
+ ]
122
+ )
123
+ )
124
+ )
125
+
126
+ if exclude_table_name_like_filters:
127
+ for exclude_table_name_like_filter in exclude_table_name_like_filters:
128
+ table_select.append(
129
+ WHERE(NOT_LIKE(LOWER(COLUMN("table_name")), LITERAL(exclude_table_name_like_filter.lower())))
130
+ )
131
+ matview_select.append(
132
+ WHERE(NOT_LIKE(LOWER(COLUMN("name")), LITERAL(exclude_table_name_like_filter.lower())))
133
+ )
134
+
135
+ return statement
136
+
137
+ def get_results(
138
+ self, query_result: QueryResult, types_to_return: list[TableType]
139
+ ) -> list[FullyQualifiedObjectName]:
140
+ result = super().get_results(query_result, types_to_return)
141
+ filtered = []
142
+ # Redshift represents materialized views as 'VIEW' in the table_type column in svv_tables,
143
+ # so we need to remove them from the result if corresponding materialized view is present.
144
+ # Populate the filtered list with materialized views first, then add the rest if not already present.
145
+ materialized_view_names = {
146
+ (obj.database_name, obj.schema_name, obj.get_object_name())
147
+ for obj in result
148
+ if isinstance(obj, FullyQualifiedMaterializedViewName)
149
+ }
150
+ for obj in result:
151
+ if isinstance(obj, FullyQualifiedMaterializedViewName):
152
+ filtered.append(obj)
153
+ elif isinstance(obj, FullyQualifiedViewName):
154
+ if (obj.database_name, obj.schema_name, obj.get_object_name()) not in materialized_view_names:
155
+ filtered.append(obj)
156
+ else:
157
+ logger.debug(
158
+ f"Excluding view {obj.get_object_name()} in schema {obj.schema_name} from results as a materialized view with the same name exists."
159
+ )
160
+ else:
161
+ filtered.append(obj)
162
+ return filtered
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ REDSHIFT_HOST = os.getenv("REDSHIFT_HOST", "")
6
+ REDSHIFT_PORT = os.getenv("REDSHIFT_PORT", None)
7
+ REDSHIFT_DATABASE = os.getenv("REDSHIFT_DATABASE", "soda_test")
8
+ REDSHIFT_USERNAME = os.getenv("REDSHIFT_USERNAME", "")
9
+ REDSHIFT_PASSWORD = os.getenv("REDSHIFT_PASSWORD", "")
10
+
11
+ from helpers.data_source_test_helper import DataSourceTestHelper
12
+
13
+
14
+ class RedshiftDataSourceTestHelper(DataSourceTestHelper):
15
+ def _create_database_name(self) -> str | None:
16
+ return os.getenv("REDSHIFT_DATABASE", "soda_test")
17
+
18
+ def _create_data_source_yaml_str(self) -> str:
19
+ """
20
+ Called in _create_data_source_impl to initialized self.data_source_impl
21
+ self.database_name and self.schema_name are available if appropriate for the data source type
22
+ """
23
+ return f"""
24
+ type: redshift
25
+ name: {self.name}
26
+ connection:
27
+ host: '{REDSHIFT_HOST}'
28
+ port: {REDSHIFT_PORT}
29
+ database: '{REDSHIFT_DATABASE}'
30
+ user: '{REDSHIFT_USERNAME}'
31
+ password: '{REDSHIFT_PASSWORD}'
32
+ """
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: soda-redshift
3
+ Version: 4.0.5
4
+ Requires-Dist: soda-core==4.0.5
5
+ Requires-Dist: psycopg2-binary<3.0,>=2.8.5
6
+ Requires-Dist: boto3
7
+ Dynamic: requires-dist
@@ -0,0 +1,9 @@
1
+ soda_redshift/common/data_sources/redshift_data_source.py,sha256=3759lHK33qLSXDuVmaJyhYssiPFC2Kr1y4zOeTCgxMM,8792
2
+ soda_redshift/common/data_sources/redshift_data_source_connection.py,sha256=SSn6ymYcmCOjhOFVfcECn1OqQh3y_dxY4mbJGvAIx-U,6155
3
+ soda_redshift/statements/redshift_metadata_tables_query.py,sha256=kiEL7zN6Glc8Uw3AqRsvgzikwj9KW6posOkBV7Dg1qU,6646
4
+ soda_redshift/test_helpers/redshift_data_source_test_helper.py,sha256=voNNQwI0q2SyQVVUANvzCIDpFiNwAb_FanwQwzadjb4,1142
5
+ soda_redshift-4.0.5.dist-info/METADATA,sha256=7tgFpJqiIVjPKIIh8d50Vm8gggL-fUOgZLcHdJVbRAc,176
6
+ soda_redshift-4.0.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
7
+ soda_redshift-4.0.5.dist-info/entry_points.txt,sha256=vCD53lIm78uSTfxwYSgO-_4Y-7B2JwLUjRyYut3hy6o,139
8
+ soda_redshift-4.0.5.dist-info/top_level.txt,sha256=Ig6iDH2X2DjhgCaIIVhDXwu6_V9fI8CPYNsUWAKb6aw,14
9
+ soda_redshift-4.0.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [soda.plugins.data_source.redshift]
2
+ RedshiftDataSourceImpl = soda_redshift.common.data_sources.redshift_data_source:RedshiftDataSourceImpl
@@ -0,0 +1 @@
1
+ soda_redshift