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.
Files changed (23) hide show
  1. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/CHANGELOG.md +19 -0
  2. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/PKG-INFO +135 -18
  3. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/README.md +134 -17
  4. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/pyproject.toml +1 -1
  5. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/__init__.py +3 -2
  6. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/duckdb.py +112 -112
  7. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/redshift.py +35 -13
  8. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_mock_table.py +91 -0
  9. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_utils.py +91 -12
  10. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/LICENSE +0 -0
  11. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/__init__.py +0 -0
  12. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/athena.py +0 -0
  13. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/base.py +0 -0
  14. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/bigquery.py +0 -0
  15. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/presto.py +0 -0
  16. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/snowflake.py +0 -0
  17. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_adapters/trino.py +0 -0
  18. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_core.py +0 -0
  19. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_exceptions.py +0 -0
  20. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_pytest_plugin.py +0 -0
  21. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_sql_logger.py +0 -0
  22. {sql_testing_library-0.19.0 → sql_testing_library-0.21.0}/src/sql_testing_library/_types.py +0 -0
  23. {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.19.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 not yet supported
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
- class UsersMockTable(BaseMockTable):
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 example files included.
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) and [examples](examples/mocksmith_integration_example.py) for detailed usage patterns.
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
- ### Struct Type Support
1336
- - **Redshift**: Struct types are not supported due to lack of native struct/record types (uses SUPER type for JSON)
1337
- - **Snowflake**: Struct types are not supported due to lack of native struct/record types (uses VARIANT type for JSON)
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. (See TODO in `test_struct_types_integration.py:test_nested_lists`)
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 not yet supported
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
- class UsersMockTable(BaseMockTable):
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 example files included.
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) and [examples](examples/mocksmith_integration_example.py) for detailed usage patterns.
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
- ### Struct Type Support
1276
- - **Redshift**: Struct types are not supported due to lack of native struct/record types (uses SUPER type for JSON)
1277
- - **Snowflake**: Struct types are not supported due to lack of native struct/record types (uses VARIANT type for JSON)
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. (See TODO in `test_struct_types_integration.py:test_nested_lists`)
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.19.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>"]
@@ -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.19.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
- columns = ", ".join(df.columns)
150
- insert_sql = f"INSERT INTO {temp_table_name} SELECT {columns} FROM temp_df"
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
- df = self._prepare_dataframe_for_duckdb(df, mock_table)
189
- self.connection.register("temp_df", df)
190
- columns = ", ".join(df.columns)
191
- insert_sql = f"INSERT INTO {temp_table_name} SELECT {columns} FROM temp_df"
192
- self.connection.execute(insert_sql)
193
- self.connection.unregister("temp_df")
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 _generate_create_table_sql(self, mock_table: BaseMockTable, table_name: str) -> str:
221
- """Generate CREATE TABLE SQL for DuckDB."""
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
- column_types = mock_table.get_column_types()
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
- # Type mapping from Python types to DuckDB types
227
- type_mapping = {
228
- str: "VARCHAR",
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
- # Handle Optional types (both Optional[X] and X | None)
240
- if is_union_type(col_type):
241
- # Extract the non-None type from Optional[T] or T | None
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
- # Handle Optional types (both Optional[X] and X | None)
304
- if is_union_type(field_type):
305
- # Extract the non-None type from Optional[T] or T | None
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
- # Redshift returns SUPER types as Python dicts already
47
- if isinstance(value, dict):
48
- return value
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)
@@ -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}"
@@ -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-like syntax for SUPER arrays
394
- # Format elements as JSON (double quotes for strings)
395
- # Convert elements to JSON-serializable types
396
- json_elements = []
397
- for element in value:
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}")