sql-testing-library 0.19.0__tar.gz → 0.21.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.
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/CHANGELOG.md +19 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/PKG-INFO +135 -18
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/README.md +134 -17
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/pyproject.toml +1 -1
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/__init__.py +3 -2
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/duckdb.py +112 -112
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/redshift.py +35 -13
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_mock_table.py +91 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_utils.py +91 -12
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/LICENSE +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/__init__.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/athena.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/base.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/bigquery.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/presto.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/snowflake.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/trino.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_core.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_exceptions.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_pytest_plugin.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_logger.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_types.py +0 -0
- {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/py.typed +0 -0
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## 0.21.0 (2026-01-03)
|
|
9
|
+
|
|
10
|
+
### Feat
|
|
11
|
+
|
|
12
|
+
- enable Redshift nested element access tests (#140)
|
|
13
|
+
- add Redshift support for deeply nested complex types via SUPER (#139)
|
|
14
|
+
- add DuckDB support for deeply nested complex types (#138)
|
|
15
|
+
- add deeply nested complex types support for Athena and Trino (#136)
|
|
16
|
+
|
|
17
|
+
### Fix
|
|
18
|
+
|
|
19
|
+
- use GET_ARRAY_LENGTH for Redshift SUPER type arrays
|
|
20
|
+
|
|
21
|
+
## 0.20.0 (2025-12-07)
|
|
22
|
+
|
|
23
|
+
### Feat
|
|
24
|
+
|
|
25
|
+
- add BigQueryMockTable class for explicit three-part naming (#134)
|
|
26
|
+
|
|
8
27
|
## 0.19.0 (2025-12-07)
|
|
9
28
|
|
|
10
29
|
### Feat
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-testing-library
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.0
|
|
4
4
|
Summary: SQL Testing Framework for Python: Unit test SQL queries with mock data injection for BigQuery, Snowflake, Redshift, Athena, Trino, and DuckDB. Simplify data engineering ETL testing and analytics validation.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -117,7 +117,7 @@ For more details on our journey and the engineering challenges we solved, read t
|
|
|
117
117
|
- **CTE or Physical Tables**: Automatic fallback for query size limits
|
|
118
118
|
- **Type-Safe Results**: Deserialize results to Pydantic models
|
|
119
119
|
- **Pytest Integration**: Seamless testing with `@sql_test` decorator
|
|
120
|
-
- **SQL Logging**: Comprehensive SQL logging with formatted output, error traces, and temp table queries
|
|
120
|
+
- **SQL Logging**: Comprehensive SQL logging with formatted output, error traces, and temp table queries. Automatically logs SQL on SQL-level failures (syntax errors, table not found). For assertion failures or debugging, use `log_sql=True` or `SQL_TEST_LOG_ALL=true` environment variable
|
|
121
121
|
|
|
122
122
|
## Data Types Support
|
|
123
123
|
|
|
@@ -145,17 +145,20 @@ The library supports different data types across database engines. All checkmark
|
|
|
145
145
|
| **Decimal Array** | `List[Decimal]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
146
146
|
| **Optional Array** | `Optional[List[T]]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
147
147
|
| **Map/Dict** | `Dict[K, V]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
148
|
-
| **Struct/Record** | `dataclass` | ✅ | ✅ |
|
|
149
|
-
| **Nested Arrays** | `List[List[T]]` | ❌ |
|
|
148
|
+
| **Struct/Record** | `dataclass` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
|
149
|
+
| **Nested Arrays** | `List[List[T]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
150
|
+
| **Arrays of Structs** | `List[dataclass]` | ✅ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
151
|
+
| **3D Arrays** | `List[List[List[T]]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
152
|
+
| **Arrays of Arrays of Structs** | `List[List[dataclass]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
150
153
|
|
|
151
154
|
### Database-Specific Notes
|
|
152
155
|
|
|
153
|
-
- **BigQuery**: NULL arrays become empty arrays `[]`; uses scientific notation for large decimals; dict/map types stored as JSON strings; struct types supported using `STRUCT` syntax with named fields (dataclasses and Pydantic models)
|
|
154
|
-
- **Athena**: 256KB query size limit; supports arrays and maps using `ARRAY[]` and `MAP(ARRAY[], ARRAY[])` syntax; supports struct types using `ROW` with named fields (dataclasses and Pydantic models)
|
|
155
|
-
- **Redshift**: Arrays and maps implemented via SUPER type (JSON parsing); 16MB query size limit; struct types
|
|
156
|
-
- **Trino**: Memory catalog for testing; excellent decimal precision; supports arrays, maps, and struct types using `ROW` with named fields (dataclasses and Pydantic models)
|
|
157
|
-
- **Snowflake**: Column names normalized to lowercase; 1MB query size limit; dict/map types implemented via VARIANT type (JSON parsing); struct types not yet supported
|
|
158
|
-
- **DuckDB**: Fast embedded analytics database; excellent SQL standards compliance; supports arrays, maps, and struct types using `STRUCT` syntax with named fields (dataclasses and Pydantic models)
|
|
156
|
+
- **BigQuery**: NULL arrays become empty arrays `[]`; uses scientific notation for large decimals; dict/map types stored as JSON strings; struct types supported using `STRUCT` syntax with named fields (dataclasses and Pydantic models); **does not support nested arrays** (database limitation)
|
|
157
|
+
- **Athena**: 256KB query size limit; supports arrays and maps using `ARRAY[]` and `MAP(ARRAY[], ARRAY[])` syntax; supports struct types using `ROW` with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays, arrays of structs, and 3D arrays
|
|
158
|
+
- **Redshift**: Arrays and maps implemented via SUPER type (JSON parsing); 16MB query size limit; **full support for deeply nested types** including struct types using SUPER + JSON_PARSE, nested arrays (2D, 3D+), arrays of structs, and arrays of arrays of structs
|
|
159
|
+
- **Trino**: Memory catalog for testing; excellent decimal precision; supports arrays, maps, and struct types using `ROW` with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays, arrays of structs, and 3D arrays
|
|
160
|
+
- **Snowflake**: Column names normalized to lowercase; 1MB query size limit; dict/map types implemented via VARIANT type (JSON parsing); struct types not yet supported (🚧 TODO); nested arrays not yet supported (🚧 TODO)
|
|
161
|
+
- **DuckDB**: Fast embedded analytics database; excellent SQL standards compliance; supports arrays, maps, and struct types using `STRUCT` syntax with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays (2D, 3D+), arrays of structs, and arrays of arrays of structs
|
|
159
162
|
|
|
160
163
|
## Execution Modes Support
|
|
161
164
|
|
|
@@ -570,8 +573,16 @@ TestCase(
|
|
|
570
573
|
#### Example Mock Table Implementations:
|
|
571
574
|
|
|
572
575
|
```python
|
|
573
|
-
# BigQuery Mock Table
|
|
574
|
-
|
|
576
|
+
# BigQuery Mock Table (Recommended: Use BigQueryMockTable for clearer three-part naming)
|
|
577
|
+
from sql_testing_library import BigQueryMockTable
|
|
578
|
+
|
|
579
|
+
class UsersMockTable(BigQueryMockTable):
|
|
580
|
+
project_name = "test-project"
|
|
581
|
+
dataset_name = "test_dataset"
|
|
582
|
+
table_name = "users"
|
|
583
|
+
|
|
584
|
+
# BigQuery Mock Table (Alternative: Use BaseMockTable with combined project.dataset)
|
|
585
|
+
class UsersMockTableAlternative(BaseMockTable):
|
|
575
586
|
def get_database_name(self) -> str:
|
|
576
587
|
return "test-project.test_dataset" # project.dataset format
|
|
577
588
|
|
|
@@ -1047,6 +1058,7 @@ The adapter_type parameter will use the configuration from the corresponding sec
|
|
|
1047
1058
|
- Supports Google Cloud BigQuery service
|
|
1048
1059
|
- Uses UNION ALL pattern for CTE creation with complex data types
|
|
1049
1060
|
- Handles authentication via service account or application default credentials
|
|
1061
|
+
- **Special Feature**: `BigQueryMockTable` class for explicit three-part naming (project.dataset.table)
|
|
1050
1062
|
|
|
1051
1063
|
#### Athena Adapter
|
|
1052
1064
|
- Supports Amazon Athena service for querying data in S3
|
|
@@ -1085,6 +1097,94 @@ The adapter_type parameter will use the configuration from the corresponding sec
|
|
|
1085
1097
|
- No authentication required - perfect for local development and testing
|
|
1086
1098
|
- Excellent performance for analytical workloads
|
|
1087
1099
|
|
|
1100
|
+
### BigQuery-Specific: BigQueryMockTable
|
|
1101
|
+
|
|
1102
|
+
BigQuery uses a three-part naming scheme (`project.dataset.table`) which doesn't fit naturally into the two-part `database.table` model used by most databases. The `BigQueryMockTable` class provides explicit support for BigQuery's naming convention.
|
|
1103
|
+
|
|
1104
|
+
#### The Problem with BaseMockTable
|
|
1105
|
+
|
|
1106
|
+
Using `BaseMockTable` for BigQuery requires awkwardly cramming the project and dataset together:
|
|
1107
|
+
|
|
1108
|
+
```python
|
|
1109
|
+
# Awkward: Combines project.dataset into database_name
|
|
1110
|
+
class UsersMockTable(BaseMockTable):
|
|
1111
|
+
def get_database_name(self) -> str:
|
|
1112
|
+
return "test-project.test_dataset" # Confusing!
|
|
1113
|
+
|
|
1114
|
+
def get_table_name(self) -> str:
|
|
1115
|
+
return "users"
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
#### The Solution: BigQueryMockTable
|
|
1119
|
+
|
|
1120
|
+
`BigQueryMockTable` makes BigQuery's three-part naming explicit and clear:
|
|
1121
|
+
|
|
1122
|
+
```python
|
|
1123
|
+
from sql_testing_library import BigQueryMockTable
|
|
1124
|
+
|
|
1125
|
+
class UsersMockTable(BigQueryMockTable):
|
|
1126
|
+
project_name = "test-project"
|
|
1127
|
+
dataset_name = "test_dataset"
|
|
1128
|
+
table_name = "users"
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
#### Usage Examples
|
|
1132
|
+
|
|
1133
|
+
**Basic Usage:**
|
|
1134
|
+
```python
|
|
1135
|
+
from sql_testing_library import BigQueryMockTable
|
|
1136
|
+
|
|
1137
|
+
class UsersMockTable(BigQueryMockTable):
|
|
1138
|
+
project_name = "my-project"
|
|
1139
|
+
dataset_name = "analytics"
|
|
1140
|
+
table_name = "users"
|
|
1141
|
+
|
|
1142
|
+
class OrdersMockTable(BigQueryMockTable):
|
|
1143
|
+
project_name = "my-project"
|
|
1144
|
+
dataset_name = "analytics"
|
|
1145
|
+
table_name = "orders"
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
**Avoid Repetition with Inheritance:**
|
|
1149
|
+
```python
|
|
1150
|
+
# Base class for all tables in the same project
|
|
1151
|
+
class MyProjectTable(BigQueryMockTable):
|
|
1152
|
+
project_name = "my-project"
|
|
1153
|
+
|
|
1154
|
+
# Subclasses only specify dataset and table
|
|
1155
|
+
class UsersTable(MyProjectTable):
|
|
1156
|
+
dataset_name = "analytics"
|
|
1157
|
+
table_name = "users"
|
|
1158
|
+
|
|
1159
|
+
class OrdersTable(MyProjectTable):
|
|
1160
|
+
dataset_name = "analytics"
|
|
1161
|
+
table_name = "orders"
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
**Available Methods:**
|
|
1165
|
+
```python
|
|
1166
|
+
table = UsersMockTable([...])
|
|
1167
|
+
|
|
1168
|
+
# BigQuery-specific methods
|
|
1169
|
+
table.get_project_name() # "my-project"
|
|
1170
|
+
table.get_dataset_name() # "analytics"
|
|
1171
|
+
table.get_fully_qualified_name() # "my-project.analytics.users"
|
|
1172
|
+
|
|
1173
|
+
# Backwards compatible methods (from BaseMockTable)
|
|
1174
|
+
table.get_database_name() # "my-project.analytics"
|
|
1175
|
+
table.get_table_name() # "users"
|
|
1176
|
+
table.get_qualified_name() # "my-project.analytics.users"
|
|
1177
|
+
table.get_cte_alias() # "my_project_analytics__users"
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
**Benefits:**
|
|
1181
|
+
- ✅ **Clear Semantics**: Each BigQuery component is explicit
|
|
1182
|
+
- ✅ **No Confusion**: No more cramming project.dataset together
|
|
1183
|
+
- ✅ **Type Safe**: Full type hints and IDE autocomplete
|
|
1184
|
+
- ✅ **Backwards Compatible**: Still implements all `BaseMockTable` methods
|
|
1185
|
+
- ✅ **Simple**: Just 3 class variables to set
|
|
1186
|
+
- ✅ **Flexible**: Use inheritance to share common properties
|
|
1187
|
+
|
|
1088
1188
|
**Default Behavior:**
|
|
1089
1189
|
- If adapter_type is not specified in the TestCase or decorator, the library will use the adapter specified in the `[sql_testing]` section's `adapter` setting.
|
|
1090
1190
|
- If no adapter is specified in the `[sql_testing]` section, it defaults to "bigquery".
|
|
@@ -1291,7 +1391,7 @@ The library automatically:
|
|
|
1291
1391
|
- Injects mock data via CTEs or temp tables
|
|
1292
1392
|
- Deserializes results to typed Python objects
|
|
1293
1393
|
|
|
1294
|
-
For detailed usage and configuration options, see the
|
|
1394
|
+
For detailed usage and configuration options, see the [documentation](https://gurmeetsaran.github.io/sqltesting/).
|
|
1295
1395
|
|
|
1296
1396
|
## Integration with Mocksmith
|
|
1297
1397
|
|
|
@@ -1326,19 +1426,36 @@ class Customer:
|
|
|
1326
1426
|
customers = [Customer.mock() for _ in range(100)]
|
|
1327
1427
|
```
|
|
1328
1428
|
|
|
1329
|
-
See the [Mocksmith Integration Guide](docs/mocksmith_integration.md)
|
|
1429
|
+
See the [Mocksmith Integration Guide](docs/mocksmith_integration.md) for detailed usage patterns.
|
|
1330
1430
|
|
|
1331
1431
|
## Known Limitations and TODOs
|
|
1332
1432
|
|
|
1333
1433
|
The library has a few known limitations that are planned to be addressed in future updates:
|
|
1334
1434
|
|
|
1335
|
-
###
|
|
1336
|
-
|
|
1337
|
-
|
|
1435
|
+
### Deeply Nested Complex Types Support
|
|
1436
|
+
|
|
1437
|
+
**✅ Fully Supported (Athena, Trino, DuckDB & Redshift):**
|
|
1438
|
+
- Nested arrays (2D, 3D+): `List[List[int]]`, `List[List[List[int]]]`
|
|
1439
|
+
- Arrays of structs: `List[Address]` where Address is a dataclass
|
|
1440
|
+
- Arrays of arrays of structs: `List[List[OrderItem]]`
|
|
1441
|
+
- Maps with complex values: `Dict[str, str]`, `Dict[str, int]`
|
|
1442
|
+
- See `tests/integration/test_deeply_nested_types_integration.py` for comprehensive examples
|
|
1443
|
+
- **16 tests passing** across Athena, Trino, DuckDB, and Redshift (both CTE and physical tables modes)
|
|
1444
|
+
|
|
1445
|
+
**Implementation Details:**
|
|
1446
|
+
- **Athena/Trino**: Use ROW types with named fields
|
|
1447
|
+
- **DuckDB**: Use STRUCT types with dictionary-style syntax
|
|
1448
|
+
- **Redshift**: Use SUPER type with JSON_PARSE for serialization, recursive JSON parsing for deserialization
|
|
1338
1449
|
|
|
1450
|
+
**🚧 TODO - Implementation Needed:**
|
|
1451
|
+
- **BigQuery**: Does not support nested arrays - this is a **database limitation**, not a library limitation. BigQuery's type system doesn't allow `ARRAY<ARRAY<T>>` constructs
|
|
1452
|
+
- **Snowflake**:
|
|
1453
|
+
- Struct type support not implemented (see `Struct type not yet supported for dialect: snowflake`)
|
|
1454
|
+
- Nested arrays not yet supported
|
|
1455
|
+
- TODO: Implement struct/nested type support using VARIANT/OBJECT types
|
|
1339
1456
|
|
|
1340
1457
|
### Database-Specific Limitations
|
|
1341
|
-
- **BigQuery**: Does not support nested arrays (arrays of arrays). This is a BigQuery database limitation, not a library limitation.
|
|
1458
|
+
- **BigQuery**: Does not support nested arrays (arrays of arrays). This is a BigQuery database limitation, not a library limitation.
|
|
1342
1459
|
|
|
1343
1460
|
### General Improvements
|
|
1344
1461
|
- Add support for more SQL dialects
|
|
@@ -57,7 +57,7 @@ For more details on our journey and the engineering challenges we solved, read t
|
|
|
57
57
|
- **CTE or Physical Tables**: Automatic fallback for query size limits
|
|
58
58
|
- **Type-Safe Results**: Deserialize results to Pydantic models
|
|
59
59
|
- **Pytest Integration**: Seamless testing with `@sql_test` decorator
|
|
60
|
-
- **SQL Logging**: Comprehensive SQL logging with formatted output, error traces, and temp table queries
|
|
60
|
+
- **SQL Logging**: Comprehensive SQL logging with formatted output, error traces, and temp table queries. Automatically logs SQL on SQL-level failures (syntax errors, table not found). For assertion failures or debugging, use `log_sql=True` or `SQL_TEST_LOG_ALL=true` environment variable
|
|
61
61
|
|
|
62
62
|
## Data Types Support
|
|
63
63
|
|
|
@@ -85,17 +85,20 @@ The library supports different data types across database engines. All checkmark
|
|
|
85
85
|
| **Decimal Array** | `List[Decimal]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
86
86
|
| **Optional Array** | `Optional[List[T]]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
87
87
|
| **Map/Dict** | `Dict[K, V]` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
88
|
-
| **Struct/Record** | `dataclass` | ✅ | ✅ |
|
|
89
|
-
| **Nested Arrays** | `List[List[T]]` | ❌ |
|
|
88
|
+
| **Struct/Record** | `dataclass` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
|
89
|
+
| **Nested Arrays** | `List[List[T]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
90
|
+
| **Arrays of Structs** | `List[dataclass]` | ✅ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
91
|
+
| **3D Arrays** | `List[List[List[T]]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
92
|
+
| **Arrays of Arrays of Structs** | `List[List[dataclass]]` | ❌ | ✅ | ✅ | ✅ | 🚧 | ✅ |
|
|
90
93
|
|
|
91
94
|
### Database-Specific Notes
|
|
92
95
|
|
|
93
|
-
- **BigQuery**: NULL arrays become empty arrays `[]`; uses scientific notation for large decimals; dict/map types stored as JSON strings; struct types supported using `STRUCT` syntax with named fields (dataclasses and Pydantic models)
|
|
94
|
-
- **Athena**: 256KB query size limit; supports arrays and maps using `ARRAY[]` and `MAP(ARRAY[], ARRAY[])` syntax; supports struct types using `ROW` with named fields (dataclasses and Pydantic models)
|
|
95
|
-
- **Redshift**: Arrays and maps implemented via SUPER type (JSON parsing); 16MB query size limit; struct types
|
|
96
|
-
- **Trino**: Memory catalog for testing; excellent decimal precision; supports arrays, maps, and struct types using `ROW` with named fields (dataclasses and Pydantic models)
|
|
97
|
-
- **Snowflake**: Column names normalized to lowercase; 1MB query size limit; dict/map types implemented via VARIANT type (JSON parsing); struct types not yet supported
|
|
98
|
-
- **DuckDB**: Fast embedded analytics database; excellent SQL standards compliance; supports arrays, maps, and struct types using `STRUCT` syntax with named fields (dataclasses and Pydantic models)
|
|
96
|
+
- **BigQuery**: NULL arrays become empty arrays `[]`; uses scientific notation for large decimals; dict/map types stored as JSON strings; struct types supported using `STRUCT` syntax with named fields (dataclasses and Pydantic models); **does not support nested arrays** (database limitation)
|
|
97
|
+
- **Athena**: 256KB query size limit; supports arrays and maps using `ARRAY[]` and `MAP(ARRAY[], ARRAY[])` syntax; supports struct types using `ROW` with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays, arrays of structs, and 3D arrays
|
|
98
|
+
- **Redshift**: Arrays and maps implemented via SUPER type (JSON parsing); 16MB query size limit; **full support for deeply nested types** including struct types using SUPER + JSON_PARSE, nested arrays (2D, 3D+), arrays of structs, and arrays of arrays of structs
|
|
99
|
+
- **Trino**: Memory catalog for testing; excellent decimal precision; supports arrays, maps, and struct types using `ROW` with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays, arrays of structs, and 3D arrays
|
|
100
|
+
- **Snowflake**: Column names normalized to lowercase; 1MB query size limit; dict/map types implemented via VARIANT type (JSON parsing); struct types not yet supported (🚧 TODO); nested arrays not yet supported (🚧 TODO)
|
|
101
|
+
- **DuckDB**: Fast embedded analytics database; excellent SQL standards compliance; supports arrays, maps, and struct types using `STRUCT` syntax with named fields (dataclasses and Pydantic models); **full support for deeply nested types** including nested arrays (2D, 3D+), arrays of structs, and arrays of arrays of structs
|
|
99
102
|
|
|
100
103
|
## Execution Modes Support
|
|
101
104
|
|
|
@@ -510,8 +513,16 @@ TestCase(
|
|
|
510
513
|
#### Example Mock Table Implementations:
|
|
511
514
|
|
|
512
515
|
```python
|
|
513
|
-
# BigQuery Mock Table
|
|
514
|
-
|
|
516
|
+
# BigQuery Mock Table (Recommended: Use BigQueryMockTable for clearer three-part naming)
|
|
517
|
+
from sql_testing_library import BigQueryMockTable
|
|
518
|
+
|
|
519
|
+
class UsersMockTable(BigQueryMockTable):
|
|
520
|
+
project_name = "test-project"
|
|
521
|
+
dataset_name = "test_dataset"
|
|
522
|
+
table_name = "users"
|
|
523
|
+
|
|
524
|
+
# BigQuery Mock Table (Alternative: Use BaseMockTable with combined project.dataset)
|
|
525
|
+
class UsersMockTableAlternative(BaseMockTable):
|
|
515
526
|
def get_database_name(self) -> str:
|
|
516
527
|
return "test-project.test_dataset" # project.dataset format
|
|
517
528
|
|
|
@@ -987,6 +998,7 @@ The adapter_type parameter will use the configuration from the corresponding sec
|
|
|
987
998
|
- Supports Google Cloud BigQuery service
|
|
988
999
|
- Uses UNION ALL pattern for CTE creation with complex data types
|
|
989
1000
|
- Handles authentication via service account or application default credentials
|
|
1001
|
+
- **Special Feature**: `BigQueryMockTable` class for explicit three-part naming (project.dataset.table)
|
|
990
1002
|
|
|
991
1003
|
#### Athena Adapter
|
|
992
1004
|
- Supports Amazon Athena service for querying data in S3
|
|
@@ -1025,6 +1037,94 @@ The adapter_type parameter will use the configuration from the corresponding sec
|
|
|
1025
1037
|
- No authentication required - perfect for local development and testing
|
|
1026
1038
|
- Excellent performance for analytical workloads
|
|
1027
1039
|
|
|
1040
|
+
### BigQuery-Specific: BigQueryMockTable
|
|
1041
|
+
|
|
1042
|
+
BigQuery uses a three-part naming scheme (`project.dataset.table`) which doesn't fit naturally into the two-part `database.table` model used by most databases. The `BigQueryMockTable` class provides explicit support for BigQuery's naming convention.
|
|
1043
|
+
|
|
1044
|
+
#### The Problem with BaseMockTable
|
|
1045
|
+
|
|
1046
|
+
Using `BaseMockTable` for BigQuery requires awkwardly cramming the project and dataset together:
|
|
1047
|
+
|
|
1048
|
+
```python
|
|
1049
|
+
# Awkward: Combines project.dataset into database_name
|
|
1050
|
+
class UsersMockTable(BaseMockTable):
|
|
1051
|
+
def get_database_name(self) -> str:
|
|
1052
|
+
return "test-project.test_dataset" # Confusing!
|
|
1053
|
+
|
|
1054
|
+
def get_table_name(self) -> str:
|
|
1055
|
+
return "users"
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
#### The Solution: BigQueryMockTable
|
|
1059
|
+
|
|
1060
|
+
`BigQueryMockTable` makes BigQuery's three-part naming explicit and clear:
|
|
1061
|
+
|
|
1062
|
+
```python
|
|
1063
|
+
from sql_testing_library import BigQueryMockTable
|
|
1064
|
+
|
|
1065
|
+
class UsersMockTable(BigQueryMockTable):
|
|
1066
|
+
project_name = "test-project"
|
|
1067
|
+
dataset_name = "test_dataset"
|
|
1068
|
+
table_name = "users"
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
#### Usage Examples
|
|
1072
|
+
|
|
1073
|
+
**Basic Usage:**
|
|
1074
|
+
```python
|
|
1075
|
+
from sql_testing_library import BigQueryMockTable
|
|
1076
|
+
|
|
1077
|
+
class UsersMockTable(BigQueryMockTable):
|
|
1078
|
+
project_name = "my-project"
|
|
1079
|
+
dataset_name = "analytics"
|
|
1080
|
+
table_name = "users"
|
|
1081
|
+
|
|
1082
|
+
class OrdersMockTable(BigQueryMockTable):
|
|
1083
|
+
project_name = "my-project"
|
|
1084
|
+
dataset_name = "analytics"
|
|
1085
|
+
table_name = "orders"
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
**Avoid Repetition with Inheritance:**
|
|
1089
|
+
```python
|
|
1090
|
+
# Base class for all tables in the same project
|
|
1091
|
+
class MyProjectTable(BigQueryMockTable):
|
|
1092
|
+
project_name = "my-project"
|
|
1093
|
+
|
|
1094
|
+
# Subclasses only specify dataset and table
|
|
1095
|
+
class UsersTable(MyProjectTable):
|
|
1096
|
+
dataset_name = "analytics"
|
|
1097
|
+
table_name = "users"
|
|
1098
|
+
|
|
1099
|
+
class OrdersTable(MyProjectTable):
|
|
1100
|
+
dataset_name = "analytics"
|
|
1101
|
+
table_name = "orders"
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
**Available Methods:**
|
|
1105
|
+
```python
|
|
1106
|
+
table = UsersMockTable([...])
|
|
1107
|
+
|
|
1108
|
+
# BigQuery-specific methods
|
|
1109
|
+
table.get_project_name() # "my-project"
|
|
1110
|
+
table.get_dataset_name() # "analytics"
|
|
1111
|
+
table.get_fully_qualified_name() # "my-project.analytics.users"
|
|
1112
|
+
|
|
1113
|
+
# Backwards compatible methods (from BaseMockTable)
|
|
1114
|
+
table.get_database_name() # "my-project.analytics"
|
|
1115
|
+
table.get_table_name() # "users"
|
|
1116
|
+
table.get_qualified_name() # "my-project.analytics.users"
|
|
1117
|
+
table.get_cte_alias() # "my_project_analytics__users"
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
**Benefits:**
|
|
1121
|
+
- ✅ **Clear Semantics**: Each BigQuery component is explicit
|
|
1122
|
+
- ✅ **No Confusion**: No more cramming project.dataset together
|
|
1123
|
+
- ✅ **Type Safe**: Full type hints and IDE autocomplete
|
|
1124
|
+
- ✅ **Backwards Compatible**: Still implements all `BaseMockTable` methods
|
|
1125
|
+
- ✅ **Simple**: Just 3 class variables to set
|
|
1126
|
+
- ✅ **Flexible**: Use inheritance to share common properties
|
|
1127
|
+
|
|
1028
1128
|
**Default Behavior:**
|
|
1029
1129
|
- If adapter_type is not specified in the TestCase or decorator, the library will use the adapter specified in the `[sql_testing]` section's `adapter` setting.
|
|
1030
1130
|
- If no adapter is specified in the `[sql_testing]` section, it defaults to "bigquery".
|
|
@@ -1231,7 +1331,7 @@ The library automatically:
|
|
|
1231
1331
|
- Injects mock data via CTEs or temp tables
|
|
1232
1332
|
- Deserializes results to typed Python objects
|
|
1233
1333
|
|
|
1234
|
-
For detailed usage and configuration options, see the
|
|
1334
|
+
For detailed usage and configuration options, see the [documentation](https://gurmeetsaran.github.io/sqltesting/).
|
|
1235
1335
|
|
|
1236
1336
|
## Integration with Mocksmith
|
|
1237
1337
|
|
|
@@ -1266,19 +1366,36 @@ class Customer:
|
|
|
1266
1366
|
customers = [Customer.mock() for _ in range(100)]
|
|
1267
1367
|
```
|
|
1268
1368
|
|
|
1269
|
-
See the [Mocksmith Integration Guide](docs/mocksmith_integration.md)
|
|
1369
|
+
See the [Mocksmith Integration Guide](docs/mocksmith_integration.md) for detailed usage patterns.
|
|
1270
1370
|
|
|
1271
1371
|
## Known Limitations and TODOs
|
|
1272
1372
|
|
|
1273
1373
|
The library has a few known limitations that are planned to be addressed in future updates:
|
|
1274
1374
|
|
|
1275
|
-
###
|
|
1276
|
-
|
|
1277
|
-
|
|
1375
|
+
### Deeply Nested Complex Types Support
|
|
1376
|
+
|
|
1377
|
+
**✅ Fully Supported (Athena, Trino, DuckDB & Redshift):**
|
|
1378
|
+
- Nested arrays (2D, 3D+): `List[List[int]]`, `List[List[List[int]]]`
|
|
1379
|
+
- Arrays of structs: `List[Address]` where Address is a dataclass
|
|
1380
|
+
- Arrays of arrays of structs: `List[List[OrderItem]]`
|
|
1381
|
+
- Maps with complex values: `Dict[str, str]`, `Dict[str, int]`
|
|
1382
|
+
- See `tests/integration/test_deeply_nested_types_integration.py` for comprehensive examples
|
|
1383
|
+
- **16 tests passing** across Athena, Trino, DuckDB, and Redshift (both CTE and physical tables modes)
|
|
1384
|
+
|
|
1385
|
+
**Implementation Details:**
|
|
1386
|
+
- **Athena/Trino**: Use ROW types with named fields
|
|
1387
|
+
- **DuckDB**: Use STRUCT types with dictionary-style syntax
|
|
1388
|
+
- **Redshift**: Use SUPER type with JSON_PARSE for serialization, recursive JSON parsing for deserialization
|
|
1278
1389
|
|
|
1390
|
+
**🚧 TODO - Implementation Needed:**
|
|
1391
|
+
- **BigQuery**: Does not support nested arrays - this is a **database limitation**, not a library limitation. BigQuery's type system doesn't allow `ARRAY<ARRAY<T>>` constructs
|
|
1392
|
+
- **Snowflake**:
|
|
1393
|
+
- Struct type support not implemented (see `Struct type not yet supported for dialect: snowflake`)
|
|
1394
|
+
- Nested arrays not yet supported
|
|
1395
|
+
- TODO: Implement struct/nested type support using VARIANT/OBJECT types
|
|
1279
1396
|
|
|
1280
1397
|
### Database-Specific Limitations
|
|
1281
|
-
- **BigQuery**: Does not support nested arrays (arrays of arrays). This is a BigQuery database limitation, not a library limitation.
|
|
1398
|
+
- **BigQuery**: Does not support nested arrays (arrays of arrays). This is a BigQuery database limitation, not a library limitation.
|
|
1282
1399
|
|
|
1283
1400
|
### General Improvements
|
|
1284
1401
|
- Add support for more SQL dialects
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "sql-testing-library"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.21.0"
|
|
8
8
|
description = "SQL Testing Framework for Python: Unit test SQL queries with mock data injection for BigQuery, Snowflake, Redshift, Athena, Trino, and DuckDB. Simplify data engineering ETL testing and analytics validation."
|
|
9
9
|
authors = ["Gurmeet Saran <gurmeetx@gmail.com>", "Kushal Thakkar <kushal.thakkar@gmail.com>"]
|
|
10
10
|
maintainers = ["Gurmeet Saran <gurmeetx@gmail.com>", "Kushal Thakkar <kushal.thakkar@gmail.com>"]
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/__init__.py
RENAMED
|
@@ -10,7 +10,7 @@ from ._exceptions import (
|
|
|
10
10
|
SQLTestingError, # noqa: F401
|
|
11
11
|
TypeConversionError, # noqa: F401
|
|
12
12
|
)
|
|
13
|
-
from ._mock_table import BaseMockTable # noqa: F401
|
|
13
|
+
from ._mock_table import BaseMockTable, BigQueryMockTable # noqa: F401
|
|
14
14
|
from ._pytest_plugin import sql_test # noqa: F401
|
|
15
15
|
|
|
16
16
|
|
|
@@ -27,12 +27,13 @@ try:
|
|
|
27
27
|
except ImportError:
|
|
28
28
|
__all__ = []
|
|
29
29
|
|
|
30
|
-
__version__ = "0.
|
|
30
|
+
__version__ = "0.21.0"
|
|
31
31
|
__all__.extend(
|
|
32
32
|
[
|
|
33
33
|
"SQLTestFramework",
|
|
34
34
|
"TestCase",
|
|
35
35
|
"BaseMockTable",
|
|
36
|
+
"BigQueryMockTable",
|
|
36
37
|
"DatabaseAdapter",
|
|
37
38
|
"sql_test",
|
|
38
39
|
"SQLTestingError",
|
|
@@ -34,6 +34,18 @@ except ImportError:
|
|
|
34
34
|
duckdb = None # type: ignore
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# Type mapping from Python types to DuckDB types
|
|
38
|
+
DUCKDB_TYPE_MAPPING = {
|
|
39
|
+
str: "VARCHAR",
|
|
40
|
+
int: "BIGINT",
|
|
41
|
+
float: "DOUBLE",
|
|
42
|
+
bool: "BOOLEAN",
|
|
43
|
+
date: "DATE",
|
|
44
|
+
datetime: "TIMESTAMP",
|
|
45
|
+
Decimal: "DECIMAL",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
37
49
|
class DuckDBTypeConverter(BaseTypeConverter):
|
|
38
50
|
"""DuckDB-specific type converter."""
|
|
39
51
|
|
|
@@ -146,8 +158,35 @@ class DuckDBAdapter(DatabaseAdapter):
|
|
|
146
158
|
|
|
147
159
|
# Use DuckDB's efficient DataFrame insertion
|
|
148
160
|
self.connection.register("temp_df", df)
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
|
|
162
|
+
# Build SELECT with explicit casts for MAP types to avoid STRUCT inference
|
|
163
|
+
column_selects = []
|
|
164
|
+
column_types = mock_table.get_column_types()
|
|
165
|
+
for col_name in df.columns:
|
|
166
|
+
col_type = column_types.get(col_name, str)
|
|
167
|
+
|
|
168
|
+
# Handle Optional types
|
|
169
|
+
if is_union_type(col_type):
|
|
170
|
+
non_none_types = [arg for arg in get_args(col_type) if arg is not type(None)]
|
|
171
|
+
if non_none_types:
|
|
172
|
+
col_type = non_none_types[0]
|
|
173
|
+
|
|
174
|
+
# For MAP types, explicitly cast to avoid STRUCT inference
|
|
175
|
+
if hasattr(col_type, "__origin__") and col_type.__origin__ is dict:
|
|
176
|
+
key_type = get_args(col_type)[0] if get_args(col_type) else str
|
|
177
|
+
value_type = get_args(col_type)[1] if len(get_args(col_type)) > 1 else str
|
|
178
|
+
|
|
179
|
+
key_db_type = DUCKDB_TYPE_MAPPING.get(key_type, "VARCHAR")
|
|
180
|
+
value_db_type = DUCKDB_TYPE_MAPPING.get(value_type, "VARCHAR")
|
|
181
|
+
|
|
182
|
+
column_selects.append(
|
|
183
|
+
f"CAST({col_name} AS MAP({key_db_type}, {value_db_type}))"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
column_selects.append(col_name)
|
|
187
|
+
|
|
188
|
+
columns_sql = ", ".join(column_selects)
|
|
189
|
+
insert_sql = f"INSERT INTO {temp_table_name} SELECT {columns_sql} FROM temp_df"
|
|
151
190
|
self.connection.execute(insert_sql)
|
|
152
191
|
self.connection.unregister("temp_df")
|
|
153
192
|
|
|
@@ -185,12 +224,35 @@ class DuckDBAdapter(DatabaseAdapter):
|
|
|
185
224
|
|
|
186
225
|
# Insert data if any
|
|
187
226
|
if not df.empty:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
# Check if table has MAP types - use VALUES approach to avoid STRUCT/MAP confusion
|
|
228
|
+
column_types = mock_table.get_column_types()
|
|
229
|
+
has_map_types = any(
|
|
230
|
+
hasattr(ct, "__origin__")
|
|
231
|
+
and ct.__origin__ is dict
|
|
232
|
+
or (
|
|
233
|
+
is_union_type(ct)
|
|
234
|
+
and any(
|
|
235
|
+
hasattr(arg, "__origin__") and arg.__origin__ is dict
|
|
236
|
+
for arg in get_args(ct)
|
|
237
|
+
if arg is not type(None)
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
for ct in column_types.values()
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if has_map_types:
|
|
244
|
+
# Use VALUES approach for tables with MAP types to preserve type information
|
|
245
|
+
# This avoids DuckDB inferring dicts as STRUCTs from pandas DataFrames
|
|
246
|
+
insert_sql_actual = f"INSERT INTO {temp_table_name} VALUES\n{values_sql}"
|
|
247
|
+
self.connection.execute(insert_sql_actual)
|
|
248
|
+
else:
|
|
249
|
+
# Use DataFrame approach for better performance when no MAP types
|
|
250
|
+
df = self._prepare_dataframe_for_duckdb(df, mock_table)
|
|
251
|
+
self.connection.register("temp_df", df)
|
|
252
|
+
columns = ", ".join(df.columns)
|
|
253
|
+
insert_sql_actual = f"INSERT INTO {temp_table_name} SELECT {columns} FROM temp_df"
|
|
254
|
+
self.connection.execute(insert_sql_actual)
|
|
255
|
+
self.connection.unregister("temp_df")
|
|
194
256
|
|
|
195
257
|
return temp_table_name, full_sql
|
|
196
258
|
|
|
@@ -217,126 +279,64 @@ class DuckDBAdapter(DatabaseAdapter):
|
|
|
217
279
|
# DuckDB doesn't have strict query size limits like cloud databases
|
|
218
280
|
return None
|
|
219
281
|
|
|
220
|
-
def
|
|
221
|
-
"""
|
|
282
|
+
def _get_column_sql_type(self, col_type: Type) -> str:
|
|
283
|
+
"""Recursively convert Python type to DuckDB SQL type.
|
|
284
|
+
|
|
285
|
+
Handles nested types like List[List[int]], List[Struct], etc.
|
|
286
|
+
"""
|
|
222
287
|
from .._types import is_struct_type
|
|
223
288
|
|
|
224
|
-
|
|
289
|
+
# Handle Optional types
|
|
290
|
+
if is_union_type(col_type):
|
|
291
|
+
non_none_types = [arg for arg in get_args(col_type) if arg is not type(None)]
|
|
292
|
+
if non_none_types:
|
|
293
|
+
col_type = non_none_types[0]
|
|
294
|
+
|
|
295
|
+
# Handle List/Array types (recursive)
|
|
296
|
+
if hasattr(col_type, "__origin__") and col_type.__origin__ is list:
|
|
297
|
+
element_type = get_args(col_type)[0] if get_args(col_type) else str
|
|
298
|
+
element_sql_type = self._get_column_sql_type(element_type) # Recursive call
|
|
299
|
+
return f"{element_sql_type}[]"
|
|
300
|
+
|
|
301
|
+
# Handle Dict/Map types (recursive for key/value types)
|
|
302
|
+
elif hasattr(col_type, "__origin__") and col_type.__origin__ is dict:
|
|
303
|
+
key_type = get_args(col_type)[0] if get_args(col_type) else str
|
|
304
|
+
value_type = get_args(col_type)[1] if len(get_args(col_type)) > 1 else str
|
|
305
|
+
key_sql_type = self._get_column_sql_type(key_type) # Recursive call
|
|
306
|
+
value_sql_type = self._get_column_sql_type(value_type) # Recursive call
|
|
307
|
+
return f"MAP({key_sql_type}, {value_sql_type})"
|
|
308
|
+
|
|
309
|
+
# Handle Struct types
|
|
310
|
+
elif is_struct_type(col_type):
|
|
311
|
+
struct_def = self._get_struct_definition(col_type)
|
|
312
|
+
return f"STRUCT{struct_def}"
|
|
313
|
+
|
|
314
|
+
# Handle scalar types
|
|
315
|
+
else:
|
|
316
|
+
return DUCKDB_TYPE_MAPPING.get(col_type, "VARCHAR")
|
|
225
317
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
int: "BIGINT",
|
|
230
|
-
float: "DOUBLE",
|
|
231
|
-
bool: "BOOLEAN",
|
|
232
|
-
date: "DATE",
|
|
233
|
-
datetime: "TIMESTAMP",
|
|
234
|
-
Decimal: "DECIMAL",
|
|
235
|
-
}
|
|
318
|
+
def _generate_create_table_sql(self, mock_table: BaseMockTable, table_name: str) -> str:
|
|
319
|
+
"""Generate CREATE TABLE SQL for DuckDB."""
|
|
320
|
+
column_types = mock_table.get_column_types()
|
|
236
321
|
|
|
237
322
|
column_defs = []
|
|
238
323
|
for col_name, col_type in column_types.items():
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
non_none_types = [arg for arg in get_args(col_type) if arg is not type(None)]
|
|
243
|
-
if non_none_types:
|
|
244
|
-
col_type = non_none_types[0]
|
|
245
|
-
|
|
246
|
-
# Handle List/Array types
|
|
247
|
-
if hasattr(col_type, "__origin__") and col_type.__origin__ is list:
|
|
248
|
-
# Get the element type from List[T]
|
|
249
|
-
element_type = get_args(col_type)[0] if get_args(col_type) else str
|
|
250
|
-
|
|
251
|
-
# Check if it's a list of structs
|
|
252
|
-
if is_struct_type(element_type):
|
|
253
|
-
# Create struct array type
|
|
254
|
-
struct_def = self._get_struct_definition(element_type)
|
|
255
|
-
column_defs.append(f"{col_name} STRUCT{struct_def}[]")
|
|
256
|
-
else:
|
|
257
|
-
# Map element type to DuckDB type
|
|
258
|
-
element_db_type = type_mapping.get(element_type, "VARCHAR")
|
|
259
|
-
column_defs.append(f"{col_name} {element_db_type}[]")
|
|
260
|
-
|
|
261
|
-
# Handle Dict/Map types
|
|
262
|
-
elif hasattr(col_type, "__origin__") and col_type.__origin__ is dict:
|
|
263
|
-
# DuckDB has native MAP support
|
|
264
|
-
key_type = get_args(col_type)[0] if get_args(col_type) else str
|
|
265
|
-
value_type = get_args(col_type)[1] if len(get_args(col_type)) > 1 else str
|
|
266
|
-
|
|
267
|
-
key_db_type = type_mapping.get(key_type, "VARCHAR")
|
|
268
|
-
value_db_type = type_mapping.get(value_type, "VARCHAR")
|
|
269
|
-
column_defs.append(f"{col_name} MAP({key_db_type}, {value_db_type})")
|
|
270
|
-
|
|
271
|
-
# Handle Struct types
|
|
272
|
-
elif is_struct_type(col_type):
|
|
273
|
-
struct_def = self._get_struct_definition(col_type)
|
|
274
|
-
column_defs.append(f"{col_name} STRUCT{struct_def}")
|
|
275
|
-
|
|
276
|
-
else:
|
|
277
|
-
# Handle scalar types
|
|
278
|
-
db_type = type_mapping.get(col_type, "VARCHAR")
|
|
279
|
-
column_defs.append(f"{col_name} {db_type}")
|
|
324
|
+
# Use the recursive type resolver for all types
|
|
325
|
+
sql_type = self._get_column_sql_type(col_type)
|
|
326
|
+
column_defs.append(f"{col_name} {sql_type}")
|
|
280
327
|
|
|
281
328
|
columns_sql = ",\n ".join(column_defs)
|
|
282
329
|
return f"CREATE TABLE {table_name} (\n {columns_sql}\n)"
|
|
283
330
|
|
|
284
331
|
def _get_struct_definition(self, struct_type: Type) -> str:
|
|
285
332
|
"""Convert struct type to DuckDB STRUCT definition."""
|
|
286
|
-
from .._types import is_struct_type
|
|
287
|
-
|
|
288
|
-
# Type mapping from Python types to DuckDB types
|
|
289
|
-
type_mapping = {
|
|
290
|
-
str: "VARCHAR",
|
|
291
|
-
int: "BIGINT",
|
|
292
|
-
float: "DOUBLE",
|
|
293
|
-
bool: "BOOLEAN",
|
|
294
|
-
date: "DATE",
|
|
295
|
-
datetime: "TIMESTAMP",
|
|
296
|
-
Decimal: "DECIMAL",
|
|
297
|
-
}
|
|
298
|
-
|
|
299
333
|
type_hints = get_type_hints(struct_type)
|
|
300
334
|
field_defs = []
|
|
301
335
|
|
|
302
336
|
for field_name, field_type in type_hints.items():
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
non_none_types = [arg for arg in get_args(field_type) if arg is not type(None)]
|
|
307
|
-
if non_none_types:
|
|
308
|
-
field_type = non_none_types[0]
|
|
309
|
-
|
|
310
|
-
# Handle nested structs
|
|
311
|
-
if is_struct_type(field_type):
|
|
312
|
-
nested_struct_def = self._get_struct_definition(field_type)
|
|
313
|
-
field_defs.append(f"{field_name} STRUCT{nested_struct_def}")
|
|
314
|
-
|
|
315
|
-
# Handle List types in structs
|
|
316
|
-
elif hasattr(field_type, "__origin__") and field_type.__origin__ is list:
|
|
317
|
-
element_type = get_args(field_type)[0] if get_args(field_type) else str
|
|
318
|
-
if is_struct_type(element_type):
|
|
319
|
-
# List of structs
|
|
320
|
-
nested_struct_def = self._get_struct_definition(element_type)
|
|
321
|
-
field_defs.append(f"{field_name} STRUCT{nested_struct_def}[]")
|
|
322
|
-
else:
|
|
323
|
-
# List of scalars
|
|
324
|
-
element_db_type = type_mapping.get(element_type, "VARCHAR")
|
|
325
|
-
field_defs.append(f"{field_name} {element_db_type}[]")
|
|
326
|
-
|
|
327
|
-
# Handle Dict types in structs
|
|
328
|
-
elif hasattr(field_type, "__origin__") and field_type.__origin__ is dict:
|
|
329
|
-
key_type = get_args(field_type)[0] if get_args(field_type) else str
|
|
330
|
-
value_type = get_args(field_type)[1] if len(get_args(field_type)) > 1 else str
|
|
331
|
-
|
|
332
|
-
key_db_type = type_mapping.get(key_type, "VARCHAR")
|
|
333
|
-
value_db_type = type_mapping.get(value_type, "VARCHAR")
|
|
334
|
-
field_defs.append(f"{field_name} MAP({key_db_type}, {value_db_type})")
|
|
335
|
-
|
|
336
|
-
else:
|
|
337
|
-
# Handle scalar types
|
|
338
|
-
db_type = type_mapping.get(field_type, "VARCHAR")
|
|
339
|
-
field_defs.append(f"{field_name} {db_type}")
|
|
337
|
+
# Use the recursive type resolver for each field
|
|
338
|
+
field_sql_type = self._get_column_sql_type(field_type)
|
|
339
|
+
field_defs.append(f"{field_name} {field_sql_type}")
|
|
340
340
|
|
|
341
341
|
return f"({', '.join(field_defs)})"
|
|
342
342
|
|
|
@@ -29,6 +29,17 @@ except ImportError:
|
|
|
29
29
|
class RedshiftTypeConverter(BaseTypeConverter):
|
|
30
30
|
"""Redshift-specific type converter."""
|
|
31
31
|
|
|
32
|
+
def _parse_json_if_string(self, value: Any) -> Any:
|
|
33
|
+
"""Helper to parse JSON strings from SUPER columns."""
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
import json
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(value)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return value
|
|
41
|
+
return value
|
|
42
|
+
|
|
32
43
|
def convert(self, value: Any, target_type: Type) -> Any:
|
|
33
44
|
"""Convert Redshift result value to target type."""
|
|
34
45
|
# Handle None/NULL values first
|
|
@@ -41,21 +52,32 @@ class RedshiftTypeConverter(BaseTypeConverter):
|
|
|
41
52
|
return None
|
|
42
53
|
target_type = self.get_optional_inner_type(target_type)
|
|
43
54
|
|
|
55
|
+
# Handle struct types from Redshift SUPER columns
|
|
56
|
+
from .._types import is_struct_type
|
|
57
|
+
|
|
58
|
+
if is_struct_type(target_type):
|
|
59
|
+
# Parse JSON string if needed
|
|
60
|
+
parsed_value = self._parse_json_if_string(value)
|
|
61
|
+
# Delegate to base converter (handles dict → struct)
|
|
62
|
+
return super().convert(parsed_value, target_type)
|
|
63
|
+
|
|
64
|
+
# Handle list/array types from Redshift SUPER columns
|
|
65
|
+
if hasattr(target_type, "__origin__") and target_type.__origin__ is list:
|
|
66
|
+
# Parse JSON string if needed
|
|
67
|
+
parsed_value = self._parse_json_if_string(value)
|
|
68
|
+
|
|
69
|
+
if isinstance(parsed_value, list):
|
|
70
|
+
# Recursively convert each element with proper typing
|
|
71
|
+
element_type = get_args(target_type)[0] if get_args(target_type) else str
|
|
72
|
+
return [self.convert(elem, element_type) for elem in parsed_value]
|
|
73
|
+
else:
|
|
74
|
+
return []
|
|
75
|
+
|
|
44
76
|
# Handle dict/map types from Redshift SUPER columns
|
|
45
77
|
if hasattr(target_type, "__origin__") and target_type.__origin__ is dict:
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# If it's a string (shouldn't happen with psycopg2), parse it
|
|
50
|
-
elif isinstance(value, str):
|
|
51
|
-
import json
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
return json.loads(value)
|
|
55
|
-
except json.JSONDecodeError:
|
|
56
|
-
return {}
|
|
57
|
-
else:
|
|
58
|
-
return {}
|
|
78
|
+
# Parse JSON string if needed
|
|
79
|
+
parsed_value = self._parse_json_if_string(value)
|
|
80
|
+
return parsed_value if isinstance(parsed_value, dict) else {}
|
|
59
81
|
|
|
60
82
|
# Redshift returns proper Python types in most cases, so use base converter
|
|
61
83
|
return super().convert(value, target_type)
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_mock_table.py
RENAMED
|
@@ -204,3 +204,94 @@ class BaseMockTable(ABC):
|
|
|
204
204
|
db_name = self.get_database_name().replace("-", "_").replace(".", "_")
|
|
205
205
|
table_name = self.get_table_name().replace("-", "_").replace(".", "_")
|
|
206
206
|
return f"{db_name}__{table_name}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class BigQueryMockTable(BaseMockTable):
|
|
210
|
+
"""Mock table specifically for BigQuery with three-part naming support.
|
|
211
|
+
|
|
212
|
+
BigQuery uses a three-part naming scheme: project.dataset.table
|
|
213
|
+
This class makes it explicit and provides better semantics than cramming
|
|
214
|
+
project and dataset into the generic database_name field.
|
|
215
|
+
|
|
216
|
+
Two usage patterns are supported:
|
|
217
|
+
|
|
218
|
+
Usage - Class variables for table definition:
|
|
219
|
+
>>> class UsersMockTable(BigQueryMockTable):
|
|
220
|
+
... project_name = "my-project"
|
|
221
|
+
... dataset_name = "analytics"
|
|
222
|
+
... table_name = "users"
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
# Class variables that subclasses must set (mandatory)
|
|
226
|
+
project_name: str
|
|
227
|
+
dataset_name: str
|
|
228
|
+
table_name: str
|
|
229
|
+
|
|
230
|
+
def get_bigquery_project(self) -> str:
|
|
231
|
+
"""Return the BigQuery project name from class variable.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
BigQuery project ID
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
AttributeError: If project_name class variable not set
|
|
238
|
+
"""
|
|
239
|
+
return self.project_name
|
|
240
|
+
|
|
241
|
+
def get_bigquery_dataset(self) -> str:
|
|
242
|
+
"""Return the BigQuery dataset name from class variable.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
BigQuery dataset name
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
AttributeError: If dataset_name class variable not set
|
|
249
|
+
"""
|
|
250
|
+
return self.dataset_name
|
|
251
|
+
|
|
252
|
+
def get_bigquery_table(self) -> str:
|
|
253
|
+
"""Return the BigQuery table name from class variable.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
BigQuery table name
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
AttributeError: If table_name class variable not set
|
|
260
|
+
"""
|
|
261
|
+
return self.table_name
|
|
262
|
+
|
|
263
|
+
def get_project_name(self) -> str:
|
|
264
|
+
"""Return the BigQuery project name (alias for get_bigquery_project)."""
|
|
265
|
+
return self.get_bigquery_project()
|
|
266
|
+
|
|
267
|
+
def get_dataset_name(self) -> str:
|
|
268
|
+
"""Return the BigQuery dataset name (alias for get_bigquery_dataset)."""
|
|
269
|
+
return self.get_bigquery_dataset()
|
|
270
|
+
|
|
271
|
+
def get_database_name(self) -> str:
|
|
272
|
+
"""Return database name (for BigQuery, this is project.dataset).
|
|
273
|
+
|
|
274
|
+
This implements the BaseMockTable abstract method by combining
|
|
275
|
+
project and dataset to maintain backwards compatibility with
|
|
276
|
+
the two-part naming assumption in the base class.
|
|
277
|
+
"""
|
|
278
|
+
return f"{self.get_bigquery_project()}.{self.get_bigquery_dataset()}"
|
|
279
|
+
|
|
280
|
+
def get_table_name(self) -> str:
|
|
281
|
+
"""Return the table name (alias for get_bigquery_table)."""
|
|
282
|
+
return self.get_bigquery_table()
|
|
283
|
+
|
|
284
|
+
def get_fully_qualified_name(self) -> str:
|
|
285
|
+
"""Return the three-part BigQuery table reference.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Fully qualified table name in format: project.dataset.table
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
>>> table.get_fully_qualified_name()
|
|
292
|
+
'my-project.analytics.users'
|
|
293
|
+
"""
|
|
294
|
+
project = self.get_bigquery_project()
|
|
295
|
+
dataset = self.get_bigquery_dataset()
|
|
296
|
+
table = self.get_bigquery_table()
|
|
297
|
+
return f"{project}.{dataset}.{table}"
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_utils.py
RENAMED
|
@@ -5,7 +5,44 @@ import json
|
|
|
5
5
|
from dataclasses import is_dataclass
|
|
6
6
|
from datetime import date, datetime
|
|
7
7
|
from decimal import Decimal
|
|
8
|
-
from typing import Any, Dict, Type, get_type_hints
|
|
8
|
+
from typing import Any, Dict, Type, get_args, get_type_hints
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _convert_to_json_serializable(val: Any, val_type: Type) -> Any:
|
|
12
|
+
"""Convert Python value to JSON-serializable format for Redshift SUPER type.
|
|
13
|
+
|
|
14
|
+
Recursively handles structs, nested lists, and Decimal types.
|
|
15
|
+
"""
|
|
16
|
+
from ._types import is_struct_type
|
|
17
|
+
|
|
18
|
+
if val is None:
|
|
19
|
+
return None
|
|
20
|
+
elif is_struct_type(val_type):
|
|
21
|
+
# Convert struct to dict
|
|
22
|
+
if hasattr(val, "__dataclass_fields__"):
|
|
23
|
+
from dataclasses import asdict
|
|
24
|
+
|
|
25
|
+
return asdict(val)
|
|
26
|
+
elif hasattr(val, "model_dump"):
|
|
27
|
+
return val.model_dump()
|
|
28
|
+
elif hasattr(val, "dict"):
|
|
29
|
+
return val.dict()
|
|
30
|
+
else:
|
|
31
|
+
return val
|
|
32
|
+
elif isinstance(val, Decimal):
|
|
33
|
+
return float(val)
|
|
34
|
+
elif isinstance(val, (list, tuple)):
|
|
35
|
+
# Recursively handle nested lists
|
|
36
|
+
inner_type = (
|
|
37
|
+
get_args(val_type)[0]
|
|
38
|
+
if hasattr(val_type, "__origin__") and get_args(val_type)
|
|
39
|
+
else type(val[0])
|
|
40
|
+
if val
|
|
41
|
+
else str
|
|
42
|
+
)
|
|
43
|
+
return [_convert_to_json_serializable(item, inner_type) for item in val]
|
|
44
|
+
else:
|
|
45
|
+
return val
|
|
9
46
|
|
|
10
47
|
|
|
11
48
|
# SQL type mappings for different dialects
|
|
@@ -390,17 +427,11 @@ def format_sql_value(value: Any, column_type: Type, dialect: str = "standard") -
|
|
|
390
427
|
formatted_elements.append(formatted_element)
|
|
391
428
|
return f"ARRAY[{', '.join(formatted_elements)}]"
|
|
392
429
|
elif dialect == "redshift":
|
|
393
|
-
# Redshift uses JSON-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if isinstance(element, Decimal):
|
|
399
|
-
json_elements.append(float(element))
|
|
400
|
-
else:
|
|
401
|
-
json_elements.append(element)
|
|
402
|
-
|
|
403
|
-
json_array = json.dumps(json_elements)
|
|
430
|
+
# Redshift uses SUPER type - convert to JSON-serializable then wrap in JSON_PARSE
|
|
431
|
+
json_elements = [
|
|
432
|
+
_convert_to_json_serializable(element, element_type) for element in value
|
|
433
|
+
]
|
|
434
|
+
json_array = json.dumps(json_elements, cls=DecimalEncoder)
|
|
404
435
|
return f"JSON_PARSE('{json_array}')"
|
|
405
436
|
elif dialect == "snowflake":
|
|
406
437
|
# Format each element in the array for Snowflake
|
|
@@ -426,6 +457,8 @@ def format_sql_value(value: Any, column_type: Type, dialect: str = "standard") -
|
|
|
426
457
|
# If it's not a dict, return an empty map
|
|
427
458
|
if dialect in ("athena", "trino"):
|
|
428
459
|
return "MAP(ARRAY[], ARRAY[])"
|
|
460
|
+
elif dialect == "duckdb":
|
|
461
|
+
return "MAP {}"
|
|
429
462
|
else:
|
|
430
463
|
raise NotImplementedError(f"Map type not yet supported for dialect: {dialect}")
|
|
431
464
|
|
|
@@ -443,6 +476,14 @@ def format_sql_value(value: Any, column_type: Type, dialect: str = "standard") -
|
|
|
443
476
|
keys.append(format_sql_value(k, key_type, dialect))
|
|
444
477
|
values.append(format_sql_value(v, value_type, dialect))
|
|
445
478
|
return f"MAP(ARRAY[{', '.join(keys)}], ARRAY[{', '.join(values)}])"
|
|
479
|
+
elif dialect == "duckdb":
|
|
480
|
+
# DuckDB uses MAP {key: value, ...} syntax
|
|
481
|
+
pairs = []
|
|
482
|
+
for k, v in value.items():
|
|
483
|
+
formatted_key = format_sql_value(k, key_type, dialect)
|
|
484
|
+
formatted_value = format_sql_value(v, value_type, dialect)
|
|
485
|
+
pairs.append(f"{formatted_key}: {formatted_value}")
|
|
486
|
+
return f"MAP {{{', '.join(pairs)}}}"
|
|
446
487
|
elif dialect == "redshift":
|
|
447
488
|
# Redshift uses SUPER type with JSON-like syntax for maps
|
|
448
489
|
json_str = json.dumps(value, cls=DecimalEncoder)
|
|
@@ -597,6 +638,44 @@ def _format_struct_value(value: Any, struct_type: Type, dialect: str) -> str:
|
|
|
597
638
|
|
|
598
639
|
return f"STRUCT({', '.join(field_pairs)})"
|
|
599
640
|
|
|
641
|
+
# For DuckDB
|
|
642
|
+
elif dialect == "duckdb":
|
|
643
|
+
# Handle NULL struct values
|
|
644
|
+
if value is None:
|
|
645
|
+
return "NULL"
|
|
646
|
+
|
|
647
|
+
# Get type hints for the struct
|
|
648
|
+
type_hints = get_type_hints(struct_type)
|
|
649
|
+
|
|
650
|
+
# Format non-NULL struct values as DuckDB struct literals
|
|
651
|
+
field_pairs = []
|
|
652
|
+
for field_name, field_type in type_hints.items():
|
|
653
|
+
# Get the field value
|
|
654
|
+
if is_dataclass(value):
|
|
655
|
+
field_value = getattr(value, field_name, None)
|
|
656
|
+
elif is_pydantic_model_class(type(value)):
|
|
657
|
+
field_value = getattr(value, field_name, None)
|
|
658
|
+
else:
|
|
659
|
+
# If it's a dict
|
|
660
|
+
field_value = value.get(field_name) if isinstance(value, dict) else None
|
|
661
|
+
|
|
662
|
+
# Format the field value recursively
|
|
663
|
+
formatted_value = format_sql_value(field_value, field_type, dialect)
|
|
664
|
+
field_pairs.append(f"'{field_name}': {formatted_value}")
|
|
665
|
+
|
|
666
|
+
return f"{{{', '.join(field_pairs)}}}"
|
|
667
|
+
|
|
668
|
+
# For Redshift (using SUPER type with JSON)
|
|
669
|
+
elif dialect == "redshift":
|
|
670
|
+
# Handle NULL struct values
|
|
671
|
+
if value is None:
|
|
672
|
+
return "NULL"
|
|
673
|
+
|
|
674
|
+
# Use helper function to convert struct to JSON-serializable dict
|
|
675
|
+
json_obj = _convert_to_json_serializable(value, struct_type)
|
|
676
|
+
json_str = json.dumps(json_obj, cls=DecimalEncoder)
|
|
677
|
+
return f"JSON_PARSE('{json_str}')"
|
|
678
|
+
|
|
600
679
|
# For other databases, struct support would need to be implemented
|
|
601
680
|
else:
|
|
602
681
|
raise NotImplementedError(f"Struct type not yet supported for dialect: {dialect}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/trino.py
RENAMED
|
File without changes
|
|
File without changes
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_exceptions.py
RENAMED
|
File without changes
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_pytest_plugin.py
RENAMED
|
File without changes
|
{sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_logger.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|